【C++】多态 —— 条件 | 虚函数重写 | 抽象类 | 多态的原理

简介: 多态即多种形态。在Linux基础IO一文中@一切皆文件,咱们说过语言上的多态是漫长软件开发过程中探索出的实现“一切皆...”的高级版本。那现在就来了解多态的语法细节。

多态即多种形态。在Linux基础IO一文中@一切皆文件,咱们说过语言上的多态是漫长软件开发过程中探索出的实现“一切皆...”的高级版本。那现在就来了解多态的语法细节。

不要害怕!不要害怕!不要害怕!怕了咱们就先玩儿完了!!

正文开始@一个人的乐队:guitar:

前置文章:继承;类和对象;指针进阶

反爬链接:

1. 多态

多态分为两类 ——

  • 静态的多态函数重载。传入不同参数,看起来调用一个函数,但是有不同的行为,最典型的比如流插入流提取的“自动识别类型”。

        int i = 10;
        double d = 1.1;
        cout << i; //cout.operator<<(int)
        cout << d; //cout.operator<<(double)
  • 动态的多态:一个父类引用或指针调用同一个函数,传递不同的对象,会调用不同的函数。

所谓静态还是动态在于 ——

  • 静态:在编译时决议,(编译时决定调用谁)
  • 动态:在运行时决议,(运行时决定调用谁)

本文重点讨论的是动态多态。

现在人类有一个买票行为,我们想让不同身份的人,买票的价格不同,就可以借助多态实现。

上层看来我们都是人类,只不过传入对象的身份不同,因买票行为也不同。

这是怎么实现的?这是传子类对象时发生切片,与类型转换无关,否则会产生临时变量,临时变量具有常属性,需要加const

class Person {
public:
    virtual void BuyTicket() 
    { 
        cout << "买票-全价" << endl; 
    }
};

class Student : public Person {
public:
    virtual void BuyTicket() 
    { 
        cout << "买票-半价" << endl; 
    }
};

void Func(Person& p) //父类的指针/引用
{
    p.BuyTicket(); /*多态*/
}

int main()
{
    Person p;
    Student s;

    Func(p); //传父类对象 —— 调父类的
    Func(s); //传子类对象 —— 调子类的
    return 0;
}

子类中的函数满足三同(返回值类型、函数名、参数列表完全相同)的虚函数两个条件,叫做重写(覆盖)。

注:此时函数名相同也不再是所谓的构成隐藏,实际上,父子类的同名函数非重写即隐藏。

这样就做到了,同一函数不同类型的人来做,有不同行为 ——

<img src=" title="image-20220601100524096">

2. 多态的定义和实现

2.1 多态的条件

:purple_heart: 多态两个条件,缺一不可 ——

  • 必须通过基类指针或者引用调用虚函数,对象没有多态。
  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写(底层原理后面看)

    • 虚函数:被virtual修饰的类成员函数 ( 准确的说,只有类的非静态成员函数才能是虚函数,其他函数不能成为虚函数,原理虚函数表详谈)
    • 重写要求:虚函数 + 三同,总之,这条可以算作四个条件

下面进行一系列验证,first one,若用对象来调用,没有多态 ——

<img src=" title="image-20220601210243758">

  • 构成多态,传的哪个类型的对象,调用的就是哪个类型的虚函数 - 跟对象有关
  • 不构成多态,调用的就是p类型函数 - 跟类型有关

思考为什么一定要是父类的指针或引用呢?因为这样才能既接收基类对象,又接收子类对象。这样才能在上层看来,"一切皆...",而在其下各自行为不同。

那父类对象呢?父类对象不也是既能接收基类对象,又接收子类对象吗?yes...但对于引用,会从一而终,没法一会儿父类对象一会儿子类对象;对于指针,

next,若不是虚函数, 没有多态,所以调用什么跟p的类型有关 (这跟与隐藏无关,隐藏是针对子类对象调用的而言的) ——

<img src=" title="image-20220601210545872">

then,破坏参数,也没有多态,所以调用什么跟p的类型有关——

<img src=" title="image-20220601211128193">

last,返回值不同,破坏多态,直接报错咧?! ——

<img src=" title="image-20220601214653984">

