跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
C++算法

C++ 继承机制深度解析:从基础到菱形继承

C++ 继承允许派生类复用基类成员,提升开发效率。核心涉及访问控制、对象切片转换及作用域隐藏规则。多继承中菱形继承会导致数据冗余和二义性,虚拟继承通过虚基表解决此问题。析构顺序遵循派生类先于基类释放资源原则。设计时应优先组合而非继承,以降低耦合度并增强扩展性。

beaabea发布于 2026/3/30更新于 2026/6/1117 浏览
C++ 继承机制深度解析:从基础到菱形继承

继承的概念

在 C++ 中,继承 (inheritance) 允许程序员在保持原有类(基类 / 父类)特性的基础上,扩展功能生成新的类(派生类 / 子类),是区别于函数复用的类设计层次复用。

简单来说,继承让派生类天然拥有基类的所有成员(成员变量 + 成员函数),无需重复编写代码,极大提升了开发效率和代码可维护性。比如定义表示'人'的 Person 类,再通过继承派生出 Student 和 Teacher 类,二者可直接复用 Person 的姓名、年龄等属性和打印方法,只需新增各自的特有属性(学号、工号)即可。

#include <iostream>
#include <string>
using namespace std;
// 基类/父类:Person
class Person {
public:
    void Print() { cout << "name:" << _name << endl; cout << "age:" << _age << endl; }
protected:
    string _name = "peter"; // 姓名
    int _age = 18; // 年龄
};
// 派生类/子类:Student 公有继承 Person
class Student : public Person {
protected:
    int _stuid; // 学号(特有属性)
};
// 派生类/子类:Teacher 公有继承 Person
class Teacher : public Person {
protected:
    int _jobid; // 工号(特有属性)
};
int main() {
    Student s;
    Teacher t;
    s.(); 
    t.(); 
     ;
}
Print
// 复用基类 Print 方法
Print
// 复用基类 Print 方法
return
0

从代码中能清晰看到,Student 和 Teacher 未定义 Print 方法,却能直接调用,这就是继承带来的代码复用效果。

继承的基本定义

要灵活使用继承,首先要掌握其定义格式和核心的访问限定规则,这是避免继承中成员访问错误的基础。

继承的定义格式

派生类的定义遵循固定格式,核心是派生类 + 继承方式 + 基类,其中继承方式和基类的访问限定符共同决定了基类成员在派生类中的访问权限。

class 派生类名 : 继承方式 基类名 {
    // 派生类的成员
};

示例中 class Student : public Person 就是标准格式,public 为继承方式,Person 为基类。

三大继承方式与访问限定符

C++ 提供三种继承方式:public(公有继承)、protected(保护继承)、private(私有继承);类成员的访问限定符同样有这三种,二者组合后,基类成员在派生类中的访问权限遵循严格的规则。

基类成员 / 继承方式public 继承protected 继承private 继承
基类 public 成员派生类 public派生类 protected派生类 private
基类 protected 成员派生类 protected派生类 protected派生类 private
基类 private 成员不可见不可见不可见

基类 private 成员始终不可见:基类的私有成员会被继承到派生类对象中,但语法上限制派生类无论在类内还是类外都无法访问,这是封装性的体现。

protected 的专属价值:若基类成员不想被类外访问,但需要让派生类访问,就定义为 protected——保护成员限定符是因继承而诞生的。

权限取最小值:基类非私有成员在派生类中的访问权限 = Min(成员在基类的访问限定符,继承方式),权限优先级:public > protected > private。

默认继承方式:使用 class 定义类时,默认继承方式为 private;使用 struct 时,默认继承方式为 public,建议显式写出继承方式,提升代码可读性。

实战首选公有继承:实际开发中几乎只使用 public 继承,protected/private 继承会让派生类的成员仅能在类内使用,扩展和维护性极差,不推荐使用。

基类与派生类的对象赋值转换

继承体系中,基类和派生类的对象、指针、引用之间存在特定的赋值转换规则,核心被称为切片(切割)——将派生类中属于基类的那部分成员'切下来'赋值给基类对象,具体规则如下,这是面试高频考点。

合法的赋值转换

