跳到主要内容
C++ 继承机制深度解析:从基础到菱形继承 | 极客日志
C++ 算法
C++ 继承机制深度解析:从基础到菱形继承 C++ 继承允许派生类复用基类成员,提升开发效率。核心涉及访问控制、对象切片转换及作用域隐藏规则。多继承中菱形继承会导致数据冗余和二义性,虚拟继承通过虚基表解决此问题。析构顺序遵循派生类先于基类释放资源原则。设计时应优先组合而非继承,以降低耦合度并增强扩展性。
继承的概念
在 C++ 中,继承 (inheritance) 允许程序员在保持原有类(基类 / 父类)特性的基础上,扩展功能生成新的类(派生类 / 子类),是区别于函数复用的类设计层次复用 。
简单来说,继承让派生类天然拥有基类的所有成员(成员变量 + 成员函数),无需重复编写代码,极大提升了开发效率和代码可维护性。比如定义表示'人'的 Person 类,再通过继承派生出 Student 和 Teacher 类,二者可直接复用 Person 的姓名、年龄等属性和打印方法,只需新增各自的特有属性(学号、工号)即可。
#include <iostream>
#include <string>
using namespace std;
class Person {
public :
void Print () { cout << "name:" << _name << endl; cout << "age:" << _age << endl; }
protected :
string _name = "peter" ;
int _age = 18 ;
};
class Student : public Person {
protected :
int _stuid;
};
class Teacher : public Person {
protected :
int _jobid;
};
int main () {
Student s;
Teacher t;
s.Print ();
t. ();
;
}
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 ;
};
void Test () {
Student s1;
s1. Print ();
}
成员函数的隐藏 成员函数的隐藏只需函数名相同即可触发 ,与函数的参数列表、返回值无关,这一点与函数重载(同一作用域、函数名相同 + 参数列表不同)有本质区别。
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 );
}
**提示:**在继承体系中,尽量不要定义同名的成员,无论是成员变量还是成员函数,都会增加代码的混淆度,提升调试难度。
派生类的默认成员函数 C++ 中每个类都有六个默认成员函数(构造、拷贝构造、赋值重载、析构、取地址重载、const 取地址重载),若程序员不写,编译器会自动生成。在继承体系中,派生类的默认成员函数并非完全独立生成 ,而是需要调用基类的对应成员函数,完成基类部分的初始化和清理,核心规则共 7 条,是继承的核心重点。
核心规则
基类无默认构造函数时,派生类必须在初始化列表显式调用基类构造函数。
派生类的拷贝构造函数必须调用基类的拷贝构造 ,完成基类成员的拷贝初始化。
派生类的 operator=必须调用基类的 operator= ,完成基类成员的赋值。
派生类的析构函数执行完毕后,编译器会自动调用基类的析构函数 ,保证先清理派生类成员、再清理基类成员的顺序。
派生类对象的初始化顺序:先调用基类构造,再调用派生类构造 。
派生类对象的析构顺序:先调用派生类析构,再调用基类析构 (与构造顺序相反)。
析构函数的隐藏:编译器会将所有析构函数名统一处理为 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[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;
};
void Display (const Person& p, const Student& s) {
cout << p._name << 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;
Student::_count = 0 ;
cout << "人数:" << Person::_count << endl;
}
静态成员的访问方式:基类::静态成员或 派生类::静态成员 ,本质访问的是同一个实例。
菱形继承 **单继承:**一个子类只有一个直接父类时称这个继承关系为单继承
**多继承:**一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承的问题 菱形继承的底层问题是数据冗余 和二义性 ,即派生类对象中会包含多份基类成员,导致访问基类成员时无法确定具体访问哪一份。
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)的成员会被放置在对象内存布局的最底部,由所有中间层子类共享,从而保证整个继承体系中仅存在一份基类实例,避免了数据冗余与二义性。
注意:虚拟继承的设计初衷是解决菱形继承问题,不应在其他场景随意使用 ,其底层的虚基表与偏移量查找机制会带来一定的性能开销。
常见问题解析 为什么用虚基表存储偏移量?
当有多个虚基类时,会产生多个偏移量。如果直接将这些偏移量存对象中,创建大量对象时会重复存储相同数据,造成内存浪费。而将偏移量统一存到类级别的虚基表中,每个对象只需用一个指针指向该表,即可共享所有偏移量,大幅节省内存,同时保证在不同继承场景下都能正确定位虚基类。
直接访问(无需偏移量) :当你用最终派生类对象(如 D d)直接访问虚基类成员 时,此时,虚基类 A 的位置在编译期就已确定,编译器可以直接计算出 _a 的地址,不需要在运行时通过虚基表动态查找偏移量 来计算。
切片访问(必须用偏移量) :当你用基类指针(如 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,这是因为:
虚基类 A 总是最先被构造。
非虚基类 B 和 C 的顺序,取决于 D 类的继承声明顺序(public B, public C),而非构造函数初始化列表的顺序。
最后才会执行派生类 D 自身的构造函数。
至于为啥基类先构造,是因为派生类的构造函数可能会使用基类的成员。如果先构造派生类,就会出现使用未初始化基类成员的风险,从而导致程序错误。
继承和组合 掌握了继承的所有语法规则后,更重要的是理解继承的设计原则 —— 何时该用继承,何时该用更优的组合?这是体现 C++ 设计思维的关键。
对 C++ 多继承的客观认知 多继承是 C++ 语法复杂的重要体现,菱形继承和菱形虚拟继承的底层实现繁琐,易引发问题,实际开发中应尽量避免设计多继承,坚决避免菱形继承 。
多继承被认为是 C++ 的缺陷之一,后续的面向对象语言(如 Java、C#)都取消了多继承,仅保留单继承 + 接口的方式,规避了菱形继承的问题。
继承与组合的区别 类之间的关系主要分为两种,对应两种复用方式:继承(is-a) 和组合(has-a) ,二者的设计思想和适用场景有本质区别。
特性 继承(is-a 关系) 组合(has-a 关系) 关系描述 每个派生类对象都是一个基类对象 假设 B 组合了 A,每个 B 对象中都有一个 A 对象 复用类型 白箱复用:基类内部细节对子类可见 黑箱复用:被组合类内部细节对组合类不可见 封装性 破坏基类封装,基类修改会影响派生类 保持封装,被组合类修改对组合类影响极小 耦合度 高耦合:派生类与基类强依赖 低耦合:组合类与被组合类弱依赖 扩展性 派生类受基类限制,扩展性差 基于接口组合,扩展性强
总结 继承是 C++ 面向对象的核心基石,理解其机制不仅关乎语法,更关乎设计。菱形继承带来的二义性与冗余问题,通过虚拟继承得以解决,但代价是性能开销。在实际工程中,应优先考虑组合模式以降低耦合,仅在明确的"is-a"关系且符合里氏替换原则时使用继承。掌握析构顺序、对象切片及作用域隐藏规则,能有效避免常见的内存泄漏与逻辑错误。
相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,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
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online