它们背后的原理在第5小节详谈。

2.2 虚函数重写的两个例外

虚函数重写条件例外。

2.2.1 协变

协变返回值父子关系指针或引用 (不能是对象),依然可以构成多态。

<img src=" title="image-20220602090407099">

用处很少。

2.2.2 析构函数的重写

如果析构函数是虚函数,是否构成重写?yes,是因为在继承我们就说过,析构函数名儿被特殊处理了,都处理成了destructor,至于为什么要特殊处理,就是源于多态。

<img src=" title="image-20220602094115884">

如上我们发现,对于普通对象不构成多态,都能正确调用:父类的调用父类的;子类的调用子类的,完了自动调用父类的,这我们在继承时候就谈过。就算我不把析构函数写成虚函数,我也不重写,都没关系。你可以自己验证。

but~ 下面这时候就糟了,这也是面试时的高频问题:

:purple_heart: 那什么场景下,析构函数要是虚函数呢?

如果是动态申请的子类对象,给了父类的指针,若想正确调用,那么析构函数需要是虚函数(右图)

因为如果不是虚函数,不构成多态,那与类型有关,都会去调用父类的析构函数(左图),但是这样会导致子类对象可能有资源未被清理;我们希望指向父类调用父类的,指向子类调用子类的(完了再调用父类的),那就需要满足多态的条件,完成重写

当然了,析构函数的重写特简单,它本身函数名“相同”,没参数,自己再加一个virtual就行 ——

<img src=" title="image-20220602103420588">

其他场景,析构函数是不是虚函数都可以,都可以正确调用析构函数。

当然了,我们推荐在继承体系中,把析构函数写成虚函数。

2.3 只有父类带 virtual 的情况

虚函数,允许父子类两个都是虚函数 或 只有父类是虚函数也行。这其实是C++不是很规范的地方,建议两个都写上virtual.

这是因为虽然子类没带virtual,但是它继承了父类的虚函数属性。

<img src=" title="image-20220603190101354">

大佬这样设计的初衷是,考虑到“析构函数”。。因为在一个巨大的项目中父&子类可能不是一个人儿写的,如果只是父类加了virtual而子类没加,因此不构成多态,没有调用子类析构函数,就可能有内存泄漏问题。

那经过大佬的一番思索,在一个项目中,最好在父类析构函数加上virtual,那么这个漏洞确实就被完完全全的补上了。

但是大佬没有考虑到,这又构成了其他的歧义。令人震惊的事情发生了,如下图,就算Buyticket()是private的,还是继承了父类的属性,能够调的到,震惊!!

不过没关系,我们学完虚表就知道原因了。

<img src=" title="image-20220603095211314">

建议我们自己写的时候,都加上virtual,肯定不会出错。

2.4 C++11 final & override

2.4.1 final

可以修饰重写函数

:purple_heart:1. 设计一个不能被继承的类

在C++11没有引入final时,C++98中通过间接限制,是把父类构造函数设为私有,因为子类一定要调用父类的构造函数要初始化父类的部分,但是private对子类不可见(左图),因此这样无法实例化子类对象。同时这也带来了问题,父类A也构造不了喂!

<img src=" title="image-20220603200546565">

这就是要蛋没鸡,要鸡没蛋的问题。。。现在父类构造函数private修饰,也就是我在类外调不到它(嘘~我在类内还可以调到),可是成员函数的调用本来又依赖对象,可是我连对象都没有呜呜(我就是想造对象)