派生类对象可以直接赋值给基类的对象、基类的指针、基类的引用,这是编译器自动完成的隐式转换,本质就是切片。

class Person {
protected:
    string _name;
    string _sex;
    int _age;
};
class Student : public Person {
public:
    int _No; // 学号
};
void Test() {
    Student sobj; // 合法:子类对象赋值给父类对象/指针/引用(切片)
    Person pobj = sobj;
    Person* pp = &sobj;
    Person& rp = sobj;
}

对象切片示意图

对象切片:把派生类对象里属于基类的成员,拷贝赋值给新的基类对象(派生类特有成员被丢弃);

指针 / 引用切片:基类指针(引用)直接指向(绑定)到派生类对象中属于基类的那部分内存,并非新建对象,只是只能访问基类成员。

注意:子类对象赋值给父类对象不会产生临时变量。

非法的赋值转换

基类对象不能直接赋值给派生类对象,因为基类对象缺少派生类的特有成员,无法完成完整的赋值。

非法赋值示意图

强制类型转换的注意事项

基类的指针 / 引用可以通过强制类型转换赋值给派生类的指针 / 引用,但仅当基类指针 / 引用指向派生类对象时才安全,否则会导致越界访问。

void Test() {
    Student sobj;
    Person pobj;
    Person* pp = &sobj; // 安全:基类指针指向派生类对象,强制转换后可访问派生类成员
    Student* ps1 = (Student*)pp;
    ps1->_No = 10;
    pp = &pobj; // 危险:基类指针指向基类对象,强制转换后访问派生类成员会越界
    Student* ps2 = (Student*)pp;
    ps2->_No = 10; // 未定义行为
}

若基类是多态类型,可使用 dynamic_cast 进行安全的类型转换(依赖 RTTI 运行时类型识别),后续讲解多态时会详细说明。

继承中的作用域

继承体系中,基类和派生类拥有相互独立的作用域,这是理解成员隐藏的关键。当子类和父类出现同名成员时,会触发隐藏(重定义) 规则,这是继承中最容易踩坑的点之一。

成员变量的隐藏

子类和父类的同名成员变量,子类成员会屏蔽父类对同名成员的直接访问,若想在子类中访问父类的同名成员,需通过 基类::基类成员 显式指定。

class Person {
protected:
    string _name = "小李子";
    int _num = 111; // 身份证号
};
class Student : public Person {
public:
    void Print() {
        cout << "姓名:" << _name << endl;
        cout << "身份证号:" << Person::_num << endl; // 显式访问父类同名成员
        cout << "学号:" << _num << endl; // 访问子类自身成员
    }
protected:
    int _num = 999; // 学号:与父类_num 同名,触发隐藏
};
void Test() {
    Student s1;
    s1.Print(); // 输出:小李子 111 999
}

成员函数的隐藏

成员函数的隐藏只需函数名相同即可触发,与函数的参数列表、返回值无关,这一点与函数重载(同一作用域、函数名相同 + 参数列表不同)有本质区别。

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); // 调用子类的 fun(int)
    // b.fun(); // 编译错误:父类 fun 被隐藏,需显式调用 A::fun()
}

**提示:**在继承体系中,尽量不要定义同名的成员,无论是成员变量还是成员函数,都会增加代码的混淆度,提升调试难度。

派生类的默认成员函数

C++ 中每个类都有六个默认成员函数(构造、拷贝构造、赋值重载、析构、取地址重载、const 取地址重载),若程序员不写,编译器会自动生成。在继承体系中,派生类的默认成员函数并非完全独立生成,而是需要调用基类的对应成员函数,完成基类部分的初始化和清理,核心规则共 7 条,是继承的核心重点。

核心规则

  1. 基类无默认构造函数时,派生类必须在初始化列表显式调用基类构造函数。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造,完成基类成员的拷贝初始化。
  3. 派生类的 operator=必须调用基类的 operator=,完成基类成员的赋值。
  4. 派生类的析构函数执行完毕后,编译器会自动调用基类的析构函数,保证先清理派生类成员、再清理基类成员的顺序。
  5. 派生类对象的初始化顺序:先调用基类构造,再调用派生类构造。
  6. 派生类对象的析构顺序:先调用派生类析构,再调用基类析构(与构造顺序相反)。
  7. 析构函数的隐藏:编译器会将所有析构函数名统一处理为 destructor(),因此父类析构函数不加 virtual 时(后续多态会进行讲解),子类析构函数与父类析构函数构成隐藏。

