C++ 继承进阶:默认成员函数、多继承、虚继承与组合设计

🔥 星恒随风:
个人主页
❄️ 个人专栏:
《指针合集》
|
《C语言基础》
|
《数据结构》
|
《机器学习导论》
|
《前端基础》
|
《python基础》
|
《C++从入门到入土》
✨ 数据即知识,压缩即智能
文章目录
- C++ 继承进阶:默认成员函数、多继承、虚继承与组合设计
-
- 前言
- 一、派生类对象由哪些部分组成?
-
- 1.1 基类子对象和派生类成员
- 1.2 对象初始化不是简单地执行构造函数体
- 二、派生类构造函数如何初始化基类?
-
- 2.1 基类有默认构造函数
- 2.2 基类没有默认构造函数
- 三、派生类的拷贝构造
-
- 3.1 编译器默认生成时会处理基类部分
- 3.2 手动编写时要显式拷贝基类
- 四、派生类的赋值运算符
-
- 4.1 赋值也要处理两部分
- 4.2 可以优先使用 = default
- 五、构造和析构顺序
-
- 5.1 构造顺序
- 5.2 析构顺序
- 六、基类析构函数什么时候要加 virtual?
-
- 6.1 通过基类指针删除派生对象
- 6.2 哪些基类需要虚析构?
- 七、如何禁止一个类被继承?
-
- 7.1 使用 final
- 7.2 私有构造函数的旧思路
- 八、友元关系不会沿继承链传播
-
- 8.1 基类友元只能访问基类
- 8.2 友元也不具有传递性
- 九、静态成员在继承体系中只有一份
-
- 9.1 普通成员每个对象各有一份
- 9.2 static 成员属于类
- 十、多继承是什么?
-
- 10.1 单继承
- 10.2 多继承
- 10.3 多继承中的同名成员二义性
- 十一、多继承中的指针调整
-
- 11.1 不同基类指针可能具有不同地址值
- 11.2 不要依赖具体布局
- 十二、菱形继承为什么麻烦?
-
- 12.1 菱形结构
- 12.2 数据冗余
- 12.3 访问二义性
- 十三、虚继承如何解决菱形问题?
-
- 13.1 声明虚继承
- 13.2 virtual 写在什么位置?
- 十四、虚基类由谁负责构造?
-
- 14.1 普通继承中的基类构造
- 14.2 虚继承中的最派生类规则
- 14.3 单独构造中间类时仍由中间类负责
- 十五、标准库中的虚继承例子
- 十六、继承和组合如何选择?
-
- 16.1 继承表达 is-a
- 16.2 组合表达 has-a
- 16.3 白盒复用和黑盒复用
- 16.4 优先组合,但不是拒绝继承
- 16.5 一个简单判断流程
- 十七、常见错误整理
-
- 17.1 在构造函数体中“调用”基类构造
- 17.2 手动写拷贝构造却忘记基类部分
- 17.3 派生类赋值时只复制自己的成员
- 17.4 多态基类没有虚析构函数
- 17.5 把友元理解成可继承权限
- 17.6 认为每个对象都有一份 static 成员
- 17.7 用类域解决菱形继承后认为问题全部消失
- 17.8 依赖多继承对象的具体内存排列
- 17.9 为了复用实现随意使用 public 继承
- 总结
前言
掌握继承的基本语法后,接下来会遇到一些更容易出错的问题:
- 派生类构造时,基类部分由谁初始化?
- 拷贝构造和赋值运算符是否需要处理基类?
- 为什么构造顺序是从基类到派生类,析构顺序却正好相反?
- 基类析构函数什么时候必须是虚函数?
- 友元关系和静态成员会不会被继承?
- 多继承为什么会产生二义性?
- 菱形继承为什么会造成数据冗余?
- 虚继承如何让多个路径共享同一个基类子对象?
- 继承和组合到底应该如何选择?
这些内容看起来比较分散,实际上都围绕一个核心问题:
一个派生类对象中,不仅有派生类自己的成员,还包含基类子对象。
接下来我们就围绕着这点展开
一、派生类对象由哪些部分组成?
1.1 基类子对象和派生类成员
假设有下面两个类:
#include <string>
using namespace std;
class Person
{
protected:
string _name;
int _age = 0;
};
class Student : public Person
{
protected:
int _studentId = 0;
};
一个 Student 对象可以抽象成:
Student 对象
├── Person 基类子对象
│ ├── _name
│ └── _age
└── Student 自己的成员
└── _studentId
这里的 Person 不是独立于 Student 的另一个对象,而是 Student 对象内部的一个基类子对象。
因此,构造 Student 时,需要先让 Person 部分进入有效状态,再初始化学生自己的成员。 
1.2 对象初始化不是简单地执行构造函数体
构造对象时,真正的顺序大致是:
虚基类
→ 直接非虚基类
→ 成员变量
→ 派生类构造函数体
其中:
- 基类按照继承列表中的声明顺序初始化
- 成员变量按照它们在类中的声明顺序初始化
- 和初始化列表中的书写先后无关
因此不要故意把初始化列表写成与声明顺序不同的样子,虽然程序可能仍能编译,但阅读起来很容易产生误解。
二、派生类构造函数如何初始化基类?
2.1 基类有默认构造函数
#include <iostream>
#include <string>
using namespace std;
class Person
{
public:
Person()
{
cout << "Person()" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
Student()
{
cout << "Student()" << endl;
}
private:
int _studentId = 0;
};
创建对象:
Student student;
输出顺序是:
Person()
Student()
编译器会先构造基类子对象,再执行派生类构造函数。
2.2 基类没有默认构造函数
假设基类只能通过姓名构造:
class Person
{
public:
explicit Person(const string& name)
: _name(name)
{
cout << "Person(const string&)" << endl;
}
protected:
string _name;
};
派生类必须在初始化列表中明确调用基类构造函数:
class Student : public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
cout << "Student(const string&, int)" << endl;
}
private:
int _studentId;
};
不能写成:
Student(const string& name, int studentId)
{
Person(name);
}
因为构造函数体开始执行时,基类子对象早就应该完成初始化了。
函数体中的:
Person(name);
只会创建一个临时的 Person 对象,并不能重新初始化当前对象中的基类部分。
三、派生类的拷贝构造
3.1 编译器默认生成时会处理基类部分
class Person
{
public:
Person(const Person& other)
: _name(other._name)
{
}
protected:
string _name;
};
class Student : public Person
{
private:
int _studentId = 0;
};
如果没有手动编写 Student 的拷贝构造,编译器生成的版本会依次拷贝:
Person 基类子对象
Student 自己的成员
3.2 手动编写时要显式拷贝基类
class Student : public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
}
Student(const Student& other)
: Person(other)
, _studentId(other._studentId)
{
}
private:
int _studentId;
};
其中:
Person(other)
负责调用基类的拷贝构造,复制基类部分。
如果忘记写这一项,编译器会尝试默认构造基类,而不是自动猜测你想调用基类拷贝构造。
如果基类没有默认构造函数,就会直接编译失败。
四、派生类的赋值运算符
4.1 赋值也要处理两部分
派生类赋值时,需要复制:
基类部分
派生类自己的部分
可以这样实现:
class Student : public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
}
Student& operator=(const Student& other)
{
if (this != &other)
{
Person::operator=(other);
_studentId = other._studentId;
}
return *this;
}
private:
int _studentId;
};
这里需要明确写:
Person::operator=(other);
原因是派生类自己的 operator= 会隐藏基类中的同名函数。
如果只写:
operator=(other);
很可能再次调用当前派生类的赋值函数,形成错误递归。 
4.2 可以优先使用 = default
如果成员本身都支持正确的拷贝和赋值,通常不需要手动写这些函数。
class Student : public Person
{
public:
Student(const Student&) = default;
Student& operator=(const Student&) = default;
private:
int _studentId = 0;
};
编译器生成的版本会自动处理基类子对象和成员变量。
只有在类需要特殊资源管理、日志或额外约束时,才有必要手动实现。
五、构造和析构顺序
5.1 构造顺序
假设:
class Base
{
public:
Base()
{
cout << "Base()" << endl;
}
};
class Member
{
public:
Member()
{
cout << "Member()" << endl;
}
};
class Derived : public Base
{
public:
Derived()
{
cout << "Derived()" << endl;
}
private:
Member _member;
};
创建:
Derived object;
输出:
Base()
Member()
Derived()
构造顺序为:
基类
→ 成员对象
→ 派生类构造函数体