我们可以造一个静态成员函数来造对象(右图),在Java中就是经典的静态工厂方法(算了我也不会jvav,那为什么用静态函数呢?因为静态函数的调用不依赖于对象而依赖于类,可以通过类域直接访问。

但是在C++11就优化掉了这个复杂的方法,加final直接限制 ——

<img src=" title="image-20220603201431235">

:purple_heart:2. C++11中final还可以限制重写

修饰虚函数,限制它不能被子类中的虚函数重写。

<img src=" title="image-20220603202408748">

2.4.2 override

override放在子类重写的虚函数后面,帮助检查是否完成重写,没有重写会报错

在Java中有@override来帮助检查 (算了我也不会jvav

<img src=" title="image-20220603203218381">

3. 重载 vs 重写 vs 隐藏

<img src=" title="image-20220603205552769">

4. 抽象类

:yellow_heart: 包含纯虚函数的类叫做抽象类(接口类)。在虚函数的后面写上=0 ,则这个函数为纯虚函数

纯虚函数一般只声明,不实现,抽象类不能实例化出对象

(它是可以实现的,只不过实现的没有价值。为什么呢?因为,抽象类不能实例化出对象)

哦,那好吧,我总可以定义一个Car的指针/引用来调用函数吧(如下图)。极端一点给一个nullptr,发现可以编译通过,你可能有些震惊,p->Drive();这不是空指针解引用了吗?嘿嘿,这是我们在类和对象 - 隐藏的this指针一文中就讨论过的问题,这只是把nullptr传给了隐藏的this指针而已,并没有发生解引用,所以编译是通过了(我还用p->func();调用普通函数再次验证了这件事儿)

当然了,再看这调用虚函数的p->Drive();看上去是编译通过了,但是运行起来什么也没有打印,这是不是崩了?一调试,诶确实崩了。这就关系到后面的虚函数表了,在这里浅说一下,我们调用的这个虚函数地址存放在虚函数表中,虚表指针在对象中,需要通过this指针解引用去找到,这理所当然的崩了;那对于p->func();呢,确实也没崩,因为普通成员函数存放在公共的代码段,不在对象中,不需要this指针去找。

<img src=" title="image-20220604185634532">

那好吧,既然我也造不出Car父类对象给Car*,那我造一个子类对象,darn it,其派生类继承后也不能实例化出对象(左图),因为继承了抽象类后,这个派生类就继承了纯虚函数,那它同样也是一个抽象类!

只有重写纯虚函数,派生类才能实例化出对象(右图)。所以呀,抽象类本质上强制继承它的子类完成虚函数重写

现在就能调用到子类的虚函数了(如右图)

<img src=" title="image-20220604194820179">

综上,你真没必要去实现纯虚函数,因为实现了,也没人调用你这个父类实现,声明一下即可。

上述提到的这种种现象,你都可以自己实操一下,代码贴给宝子们了 ——

class Car
{
public:
    // virtual void Drive() = 0;
    virtual void Drive() = 0
    {
        cout << "virtual void Drive() = 0" << endl;
    }

    void func()
    {
        cout << "void func()" << endl;
    }
};

class Benz :public Car
{
public:
    public:
        virtual void Drive()
        {
            cout << "Benz-舒适" << endl;
        }
};


int main()
{
    Car* p = new Benz;
    p->Drive();
    return 0;
}

什么样的类要设计为抽象类呢?一个类型如果在现实世界中,没有具体的对应实物,就定义为抽象类,这个类没必要实例化出来。这话也够抽象的了(

在Java中,这应该是类似一个叫做“接口”的语法,确实很常用,算了我不在这儿胡说了( 我也不会jvav

override只是在语法上检查是否完成重写。

继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。

5. 多态的原理

5.1 虚函数表

:yellow_heart: 引入

// sizeof(Base)是多少?
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }

    virtual void Func2()
    {
        cout << "Func2()" << endl;
    }
private:
    int _b = 1;
    char _ch = 'A';
};

由结构体对齐规则,我就知道肯定不是8哈哈( ——

在这里插入图片描述

那是多了什么呢?通过监视窗口,发现这个对象多了一个成员,虚函数表指针_vfptr(简称虚表指针) ,所谓的虚函数表就是一个指针数组,里面存放的是函数指针(虚函数地址),一般这个数组的最后面放了一个nullptr——

在这里插入图片描述
这就实在的解释了我们在抽象类中讨论好久的问题@4

5.2 多态的原理

虚函数表是理解多态原理的关键,下面将集中从原理层解释2.*小节中的现象,以如下代码为例 ——

class Person {
public:
    virtual void BuyTicket() 
    { cout << "买票-全价" << endl; }
protected:
    int _a = 0;
};

class Student : public Person 
{
public:
    virtual void BuyTicket() 
    { cout << "买票-半价" << endl; }
protected:
    int _b = 0;
};

void Func(Person& p) {
    p.BuyTicket();
}

int main()
{
    Person Peter;
    Func(Peter);

    Student Mable;
    Func(Mable);

    return 0;
}

虚函数的“重写”也叫“覆盖”,重写是语法上的概念,覆盖是原理层的概念。子类继承父类的虚函数,可以认为深拷贝了一份虚函数表,没重写时,子类与父类虚表完全相同;若重写了,便会用新地址覆盖。这些及建议你都可以,或者你也应该自己打开监视窗口动手验证 (效果已经展示在下下图中了),代码贴给宝子们了。

转到反汇编可以发现,对于普通成员函数的调用,是在编译后就已经确定了调用地址(橙色的);却发现,给父类/子类对象,调用虚函数p.BuyTichet();汇编代码一模一样儿,那一样儿是怎样实现多态的呢?发现此时调用函数时,不再是直接确定地址,而是借助了eax这个寄存器,这是多态原理的关键。

<img src=" title="image-20220605174439343">

(这段汇编指令不强求看懂,但你应该大胆猜测,这是在去虚表中拿待调用的虚函数地址,放入eax中)

:purple_heart: 多态的原理:基类的指针/引用指向谁就去谁的虚函数表中找到对应位置的虚函数进行调用。这是在运行时确定的,所以这叫“动态的多态”。

<img src=" title="image-20220628215450899">

:yellow_heart: 那现在我们再来反思,为什么一定要是基类的指针或引用类型,而对象不行,对象不是也可以传父类/子类切片吗?@2.1多态的条件

  • 引用切片就是作为给过去的子类的父类部分别名,它的_vfptr就是理所当然的和子类指向同一空间,哦那就可以实现多态了;
  • 对象切片,我们打开监视窗口, 观察发现相当于拷贝构造对象的确可以接收父类或子类,但并没有把_vfptr拷贝过来(拷贝过来可就乱了,后面说),也就是此时这个父类对象的_vfptr指向父类虚表,那当然就然调用的是父类的虚函数,就算你传参时确实好像发生了子类切片,就算你重写了虚函数,但都没用呀,因此没法实现多态——

<img src=" title="image-20220605115822544">

同类型的对象虚表指针_vfptr是否一样?是的,同类型的对象,虚表指针指向同一张虚表

也就是说多个对象共享一个虚函数,虚表中的内容是不允许修改的。

<img src=" title="image-20220605154817421">

我们总说多态是“运行时多态”,不构成多态时,编译时就会确定调用函数的地址;构成多态,编译时,不能确定调用哪个函数(eax),它还不知道传的是啥对象,运行时,才确定传入的是父类还是子类对象,去p指向对象的虚表中找到虚函数地址。此时p作为父类对象/子类对象那部分的引用 (指针视角同理,在原理层也是“一切皆...”的视角)。 (不要搞混,编译时是会确定虚函数地址的,不过是运行时再确定填入哪个对象的虚表)

究竟是怎样?判断的唯一标准就是“是否构成多态”,又回归到构成多态的两个条件(1+4)。

什么?!你说我编译器处理的时候可以把子类的_vfptr强制拷贝过来,这样就能实现多态了?是,但那可就乱了,因为在此之前你可以做任意行为,你都不知道这个对象里存的是父类的虚表还是子类的虚表,会造成混乱的结果:比如一父类对象调用的是子类的析构函数。。。

:yellow_heart: 为什子类么重写虚函数时,设置为private权限,依然能调用到,呈现多态?

因为。。因为编译器是不会检查出来的,它看到的是一个父类调用虚函数p.BuyTicket();,看到的就是父类的publlic接口。子类对象该去虚表中找,能找到就能调用,虚表中也不分公私有。重写是一种“接口继承”。

<img src=" title="image-20220629091332974">

这么说,C++的访问修饰符不一定安全。。那我能不能通过一些bug的操作,访问到私有的虚函数呢?是可以的。。这个我们在6.1小节会给出。

直接顺着虚表拿到函数地址,这种非常规的操作不受公私有限制。

5.3 虚函数表在哪

普通函数和虚函数存储的位置是否一样?一样的,都在公共代码段,只不过虚函数要把地址存一份到虚表,以实现多态。

虚表在哪里呢?从前从前,我们就铺垫过虚函数表不能修改,所以我大胆的猜测是在常量区嘿嘿。

我们写一段代码来验证一下 ——

<img src=" title="image-20220629113900555">

所以,虚函数表是存在“常量区”的。

(在操作系统角度,是不区分常量区和代码段的,都叫代码段。这儿是语言角度)

代码贴给宝子们了 ——

int main()
{
    int* ptr = (int*)malloc(4); 
    printf("heap: %p\n", ptr);

    int a = 0;
    printf("stack: %p\n", &a);

    static int s = 0;
    printf("数据段:%p\n", &s);

    const char* p = "always";
    printf("常量区:%p\n", p);

    printf("代码段:%p\n", &Base::func1);

    Base b;
    // 取对象头4/8个字节 —— 强转(Base* -> int*) —— 再解引用拿到_vfptr
    printf("虚函数表: %p\n", *((int*)&b));

    return 0;
}
//ps: 有一小点代码在上面

6. 单继承和多继承关系中的虚函数表

首先我们要再来观察如下代码在监视窗中的状况,这儿vs起到了很好的误导作用,我们要解释一下,以便后续内容的正常进行,当然了其实并不复杂 ——

class Base 
{
private:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
    void func3() { cout << "Base::func3" << endl; }

private:
    int _a = 0;
};

class Derive : public Base 
{
public:
    virtual void func1() { cout << "Derive::func1" << endl; }
    virtual void fun4() { cout << "Derive::func4()" << endl; }
private:
    int _b = 1;
};

int main()
{
    Base b;
    Derive d;
    return 0;  
}

打开监视 窗口观察,发现父类对象虚表中如期有两个虚函数地址func1func2;子类继承父类,可以认为深拷贝了虚函数表,重写func1,覆盖了原来的地址,其中的func2安然不动。

对于子类新增加的虚函数func4,却没看到,是被vs给隐藏了,我们暂且通过内存 窗口观察(如下图) ——

<img src=" title="image-20220630120002485">

令人疑惑的地方又来了,这内存 中的Derive::func4怎么跟监视 窗的&Derive::func4地址不一样呀。

事实上,虚函数表并不是真实地址,而是这句jmp跳转指令的地址(橙色的)!&Derive::func4这样取到的,即jmp后面跟的地址(绿色的),才是虚函数的真实地址.

<img src=" title="image-20220630120519068">

6.0 打印虚函数表

虚表指针是在什么时候初始化的?是在构造函数的初始化列表初始化的。

前文我们就说过,有时虚函数地址被隐藏掉了,之前我们委屈的在内存窗口中观察,现在我们来学习打印虚函数表

从前从前,我们仔细研究过“函数指针”如何定义变量,它与一般int a=0;这样类型跟名字不同,是混杂在其中的,我们在typedef时,依然保留了这个原则。

我们已经熟知,虚表是一个函数指针数组,打印它并不难,这个经验来自于我们学习Linux环境变量时打印过环境表、命令行参数数组 ——

typedef void(*VF_PTR)(); //类型重定义:(虚)函数指针

void VFTable(VF_PTR table[])
{
    for (int i = 0; table[i] != nullptr; i++)
    {
        printf("vft[%d]: %p\n", i, table[i]);
    }
}

那么在调用这个函数的时候,就需要传入虚函数表的地址,即指针数组的(首元素)地址,即对象中的虚表指针_vfptr

问题就转化成了如何取到对象头4/8个字节呢?

嗯...没法直接转成int,那取&个地址,指针的类型决定看待内存的视角,如果强转为(int*),再解引用拿的就是头四个字节。可是传入的参数类型还不匹配,那就再(VF_PTR*)强转一下 ——

    Base b;
    PrintVFTable((VF_PTR*)(*(int*)&b));

嗯如果你直接看这一坨当然会有些眼晕,但其实只要你能稍稍的独立思考就很很很简单!

(注:vs有一些bug,你一打印可能打印出了很多无关地址,可以清理一下解决方案;或者通过函数地址来手动调用函数验证,方法如下)

typedef void(*VF_PTR)(); //(虚)函数指针

void PrintVFTable(VF_PTR table[])
{
    for (int i = 0; table[i] != nullptr; i++)
    {
        printf("vft[%d]: %p -> ", i, table[i]);
        VF_PTR f = table[i];
        f();
    }
}

是的,在尝试写它的时候,我就感受到这有多bug。。按理来说,这些函数都要通过对象调用,传入this指针,受到访问修饰限定;但是在这儿,我都没搞,就直接拿着函数地址调用。。也就是说此时this指针是一个随机值,如果访问成员就可能出现一些越界访问,打印出随机值甚至崩溃。。

我们也完全可以通过这种bug的方式,访问到私有虚函数,所以虚表是有安全隐患的。。


    Base b;
    PrintVFTable((VF_PTR*)(*(int*)&b));            // 32位
    PrintVFTable((VF_PTR*)(*(long long*)&b));    // 64位
  • 32位平台,用(int*)强转。
  • 64位平台,用(long long*)强转。用double好像行但其实不太行,因为会有精度丢失 ( double在内存中确实占了64个字节,但是double类型的有效位M只有52位,虽然我们把取出的数字按照地址看待,但是和double转int同理)

震惊的是在32位平台下,用(long long*)强转的居然还能正常跑。。

是因为之后再用(VF_PTR*)这个函数指针强转,8字节恰好截断到头上4字节。

:yellow_heart: 我们想要探索出,32/64位平台下能自适应的方式 ——

    Base b;
    PrintVFTable((VF_PTR*)(*(int*)&b));            // 32位
    PrintVFTable((VF_PTR*)(*(long long*)&b));    // 64位
    PrintVFTable((VF_PTR*)(*(void**)&b));        // 32/64位

怎么忽然就是是void**呢?(int*) 解引用看一个int的大小,(long long*)解引用看的是long long的大小,void 不能解引用,这(void**)解引用看的是void 的大小,void* 的大小就和平台相关。

当然了,这样说char /int 什么的都可以,只要是二级指针都可以。

:yellow_heart: 也可以条件编译 ——

    Base b;
#ifdef _WIN64
    PrintVFTable((VF_PTR*)(*(long long*)&b));
#else
    PrintVFTable((VF_PTR*)(*(int*)&b));
#endif

注:_WIN32:Defined for applications for Win32 and Win64. Always defined. 不能用于判断平台环境。

​ _WIN64:Defined for applications for Win64.

那好嘞,我们现在就能方便的看一看子类虚表了。

6.1 单继承的虚函数表

代码贴给宝子们了,你最好,哦不,你也应该自己验证一下 ——

class Base 
{
private:
    virtual void func1() { cout << "Base::func1" << endl; }
    virtual void func2() { cout << "Base::func2" << endl; }
private:
    int _a = 0;
};

class Derive :public Base 
{
public:
    virtual void func1() { cout << "Derive::func1" << endl; }
    virtual void func3() { cout << "Derive::func3" << endl; }
    void fun4() { cout << "func4()" << endl; }
private:
    int _b = 1;
};

int main()
{
    Base b;
    PrintVFTable((VF_PTR*)(*(void**)&b));

    Derive d;
    PrintVFTable((VF_PTR*)(*(void**)&d));

    return 0;
}

重写的fuc1,拷贝继承下来的func2,自己的func3 ——

<img src=" title="image-20220701215118979">

6.2 多继承的虚函数表

代码贴给宝子们了,你最好,哦或者说,你也应该自己验证一下 ——

class Base1 {
public:
    virtual void func1() { cout << "Base1::func1" << endl; }
    virtual void func2() { cout << "Base1::func2" << endl; }
private:
    int _b1 = 0;
};

class Base2 {
public:
    virtual void func1() { cout << "Base2::func1" << endl; }
    virtual void func2() { cout << "Base2::func2" << endl; }
private:
    int _b2 = 0;
};

class Derive : public Base1, public Base2 {
public:
    virtual void func1() { cout << "Derive::func1" << endl; }
    virtual void func3() { cout << "Derive::func3" << endl; }
private:
    int _d1 = 0;
};

int main()
{
    Derive d;

    Base1* p1 = &d;
    p1->func1();

    Base2* p2 = &d;
    p2->func1();

    return 0;
}

打开监视 窗口观察,此时Derive对象中理所当然的有两个虚表,并且即使子类重写了func1后,你发现这对象虚表中,Base1和Base2的虚函数func1的地址不一样,你早就不应该感到惊奇,因为这时jmp跳转指令的地址,最终会一跳到同一位置执行函数Derive::func1的 ——

<img src=" title="image-20220701222808420">

发现p2->func1()调用函数时,还跳了好多层。这是为了做准备工作ecx-8 ,修正this指针(eax),为什么呢?调用虚函数时,要传递this指针,-8由指向Base1到指向Base2,从而看到对应类型视角下的那部分。当然,这你了解即可。

:purple_heart: 我们需要打印虚函数表,来观察多继承下的对象模型:

由于子类中有两份虚表,我们需要再认真思考如何传入第二个虚表指针_vfptr ——

(建议,哦不,你也应该独立思考,因为你直接看下面这一坨肯定会眼晕,当然了,我就是知道你会眼晕,所以我好好给你解释)

int main()
{
    Base1 b1;
    PrintVFTable((VF_PTR*)(*(void**)&b1));

    Base2 b2;
    PrintVFTable((VF_PTR*)(*(void**)&b2));

    cout << "_____________________________________" << endl;
    Derive d;
    PrintVFTable((VF_PTR*)(*(void**)&d));
    /*打印第二个虚函数表*/
    PrintVFTable((VF_PTR*)(*(void**)((char*)&d+sizeof(Base1))));

    return 0;
}

<img src=" title="image-20220702102110949">

切片时会引起指针的自动偏移,可以直接打印:) ——

    Base1* p1 = &d;
    Base2* p2 = &d; // 切片 - 也干了类似 (char*)&d+sizeof(Base1) 这样的操作
    PrintVFTable((VF_PTR*)*((void**)p2)); 

当然了,在汇编角度它一定也做了我们手动移动类似的事情。

:yellow_heart: 发现多继承时,子类自己的虚函数Derive::func3放在第一个父亲的虚表中 ——

<img src=" title="image-20220701230054680">

你可以把Base2放到前面试试。

6.3 菱形继承的虚函数表

实际中我们不建议设计出菱形继承及菱形虚拟继承,一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定得性能损耗。所以菱形继承、菱形虚拟继承我们的虚表我们就不看了,一般我们也不需要研究清楚,因为实际中很少用。可以去看下面的两篇链接:

C++ 虚函数表解析 | 酷 壳 - CoolShell

C++ 对象的内存布局 | 酷 壳 - CoolShell

当然了,还是简单跟宝子们说说,来一段老朋友代码,打开监视 窗口 ——

class A
{
public:
    virtual void f()
    {}
public:
    int _a;
};

class B : virtual public A 
{
public:
    int _b;
};

class C : virtual public A 
{
public:
    int _c;
};

class D : public B, public C
{
public:
    int _d;
};

int main()
{
    D d;
    d.B::_a = 1;
    d.C::_a = 2;
    d._b = 3;
    d._c = 4;
    d._d = 5;

    //d._a = 0; //不存在二义性,可以直接找
    return 0;
}

<img src=" title="image-20220702124725825">

另外,在钻石型继承中,如果B和C都重写了A的虚函数func1,那么D必须重写func1,否则会报错“D”:“void A::f1(void)”的不明确继承,因为这儿是继承,共用一个虚表,不知道用哪个重写,看如下代码:

public:
    virtual void f1() {}
public:
    int _a;
};

class B : virtual public A 
{
public:
    virtual void f1() {}
    virtual void f2() {}
public:
    int _b;
};

class C : virtual public A 
{
public:
    virtual void f1() {}
    virtual void f2() {}
public:
    int _c;
};

class D : public B, public C
{
public:
    virtual void f1() {}
public:
    int _d;
};

int main()
{
    D d;
    d.B::_a = 1;
    d.C::_a = 2;
    d._b = 3;
    d._c = 4;
    d._d = 5;

    return 0;
}

在继承中@7.3 菱形继承的原理,我们说过虚基表中,曾经内容是00000000是为其他东西预留的,那它究竟是什么呢?这是找虚表的偏移量。

<img src=" title="image-20220702155507593">

还是挺麻烦的,所以你没事儿别定义菱形继承(

7. 总结

来几道经典的问答题小伙子小姑娘?!不完全给答案是为了让你别背~

  1. 什么是多态?
  2. 什么是重载、重写(覆盖)、重定义(隐藏)?
  3. 多态的实现原理?
  4. inline函数可以是虚函数吗?

    准确的说不可以,因为内联函数没有地址,但是虚函数地址要被填入到虚表中。不过是可以编译通过的,因为inline只是个建议,到底有没有展开要视情况而定:若调用时不构成多态,保持inline属性;若构成多态,则没有inline属性。

  5. 静态成员函数可以是虚函数吗? 不可以,会直接报错“virtual”不能和"static"一起使用。
    因为静态成员函数没有this指针,只能使用类型::成员函数的调用方式,这样无法构成多态,而虚函数的价值就在于重写后构成多态。
  6. 构造函数可以是虚函数吗? nope
    同样的,构造函数设为虚函数没有价值,虚函数的意义就在于构成多态调用。多态调用就要去虚函数表中查找虚函数,这又涉及先有鸡还是先有蛋的问题,因为对象中的虚表中的虚函数指针,就是在构造函数初始化列表阶段才初始化的。
  7. 析构函数可以是虚函数吗? 什么场景下析构函数必须是虚函数?

    yes,并且继承体系中推荐写成虚函数。

  8. 对象访问普通函数更快还是虚函数更快?要看是否构成多态。
    如果不构成多态,那都是编译时确定调用函数的地址,一样快;如果构成多态,那么虚函数调用是运行时虚函数表中确定函数地址,普通函数编译时直接确定地址,则普通函数更快。
  9. 虚函数表是在什么阶段生成的?存在于哪?

    注:别把虚基表和虚函数表搞混了。编译阶段;常量区。

  10. C++菱形继承的问题?虚继承原理?
  11. 什么是抽象类?抽象类的作用?

​ 强制子类重写虚函数,另外体现了接口继承关系。

持续更新@一个人的乐队:guitar:

所以你看,其实也没多复杂。还是那句话,不要害怕,不要害怕,怕了咱们就先玩儿完了!!!

相关文章
|
2月前
|
C++
【C++】深入解析C/C++内存管理:new与delete的使用及原理(二)
【C++】深入解析C/C++内存管理:new与delete的使用及原理
|
2月前
|
编译器 C++ 开发者
【C++】深入解析C/C++内存管理:new与delete的使用及原理(三)
【C++】深入解析C/C++内存管理:new与delete的使用及原理
|
2月前
|
存储 C语言 C++
【C++】深入解析C/C++内存管理:new与delete的使用及原理(一)
【C++】深入解析C/C++内存管理:new与delete的使用及原理
|
1月前
|
存储 编译器 数据安全/隐私保护
【C++】多态
多态是面向对象编程中的重要特性,允许通过基类引用调用派生类的具体方法,实现代码的灵活性和扩展性。其核心机制包括虚函数、动态绑定及继承。通过声明虚函数并让派生类重写这些函数,可以在运行时决定具体调用哪个版本的方法。此外,多态还涉及虚函数表(vtable)的使用,其中存储了虚函数的指针,确保调用正确的实现。为了防止资源泄露,基类的析构函数应声明为虚函数。多态的底层实现涉及对象内部的虚函数表指针,指向特定于类的虚函数表,支持动态方法解析。
33 1
|
2月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
47 2
C++入门12——详解多态1
|
2月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
89 1
|
2月前
|
C++
C++番外篇——虚拟继承解决数据冗余和二义性的原理
C++番外篇——虚拟继承解决数据冗余和二义性的原理
50 1
|
4月前
|
存储 编译器 C++
|
5月前
|
存储 编译器 C++
【C++】深度解剖多态(下)
【C++】深度解剖多态(下)
57 1
【C++】深度解剖多态(下)
|
5月前
|
机器学习/深度学习 算法 C++
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)