继承详解
在面向对象编程(OOP)中,继承(Inheritance) 是三大核心特性之一(封装、继承、多态),它让我们能够基于已有类进行功能扩展与重用,从而减少代码冗余、提升代码的可维护性与可扩展性。继承不仅是一种语法机制,更是一种软件设计思想,它为程序构建了清晰的层次结构,帮助我们从抽象到具体、由通用到专用地组织代码。
然而,继承的使用并非没有代价。不同的继承方式(public / protected / private)、对象切片、同名成员隐藏、默认成员函数的调用规则,以及多继承与菱形继承带来的数据冗余与二义性,都是每一个 C++ 开发者必须深刻理解的知识点。
本文将从继承的基础语法与访问控制规则出发,深入剖析继承过程中的对象模型、构造与析构顺序、作用域与成员隐藏、友元与静态成员的继承特性,再到菱形继承与虚拟继承的底层原理,并结合实际开发经验探讨继承与组合的取舍。
1. 继承的概念与语法
概念与效果
继承(inheritance) 机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
以下为继承代码示例:
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 继承了 Person 的全部成员,派生类对象中包含了父类对象的所有成员,实现了代码层次上的复用。
- 继承后父类的 Person 的成员(成员函数 + 成员变量)都会变成子类的一部分。
- 这里体现出了
Student和Teacher复用 了Person的成员。
语法和相关细节
语法:Person 是父类,也称作基类。Student 是子类,也称作派生类。
类的成员访问限定符
继承方式
三种继承方式的访问控制比较
| 成员类型 | public 继承后 | protected 继承后 | private 继承后 |
|---|---|---|---|
| 基类的 public 成员 | 派生类的 public 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 protected 成员 | 派生类的 protected 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 private 成员 | 派生类中不可见 | 派生类中不可见 | 派生类中不可见 |
- 最常用的继承方式为
public继承,继承就是为了复用,public继承可以很好的使代码复用。
总结要点
- 无论以什么方式继承,基类
private成员在派生类中都是不可见的。- 这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类内还是类外都不能去访问基类的 private 成员。
- 基类 private 成员在派生类中不能被访问。如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为
protected。- 可以认为 protected 成员限定符是为了继承而设立的。
- 派生类中的成员访问控制权限:
- 基类的
private成员在在派生类中不可见但仍存在于派生类对象中。 - 派生类中的成员访问控制 = min(成员在基类中的访问权限,继承方式),
public > protected > private
- 基类的
struct和class都继承一个类时,在不指明继承方式时 (不过最好显式的写出继承方式):- class 的默认的继承方式是
private。 - struct 的默认的继承方式是
public。
- class 的默认的继承方式是
- 实际中一般使用都是 public 继承,几乎很少使用
protetced/private继承,也不提倡使用 protetced/private 继承。- 因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
2. 基类和派生类对象赋值转换
我们知道,两个不同类型的对象赋值,都是不允许的。如果允许,那就时发生了类型转换
类型转换包括强制类型转换和隐式类型转换 (单参数的构造函数支持隐式类型转换)。
int i = 0;
double d = i; // 这里发生了隐式类型转换
// 不同类型之间的赋值,都会用 临时变量进行转换
要点:
- 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割,寓意把派生类中父类那部分切来进行操作。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information) 的
dynamic_cast来进行识别后进行安全转换。
以下代码示例:
void test2() {
int i = 0;
double d = i; // 隐式类型转换,合法
const double& d = i; // 需要加上 const
Student stu; // 切片
Person& rp = stu; // 这里的 rp 是 子类中父类的那一部分的别名
Person* ptr_p = &stu; // 这里的 指针 指向 子类中父类的那一部分
rp._name = "张三";
ptr_p->_name = "李四";
return 0;
}
- 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用
- 这里的切片、切割:赋值时,把子类中父类的那部分,拷贝给父类
- 这里的切片、切割:这里的
rp是**子类中父类的那一部分的别名 **(引用) - 这里的切片、切割:这里的 指针
ptr_p,指向子类中父类的那一部分
3. 继承中的作用域
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员时,子类成员将屏蔽对父类同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员/或函数 显示访问)
- 如果是成员函数的隐藏,只需要基类和派生类函数名相同就构成隐藏。
- 在实际开发中,继承体系里面最好不要定义同名的成员
同名成员变量/函数的隐藏
- 隐藏/重定义的概念:
- 当子类和父类有同名的成员时,子类的成员隐藏了父类的成员
- 隐藏/重定义的成员:包括成员变量和成员函数
- 实际中,不建议子类和父类写同名的成员变量和成员函数
- 子类和父类中的 同名函数也可以同时存在,**不指明类域时,优先访问子类的成员函数 **(局部域优先) 访问的顺序:函数局部域 > 子类域 > 父类域 > 命名空间域 > 全局域
// 只要在 不同的作用域 就可以有同名的成员和同名的函数
class Person {
public:
void func() { cout << "Person::func()" << endl; }
protected:
string _name = "小李子";
int _num = 111; // 身份证号
};
// 子类
class Student : public Person {
public:
void func() { cout << "student::func()" << endl; }
void print() {
cout << "num: " << _num << endl; // 先访问子类中的成员
// 子类中,访问父类中的同名成员时,需要指定作用域
cout << "Person::num: " << Person::_num << endl;
}
protected:
int _num = 999; // 学号
};
int main() {
Student s;
// 子类中存在 func 函数时,调用子类中的,否则调用父类中的 再没有的话,调用全局的,
s.func(); // 都没有的话,报错
// 如果指定域中没有该函数,报错
s.Person::func(); // 指定类域,调用父类中的 func 函数
return 0;
}
关于隐藏的考察
namespace question {
class Person {
public:
void func() { cout << "Person:func()" << endl; }
};
class Student : public Person {
public:
void func(int i) { cout << "student:func()" << endl; }
};
void test1() {
Student s;
// s.func(); // 子类中存在该函数 但是需要参数 编译器默认先在子类中查找 如果调用时不传参 报错
s.func(1); // 传参后不报错
s.Person::func(); // 手动指定作用域后,正确
}
}
问题:
- 以上类中两个 func 函数构成什么关系
- a. 隐藏/重定义 b. 重载 c. 重写/覆盖 d. 编译报错
- 答案 a (父类子类中,成员函数名相同,就构成隐藏)
- 不构成重载,因为不是在同一作用域
- 构成隐藏,父类子类中,成员函数满足函数名相同就构成隐藏
4. 派生类的默认成员函数
- C++ 的类中会自动生成6 个默认成员函数,'默认'的意思就是指我们不写,编译器会帮我们自动生成,那么在派生类中,这几个成员函数是如何生成的呢?
- 我们用以下
Person类作为基类,验证派生类中默认成员函数的行为
class Person {
protected:
string _name; // 姓名
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; }
};
1. 构造函数
- C++ 规定,**派生类的构造函数必须调用基类的构造函数 **(默认构造),去初始化派生类中基类的那部分。
- 如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用基类的构造函数
class Student : public Person {
public:
Student(const char* name = "张三", int id = 0) : _id(id) {}
protected:
int _id; // 学号
};
int main() {
Student s;
return 0;
}
我们没有定义父类对象,却调用了父类的构造函数。
这是因为:
- **派生类构造函数的初始化列表,会自动调用基类的默认构造函数 **(基类的构造函数是在派生类的初始化列表中调用的)
如果基类没有默认构造,则必须在派生类的初始化列表中显式调用基类的构造函数
我们将基类的构造函数修改,修改成非默认构造
public:
// 非默认构造
Person(const char* name) : _name(name) {
cout << "Person()" << endl;
}
- 基类没有默认构造,那么派生类的初始化列表必须显式调用基类的构造函数。如果此时不显式调用,会报错,因为没有可用的默认构造可以调用
public:
Student(const char* name = "张三", int id = 0) : _id(id), Person(name) // 基类中无默认构造,在派生类中显式调用基类的构造函数 {}
关于这里的初始化顺序问题:
- 初始化列表中,成员变量初始化的顺序与在初始化列表中出现的顺序无关。
- 而是和成员变量声明的顺序一致
- **在继承中,继承过来的父类成员,相当于在子类的成员变量前先声明 **(继承过来的父类成员最先声明)。
- 因此初始化列表会先初始化父类成员,再初始化子类成员
- 但最好初始化列表的顺序应该和成员变量声明的顺序一致,因此最好这么写
Student(const char* name = "张三", int id = 0) : Person(name) // 基类中无默认构造,在派生类中显式调用基类的构造函数, _id(id) {}
总结:
- **派生类的构造函数必须调用基类的构造函数 **(默认构造),去初始化派生类中基类的那部分
- 相当于:派生类的构造函数,只需手动初始化自己的那部分成员,子类中的父类的那部分成员,交给父类的构造函数处理
- 如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用基类的构造函数
- 如果基类存在默认的构造函数,则派生类的构造函数可以不手动调用。不手动调用构造函数时,编译器自动在派生类构造函数的初始化列表调用基类的默认构造
- 继承过来的父类中的成员,比子类中自己的成员先被声明。因此初始化列表会先初始化父类成员,再初始化子类成员
2. 拷贝构造
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
public:
// 派生类的拷贝构造
Student(const Student& s) : _id(s._id) {}
- 由于拷贝构造函数是构造函数的一个重载形式,因此,如果我们不显式调用,以上代码会调用父类的默认构造函数,无法完成拷贝的目的。
因此,派生类的拷贝构造函数必须显式调用基类的拷贝构造完成基类的拷贝初始化
// 父类的拷贝构造
Person(const Person& p) // 子类对象 可以 赋值给 父类的引用
: _name(p._name) {
cout << "Person(const Person& p)" << endl;
}
// 子类的拷贝构造
Student(const Student& s) : Person(s), _id(s._id) {}
Person(s):该行为合法,因为s是子类对象的别名,表示一个子类对象。子类对象 可以 赋值给 父类的引用Person(const Person& p = s):s 是 Student 类对象的引用,这里发生了切片,将 s 中的父类的那部分,给父类的拷贝构造函数拷贝
3. 赋值重载 operator=
- 派生类的
operator=必须要调用基类的 operator= 完成基类的复制
// 派生类的 operator= 必须要调用基类的 operator= 完成基类的复制
Student& operator=(const Student& stu) {
if (this != &stu) {
Person::operator=(stu);
}
return *this;
}
Person::operator=(stu):父类中也存在operator=函数,这里需要指定作用域,指定调用父类中的 operator= 函数。否则会引发无穷递归- 引发无穷递归的原因:由于子类和父类中
operator=函数同名,触发了子类和父类中同名成员的隐藏。不指定作用域调用时,默认调用子类中的operator=函数。因此不指定作用域调用时,会引发无穷递归。
- 引发无穷递归的原因:由于子类和父类中
- 因此派生类中
operator=的实现需要指定作用域
Student& operator=(const Student& stu) {
if (this != &stu) {
Person::operator=(stu); // 指定作用域
}
return *this;
}
4. 析构函数
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序
public:
// 子类析构函数
~Student() {
// 由于后面多态设计的原因,子类父类中析构函数的函数名被特殊处理了
// 父子类中析构函数的名字被统一处理成立 destructor
// 由于父子类中函数同名,因此触发了隐藏,访问父类的析构函数需要指定作用域
Person::~Person();
}
- 这里,我们的三个对象,调用了 6 次析构函数,显然不对。
这是因为:
- 派生类的析构函数会在被调用结束后自动调用基类的析构函数清理基类成员。父类的析构函数会自动调用,因此我们无需手动调用父类的析构函数。
因此:
子类的析构函数中,只需要完成对子类中成员的析构清理,子类中父类的那部分交给父类的析构函数清理。
- 派生类的析构函数会在被调用结束后自动调用基类的析构函数
- 这也就规定了,子类对象先析构,父类对象再析构
- 要先析构子类,再析构父类的原因
- 保证父子对象在栈中定义的顺序,派生类对象的构造函数的栈帧,一般是先定义父类对象,再定义子类对象。栈帧退出时,会先析构子类对象,再析构父类对象
- 如果先析构父类对象,子类可能还会访问父类中的成员,这时就会报错。而 先析构子类后,对父类无影响,因为父类永远访问不到子类的成员
- 后续的多态场景析构函数需要构成重写,重写的条件之一是函数名相同 (后续文章会讲解)。因此编译器会对析构函数名进行特殊处理,处理成 destructor(),所以父类析构函数不加 virtual 的情况下,子类析构函数和父类析构函数构成隐藏关系
总结
总结下来就是:
- 该部分的设计理念为各司其职:
- 子类的构造函数完成子类部分的初始化,子类中父类部分的初始化由父类的构造函数完成
- 父类的默认构造会被子类自动调用
- 子类的拷贝构造函数完成子类部分的拷贝,父类部分的拷贝由父类的拷贝构造函数完成 (需在初始化列表中显式调用)
- 子类的
operator=函数完成子类部分的赋值,父类部分的赋值由父类的operator=函数完成 - 子类成员的析构由子类的析构函数完成,父类部分的析构由父类的析构函数完成
- 父类的析构函数会在子类的析构函数结束后自动调用,无需手动调用
- 子类的构造函数完成子类部分的初始化,子类中父类部分的初始化由父类的构造函数完成
5. 继承与友元
友元的继承较为简单:
友元关系不能继承:即基类的友元函数不能访问派生类类的私有和保护成员
- 简单来理解就是:父亲的朋友不一定是孩子的朋友
// 友元不能继承的测试代码
class Student;
class Person {
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
// public 继承
class Student : public Person {
protected:
int _stuNum; // 学号
};
// 父类的友元函数
void Display(const Person& p, const Student& s) {
cout << p._name << endl;
// cout << s._stuNum << endl; // 不能访问!
}
int main() {
Person p;
Student s;
Display(p, s);
return 0;
}
如果该函数想要访问子类中的
private或protected成员,需要将该外部函数也声明为子类的友元函数
// 父类无需修改
// 在子类中 定义友元
class Student : public Person {
friend void Display(const Person& p, const Student& s);
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s) {
cout << p._name << endl;
cout << s._stuNum << endl; // 外部函数也声明为子类的友元函数后可以访问
}
6. 继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例
static成员是类级别的。- 一个静态成员只存在一份,在整个继承体系中共享。
- 静态成员 同时属于父类和所有的派生类,在派生类中不会单独拷贝一份
- 基类中的静态成员,派生类继承的是使用权
class Person {
public:
Person() { ++_count; }
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person {
protected:
int _stuNum; // 学号
};
class Graduate : public Student {
protected:
string _seminarCourse; // 研究科目
};
// 以下代码测试访问
int main() {
Person p; // 两个对象中都有 name , 这两个 name 是两个独立的 name
Student s;
cout << &p._name << endl;
cout << &s._name << endl;
// 静态成员
// 可以通过对象访问
cout << &p._count << endl;
cout << &s._count << endl;
// 通过类名访问静态成员变量
cout << &Person::_count << endl;
cout << &Student::_count << endl;
return 0;
}
- 可以看到:静态成员变量的地址只有一个,因此只有一份实例
与普通成员的区别:
| 特性 | 静态成员 | 普通成员 |
|---|---|---|
| 实例数量 | 整个继承体系唯一 | 每个对象私有独立副本 |
| 存储位置 | 全局数据区 | 对象内存布局中 |
| 访问方式 | 通过类名或对象访问 | 只能通过对象访问 |
| 继承影响 | 共享,不因派生类增多而分裂 | 派生类拥有独立副本 |
- 由于静态成员变量在整个继承体系中只有一个实例,因此还可以结合构造函数统计整个继承体系中,基类和派生类共有多少个对象实例
- 原理:派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员,而我们可以设计这里派生类的构造函数,每次调用会把静态成员变量加一
// 基类
class Person {
public:
Person() { ++_count; }
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
// 计算对象的数目测试
int main() {
Person p1;
Person p2;
Student s1;
Student s2;
Graduate g1;
Graduate g2;
Graduate g3;
cout << Person::_count << endl;
cout << Student::_count << endl;
cout << Graduate::_count << endl;
return 0;
}
7. 菱形继承与虚拟继承
单继承与多继承
单继承:
多继承:
菱形继承和虚拟继承
// 菱形继承样例
class Person {
public:
string _name; // 姓名
int _age;
};
class Student : public Person {
protected:
int _num; // 学号
};
class Teacher : public Person {
protected:
int _id; // 职工编号
};
// 多继承的语法
class Assistant : public Student, public Teacher {
protected:
string _majorCourse; // 主修课程
};
菱形继承的对象模型如下:
可以看到,菱形继承存在数据冗余和二义性的问题:
- 数据冗余:
- 对象
Assistant中存在两个Person类对象,而实际生活中,一个人只需要有一个名字,一个年龄。a 对象中,既有Student对象,也有Teacher对象。且两对象中均有 Person 对象,各包含 name 变量。数据确实冗余
- 对象
- 二义性:
- 可以通过指定作用域的方式来解决二义性问题
as.Student::_name = "xxx";
as.Teacher::_name = "yyy";
as.Person::_name = "zzz"; // 二义性问题解决了,但是数据冗余问题依然未解决
Assistant as; // 二义性,as 对象中有两个_name 成员,一个位于 Student 中,一个位于 Teacher 中
as._name = "peter"; // 这样访问,编译器不知道该访问哪个_name,因此报错了
但数据的冗余性该如何解决呢?
- C++ 提供了虚拟继承,同时解决了数据冗余和数据的二义性问题。
- 虚拟继承专用于解决菱形继承的数据冗余和二义性问题,虚拟继承不要在其他地方使用
虚拟继承
class Person {
public:
string _name; // 姓名
int _age;
};
class Student : virtual public Person {
protected:
int _num; // 学号
};
class Teacher : virtual public Person {
protected:
int _id; // 职工编号
};
// 多继承的语法
class Assistant : public Student, public Teacher {
protected:
string _majorCourse; // 主修课程
};
- 在
Student和Assistant的继承方式前加上virtual关键字,解决了二义性和数据的冗余性问题
通过上面的调试我们发现:
- 对象
a中的Person对象似乎变成了一份实例,我们通过各种方式修的数据,同步到了所有 Person 的数据中。 - 为什么呢?接下来我们探秘原理
虚拟继承的原理
- 虚拟继承专用于解决菱形继承的数据冗余和二义性问题,虚拟继承不要在其他地方去使用
用故事详谈虚拟继承的原理
1. 先从故事开始
假设我们有一个继承关系:
class A {
public:
int a;
};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};
int main() {
D obj;
obj.a = 100;
cout << obj.a << endl;
}
继承图像个菱形:
A
/ \
B C
\ /
D
没有虚拟继承时:
- B 继承 A → 有一份 A
- C 继承 A → 也有一份 A
- D 继承 B、C → 最终有两份 A,访问
obj.a就会歧义。
有虚拟继承时:
- B、C 不直接带着 A,而是带着一个'指针',指向同一份 A。
- D 里只有一份 A,访问不会二义性。
2. 底层对象模型怎么变化?
普通继承:
[D]
├─ [B::A] // B 里的 A
└─ [C::A] // C 里的 A
虚拟继承:
[D]
├─ [B::vbptr] → [A] // B 里有一个虚基类指针
├─ [C::vbptr] → [A] // C 里也有一个虚基类指针
└─ [A] // 真正的 A 数据
这里的 vbptr(Virtual Base Pointer)就是虚基类指针。
3. vbptr 和 vbtable 的作用
虚拟继承底层有两个重要概念:
- vbptr(虚基类指针):对象中的一个隐藏指针,指向虚基表(vbtable)。每个带虚拟继承的类对象里都会有这个指针。
- vbtable(虚基表):编译器生成的一个表,里面记录虚基类相对当前对象的偏移量。
访问虚拟继承的父类成员时,编译器会:
- 通过对象里的
vbptr找到vbtable; - 查
vbtable得到虚基类的偏移量; - 用偏移量跳到真正的虚基类数据位置;
- 访问成员变量。
4. 内存布局
假设我们的类是这样的,在每个类中各定义一个 int 成员:
class A { int a; };
class B : virtual public A { int b; };
class C : virtual public A { int c; };
class D : public B, public C { int d; };
D 对象的内存布局(伪示意)
[D 对象]
+---------------------+
| B 部分 |
| [vbptr_B] ---------+---> vbtable_B: [偏移到 A: +??] |
| b |
+---------------------+
| C 部分 |
| [vbptr_C] ---------+---> vbtable_C: [偏移到 A: +??] |
| c |
+---------------------+
| D 自己的成员 d |
+---------------------+
| A 部分 (虚基类) a |
+---------------------+
特点:
- B 部分有一个 vbptr_B,指向自己的虚基表。
- C 部分有一个 vbptr_C,也指向自己的虚基表。
- 虚基表里存的不是对象数据,而是偏移量,告诉你'要找 A,要跳多少字节'。
- 最终 D 里只有一份 A 的存储空间。
5. 为什么需要 vbtable?
因为虚拟继承可能有多层,虚基类的位置不是固定的,编译器需要在运行时计算偏移。
- 普通继承:编译期就知道 A 在对象的第几个字节,直接加偏移访问。
- 虚拟继承:A 的位置取决于最终派生类的布局,所以不能写死,必须查表。
6. 访问过程举例
当你写:
obj.a = 100;
编译器会生成类似的步骤:
- 找到
obj中的B部分; - 取出
B部分的vbptr_B; - 用
vbptr_B找到vbtable_B; - 查表得到 A 的偏移量;
- 用
obj的起始地址 + 偏移量 → 定位到唯一的 A; - 访问
A::a。
7. 总结口诀
菱形继承有两爷爷,菱形虚拟继承共用一个爷爷。
vbptr 是导航针,vbtable 是地图,按地图找爷爷。
8. 继承的总结与反思
1. 多继承与菱形继承的复杂性
C++ 语法复杂,其中一个重要体现就是支持 多继承。相比 Java 等语言只支持单继承,C++ 允许一个类同时继承自多个父类。这虽然带来了更高的表达能力,却也容易引入菱形继承问题:
class A {};
class B : public A {};
class C : public A {};
class D : public B, public C {}; // 产生菱形继承
在上述结构中,D 同时继承了 B 和 C,而 B 和 C 又都继承了 A,这将导致 D 中拥有两份 A 的拷贝,产生 数据冗余 和 二义性问题。
为了解决这一问题,C++ 引入了 虚拟继承(virtual inheritance):
class A {};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {}; // 菱形虚拟继承
通过使用 virtual 关键字,编译器保证 D 中只存在一份 A 的成员,从而有效解决菱形继承引发的资源冗余与访问二义性问题。
尽管 C++ 提供了解决手段,但由于虚拟继承背后的底层实现十分复杂(如虚基表、偏移指针、内存布局调整等),多继承尤其是菱形继承在实际项目中应 慎重使用或尽量避免。
2. 继承 vs. 组合 —— 复用策略的权衡
在类之间建立联系时,继承(inheritance) 和 组合(composition) 是最常见的两种手段,它们代表了两种不同的设计思想:
组合 表示 has-a 关系:一个类拥有另一个类的成员对象。
class Tire {};
class Car {
Tire _t; // Car has-a Tire
};
public 继承 表示 is-a 关系:派生类是基类的一个特例。
class Car {};
class BMW : public Car {}; // BMW is-a Car
从复用的角度来看:
- 继承是白箱复用(white-box reuse):继承允许你根据基类的实现来定义派生类的实现。这种继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。
- 通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语'白箱'是相对可视性而言:在继承方式中,基类的内部细节对子类可见。
- 派生类能访问基类的内部实现,耦合度高,封装性差。基类一旦修改,派生类容易受到波及。派生类和基类间的依赖关系很强,耦合度高。
- 组合是黑箱复用(black-box reuse):对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。
- 对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse),因为对象的内部细节是不可见的。对象只以'黑箱'的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 依赖对方的接口,不关心其内部实现,耦合度低,封装性好,更利于模块的独立与维护。
因此,在实际开发中,我们应优先使用组合。只有当明确需要 is-a 语义、需要利用基类接口实现运行时多态(如虚函数调用)时,才使用继承。
3. 什么时候使用继承?什么时候使用组合?
| 场景 | 选择方式 | 说明 |
|---|---|---|
| 类之间具有强 is-a 关系 | 继承 | 如'BMW 是 Car',需使用基类接口、实现多态时使用 |
| 类之间为 has-a 或弱联系 | 组合 | 如'Car 有 Tire',避免过度耦合,提升维护性 |
| 需要代码复用但无 is-a 语义 | 组合 | 尽量不要为了复用成员函数而滥用继承 |
| 系统结构需支持多态扩展 | 继承 + 虚函数 | 典型如策略模式、抽象工厂模式等 |
总而言之:'能组合就不要继承,继承只用于真正的 is-a 关系和多态需求' 是 C++ 设计中应牢记的重要原则。
4. 继承相关的笔试与面试要点
继承机制是 C++ 面试的高频考点,常见问题包括:
- 什么是菱形继承?
- 指一个派生类继承自两个类,而这两个类又继承自同一个基类,形成一个'菱形'类图结构。
- 容易造成基类成员的冗余与访问二义性。
- 如何解决菱形继承的问题?
- 使用 虚继承(
virtual),让派生类只拥有一份共同基类的数据,从而避免冗余和二义性。
- 使用 虚继承(
- 继承和组合的区别?
- 继承是 is-a,组合是 has-a;继承是白箱复用,耦合度高;组合是黑箱复用,耦合度低。
- 何时使用继承,何时使用组合?
- 有明显的 is-a 语义或需要运行时多态时使用继承,其他场景优先考虑组合。
通过实际例子加深理解:
class Car {
protected:
string _colour = "白色";
string _num = "陕 ABIT00";
};
class BMW : public Car {
public:
void Drive() { cout << "好开 - 操控" << endl; }
};
class Benz : public Car {
public:
void Drive() { cout << "好坐 - 舒适" << endl; }
};
上述结构中,BMW 和 Benz 与 Car 构成 is-a 关系,适合继承。
而如下结构更适合组合:
// 轮胎
class Tire {
protected:
string _brand = "Michelin";
size_t _size = 17;
};
class Car {
protected:
string _colour = "白色";
string _num = "陕 ABIT00";
Tire _t; // has-a 关系
};
9. 结语
继承是 C++ 提供的一把'双刃剑'。它在合适的场景中能够显著提升代码复用率和可维护性,赋予程序灵活的多态行为;但在设计不当时,它也可能带来高耦合、可维护性下降以及隐藏的二义性问题。
通过本文的学习,我们不仅掌握了继承的语法规则与访问控制,还深入探讨了默认成员函数在继承中的行为、作用域与隐藏机制、静态成员与友元的继承特性,以及多继承和虚拟继承的底层原理与应用场景。更重要的是,我们结合实际经验,强调了在工程实践中应当谨慎对待继承,能用组合解决的问题,不必滥用继承,而当确实需要继承时,应确保类之间存在明确的 is-a 关系,并尽可能保持基类的稳定性和接口清晰性。
在 C++ 的世界中,继承不仅是一种代码结构上的关系,更是一种设计思想的体现。希望这篇文章能够帮助你在未来的开发中更加得心应手地运用继承,既发挥它的优势,又规避它的陷阱,让你的代码既优雅又健壮。


