【C++】继承和多态扩展学习

发布于:2025-07-23 ⋅ 阅读:(14) ⋅ 点赞:(0)

目录

1. 菱形虚拟继承原理剖析

1.1.虚基表

2. 单继承和多继承的虚函数表深入探索

2.1 单继承虚函数表深入探索

2.2 多继承虚函数表深入探索

​编辑

2.3 菱形继承、菱形虚拟继承

3. 继承和多态考察的一些常见问题


1. 菱形虚拟继承原理剖析

继承的文章中我们讲到C++的多继承就会引发一些场景出现菱形继承,有了菱形继承,就会出现数据冗余和二义性的问题,C++又引入了虚继承来解决数据冗余和二义性。

class Person
{
public:
    string _name; // 姓名
};
// class Student : public Person
class Student : virtual public Person
{
protected:
    int _num; // 学号
};
// class Teacher : public Person
class Teacher : virtual public Person
{
protected:
    int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
    string _majorCourse; // 主修课程
};
void Test()
{
    // 这样会有二义性无法明确知道访问的是哪一个
    Assistant a;
    a._name = "peter";
    // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
    a.Student::_name = "xxx";
    a.Teacher::_name = "yyy";
}

• 为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型。要注意的是这里必须借助内存窗口才能看到真实的底层对象内存模型,vs编译器的监视窗口是经过特殊处理的,以它的角度给出了一个方便看的样子,但并不是本来的样子。但是有时想看清真实的内存模型,往往需要借助内存窗口。

1.1.虚基表

在前面继承的文章中,我们了解到为了避免菱形继承所导致的数据冗余,子类会将重复继承的部分合并为一份,放在类的最上或者最下面。但是这里引出一个问题是当我们通过父类指针访问子类对象,这是对于合并的部分,要如何确定位置呢?大家可能觉得合并的部分不是已经放在最后或者最上面了吗?但是这里如果我们使用不同的父类指针,偏移多少才能到底呢?因此需要虚基表记录父类对应的偏移量。

虚基表是编译器为了解决多重继承场景下的菱形继承问题所设计的,虚基表(vbtable)通过记录虚基类实例的偏移量来指示派生类如何访问唯一的虚基类实例。当子类通过多继承方式继承多个具有共同基类的父类时,如果不使用虚继承,子类会包含多分共同基类的数据,这会导致数据冗余。而是要虚继承,子类中只会包含一份共同基类的数据。

• 通过下面的简化菱形虚拟继承模型,我们可以看到,D对象中的B和C部分中分别包含一个指向虚基表的指针,B指向的虚基表中存储了B对象部分距离公共的A的相对偏移量距离,C指向的虚基表中存储了C对象部分距离公共的A的相对偏移量距离。这样公共的虚基类A部分在D对象中就只有一份了,这样就解决了数据冗余和二义性的问题。


• 通过B的对象模型,我们发现菱形虚拟继承中B和C的对象模型跟D保持的一致的方式去存储管理A,这样当B的这指针访问A时,无论B指针切片指向D对象,还是B指针直接指向B对象,访问A成员都是通过虚基表指针的方式查找到A成员再访问。

class A
{
public:
    int _a;
};
// class B : public A
class B : virtual public A
{
public:
    int _b;
};
// class C : public A
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._a = 3;
    d._b = 4;
    d._c = 5;
    d._d = 6;
    return 0;
}
int main()
{
    D d;
    d.B::_a = 1;
    d.C::_a = 2;
    d._a = 3;
    d._b = 4;
    d._c = 5;
    d._d = 6;
    B b;
    b._a = 7;
    b._b = 8;
    // B的指针指向B对象
    B *p2 = &b;
    // B的指针指向D对象切片
    B *p1 = &d;
    // p1和p2分别对指向的_a成员访问修改
    // 分析内存模型,我们发现B对象也使用了虚基表指向A成员的模型
    // 所以打开汇编我们看到下面的访问_a的方式是一样的
    p1->_a++;
    p2->_a++;
    return 0;
}

2. 单继承和多继承的虚函数表深入探索

2.1 单继承虚函数表深入探索

• vs编译器的监视窗口是经过特殊处理的,以它的角度给出了一个方便看的样子,但并不是本来的样子。多态部分我们讲了,虚函数指针都要放进虚函数表,这里我们通过监视窗口观察Derive对象,看不到func3和func4在虚表中,借助内存窗口可以看到一个地址,但是并不确认是不是func3和func4的地址。所以下面我们写了一份特殊代码,通过指针的方式,强制访问了虚函数表,调用了虚函数,确认继承中虚函数表中的真实内容。

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

