第15章 继承与派生
第15章 继承与派生
- 如何通过继承复用和扩展已有类的代码
- 理解公有、保护、私有继承的区别与用途
- 掌握派生类构造与析构的顺序及初始化列表写法
想象一下你要设计一个车辆管理系统。与其为卡车、轿车、摩托车各自从头编写所有“车辆”相关的代码(如品牌、颜色、启动),不如先定义一个通用的“车辆”蓝图,然后让具体车型去继承它,并添加自己独有的特性(如卡车的载重量)。这就是继承的核心思想:基于已有的类创建新类,实现代码复用和层次化建模。
15.1 继承的基本语法与三种方式
在C++中,被继承的类称为基类(Base Class)或父类,新创建的类称为派生类(Derived Class)或子类。派生类会自动获得基类的所有成员(数据和方法),并可以添加新成员或修改基类行为。
继承方式决定了基类成员在派生类中的“可见性”
| 继承方式 | 基类`public`成员 | 基类`protected`成员 | 基类`private`成员 | 主要用途 |
|---|---|---|---|---|
| public 继承 | 在派生类中仍为 public | 在派生类中仍为 protected | 不可直接访问 | 建模“是一个(is-a)”关系,最常用。 |
| protected 继承 | 在派生类中变为 protected | 在派生类中仍为 protected | 不可直接访问 | 基类实现细节仅供进一步派生使用,极少用。 |
| private 继承 | 在派生类中变为 private | 在派生类中变为 private | 不可直接访问 | 建模“用…实现(implemented-in-terms-of)”关系,可被组合替代。 |
看一个具体例子。假设我们有一个基类 Vehicle,现在用公有继承派生出 Car 类。
// 基类定义
class Vehicle {
public:
void start() { cout << "Vehicle started." << endl; }
protected:
string brand = "Unknown";
private:
int serialNumber = 0;
};
// 派生类定义:公有继承
class Car : public Vehicle { // 使用 public 继承
public:
void showBrand() {
cout << "Brand: " << brand << endl; // 正确:可以访问基类的protected成员
// cout << serialNumber << endl; // 错误:不能访问基类的private成员
}
int doorCount = 4; // 派生类新增成员
};
// 使用
main() {
Car myCar;
myCar.start(); // 正确:继承了基类的public方法
myCar.showBrand();
// myCar.brand = "Toyota"; // 错误:外部不能访问protected成员
}
常见误区
- 混淆“不能访问”和“不存在”:基类的 private 成员在派生类对象中依然存在并占用内存,只是派生类成员函数无法直接访问它。需要通过基类提供的 public 或 protected 接口间接操作。
- 滥用 protected 继承:实际开发中,99%的情况应使用 public 继承。protected 和 private 继承破坏了接口的直观性,通常用对象组合(将一个类作为另一个类的成员)来替代更清晰。
- 认为继承就是复制代码:继承建立的是编译时的依赖关系。派生类对象内部包含一个完整的基类子对象,而不是简单的代码拷贝。
工程经验:优先使用公有继承和组合
我在项目中踩过这个坑:曾经用 private 继承来实现一个策略类,结果导致后续维护者完全无法理解类之间的关系。后来重构为“组合”(将策略类作为成员变量),代码可读性大幅提升。记住这条原则:如果派生类和基类之间是“是一个”的关系(如 Car 是一种 Vehicle),用 public 继承;如果是“有一个”或“用…来实现”的关系,优先使用对象组合。
15.2 派生类的构造与析构:严格的顺序
创建一个派生类对象时,构造和析构函数的调用顺序是固定的,理解这个顺序对管理资源(如动态内存、文件句柄)至关重要。
顺序可以概括为:构造由内而外(先基类,再成员,最后自己),析构由外而内(先自己,再成员,最后基类)。这个顺序是自动的,但你需要用初始化列表来正确地向基类构造函数传递参数。
class Engine {
public:
Engine(int hp) : horsepower(hp) { cout << "Engine constructed: " << hp << "HP" << endl; }
~Engine() { cout << "Engine destructed" << endl; }
private:
int horsepower;
};
class BaseVehicle {
public:
BaseVehicle(const string& id) : vehicleId(id) { cout << "BaseVehicle constructed: " << id << endl; }
~BaseVehicle() { cout << "BaseVehicle destructed" << endl; }
private:
string vehicleId;
};
class Motorcycle : public BaseVehicle {
public:
// 初始化列表:先调用基类构造函数,再初始化成员对象
Motorcycle(const string& id, int engineHP)
: BaseVehicle(id), // 必须在这里调用基类构造函数
engine(engineHP), // 初始化成员对象
type("Sport") { // 初始化自身成员
cout << "Motorcycle constructed" << endl;
}
~Motorcycle() { cout << "Motorcycle destructed" << endl; }
private:
Engine engine; // 成员对象
string type;
};
// 测试代码
int main() {
Motorcycle bike("M001", 150);
return 0;
}
/* 输出顺序:
BaseVehicle constructed: M001
Engine constructed: 150HP
Motorcycle constructed
Motorcycle destructed
Engine destructed
BaseVehicle destructed
*/
关键细节:默认调用
如果你在派生类构造函数的初始化列表中没有显式调用基类构造函数,编译器会自动调用基类的默认构造函数(无参构造函数)。如果基类没有默认构造函数,编译将报错。实际开发中,养成在初始化列表中显式调用基类构造的习惯,能让代码意图更清晰。
编者提示:初始化列表的语法和重要性在第13章《封装与访问控制》中已有详细讨论。在继承体系中,它对于正确初始化基类子对象更是不可或缺。
15.3 名字隐藏与作用域解析
如果派生类定义了一个与基类同名的成员函数(即使参数列表不同),基类的同名函数在派生类作用域内会被隐藏,而不是重载。这常常是新手困惑的来源。
class Base {
public:
void func(int x) { cout << "Base::func(int)" << endl; }
};
class Derived : public Base {
public:
// 定义了同名函数,隐藏了基类的所有func版本
void func(double x) { cout << "Derived::func(double)" << endl; }
};
main() {
Derived d;
d.func(5.0); // 正确:调用 Derived::func(double)
d.func(5); // 也调用 Derived::func(double),发生int到double的隐式转换
// d.func(5); 本意想调用 Base::func(int),但被隐藏了,导致编译错误或非预期行为
// 解决方案:使用作用域解析运算符 ::
d.Base::func(5); // 正确:显式调用基类版本
}
要解决这个问题,如果你希望在派生类中重载基类函数而不是隐藏它,可以使用 using 声明将基类函数引入派生类作用域。
class Derived2 : public Base {
public:
using Base::func; // 引入Base::func到当前作用域
void func(double x) { cout << "Derived2::func(double)" << endl; }
// 现在 func(int) 和 func(double) 在Derived2中形成重载
};
main() {
Derived2 d2;
d2.func(5); // 正确:调用从Base引入的 func(int)
d2.func(5.0); // 正确:调用 Derived2::func(double)
}
公有继承 (is-a)
- 关系:派生类对象“是一个”基类对象。
- 示例:Car 是一种 Vehicle。
- 代码:class Car : public Vehicle
组合 (has-a)
- 关系:类“有一个”其他类的对象作为成员。
- 示例:Car 有一个 Engine。
- 代码:class Car { Engine engine; };
私有继承 (implemented-in-terms-of)
- 关系:派生类“用基类来实现”。
- 示例:Stack 用 List 来实现(不常见)。
- 注意:通常优先用组合替代。
实际开发中,大部分团队会严格遵循一个准则:仅当需要表现“是一个”关系,且需要利用多态性(下一章内容)时,才使用公有继承。其他情况,优先考虑组合(包含对象成员)。这能有效降低类之间的耦合度。
动手试一试
1. 定义一个基类 Shape,包含保护成员 double area 和一个公有虚函数 calculateArea()(目前可为空)。
2. 使用公有继承创建派生类 Circle,添加私有成员 double radius,并在构造函数中初始化它。重写 calculateArea() 函数,使其计算并更新 area 成员(面积公式:π * r²,使用 3.14159)。
3. 在 main 函数中创建一个 Circle 对象,调用 calculateArea(),并思考如何能安全地读取 area 值(提示:考虑添加一个公有getter函数)。
检验你的理解
- 给定基类 class Base { private: int a; protected: int b; public: int c; }; 和派生类 class Derived : protected Base { }。在Derived的成员函数内部,哪些变量可以直接访问?
- 创建一个派生类对象时,构造函数调用顺序是怎样的?如果派生类有多个直接基类(多重继承),顺序又如何确定?
- 写出代码,使派生类 Derived 能同时重载基类 Base 的 print(int) 函数和新增自己的 print(const string&) 函数,而不发生名字隐藏。
本章小结
- 继承的核心是代码复用和关系建模:通过派生类扩展基类功能,建立类之间的层次结构。
- 访问控制是关键:公有继承(is-a)最常用;基类的private成员对派生类不可见,protected成员对派生类可见。
- 构造与析构顺序固定:构造顺序为“基类 → 成员对象 → 派生类自身”;析构顺序完全相反。必须使用初始化列表正确传递参数给基类构造函数。
- 注意名字隐藏:派生类同名成员会隐藏基类所有同名成员,可使用 using 声明引入基类函数形成重载,或使用作用域解析运算符 :: 显式调用。
- 优先使用组合而非私有/保护继承:除非明确需要多态,否则“有一个”关系用组合实现更灵活、耦合度更低。
微信扫码关注
抖音扫码关注