**总结一下:**基类负责初始化和清理自己的成员,派生类负责初始化和清理自己新增的成员,两者分工明确,互不越界。

代码演示

class Person {
public:
    // 基类构造函数
    Person(const char* name = "peter") : _name(name) {
        cout << "Person()" << endl;
    }
    // 基类拷贝构造
    Person(const Person& p) : _name(p._name) {
        cout << "Person(const Person& p)" << endl;
    }
    // 基类赋值重载
    Person& operator=(const Person& p) {
        cout << "Person operator=(const Person& p)" << endl;
        if (this != &p) _name = p._name;
        return *this;
    }
    // 基类析构函数
    ~Person() {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
};
class Student : public Person {
public:
    // 派生类构造:初始化列表显式调用基类构造
    Student(const char* name, int num) : Person(name) , _num(num) {
        cout << "Student()" << endl;
    }
    // 派生类拷贝构造:初始化列表显式调用基类拷贝构造
    Student(const Student& s) : Person(s) , _num(s._num) {
        cout << "Student(const Student& s)" << endl;
    }
    // 派生类赋值重载:显式调用基类赋值重载
    Student& operator=(const Student& s) {
        cout << "Student& operator=(const Student& s)" << endl;
        if (this != &s) {
            Person::operator=(s); // 调用基类赋值重载
            _num = s._num;
        }
        return *this;
    }
    // 派生类析构:编译器自动调用基类析构
    ~Student() {
        cout << "~Student()" << endl;
    }
protected:
    int _num;
};

**提示:**取地址重载和 const 取地址重载在继承中无特殊规则,编译器自动生成的版本即可满足需求,实战中几乎无需自定义实现。

为何析构函数的调用顺序是:派生类、基类?

class Parent {
public:
    char* buf;
    // 父类动态资源
    Parent() { buf = new char[1]; }
    ~Parent() {
        delete[] buf;
        buf = nullptr;
        cout << "父类析构:buf 已释放" << endl;
    }
};
class Child : public Parent {
public:
    ~Child() {
        // 子类析构使用父类已释放的 buf → 野指针访问
        buf[0] = 'x';
        cout << "子类析构:使用父类 buf(野指针)" << endl;
    }
};

继承体系中,派生类析构可能使用基类动态资源,若先析构基类会释放资源产生野指针导致崩溃;而基类析构不会使用派生类资源,因此必须先析构派生类、再析构基类,确保资源安全释放。

继承的特殊场景:友元与静态成员

继承与友元

基类的友元可以访问基类的私有和保护成员,但无法访问派生类的私有和保护成员,即友元关系不具有传递性。

class Student;
class Person {
public:
    friend void Display(const Person& p, const Student& s);
protected:
    string _name;
};
class Student : public Person {
protected:
    int _stuNum; // 派生类保护成员
};
// 友元函数可访问基类_name,但无法直接访问派生类_stuNum(编译错误)
void Display(const Person& p, const Student& s) {
    cout << p._name << endl;
    // cout << s._stuNum << endl; // 错误:友元关系不能继承
}

继承与静态成员

基类中定义的 static 静态成员,在整个继承体系中只有一份实例,无论派生出多少个子类,所有类的对象共享这一个静态成员。

class Person {
public:
    Person() { ++_count; }
    static int _count; // 静态成员:统计人数
protected:
    string _name;
};
int Person::_count = 0; // 静态成员类外初始化
class Student : public Person {
protected:
    int _stuNum;
};
class Graduate : public Student {
protected:
    string _seminarCourse;
};
void TestPerson() {
    Student s1, s2, s3;
    Graduate s4;
    cout << "人数:" << Person::_count << endl; // 输出:4(所有对象共享_count)
    Student::_count = 0;
    cout << "人数:" << Person::_count << endl; // 输出:0(修改子类静态成员,基类也会变化)
}

静态成员的访问方式:基类::静态成员或派生类::静态成员,本质访问的是同一个实例。

菱形继承

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

单继承

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

多继承

**菱形继承:**菱形继承是多继承的一种特殊情况

菱形继承

菱形继承的问题

菱形继承的底层问题是数据冗余和二义性,即派生类对象中会包含多份基类成员,导致访问基类成员时无法确定具体访问哪一份。

class A {
public:
    int _a;
};
class B : public A {
public:
    int _b;
};
class C : public A {
public:
    int _c;
};
class D : public B, public C {
public:
    int _d;
};

菱形继承内存布局

菱形继承二义性

用域作用限定符解决了二义性问题,但是没有解决数据冗余问题。

菱形虚拟继承

虚拟继承是 C++ 专门为解决菱形继承问题设计的特性,在菱形继承的中间层子类(B、C)继承基类(A)时,添加 virtual 关键字,即可让最终的派生类(D)只保留一份基类成员,同时解决二义性和数据冗余。

class A {
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;
};

虚拟继承内存布局

虚拟继承的底层通过虚基表指针和虚基表实现:

在虚拟继承的中间层子类(如 B、C)对象中,会增加一个虚基表指针,指向对应的虚基表。

虚基表中存储的是虚基类成员在派生类对象中的偏移量,中间层子类通过该偏移量,即可在运行时定位到唯一的基类成员实例。

在最终的派生类(D)对象中,虚基类(A)的成员会被放置在对象内存布局的最底部,由所有中间层子类共享,从而保证整个继承体系中仅存在一份基类实例,避免了数据冗余与二义性。

注意:虚拟继承的设计初衷是解决菱形继承问题,不应在其他场景随意使用,其底层的虚基表与偏移量查找机制会带来一定的性能开销。

常见问题解析

为什么用虚基表存储偏移量? 当有多个虚基类时,会产生多个偏移量。如果直接将这些偏移量存对象中,创建大量对象时会重复存储相同数据,造成内存浪费。而将偏移量统一存到类级别的虚基表中,每个对象只需用一个指针指向该表,即可共享所有偏移量,大幅节省内存,同时保证在不同继承场景下都能正确定位虚基类。

什么时候需要用偏移量访问共享数据?