private:
    int a;
};
class Derive : public Base
{
public:
    virtual void func1() { cout << "Derive::func1" << endl; }
    virtual void func3() { cout << "Derive::func3" << endl; }
    virtual void func4() { cout << "Derive::func4" << endl; }
    void func5() { cout << "Derive::func5" << endl; }

private:
    int b;
};
typedef void (*VFPTR)();
void PrintVTable(VFPTR vTable[])
{
    // 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
    cout << " 虚表地址>" << vTable << endl;
    // 注意如果是在g++下面,这里就不能用nullptr去判断访问虚表结束了
    for (int i = 0; vTable[i] != nullptr; ++i)
    {
        printf(" 第%d个虚函数地址 :0X%p,->", i, vTable[i]);
        VFPTR f = vTable[i];
        f();
    }
    cout << endl;
}
int main()
{
    Base b;
    Derive d;
    // 32位程序的访问思路如下:
    // 需要注意的是如果是在64位下,指针是8byte,对应程序位置就需要进行更改
    // 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一个存虚
    函数指针的指针数组,vs下这个数组最后面放了一个nullptr,g++ 下面最后没有nullptr
            // 1.先取b的地址,强转成一个int*的指针
            // 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
            // 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
            // 4.虚表指针传递给PrintVTable进行打印虚表
            // 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚
            表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要点目录栏的 -
        生成 - 清理解决方 案,再编译就好了。 VFPTR *vTable1 = (VFPTR *)(*(int *)&b);
    PrintVTable(vTable1);
    VFPTR *vTable2 = (VFPTR *)(*(int *)&d);
    PrintVTable(vTable2);
    return 0;
}

2.2 多继承虚函数表深入探索

class Base1
{
public:
    virtual void func1() { cout << "Base1::func1" << endl; }
    virtual void func2() { cout << "Base1::func2" << endl; }

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

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

private:
    int d1;
};
typedef void (*VFPTR)();
void PrintVTable(VFPTR vTable[])
{
    cout << " 虚表地址>" << vTable << endl;
    for (int i = 0; vTable[i] != nullptr; ++i)
    {
        printf(" 第%d个虚函数地址 :0X%p,->", i, vTable[i]);
        VFPTR f = vTable[i];
        f();
    }
    cout << endl;
}
int main()
{
    Derive d;
    VFPTR *vTableb1 = (VFPTR *)(*(int *)&d);
    PrintVTable(vTableb1);
    VFPTR *vTableb2 = (VFPTR *)(*(int *)((char *)&d + sizeof(Base1)));
    PrintVTable(vTableb2);
    Base1 *p1 = &d;
    p1->func1();
    Base2 *p2 = &d;
    p2->func1();
    d.func1();
    return 0;
}

• 跟前面单继承类似,多继承时Derive对象的虚表在监视窗口也观察不到部分虚函数的指针。所以我们一样可以借助上面的思路强制打印虚函数表。


• 需要注意的是多继承时,Derive中同时继承了Base1和Base2,内存中先继承的对象在前面,并且Derive中包含的Base1和Base2各有一张虚函数表,通过观察我们发现Derive没有重写的虚函数func3,选择放在先继承的Base1的虚函数表中。


• 另外需要注意的是,有些细心的读者发现Derive对象中重写的Base1虚表的func1地址和重写Base2虚表的func1地址不一样,这是为什么呢?这个问题还比较复杂。需要我们分别对这两个函数进行多态调用,并翻阅对应的汇编代码进行分析,才能捋清楚问题所在。这里简单说一个结论就是本质Base2虚表中func1的地址并不是真实的func1的地址,而是封装过的func1地址,因为Base2指针p2指向Derive时,Base2部分在中间位置,切片时,指针会发生偏移,那么多态调用p2->func1()时,p2传递给this前需要把p2给修正回去指向Derive对象,因为func1是Derive重写的,里面this应该是指向Derive对象的

2.3 菱形继承、菱形虚拟继承

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

1. C++ 虚函数表解析
2. C++ 对象的内存布局

class A
{
public:
    virtual void func1() {}

public:
    int _a;
};
class B : virtual public A
{
public:
    virtual void func1() {}
    virtual void func2() {}

public:
    int _b;
};
class C : virtual public A
{
public:
    virtual void func1() {}
    virtual void func3() {}

public:
    int _c;
};
class D : public B, public C
{
public:
    D()
        : _d(1)
    {
    }
    inline virtual void func1() {}
    virtual void func4() {}

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

3. 继承和多态考察的一些常见问题

1. 什么是多态?答:参考前面多态文章


2. 什么是重载、重写(覆盖)、重定义(隐藏)?答:参考前面多态文章


3. 多态的实现原理?答:参考前面多态文章


4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是inline属性,因为虚函数要放到虚表中去,也就是说inline属性和虚函数属性是不同同时存在的。


5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。


6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。


7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析构函数定义成虚函数。参考本文内容


8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象调用,是一样快的。如果是指针或者是引用对去调用,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。


9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的。


10. C++菱形继承的问题?虚继承的原理?答:参考前面继承文章。注意这里不要把虚函数表和虚基表搞混了。


11. 什么是抽象类?抽象类的作用?答:参考前面继承文章;抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。


网站公告

今日签到

点亮在社区的每一天
去签到