C/C++教程

【原创】精通C++系列:再论虚函数 - 终结

本文主要是介绍【原创】精通C++系列:再论虚函数 - 终结,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

题记:
有一个东西,起不了什么用,也不能拿来挣钱,但依然饶有兴致的研究.....

C++对我而言更像是一种计算机学习的信仰。闲下来就研究下,一年进步一点........
这段时间在封控,趁居家的时间写一篇,看看境界有没有提升一些。为了说明问题,还是坚持从0开始说

聚合对象结构体和类

聚合对象,就是一系列的基本数据类型的组装,在内存中像线型一样紧密摆放,比如结构体就是典型的代表:

typedef struct tagPerson{
    int age;
    char address[128];
}PERSON;

如果我们对C这套东西很了解,能知道在这个语言中,基本上的数据类型就是1,2,4,8,或者它们之间的组合。
比如,char 1字节,int 4字节,指针(任何数据类型的)占4字节,double 8字节。在这些类型中,特别注意指针是4字节。

所以聚合对象,就是基本对象的组合,如果我们基本对象熟练,那么聚合对象自然就熟练。

类和结构体从内存结构来说几乎一样,只相当于把名字struct换成class。因此,类也可认为是聚合对象。只不过,语言设计者又给它添加了构造析构等方法。
因此,把类等价类比于结构体是认识提升的第一步。

方法初探

写完类的基础,再看类的方法。一个类的方法是在编译期或者说运行之后就已经固定了,我们认为它就是固定的,比如:

class Person{
    public:
        void show(){ printf("hello\n");}
};

实例一个对象,并调用:
Person p;
p.show();

对象p调用方法,这个show()方法确定无疑在内存中处在一个固定的位置,我们常常说,方法是通用的,图示:

 

对象有若干个,但是方法只有一个。只要进入时限定对象,就可以通用,比如对象a进入,就将a的首地址传进去,对象b进入就将b的首地址传入,这样就可以区别开。

调用父类的方法

我们可以认为所有的一切都是事先安排好的,这其中,都是编译器在背后做的工作。举例:

class Person{
public:
    void GetAge(){}
};
class Employee:public Person{
public:
    void GetInfo(){ //调用GetAge()方法
        GetAge();
    }
    void GetAge(){}
};

Employee e;
e.GetInfo();

类Employee的GetInfo方法调用GetAge(),由于不存在虚函数,而且层次又这么清楚,所以每个类方法没有任何理由是动态的,也就是所有的方法都是固定位置确定无疑的。编译器编译三个方法:
Person::GetAge()
Employee::GetInfo()
Employee::GetAge()

因此,GetInfo()里面的方法,在编译期就能确定调用的是Employee::GetAge(),这就是上面所说的,一切都是事先安排好的。有的人说,我会强转大法,如下:

Employee per;
Person* p=(Person*)&per;
p->GetAge();

将雇员地址强转为Person*,编译器一眼就看到这是确定的,这是调用Person::GetAge()方法,所以我们的结论成立

结论:所有的方法都是事先安排好的

类派生的内存结构

普通的方法理解之后,我们再看下类的内存结构,它非常类似于套娃,如图所示:

 

 

class A{
public:
    int m_a;
};
class B:public A{
public:
    int m_b;
};
class C:public B{
public:
    int m_c;
};
class D:public C{
public:
    int m_d;
};

对于派生终端的子类D来说,它将会继承上面的所有类成员,并且最高层的类成员排在内存靠前位置,如下:

 

虚表指针

如果没有虚方法,那么内存布局就像上图所示,非常明了。一旦有虚方法之后,对象的起始4字节就被虚表指针占用了(前面我们假设指针占用4字节),就变成如下样式:

就好比虚表指针在说:都让开,前面4字节必须由我来占用。怎么验证呢?其实也比较简单,我就把这个对象的首地址前4字节强转出来,看看长的什么样:

class AA{
public:
    virtual void Show(){}
};
class V:public AA{
public:
    V(){this->a=10;}
private:
    int a;
};

int main(int argc,char* argv[]){
    V v;
    int* p=(int*)&v;
    printf("%p\n",*p); //注意这是取的是对象首地址四字节存储的值;如果没有虚方法,打印结果应该是10;
    printf("%d\n",*(p+1)); //将指针向前推四个字节,打印出10;
    return 0;
}

