《穿透式理解C++继承:虚函数表、对象切片与多重继承陷阱》

前言:继承是C++面向对象的核心,既能优雅地复用代码,也可能带来隐藏的陷阱。从基础的class B : public A到复杂的虚继承体系,每个语法细节背后都影响着对象的内存布局和运行时行为。本文将剖析继承的本质,帮助你写出更健壮的C++代码。

目录

一、继承概念

二、继承定义

三、继承关系

继承方式

public继承示例

四、基类和派生类对象赋值转换

基类->派生类的转化类型

五、继承中的作用域

六、派生类的默认成员函数

(1)构造函数

(2)拷贝构造

(3)析构函数

七、继承与友元

八、继承与静态成员

九、菱形继承及菱形虚拟继承

虚继承原理


一、继承概念

继承(Inheritance):是面向对象编程(OOP)中的核心概念之一,它允许一个类(称为子类派生类)直接拥有另一个类(称为父类基类)的属性方法,并可以在此基础上扩展新的功能或修改原有实现。

究极目的是实现代码复用(Reusability)​​ 和建立类之间的层次关系(Hierarchy)

例如定义老师、学生、家长的信息类:

老师:姓名、年龄、电话、工号、职务

学生:姓名、年龄、电话、学生号

家长:姓名、年龄、电话、工作


它们有很多地方是高度相似的,我们就可以把这种经常重复的信息抽取出来,直接复用来减少代码的冗余,这就是继承。

二、继承定义

下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类。

也如下图

三、继承关系

这里补充一下父类和子类的概念

父类(基类):被继承的类,包含子类共有的属性和方法。

子类(派生类)继承父类的类,除了拥有父类的成员,还可以添加新成员或重写父类方法。

继承方式

  1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
  2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
  3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。也就是取三者的最小公约数
  4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
  5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

public继承示例

基类的public成员
例如:现在有一个基类,它有三种访问限定方式:

#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;

// 基类
class Person
{
public:
    void text1()
    {
        cout << "public" << endl;
    }
protected:
    void text2()
    {
        cout << "protected" << endl;
    }
private:
    void text3()
    {
        cout << "private" << endl;
    }
};

// 派生类
class chen : public Person
{
public:
    void testAccess()
    {
        text1();  // 可以访问基类的public成员
        text2();  // 可以访问基类的protected成员
        // text3();  // 错误:不能访问基类的private成员
    }
};

int main()
{
    chen g;
    g.text1();     // 可以访问
    // g.text2();  // 错误:protected成员不能通过对象访问
    // g.text3();  // 错误:private成员不能通过对象访问

    g.testAccess(); // 测试派生类内部的访问权限

    return 0;
}

所以我们就印证了在基类中,有三种访问限定方式,这时它们都被派生类公有继承:

基类中原来的公有成员,到了派生类中->还是派生类的公有成员

基类中原来的保护成员,到了派生类中->变成了派生类的保护成员

基类中原来的私有成员,到了派生类中->不可见(仅可在基类内使用)

四、基类和派生类对象赋值转换

继承中,派生类可以转化为基类,基类不可以转为派生类

派生类的成员是一定包含基类(大转小),而基类的成员对比派生类差了太多(小转大)。派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。例如:

基类->派生类的转化类型

究竟是强转还是隐式类型的转换?

#include <iostream>
#include <string>
using namespace std;

class Person {
protected:
    string _name; // 姓名
    string _sex;  // 性别
    int _age;     // 年龄
};

class Student : public Person {
public:
    int _No; // 学号
};

void Test() {
    Student sobj;
    sobj._No = 123; // 初始化学号

    // 1. 子类对象可以赋值给父类对象/指针/引用
    Person pobj = sobj;    // 对象切片(只保留Person部分)
    Person* pp = &sobj;    // 指向派生类对象的基类指针
    Person& rp = sobj;      // 绑定到派生类对象的基类引用

    // 2. 基类对象不能赋值给派生类对象
    // sobj = pobj;  // 错误:没有合适的赋值运算符
    
    // 3. 基类指针可以通过强制类型转换赋值给派生类指针
    pp = &sobj;
    Student* ps1 = static_cast<Student*>(pp);  // 安全的下转型
    ps1->_No = 10;

    // 危险的下转型 - 基类指针实际指向的是基类对象
    pp = &pobj;
    Student* ps2 = static_cast<Student*>(pp);  // 编译通过但运行时危险
    // ps2->_No = 10;  // 可能访问非法内存

    // 更安全的做法:使用dynamic_cast(需要至少有一个虚函数)
    // class Person { virtual ~Person() {} };  // 添加虚函数
    // Student* ps3 = dynamic_cast<Student*>(pp);
    // if (ps3) {  // 检查转换是否成功
    //     ps3->_No = 10;
    // }
}

int main() {
    Test();
    return 0;
}

这里根据代码来看:

隐式转换:适用于派生类→基类的向上转换(安全)。
强制转换:应优先使用 static_cast/dynamic_cast 替代C风格转换,尤其是向下转型时

五、继承中的作用域

在继承体系中基类和派生类都有独立的作用域。

class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};
class B : public A
{
public:
	void fun(int i)
	{
		A::fun();
		cout << "func(int i)->" << i << endl;
	}
};
void Test() {
	B b;
	b.fun(10);
};

测试代码二:

#include <iostream>
using namespace std;

/*-----------------------定义基类:“Person类”表示通用的个人信息-----------------------*/

