C++继承与组合深度解析:从三大特性到设计模式实战
1. 项目概述:从“是什么”到“为什么”
在C++的面向对象编程世界里,封装、继承和多态这三大特性,就像盖房子的三块基石。封装让你把数据和操作打包成一个“黑盒”,多态让你能用统一的接口处理不同的对象,而继承,则是我们今天要深挖的核心。它允许你基于一个已有的类(基类或父类)来创建一个新的类(派生类或子类),从而复用代码、建立类之间的层次关系。
但继承远不止是“复制粘贴”父类的代码那么简单。它背后牵涉到访问权限控制、内存布局、接口与实现的分离、以及至关重要的“is-a”关系设计。更复杂的是,当你发现单纯用继承来描述“有一个”的关系显得笨拙甚至错误时,组合(Composition)这个强大的工具就登场了。很多初学者,甚至一些有经验的开发者,常常混淆或误用这两者,导致代码结构僵化、难以维护。
这篇文章,我将结合十多年的C++项目踩坑经验,带你彻底搞懂继承的三种方式(public, protected, private)、它们的内存与行为影响,并深入剖析组合作为继承的替代或补充方案,在何时、为何以及如何被使用。我们不止步于语法,更要探究设计思想,让你在下一个项目中,能自信地做出最合适的设计选择。
2. 继承的三种方式:不仅仅是访问权限
C++提供了三种继承方式:public、protected和private。很多人只记住了它们改变了成员在派生类中的访问权限,但这只是冰山一角。更深层的区别在于派生类与基类之间的语义关系,以及这种关系对代码设计和使用的约束。
2.1 Public继承:建立“是一个(is-a)”关系
public继承是C++中最常用,也最需要谨慎使用的继承方式。它的核心语义是:派生类对象“是一个”基类对象。这意味着,在任何期望使用基类对象的地方,你都可以安全地使用派生类对象来替代。
class Vehicle { public: virtual void move() = 0; // 纯虚函数,Vehicle是抽象类 virtual ~Vehicle() {} // 虚析构函数,确保正确释放资源 }; class Car : public Vehicle { // public继承 public: void move() override { std::cout << "Car is driving on the road.\n"; } }; void transport(Vehicle& v) { v.move(); // 可以接受任何Vehicle的派生类 } int main() { Car myCar; transport(myCar); // 正确:Car is-a Vehicle }关键点解析:
- 接口继承:通过
public继承,派生类Car继承了Vehicle的接口(move)。Car对象可以被当作Vehicle对象来使用,如传递给transport函数。 - 访问权限映射:
- 基类的
public成员 -> 在派生类中仍为public。 - 基类的
protected成员 -> 在派生类中为protected。 - 基类的
private成员 ->对派生类不可见。注意,是“不可见”,而非“不存在”。派生类对象的内存布局中仍然包含基类的private成员,派生类的构造函数、析构函数、拷贝控制成员(拷贝构造、拷贝赋值等)也会隐式地操作它们,但派生类的成员函数无法直接访问它们。
- 基类的
- “is-a”的严格性:这是设计的关键。
Car继承Vehicle是合理的,因为所有Car应该具备Vehicle的所有行为(比如都能move)。但经典的“正方形继承自矩形”就是一个反例,因为正方形改变了长宽必须相等的约束,违反了矩形长宽可独立变化的基类契约。
实操心得:在设计
public继承层次时,务必用“里氏替换原则”(Liskov Substitution Principle, LSP)来检验。问问自己:所有对基类对象的假设(前置条件、后置条件、不变量)是否都适用于派生类对象?如果答案是否定的,那么public继承很可能是不合适的。
2.2 Protected与Private继承:实现继承的两种形态
当你不希望建立“is-a”关系,而只是想复用基类的实现(代码)时,protected和private继承就派上用场了。它们统称为实现继承。
class Utility { public: void helperFunc1() { /* ... */ } protected: void helperFunc2() { /* ... */ } private: int secretData; }; class MyClassProtected : protected Utility { // helperFunc1 在这里变成 protected // helperFunc2 在这里仍然是 protected // secretData 不可见 void foo() { helperFunc1(); // OK,可在派生类内部使用 helperFunc2(); // OK } }; class MyClassPrivate : private Utility { // helperFunc1 在这里变成 private // helperFunc2 在这里变成 private // secretData 不可见 void bar() { helperFunc1(); // OK,可在派生类内部使用 helperFunc2(); // OK } }; void test() { MyClassProtected objP; // objP.helperFunc1(); // 错误!helperFunc1在MyClassProtected中是protected MyClassPrivate objV; // objV.helperFunc1(); // 错误!helperFunc1在MyClassPrivate中是private Utility* ptr = &objP; // 错误!protected/private继承下,不能将派生类指针/引用转为基类指针/引用(除了在派生类成员函数内部)。 }核心区别与设计考量:
| 特性 | Protected继承 | Private继承 |
|---|---|---|
| 访问权限变化 | 基类public/protected成员 -> 派生类protected成员 | 基类public/protected成员 -> 派生类private成员 |
| 后续继承影响 | 可以被进一步继承。MyClassProtected的派生类还能访问到Utility的public/protected成员(尽管在MyClassProtected中它们已是protected)。 | 继承链终止。MyClassPrivate的派生类无法再访问Utility的任何成员,因为它们在MyClassPrivate中已是private。 |
| 语义 | “在派生类及其后续派生类中实现复用”。 | “仅在当前派生类中实现复用,且不希望被进一步暴露”。 |
| 与组合的对比 | 很多时候,可以用“包含一个protected成员对象”来替代。 | 很多时候,可以用“包含一个private成员对象”来替代。但private继承有一个独特优势:空基类优化(EBO)。 |
空基类优化(Empty Base Optimization, EBO)实战:当一个类没有任何非静态成员变量、虚函数,且其基类和所有非静态成员也都是空类型时,它的大小理论上可以为0。但C++规定独立对象必须有唯一地址,因此sizeof(空类)通常为1。然而,如果这个空类作为基类被继承,编译器可以将其优化到不占派生类的任何空间。
class Empty {}; // 空类,sizeof(Empty) == 1 (通常) // 使用组合:包含一个Empty对象成员 class HolderComposition { Empty e; // 至少占1字节(可能因内存对齐占更多) int data; }; // sizeof(HolderComposition) 很可能 >= sizeof(int) + 1,并向上对齐。 // 使用private继承:利用EBO class HolderPrivateInherit : private Empty { int data; }; // sizeof(HolderPrivateInherit) 很可能 == sizeof(int)。Empty部分被优化掉了。标准库中的std::unique_ptr、std::shared_ptr的deleter,以及boost::noncopyable等,都广泛利用了EBO来避免不必要的空间开销。
注意事项:优先考虑使用组合(对象成员)而不是
protected/private继承来实现代码复用,除非你需要:
- 重写基类的虚函数(组合无法直接做到,需要额外设计)。
- 利用空基类优化(EBO)来节省空间。
- 访问基类的
protected成员(组合时,成员对象如果是其他类的实例,则无法访问其protected成员)。如果以上三点都不满足,那么组合通常是更清晰、耦合度更低的选择。
3. 虚函数、纯虚函数与接口设计
继承机制与虚函数密不可分。虚函数实现了运行时多态,是C++动态绑定的核心。
3.1 虚函数与纯虚函数:接口与实现的博弈
class Shape { public: // 纯虚函数:只继承接口,必须被重写 virtual double area() const = 0; // 虚函数:继承接口和一份默认实现 virtual void draw() const { std::cout << "Drawing a generic shape.\n"; } // 非虚函数:继承接口和一份强制实现(不应被重写,否则会隐藏而非覆盖) int getId() const { return id_; } virtual ~Shape() = default; // 基类析构函数必须是虚的! private: int id_; }; class Circle : public Shape { public: Circle(double r) : radius_(r) {} // 必须实现纯虚函数area double area() const override { return 3.14159 * radius_ * radius_; } // 可以选择性重写虚函数draw void draw() const override { std::cout << "Drawing a circle with radius " << radius_ << ".\n"; } // 不能重写(但可以隐藏)非虚函数getId private: double radius_; };设计决策:何时用纯虚?何时用虚函数?
- 纯虚函数 (
= 0):当你定义一个接口,并且强制所有具体的派生类都必须提供自己的实现时使用。这使基类成为抽象类,无法实例化。它明确了“做什么”,但把“怎么做”完全交给派生类。 - 虚函数:当你提供一个默认实现,并且允许(但不强制)派生类根据需要进行特化或扩展时使用。危险在于,派生类可能无意中继承了不合适的默认行为。
一个经典的“默认实现”陷阱:假设有一个Bird基类,它有一个虚函数fly()提供默认实现(比如拍打翅膀)。然后你创建了Penguin(企鹅)类继承Bird。企鹅不会飞,但因为它继承了fly()的默认实现,编译器不会报错,只有在逻辑上或运行时才会出现问题。这就是糟糕的默认实现。
安全提供默认实现的模式:为了避免上述陷阱,同时避免在每个派生类中重复编写相似的代码,可以采用“纯虚函数+默认实现函数”的模式。
class Bird { public: virtual void fly() = 0; // 纯虚,强制派生类声明 protected: void defaultFly() { // 保护的默认实现,不是接口的一部分 std::cout << "Flapping wings to fly.\n"; } }; class Sparrow : public Bird { public: void fly() override { defaultFly(); // 显式调用默认实现 } }; class Penguin : public Bird { public: void fly() override { // 企鹅不会飞,必须提供不同的实现(比如游泳或行走) // 如果忘记实现,链接器会报错“未定义的引用”,因为fly()是纯虚的。 std::cout << "Sorry, I can't fly. I swim.\n"; } };这样,需要默认行为的派生类(Sparrow)可以显式调用,而不需要或不能使用默认行为的派生类(Penguin)则必须自己实现,如果忘记,会在编译期或链接期发现错误,而不是在运行时产生逻辑错误。
3.2 接口类与实现完全分离
在严格的设计中,特别是大型项目或库的开发中,我们追求接口与实现的完全分离。这催生了纯接口类的概念。
一个纯接口类通常具有以下特征:
- 所有成员函数都是纯虚函数(
= 0)。 - 没有非静态成员变量。
- 通常有一个虚析构函数(且是唯一的非纯虚函数,可能提供默认实现)。
- 所有成员都是
public的。
class ISerializable { // 通常以大写'I'开头表示接口 public: virtual ~ISerializable() = default; // 虚析构函数,保证通过接口指针删除对象正确 virtual void serialize(std::ostream& out) const = 0; virtual void deserialize(std::istream& in) = 0; // 没有数据成员 }; class MyData : public ISerializable { private: int value_; std::string name_; public: void serialize(std::ostream& out) const override { out << value_ << ' ' << name_; } void deserialize(std::istream& in) override { in >> value_ >> name_; } };使用纯接口类的好处是依赖倒置和最小化编译依赖。客户端代码只依赖于稳定的接口ISerializable,而不依赖于易变的实现MyData。当MyData的实现改变时,只需要重新编译MyData.cpp和直接链接它的模块,而所有只包含ISerializable头文件的模块都无需重新编译。
4. 组合(Composition):更灵活的“有一个”关系
当类之间的关系不是“is-a”,而是“has-a”或“uses-a”时,组合是比继承更自然、更灵活的选择。组合意味着将一个类的对象作为另一个类的成员变量。
4.1 组合 vs 继承:如何选择?
让我们通过一个具体案例来分析。假设我们有一个Engine(引擎)类和一个Car(汽车)类。
使用继承(不推荐):
class Engine { public: void start() { /* ... */ } void stop() { /* ... */ } }; class Car : public Engine { // Car is-an Engine? 这很奇怪! public: void drive() { start(); // 可以直接调用,语法上没问题 // ... 驾驶逻辑 } };问题很明显:汽车“是一个”引擎吗?不,汽车“有一个”引擎。用继承来表达“has-a”关系是语义错误,它错误地让Car继承了Engine的所有接口(比如Car对象可以直接调用stop(),这不合逻辑),也限制了Car的设计(比如一辆车不能有多个引擎)。
使用组合(推荐):
class Engine { /* 同上 */ }; class Car { private: Engine engine_; // 组合:Car has-an Engine // std::vector<Engine> engines_; // 甚至可以拥有多个引擎 public: void drive() { engine_.start(); // 通过成员对象调用其功能 // ... 驾驶逻辑 } // Car 不会暴露 Engine 的 stop 等无关接口,封装性更好。 };组合的优势:
- 语义正确:清晰表达了“有一个”的关系。
- 封装性好:
Car可以控制对外暴露哪些Engine的功能,甚至可以完全不暴露。 - 灵活性高:可以在运行时更换
Engine(通过指针或引用成员),可以拥有多个Engine,可以延迟初始化Engine。 - 降低耦合:
Car和Engine是独立的类,修改Engine的内部实现不太会影响Car。
4.2 组合的实践:委托与策略模式
组合不仅仅是包含一个对象,更是一种强大的设计工具,可以实现委托(Delegation)和策略模式(Strategy Pattern)。
委托示例:
class Printer { // 被委托者 public: void printDocument(const std::string& doc) { std::cout << "Printing: " << doc << std::endl; // 实际的打印逻辑,可能很复杂 } }; class Computer { // 委托者 private: Printer printer_; // 组合 public: void print(const std::string& doc) { // 计算机将打印任务委托给打印机对象 printer_.printDocument(doc); } // Computer 可能还有其他与打印无关的功能 };策略模式示例(运行时多态):
// 策略接口 class CompressionStrategy { public: virtual ~CompressionStrategy() = default; virtual std::vector<char> compress(const std::vector<char>& data) = 0; }; // 具体策略 class ZipCompression : public CompressionStrategy { std::vector<char> compress(const std::vector<char>& data) override { /* ZIP算法 */ } }; class RarCompression : public CompressionStrategy { std::vector<char> compress(const std::vector<char>& data) override { /* RAR算法 */ } }; // 上下文(使用策略) class FileArchiver { private: std::unique_ptr<CompressionStrategy> strategy_; // 组合一个策略指针 public: void setStrategy(std::unique_ptr<CompressionStrategy> strat) { strategy_ = std::move(strat); } void archive(const std::string& filename) { auto data = readFile(filename); auto compressed = strategy_->compress(data); // 委托给策略对象 writeFile(filename + ".arc", compressed); } };FileArchiver通过组合一个CompressionStrategy指针,可以在运行时动态切换压缩算法,而不需要修改FileArchiver本身的代码。这是组合与接口继承结合的强大威力。
5. 多重继承与菱形继承难题
C++允许一个类从多个基类继承,即多重继承。这带来了强大的表达能力,但也引入了著名的“菱形继承”问题。
5.1 多重继承的基本使用与名字冲突
class Scanner { public: void scan() { std::cout << "Scanning...\n"; } }; class Printer { public: void print() { std::cout << "Printing...\n"; } }; class AllInOneMachine : public Scanner, public Printer { public: void copy() { scan(); print(); // 可以调用两个基类的方法 } };这看起来很直观。但当两个基类有同名的成员时,就会产生二义性。
class A { public: void func() { std::cout << "A::func\n"; } }; class B { private: // 注意,这里是private! void func() { std::cout << "B::func\n"; } }; class C : public A, public B { }; int main() { C c; // c.func(); // 错误:对‘func’的请求有歧义 c.A::func(); // 正确:使用作用域解析运算符指定 // c.B::func(); // 错误:B::func()是private的 }编译器在查找c.func()时,会先在C的作用域内找,没找到,然后会去所有基类(A和B)的作用域中查找。它同时找到了A::func和B::func,此时就产生了歧义。访问控制(public/private)是在名字查找和重载决议之后才检查的,所以即使B::func是private的,也依然参与了名字查找并导致了歧义。解决方法是使用作用域解析运算符::显式指定。
5.2 菱形继承与虚继承
菱形继承是多重继承中的一个棘手问题。
class File { public: std::string name; void open() { /* ... */ } }; class InputFile : public File { /* 增加读功能 */ }; class OutputFile : public File { /* 增加写功能 */ }; class IOFile : public InputFile, public OutputFile { /* 既能读又能写 */ };现在,IOFile对象中有两个File子对象:一个来自InputFile路径,一个来自OutputFile路径。这会导致:
- 数据冗余:
IOFile对象中有两份name成员。 - 二义性:调用
ioFile.open()或访问ioFile.name时,编译器不知道你指的是哪个File子对象中的成员。
虚继承(Virtual Inheritance)就是为了解决这个问题而引入的。它保证在继承体系中,虚基类(被virtual关键字继承的基类)无论在整个继承层次中出现多少次,在最终的派生类对象中都只存在一个共享的实例。
class File { /* 同上 */ }; class InputFile : virtual public File { /* ... */ }; // 虚继承 class OutputFile : virtual public File { /* ... */ }; // 虚继承 class IOFile : public InputFile, public OutputFile { /* ... */ };现在,IOFile对象中只有一个File子对象。InputFile和OutputFile共享这个子对象。访问ioFile.name或ioFile.open()不再有二义性。
虚继承的代价与注意事项:
- 性能开销:虚继承通常通过指针(虚基类指针)来实现共享,这增加了间接寻址的开销,可能影响性能。
- 初始化责任:在非虚继承中,每个派生类负责初始化其直接基类。在虚继承中,最底层的派生类(最终派生类)负责初始化虚基类。中间层的派生类(如
InputFile)对虚基类File的初始化会被忽略。class File { public: File(const std::string& n) : name(n) {} std::string name; }; class InputFile : virtual public File { public: InputFile(const std::string& n) : File(n + ".in") {} // 这个初始化可能被忽略 }; class OutputFile : virtual public File { public: OutputFile(const std::string& n) : File(n + ".out") {} // 这个初始化可能被忽略 }; class IOFile : public InputFile, public OutputFile { public: // 最终派生类必须负责初始化虚基类File IOFile(const std::string& n) : File(n), // 必须显式初始化虚基类 InputFile(""), // 传递给InputFile的File初始化被忽略 OutputFile("") // 传递给OutputFile的File初始化被忽略 {} }; - 设计复杂:虚继承破坏了继承的局部性,使得类的初始化顺序和内存布局变得反直觉。许多编码规范(如Google C++ Style Guide)明确禁止使用多重继承,或者禁止使用虚继承。
最佳实践建议:
- 优先使用组合:绝大多数“多重继承”的场景,都可以通过组合多个成员对象来更好地实现。
- 如果必须用多重继承:尝试模仿Java或C#的接口继承。即,至多有一个基类是包含实现和数据成员的“主基类”,其他基类都应该是纯接口类(只有纯虚函数,没有数据成员)。这样可以有效避免菱形继承和数据冗余问题。
- 避免虚继承:除非你完全理解其所有含义和开销,并且有非常充分的理由,否则不要使用虚继承。
6. 高级模式:Mixin与CRTP(编译期多态)
当我们讨论组合多个正交功能时,继承和组合的简单使用有时会显得力不从心。这时,我们可以借助模板和一种称为Mixin或CRTP的惯用法。
6.1 问题:组合多个正交功能
假设我们有一个基础任务接口ITask,现在我们想为任务动态地添加一些横切关注点功能,比如日志(Logging)和计时(Timing)。我们希望这些功能模块是独立的、可插拔的。
传统继承的局限:如果通过继承链ITask->LoggingTask->TimingTask,功能之间紧密耦合,TimingTask无法单独使用,且无法灵活组合。
组合的局限:使用代理模式(LoggingTask持有一个ITask*)可以解耦,但引入了运行时多态和堆分配的开销,并且LoggingTask需要实现所有ITask接口(即使只是转发),代码臃肿。
6.2 Mixin解决方案:使用模板进行编译期组合
Mixin的核心思想是:通过模板参数,将功能“混合”进一个类中。每个Mixin都是一个小的、独立的类模板,它通过继承其模板参数来增强功能。
// 基础任务类(假设不是接口,有具体实现) class MyTask { public: void execute() { std::cout << "Executing MyTask...\n"; // 实际任务逻辑 } std::string getName() const { return "MyTask"; } }; // Mixin: 计时功能 template <typename Base> class TimingMixin : public Base { public: void execute() { auto start = std::chrono::high_resolution_clock::now(); Base::execute(); // 调用基类(即被增强的类)的execute auto end = std::chrono::high_resolution_clock::now(); auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start); std::cout << Base::getName() << " took " << duration.count() << " ms.\n"; } // 继承了Base的getName等方法 }; // Mixin: 日志功能 template <typename Base> class LoggingMixin : public Base { public: void execute() { std::cout << "[LOG] Starting execution of " << Base::getName() << std::endl; Base::execute(); std::cout << "[LOG] Finished execution of " << Base::getName() << std::endl; } }; // 使用Mixin进行灵活组合 using MyTaskWithLogging = LoggingMixin<MyTask>; using MyTaskWithTiming = TimingMixin<MyTask>; using MyTaskWithLoggingAndTiming = LoggingMixin<TimingMixin<MyTask>>; // 顺序可换:using MyTaskWithTimingAndLogging = TimingMixin<LoggingMixin<MyTask>>; int main() { MyTaskWithLoggingAndTiming task; task.execute(); // 输出: // [LOG] Starting execution of MyTask // Executing MyTask... // MyTask took X ms. // [LOG] Finished execution of MyTask }Mixin的优势:
- 零开销抽象:所有调用在编译期确定,没有虚函数开销,编译器可以进行充分的内联优化。
- 功能正交与可组合:
TimingMixin和LoggingMixin彼此完全独立,可以以任意顺序组合,也可以单独使用。 - 类型安全:生成的是具体类型,不是通过指针的运行时多态。
- 代码复用:Mixin模板可以应用于任何具有
execute()和getName()方法的类,复用性极高。
6.3 CRTP:奇特的递归模板模式
CRTP是Mixin的一种特殊形式,其中派生类将自己作为模板参数传递给基类。这允许基类在编译时使用派生类的类型信息。
// 基类模板(CRTP) template <typename Derived> class Comparable { public: // 基类可以提供基于派生类operator==的默认operator!= bool operator!=(const Derived& other) const { return !(static_cast<const Derived&>(*this) == other); } }; // 派生类 class MyValue : public Comparable<MyValue> { // 将自己作为模板参数传入 private: int value; public: MyValue(int v) : value(v) {} // 只需要实现operator== bool operator==(const MyValue& other) const { return value == other.value; } // operator!= 自动从Comparable<MyValue>继承而来 }; int main() { MyValue a(5), b(5), c(10); std::cout << (a == b) << std::endl; // true, 调用MyValue::operator== std::cout << (a != c) << std::endl; // true, 调用Comparable<MyValue>::operator!= // 注意:a != b 调用的是从Comparable继承来的operator!= }在CRTP中,基类Comparable通过static_cast<const Derived&>(*this)将自己转换到派生类类型,从而可以调用派生类的方法(这里是operator==)。这实现了编译期的“静态多态”,也是一种强大的代码复用技术,常用于实现各种编译期策略、扩展接口等。
注意事项:Mixin和CRTP都是高级模板技术,它们提高了代码的灵活性和性能,但也增加了编译器的负担和代码的复杂度,可能会使错误信息难以阅读。建议在性能关键、且需要高度灵活组合功能的场景下使用,并在团队中确保大家对这种模式有共识。
7. 常见问题与排查技巧实录
在实际项目中应用继承和组合时,会遇到各种各样的问题。这里记录一些典型坑点和排查思路。
7.1 切片问题(Object Slicing)
这是值语义和继承结合时的一个经典错误。
class Base { public: virtual void print() const { std::cout << "Base\n"; } int data = 10; }; class Derived : public Base { public: void print() const override { std::cout << "Derived\n"; } int extra = 20; }; void funcByValue(Base b) { // 按值传递 b.print(); // 总是调用Base::print(),即使传入的是Derived对象 } int main() { Derived d; funcByValue(d); // 发生切片!d的Derived部分(extra和重写的虚表)被“切掉”了。 Base b = d; // 同样发生切片 }现象:派生类对象被赋值给基类对象(或按值传递给基类参数)时,派生类特有的部分会丢失,虚函数表指针也会被重置为基类的。排查:当多态行为失效,或者派生类成员数据丢失时,检查是否无意中进行了按值传递或赋值。解决:总是通过指针或引用来传递多态对象。使用Base&或Base*(最好是智能指针std::unique_ptr<Base>或std::shared_ptr<Base>)。
7.2 继承与默认成员函数
如果你没有显式定义,编译器会为类生成默认构造函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符和析构函数。在继承体系中,这些函数的生成和调用有特定规则。
问题示例:忘记虚析构函数
class Base { public: ~Base() { std::cout << "Base dtor\n"; } // 非虚析构函数! }; class Derived : public Base { public: ~Derived() { std::cout << "Derived dtor\n"; } int* ptr = new int(100); }; int main() { Base* p = new Derived(); delete p; // 未定义行为!只调用了~Base(),没有调用~Derived(),导致内存泄漏(ptr没被delete)。 }解决:基类的析构函数必须声明为虚函数(virtual ~Base() = default;)。这样,通过基类指针删除派生类对象时,会正确调用整个继承链上的析构函数。
问题示例:拷贝控制与继承
class Base { public: Base() = default; Base(const Base&) { std::cout << "Base copy ctor\n"; } Base& operator=(const Base&) { std::cout << "Base copy assign\n"; return *this; } }; class Derived : public Base { public: // 编译器会为Derived生成默认的拷贝构造函数和拷贝赋值运算符。 // 它们会自动调用基类对应的拷贝控制成员。 Derived() = default; // 隐式生成的Derived拷贝构造函数会先调用Base的拷贝构造函数。 // 隐式生成的Derived拷贝赋值运算符会先调用Base的拷贝赋值运算符。 }; int main() { Derived d1; Derived d2 = d1; // 输出:Base copy ctor (来自隐式生成的Derived拷贝构造函数) d1 = d2; // 输出:Base copy assign (来自隐式生成的Derived拷贝赋值运算符) }关键点:派生类的拷贝/移动构造函数会先调用基类的对应构造函数。派生类的拷贝/移动赋值运算符会先调用基类的对应赋值运算符。如果你在派生类中自己定义了这些函数,必须显式调用基类的版本,否则基类部分会被默认初始化或保持不变。
class Derived : public Base { public: Derived(const Derived& other) : Base(other) { /* 派生类特有成员的拷贝 */ } // 正确 Derived& operator=(const Derived& other) { if (this != &other) { Base::operator=(other); // 显式调用基类赋值运算符 // 派生类特有成员的赋值 } return *this; } };7.3 访问控制与using声明
有时,你希望改变继承来的成员在派生类中的访问权限。
class Base { protected: void internalHelper() { /* ... */ } public: void publicMethod() { /* ... */ } }; class Derived : private Base { // private继承,所有Base的public/protected成员在Derived中都变成private public: // 使用using声明将Base::publicMethod在Derived中恢复为public访问权限 using Base::publicMethod; // 错误:using声明不能提升访问权限(从protected到public在这里是允许的,因为Derived是Base的派生类) // using Base::internalHelper; // 如果放在public区域,则internalHelper在Derived中变为public protected: using Base::internalHelper; // 正确:将internalHelper在Derived中声明为protected };using声明在这里非常有用,特别是在private或protected继承中,可以有选择地将基类的部分接口暴露给派生类的用户。
7.4 设计决策速查表
当你面临“用继承还是组合”的选择时,可以问自己以下问题:
| 问题 | 倾向于继承 | 倾向于组合 |
|---|---|---|
| 新类与旧类的关系是“是一个(is-a)”吗? | 是(Public继承) | 否 |
| 你需要重写基类的虚函数吗? | 是 | 否(组合无法直接重写) |
| 你需要基类的protected成员吗? | 是 | 通常否(组合无法访问成员对象的protected成员) |
| 你需要空基类优化(EBO)吗? | 是(Private继承) | 否 |
| 你需要在运行时动态切换或拥有多个“部分”吗? | 否 | 是 |
| 你希望隐藏基类的部分接口吗? | 否(Public继承会暴露所有public接口) | 是 |
| 你的类会是多重继承体系的一部分吗? | 小心使用 | 更安全 |
记住一个经典的设计原则:“优先使用对象组合,而不是类继承”(Favor composition over inheritance)。组合通常能提供更好的封装性、更低的耦合度和更高的灵活性。继承,尤其是public继承,应该被用于严格建模“is-a”关系,并且要确保符合里氏替换原则。