按照内存结构,开始应该打印出10,结果是0x00402020(可以看出是一个地址值),然后将指针向前推了4字节,才得到10。说明前面四字节确实是虚表地址。
(这个例子也说明,类的成员访问控制仅仅是编译层面进行,实际简单绕一下就过去了)

虚表结构

从上面的实证中,我们知道对象前四个字节确实是虚表地址,有时候,学习就是这样,必须确定无疑拿出一个结果出来,我们真正用眼睛看到了才能真正理解。
下面我们接着说虚表结构,其实是一个指针数组,而这些指针就是函数地址。如下图所示:

 

假如我们打开编辑器进行debug,定位到0x00402020,就会看到类似下面这样的地址:

10 22 40 00 1A 22 40 00,(每个机器可能都不一样,这里仅做示例)

可以看出,这是地址,而虚表就是数组,由于指针是四字节,所以每四个字节为一组向前填充,这一步验证了虚表的概念

虚函数指针的填充

有的人会问,我没有在任何地方做,或者看到虚表指针被处理,那么它在哪个地方做了处理呢?答案是,编译器在构造方法中进行的。
因此会有结论:

1. 如果派生中存在虚函数定义,那么一定会有一个构造方法。
2. 如果用户没写,那么编译器会默认定义一个
3. 如果用户写了构造函数,即使什么都没做,编译器也在这个构造方法中悄悄的将虚表指针正确部署起来

虚函数的部署

为了理解这个问题,我们做个简单的例子,假如有两个类A和B,类A有一个虚方法,如下所示:

class A{
public:
    virtual void Show(){
        printf("父类A");
    }
};
class B:public A{
public:

};

由于子类B没有重写,因此原样将A继承过来了,虚表示意图如下:

 

 

在前面说明类方法时说过,每个类的方法都是固定的,因为它没有动态的理由,因此编译器首先在内存中部署了一个函数A::Show(),它和一般的方法并没有什么不同,只是因为
它是虚方法。假定它的入口地址是0x1000,如上图所示,就将该地址登记在虚表中。(注意,这部分都在说类B)

虚方法被重写如何呢?

假如B重写了类A的虚方法呢?对不起,A::Show()退下去,如图所示:

 

 

可以看到,A::Show()被排挤出去了,只留下B::Show(),因为只要写了,编译器足够聪明的知道你重写了。想一想,如果这一步不能确定,它怎么能正确的安装虚表呢?
这一步关键在于,要知道A::Show()并不是被继承过来了,而是被排挤出去了,现在只有B::Show()在虚表中。

好了,再次验证一句话:原来一切都是安排好的。

多态

当编译器安排好之后,如何实现多态呢?我们再举一例说明:

class Base{
public:
    virtual void Show(){
        printf("父类Base\n");
    }
};
class A:public Base{
public:
    virtual void Show(){
        printf("A类Show\n");
    }
};
class B:public Base{
public:
};

void test(Base* base){
    base->Show();
}
int main(int argc,char* argv[]){
    A a;
    B b;
    test(&a);
    test(&b);
    return 0;
}

这个例子比较简单,A重写父类,B没有重写,根据前面的说明,我们知道A的虚表也被重写,B的虚表相当于继承过来,这一步是编译器干的。
因为编译器明确知道一切信息,所以虚表才能正确部署。

当调用test方法时,虽然形式上用的是基类的指针,但是我们注意到,这里实质传进去的是派生类对象的指针。

那么编译器会关心这个指针是谁吗?不,它一点都不关心,因为虚表已经确定了。

由于虚表指针是在对象首地址的前4字节,所以编译器不管你传什么指针进来,它第一步就去取虚表地址

void test(Base* base){
    base->Show();
}

为什么呢?因为代码是在调用虚方法,编译结果即如此,所以在这一步中,base实参无论是A指针或B指针或Base指针都无所谓。

当取到虚表地址之后,由于虚表已经固定,所以Show()方法的地址是可以精确计算出来的。
在我们这个例子中,虚方法Show确定是在第一行中,无论对于A来说重写,对于B来说是继承,或原类Base,第一个虚方法地址必定是Show,所以这一步能取到Show方法地址。

最后一步,我们把对象地址push入栈,首地址确定即对象确定。

在这几步操作中,都对传进来的指针是谁没有一点限定!

 

- - 完结撒花 2022-4-8号 夜 - -

 

这篇关于【原创】精通C++系列:再论虚函数 - 终结的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!