class Person
{
protected:
    string _name = "张三";  // 姓名
    int _num = 111;        // 身份证号(基类成员)
};


/*-----------------------定义派生类:“Student类”继承自Person,新增学号信息-----------------------*/

class Student : public Person
{
public:
    void Print()
    {
        //1.访问从Person继承的姓名
        cout << "姓名:" << _name << endl;

        //2.由于“身份证号”被同名的“学号”隐藏了,所以通过类域显式指定,访问基类的_num(身份证号)
        cout << "身份证号:" << Person::_num << endl;

        //3.直接访问_num,默认使用派生类隐藏的成员(学号)
        cout << "学号:" << _num << endl;
    }
protected:
    int _num = 999;   // 学号,注意:这里派生类的成员变量和基类的成员变量“同名”了(所以:派生类成员,隐藏基类的_num)
};

int main()
{
    Student s1;

    s1.Print();
    return 0;
}
  • 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
  • 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
  • 注意在实际中在继承体系里面最好不要定义同名的成员。

六、派生类的默认成员函数

6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类
中,这几个成员函数是如何生成的呢?
 

对象构造时序规则

基类优先构造原则:当创建派生类对象时,必须先完整构造基类子对象,然后才能构造派生类自身成员
初始化列表的唯一时机:初始化列表是唯一能指定基类构造方式的阶段

(1)构造函数

派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用

  • 派生类的构造必须调用基类的构造函数初始化基类
  • 如果基类没有默认的构造函数,则必须在派生类的初始化列表中显示调用基类的构造
  • 如果基类有默认的构造函数,则可以不显示调用,因为编译器会调用基类的默认构造函数

(2)拷贝构造

派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化(各干各的)

理解:现在是建设阶段各建设各的,基类和派生类的对象才是一个完整的房子

(3)析构函数

析构函数的调用我们不用管,由编译器自己调用。那么析构的时候是先父还是先子?

  • 我们的派生类是复用了基类的成员的,所以基类可以理解为地基,那么就名正言顺的     应该先析构派生类的,再去除掉地基的(先子后父)
  • 拓展:因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

七、继承与友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。

八、继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子
类,都只有一个static成员实例 。

#include <iostream>
using namespace std;

class Base {
public:
    static int count;  // 声明静态成员
    Base() { count++; }
    static void showCount() {
        cout << "Base::count = " << count << endl;
    }
};

// 初始化静态成员(必须在类外定义!)
int Base::count = 0;  

class Derived1 : public Base {
    // 无新定义,继承Base的所有成员
};

class Derived2 : public Base {
    // 无新定义,继承Base的所有成员
};

int main() {
    Base b;
    Derived1 d1;
    Derived2 d2;

    // 通过不同类访问同一个静态成员
    cout << "通过Base访问: ";
    Base::showCount();    // 输出 3

    cout << "通过Derived1访问: ";
    Derived1::showCount(); // 输出 3

    cout << "通过Derived2访问: ";
    Derived2::showCount(); // 输出 3

    // 证明是同一个内存地址
    cout << "地址验证:" << endl;
    cout << "&Base::count: " << &Base::count << endl;
    cout << "&Derived1::count: " << &Derived1::count << endl;
    cout << "&Derived2::count: " << &Derived2::count << endl;

    return 0;
}

注意:基类中定义静态成员,子类对象继承了基类,但是无法继承它的静态成员。

九、菱形继承及菱形虚拟继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

class A {  // 基类1
public:
    void funcA() { cout << "A::funcA()" << endl; }
};

class B {  // 基类2 
public:
    void funcB() { cout << "B::funcB()" << endl; }
};

class C : public A, public B {  // 同时继承A和B
public:
    void test() {
        funcA();  // 调用A的方法
        funcB();  // 调用B的方法
    }
};

// 使用示例:
// C c;
// c.funcA();  // 来自A
// c.funcB();  // 来自B

特殊情况:菱形继承

这样会有一个Bug,导致最终拥有同一个基类,基类的成员被二次重复了,到底用哪个?

这里我们就可以看到菱形继承有数据冗余和二义性的问题

解决方法:使用虚继承(需要注意的是,虚拟继承不要在其他地 方去使用)

虚拟继承的核心思想是:

(1)​让顶层基类(虚基类)   Person    在整个继承体系中只保留一个共享副本

(2)并由最底层的派生类     C 负责直接初始化它

使用虚继承时:

编译器会确保在整个继承路径中,​Person 类型的子对象只存在一个实例

那么编译器是如何确保一个最终派生类只有一个顶层基类的?

虚基类表 (vbtable) 和虚基类指针 (vbptr)​

  • 为了在运行时找到这个共享的 Person 子对象的位置,编译器会为使用了虚继承的类(这里是 Student 和 Teacher)生成一个:虚基类表
  • 在类 Student 和类 Teacher 的对象中,编译器会添加一个隐藏的指针成员:虚基类指针
  • 虚基类指针 指向 Student 或 Teacher的 虚基类表(通常是类第一个虚基类或其他虚基类指针的位置偏移信息)

虚继承原理

D d;
 
d.B::_a = 1;
d.C::_a = 2;
 
d._b = 3;
d._d = 4;

全部代码:

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._b = 3;
 d._c = 4;
 d._d = 5;
 return 0;
}

下图是菱形继承的内存对象成员模型:这里可以看到数据冗余

这里可以分析出D对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A。

也就是说,最后的内存图形化是这样

评论 18
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值