5.2 析构顺序
对象销毁时顺序反过来:
派生类析构函数体
→ 成员对象
→ 基类
如果加入析构输出,结果大致是:
~Derived()
~Member()
~Base()
为什么析构要反过来?
因为派生类可能还依赖基类提供的状态。只有先清理最外层的派生类,再逐层清理它依赖的内部部分,才能保持对象生命周期的完整性。
可以把它理解成搭积木:
搭建时:先底座,再上层
拆除时:先上层,再底座
六、基类析构函数什么时候要加 virtual?
6.1 通过基类指针删除派生对象
看下面的代码:
class Base
{
public:
~Base()
{
cout << "~Base()" << endl;
}
};
class Derived : public Base
{
public:
~Derived()
{
cout << "~Derived()" << endl;
}
};
如果执行:
Base* ptr = new Derived;
delete ptr;
基类析构函数不是虚函数时,通过基类指针删除派生对象会产生未定义行为。
正确设计通常是:
class Base
{
public:
virtual ~Base() = default;
};
此时:
Base* ptr = new Derived;
delete ptr;
会先调用:
Derived::~Derived()
再调用:
Base::~Base()

6.2 哪些基类需要虚析构?
如果一个类满足下面的情况:
它具有虚函数
它会被当作多态基类使用
外部可能通过 Base* 删除派生对象
那么它通常应该具有公有虚析构函数:
virtual ~Base() = default;
如果明确禁止通过基类指针删除对象,也可以把析构函数设计成保护的非虚函数,但这种设计需要更明确的约束。
七、如何禁止一个类被继承?
7.1 使用 final
C++11 提供了 final:
class Utility final
{
public:
void run()
{
}
};
下面的代码会编译失败:
class Derived : public Utility
{
};
final 明确表示:
当前类不允许继续派生。
它比通过特殊构造函数权限绕开继承更加直观。
7.2 私有构造函数的旧思路
在旧式设计中,也可以把基类构造函数设为私有:
class Base
{
private:
Base() = default;
};
派生类无法调用基类构造函数,因此通常无法正常构造。
不过这种写法表达意图不够直接,也会影响基类本身的创建方式。现代 C++ 中,禁止继承优先使用 final。
八、友元关系不会沿继承链传播
8.1 基类友元只能访问基类
class Student;
class Person
{
friend void display(const Person&, const Student&);
protected:
string _name;
};
class Student : public Person
{
protected:
int _studentId = 0;
};
函数:
void display(const Person& person, const Student& student)
{
cout << person._name << endl;
// cout << student._studentId << endl; // 错误
}
虽然 display 是 Person 的友元,但它不是 Student 的友元。
友元关系不会自动继承。
如果确实需要访问学生的非公有成员,需要在 Student 中再次声明:
class Student : public Person
{
friend void display(const Person&, const Student&);
protected:
int _studentId = 0;
};
8.2 友元也不具有传递性
即使:
A 是 B 的友元
B 是 C 的友元
也不能推出:
A 是 C 的友元
友元是一种由类明确授予的访问权限,不应当沿关系链自动扩散。
九、静态成员在继承体系中只有一份
9.1 普通成员每个对象各有一份
class Person
{
public:
string _name;
};
class Student : public Person
{
};
创建:
Person person;
Student student;
person._name 和 student._name 属于不同对象,地址也不同。 
9.2 static 成员属于类
class Person
{
public:
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
};
无论通过哪个类名访问:
Person::_count = 10;
cout << Person::_count << endl;
cout << Student::_count << endl;
访问的都是同一个静态成员。
也可以通过对象访问:
Person person;
Student student;
cout << &person._count << endl;
cout << &student._count << endl;
两个地址相同。
静态成员不属于某个具体对象,而属于整个类及其继承体系所引用的那一个声明。
十、多继承是什么?
10.1 单继承
一个派生类只有一个直接基类:
class Student : public Person
{
};
关系为:
Person
↓
Student
10.2 多继承
一个派生类同时拥有两个或更多直接基类:
class Scanner
{
public:
void scan()
{
}
};
class Printer
{
public:
void print()
{
}
};
class AllInOneMachine : public Scanner, public Printer
{
};
此时:
Scanner Printer
\\ /
\\ /
AllInOneMachine
对象可以同时使用两个基类提供的接口:
AllInOneMachine machine;
machine.scan();
machine.print();
10.3 多继承中的同名成员二义性
如果两个基类都有同名函数:
class Scanner
{
public:
void start()
{
cout << "Scanner::start()" << endl;
}
};
class Printer
{
public:
void start()
{
cout << "Printer::start()" << endl;
}
};
class AllInOneMachine : public Scanner, public Printer
{
};
调用:
AllInOneMachine machine;
// machine.start(); // 错误:不知道调用哪个 start
需要明确指定路径:
machine.Scanner::start();
machine.Printer::start();
多继承会让名字查找、构造过程和对象转换变得更加复杂,因此不宜为了简单复用几个函数就随意使用。
十一、多继承中的指针调整
11.1 不同基类指针可能具有不同地址值
class Base1
{
public:
int _value1 = 1;
};
class Base2
{
public:
int _value2 = 2;
};
class Derived : public Base1, public Base2
{
public:
int _value3 = 3;
};
执行:
Derived object;
Base1* ptr1 = &object;
Base2* ptr2 = &object;
Derived* ptr3 = &object;
编译器会让每个基类指针指向它对应的基类子对象。
因此,从 Derived* 转换为某个基类指针时,编译器可能需要调整地址。
可以抽象理解为:
Derived 对象
├── Base1 子对象 ← Base1*
├── Base2 子对象 ← Base2*
└── Derived 成员
11.2 不要依赖具体布局
某些编译器中,第一个非虚基类子对象可能位于对象起始位置,第二个基类会产生偏移。
但具体对象布局和偏移属于实现相关细节,不能把某个编译器观察到的地址关系当成通用语言规则。
应当依赖 C++ 的类型转换,让编译器完成正确的指针调整,而不是手动计算地址。
十二、菱形继承为什么麻烦?
12.1 菱形结构
考虑下面的类层次:
class Person
{
public:
string _name;
};
class Student : public Person
{
protected:
int _studentId = 0;
};
class Teacher : public Person
{
protected:
int _teacherId = 0;
};
class Assistant : public Student, public Teacher
{
};
结构为:
Person
/ \\
Student Teacher
\\ /
Assistant
形状像一个菱形,因此称为菱形继承。 
12.2 数据冗余
Student 中有一份 Person 子对象。
Teacher 中也有一份 Person 子对象。
因此 Assistant 中会出现两份普通的 Person 子对象:
Assistant
├── Student
│ └── Person
│ └── _name
└── Teacher
└── Person
└── _name
一个助教对象中出现两个姓名,通常不是我们真正想表达的模型。
12.3 访问二义性
Assistant assistant;
// assistant._name = "小王"; // 错误:访问路径不明确
编译器不知道要访问:
assistant.Student::_name
还是:
assistant.Teacher::_name
可以通过指定类域解决访问二义性:
assistant.Student::_name = "学生路径";
assistant.Teacher::_name = "教师路径";
但两份数据仍然存在,所以数据冗余没有消失。
十三、虚继承如何解决菱形问题?
13.1 声明虚继承
让中间层虚继承 Person:
class Student : virtual public Person
{
protected:
int _studentId = 0;
};
class Teacher : virtual public Person
{
protected:
int _teacherId = 0;
};
class Assistant : public Student, public Teacher
{
};
此时,最派生的 Assistant 对象中只保留一份共享的 Person 虚基类子对象:
Person
↗ ↖
Student Teacher
↘ ↙
Assistant
对象可以抽象成:
Assistant
├── Student 部分
├── Teacher 部分
├── Assistant 自己的成员
└── 共享的 Person 虚基类子对象
于是:
Assistant assistant;
assistant._name = "小王";
不再有两份普通 Person 子对象造成的二义性。 
13.2 virtual 写在什么位置?
下面两种写法等价:
class Student : virtual public Person
{
};
class Student : public virtual Person
{
};
一般使用第一种或团队统一的风格即可。
十四、虚基类由谁负责构造?
这是虚继承中最容易忽略的规则。
14.1 普通继承中的基类构造
普通继承中,直接派生类负责构造自己的直接基类:
class Student : public Person
{
public:
Student(const string& name)
: Person(name)
{
}
};