  1. 直接访问(无需偏移量):当你用最终派生类对象(如 D d)直接访问虚基类成员时,此时,虚基类 A 的位置在编译期就已确定,编译器可以直接计算出 _a 的地址,不需要在运行时通过虚基表动态查找偏移量来计算。
  2. 切片访问(必须用偏移量):当你用基类指针(如 B* pb)指向派生类对象(如 D d),并通过该指针访问虚基类成员时,独立 B 对象:编译期就能确定 B→A 的固定偏移量,直接用这个偏移量访问 A;D 中切片的 B 子对象:编译期不知道该用哪个偏移量(因为不知道 pb 指向的是独立 B 还是 D 中的 B),所以只能在运行时通过虚基表查'当前场景下的正确偏移量',再用这个偏移量访问 A。

是否解决了数据冗余问题? 虽然虚拟继承会引入虚基表指针(图中每个指针占 4 字节),看起来多了一点内存开销,但当虚基类 A 的成员越多、体积越大时,这份指针的开销就越微不足道,整体来看内存效率反而更高。

构造函数的调用顺序 对于菱形虚拟继承来说,虚基类 A 会被整个继承体系共享,不再属于 B 和 C 各自私有,因此在构造最底层的 D 对象时,A 只会被构造一次。至于调用顺序是 A → B → C → D,这是因为:

  1. 虚基类 A 总是最先被构造。
  2. 非虚基类 B 和 C 的顺序,取决于 D 类的继承声明顺序(public B, public C),而非构造函数初始化列表的顺序。
  3. 最后才会执行派生类 D 自身的构造函数。

至于为啥基类先构造,是因为派生类的构造函数可能会使用基类的成员。如果先构造派生类,就会出现使用未初始化基类成员的风险,从而导致程序错误。

继承和组合

掌握了继承的所有语法规则后,更重要的是理解继承的设计原则—— 何时该用继承,何时该用更优的组合?这是体现 C++ 设计思维的关键。

对 C++ 多继承的客观认知

多继承是 C++ 语法复杂的重要体现,菱形继承和菱形虚拟继承的底层实现繁琐,易引发问题,实际开发中应尽量避免设计多继承,坚决避免菱形继承。

多继承被认为是 C++ 的缺陷之一,后续的面向对象语言(如 Java、C#)都取消了多继承,仅保留单继承 + 接口的方式,规避了菱形继承的问题。

继承与组合的区别

类之间的关系主要分为两种,对应两种复用方式:继承(is-a) 和组合(has-a),二者的设计思想和适用场景有本质区别。

继承与组合

特性继承(is-a 关系)组合(has-a 关系)
关系描述每个派生类对象都是一个基类对象假设 B 组合了 A,每个 B 对象中都有一个 A 对象
复用类型白箱复用:基类内部细节对子类可见黑箱复用:被组合类内部细节对组合类不可见
封装性破坏基类封装,基类修改会影响派生类保持封装,被组合类修改对组合类影响极小
耦合度高耦合:派生类与基类强依赖低耦合:组合类与被组合类弱依赖
扩展性派生类受基类限制,扩展性差基于接口组合,扩展性强

总结

继承是 C++ 面向对象的核心基石,理解其机制不仅关乎语法,更关乎设计。菱形继承带来的二义性与冗余问题,通过虚拟继承得以解决,但代价是性能开销。在实际工程中,应优先考虑组合模式以降低耦合,仅在明确的"is-a"关系且符合里氏替换原则时使用继承。掌握析构顺序、对象切片及作用域隐藏规则,能有效避免常见的内存泄漏与逻辑错误。

目录

  1. 继承的概念
  2. 继承的基本定义
  3. 继承的定义格式
  4. 三大继承方式与访问限定符
  5. 基类与派生类的对象赋值转换
  6. 合法的赋值转换
  7. 非法的赋值转换
  8. 强制类型转换的注意事项
  9. 继承中的作用域
  10. 成员变量的隐藏
  11. 成员函数的隐藏
  12. 派生类的默认成员函数
  13. 核心规则
  14. 代码演示
  15. 为何析构函数的调用顺序是:派生类、基类?
  16. 继承的特殊场景:友元与静态成员
  17. 继承与友元
  18. 继承与静态成员
  19. 菱形继承
  20. 菱形继承的问题
  21. 菱形虚拟继承
  22. 常见问题解析
  23. 继承和组合
  24. 对 C++ 多继承的客观认知
  25. 继承与组合的区别
  26. 总结
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • AI 产品经理转型指南:从传统产品到智能产品的进阶路径
  • GitHub Copilot Pro 学生免费获取与 VS Code 配置指南
  • 前端现代化演进:从 jQuery 到微前端与工程化实践
  • Cursor 彻底卸载与设备标识重置指南
  • Python 实战:构建文档总结、代码生成与智能检索助手
  • FPGA 实现 UART 串口通信:原理与 Verilog 代码实例
  • 计算机种类详解:32 位与 64 位架构核心差异
  • WebAssembly 逆向分析:反编译 Wasm 二进制并修改游戏数据
  • 动态规划专题:子序列问题的核心思路与实战
  • Xilinx FPGA 实现 RISC-V 五级流水线 CPU 设计实战
  • FPGA 实时图像处理:流水线架构与系统优化实战
  • Xilinx FPGA 实现 RISC-V 五级流水线 CPU 实战教程
  • 学术期刊分级标准详解:A/B/C/D 类划分指南
  • 从 Webhook 到 OpenClaw:钉钉周报提醒机器人进化史
  • NWPU VHR-10 遥感目标检测数据集介绍及 YOLO 训练指南
  • 5 步解决 Llama 3.3 70B 模型输出异常问题
  • 灵感画廊实战:用梦境描述替代 Prompt 提升 AI 绘画质感
  • Qwen-Image-2512 V2 模型 ComfyUI 与 WebUI 整合包使用指南
  • Stable Diffusion 底模 VAE 推荐与配置指南
  • 网络安全入门教程:从零开始掌握基础技术与工具

相关免费在线工具

  • 加密/解密文本

    使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online

  • Gemini 图片去水印

    基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online

  • Markdown转HTML

    将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online

  • HTML转Markdown

    将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online