14.2 虚继承中的最派生类规则
虚基类由最派生类负责初始化。
class Person
{
public:
explicit Person(const string& name)
: _name(name)
{
}
protected:
string _name;
};
class Student : virtual public Person
{
public:
Student(const string& name, int studentId)
: Person(name)
, _studentId(studentId)
{
}
private:
int _studentId;
};
class Teacher : virtual public Person
{
public:
Teacher(const string& name, int teacherId)
: Person(name)
, _teacherId(teacherId)
{
}
private:
int _teacherId;
};
最终派生类:
class Assistant : public Student, public Teacher
{
public:
Assistant(const string& name)
: Person(name)
, Student("Student 路径", 1001)
, Teacher("Teacher 路径", 2001)
{
}
};
创建:
Assistant assistant("最终姓名");
共享的虚基类 Person 使用的是:
Person(name)
也就是 Assistant 初始化列表中的参数。
Student 和 Teacher 初始化列表中对 Person 的构造,在创建最派生的 Assistant 对象时不会负责最终虚基类的初始化。
14.3 单独构造中间类时仍由中间类负责
如果直接创建:
Student student("学生姓名", 1001);
此时 Student 本身就是最派生类,所以它负责初始化虚基类 Person。
“最派生类”不是固定指某个类名,而是指这一次实际创建对象时最外层的完整类型。
十五、标准库中的虚继承例子
C++ 输入输出流体系是虚继承的经典例子。
可以简化理解为:
basic_ios
/ \\
basic_istream basic_ostream
\\ /
basic_iostream
输入流和输出流都需要共享基础的流状态。
如果采用普通菱形继承,basic_iostream 中会出现两份基础流状态。使用虚继承后,最派生流对象只保留一份共享的 basic_ios 虚基类子对象。
这说明虚继承并非完全不能使用。
但它适合真正存在共享基类语义的场景,而不是为了展示语法而主动设计复杂菱形结构。
十六、继承和组合如何选择?
16.1 继承表达 is-a
公有继承通常表达:
Derived is a Base
例如:
Student is a Person
Circle is a Shape
Dog is an Animal
派生类应当能够在需要基类的地方自然替代基类。 
16.2 组合表达 has-a
组合表示一个类拥有另一个类对象:
class Tire
{
private:
string _brand = "Michelin";
};
class Car
{
private:
Tire _frontLeft;
Tire _frontRight;
Tire _rearLeft;
Tire _rearRight;
};
关系为:
Car has Tires
汽车拥有轮胎
汽车不是一种轮胎,因此不应该写:
class Car : public Tire
{
};
16.3 白盒复用和黑盒复用
继承有时被称为白盒复用。
派生类能够看到基类的公有和保护成员,容易依赖基类内部实现。基类一旦发生改变,派生类可能受到较大影响。
组合通常被称为黑盒复用。
组合类主要通过另一个对象的公有接口使用它,不需要了解其内部细节,耦合度通常更低。
16.4 优先组合,但不是拒绝继承
“优先组合而不是继承”不是说继承不能使用。
更准确的理解是:
只有确实存在稳定的 is-a 关系时,才使用 public 继承。
只是为了复用实现时,优先考虑组合。
例如,栈内部使用 vector:
template<class T>
class Stack
{
public:
void push(const T& value)
{
_data.push_back(value);
}
void pop()
{
_data.pop_back();
}
T& top()
{
return _data.back();
}
private:
vector<T> _data;
};
这里:
Stack has a vector
比:
Stack is a vector
更符合实际语义。
16.5 一个简单判断流程
设计类关系时,可以依次问:
1. 派生类能否自然替代基类?
能:考虑 public 继承
2. 当前类只是需要另一个类提供能力?
是:考虑组合
3. 只是为了复用几段实现代码?
是:优先组合或普通成员函数复用
4. 是否需要运行时多态?
是:通常需要基类接口和 public 继承
十七、常见错误整理
17.1 在构造函数体中“调用”基类构造
错误:
Student(const string& name)
{
Person(name);
}
正确:
Student(const string& name)
: Person(name)
{
}
17.2 手动写拷贝构造却忘记基类部分
错误:
Student(const Student& other)
: _studentId(other._studentId)
{
}
正确:
Student(const Student& other)
: Person(other)
, _studentId(other._studentId)
{
}
17.3 派生类赋值时只复制自己的成员
错误:
Student& operator=(const Student& other)
{
_studentId = other._studentId;
return *this;
}
正确:
Student& operator=(const Student& other)
{
if (this != &other)
{
Person::operator=(other);
_studentId = other._studentId;
}
return *this;
}
17.4 多态基类没有虚析构函数
如果会通过基类指针删除派生对象,应当使用:
virtual ~Base() = default;
17.5 把友元理解成可继承权限
基类的友元不是派生类的友元。
每个类都需要单独授予友元权限。
17.6 认为每个对象都有一份 static 成员
静态成员属于类,不属于具体对象。继承体系中引用的是同一个基类静态成员声明。
17.7 用类域解决菱形继承后认为问题全部消失
指定:
assistant.Student::_name
只能解决访问路径二义性。
普通菱形继承中的两份 Person 数据仍然存在。
17.8 依赖多继承对象的具体内存排列
不同编译器和 ABI 可能采用不同布局。
正确做法是通过语言提供的指针转换访问基类子对象,而不是手工计算偏移。
17.9 为了复用实现随意使用 public 继承
public 继承会建立公开的类型替代关系。
如果只需要另一个类提供的功能,组合通常更合适。
总结
继承进阶部分的规则虽然多,但都可以从“派生类对象包含基类子对象”这一点出发理解。
需要重点掌握:
继承本身不是越多越好。
一个清晰、稳定的类层次可以减少重复并支持多态;一个勉强拼出来的继承体系,则会让访问权限、对象生命周期和依赖关系越来越复杂。
设计继承关系时,先判断类型语义,再考虑代码复用,通常会更加稳妥。
网硕互联帮助中心




评论前必须登录!
注册