Effective C++ 条款32:确定你的 public 继承塑模出 is-a(是一种)关系
public 继承是 C++ 面向对象编程中最核心的机制之一,但也是最常被误用的特性。
本条款将揭示 public 继承的深层含义,帮助你设计出正确的继承体系。
一、问题的提出:继承真的用对了吗?
在 C++ 中,class Derived : public Base 这样的代码随处可见。但你是否真正思考过:什么情况下应该使用 public 继承?
来看几个常见的错误示例:
cpp
// 错误示例1:企鹅是一种鸟,但企鹅会飞吗?
class Bird {
public:
virtual void fly() { /* 鸟的飞行实现 */ }
};
class Penguin : public Bird { // 企鹅是一种鸟?
// 企鹅不会飞!这里的设计有问题
};
// 错误示例2:正方形是一种矩形?
class Rectangle {
public:
virtual void setWidth(int w) { width = w; }
virtual void setHeight(int h) { height = h; }
protected:
int width, height;
};
class Square : public Rectangle { // 正方形是一种矩形?
// 正方形的宽和高必须相等,但基类允许独立设置!
};
这些看似"理所当然"的继承关系,实际上隐藏着严重的设计缺陷。问题的根源在于:没有正确理解 public 继承的语义。
二、is-a 关系的本质
2.1 什么是 is-a 关系?
public 继承意味着 is-a。适用于 base classes 身上的每一件事情,一定也适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象。
这句话是理解 public 继承的关键。用更形式化的语言描述,这就是著名的里氏替换原则(Liskov Substitution Principle, LSP):
如果 S 是 T 的子类型,那么程序中所有使用 T 类型对象的地方,都可以无修改地替换为 S 类型对象,而程序的行为保持不变。
2.2 正确的 is-a 关系示例
cpp
// 正确的继承:学生是一种人
class Person {
public:
Person(const std::string& name, int age)
: name_(name), age_(age) {}
virtual ~Person() = default;
std::string getName() const { return name_; }
int getAge() const { return age_; }
virtual void introduce() const {
std::cout << "我叫" << name_ << ",今年" << age_ << "岁。\n";
}
protected:
std::string name_;
int age_;
};
class Student : public Person {
public:
Student(const std::string& name, int age, const std::string& school)
: Person(name, age), school_(school) {}
void introduce() const override {
std::cout << "我叫" << name_ << ",今年" << age_
<< "岁,就读于" << school_ << "。\n";
}
std::string getSchool() const { return school_; }
private:
std::string school_;
};
// 使用示例:里氏替换原则的完美体现
void greet(const Person& person) {
std::cout << "欢迎!";
person.introduce();
}
int main() {
Person person("张三", 30);
Student student("李四", 20, "清华大学");
greet(person); // 输出:欢迎!我叫张三,今年30岁。
greet(student); // 输出:欢迎!我叫李四,今年20岁,就读于清华大学。
// Student 可以完美替代 Person,这就是 is-a 关系
}
分析: 学生(Student)是一种人(Person),所以学生拥有人的所有属性(姓名、年龄),可以在任何需要人的地方使用。这是 public 继承的正确用法。
三、错误继承关系的深度剖析
3.1 经典反例:正方形与矩形
这是面向对象设计中最著名的陷阱之一:
cpp
class Rectangle {
public:
Rectangle(int w, int h) : width_(w), height_(h) {}
virtual void setWidth(int w) { width_ = w; }
virtual void setHeight(int h) { height_ = h; }
int getWidth() const { return width_; }
int getHeight() const { return height_; }
int area() const { return width_ * height_; }
protected:
int width_, height_;
};
class Square : public Rectangle {
public:
Square(int side) : Rectangle(side, side) {}
// 正方形的宽和高必须相等!
void setWidth(int w) override {
width_ = w;
height_ = w; // 强制保持相等
}
void setHeight(int h) override {
width_ = h; // 强制保持相等
height_ = h;
}
};
问题分析:
cpp
void processRectangle(Rectangle& rect) {
rect.setWidth(5);
rect.setHeight(3);
assert(rect.area() == 15); // 对于矩形,这个断言成立
}
int main() {
Square sq(4);
processRectangle(sq); // 传入正方形
// sq.setWidth(5) 后,height 也变成了 5
// sq.area() == 15 的断言失败!
}
| 问题 | 说明 |
|---|---|
| 行为不一致 | Square 改变了 Rectangle 的行为契约 |
| 违反 LSP | 无法在所有使用 Rectangle 的地方替换为 Square |
| 设计缺陷 | 几何上"正方形是矩形",但程序行为上不是 |
正确的解决方案: 使用组合而非继承,或者重新设计接口。
cpp
// 方案1:使用组合
class Shape {
public:
virtual ~Shape() = default;
virtual int area() const = 0;
};
class Rectangle : public Shape {
// ... 矩形特有的实现
};
class Square : public Shape {
// ... 正方形独立的实现,不继承 Rectangle
private:
int side_;
};
3.2 经典反例:企鹅与鸟
cpp
class Bird {
public:
virtual ~Bird() = default;
virtual void eat() { std::cout << "鸟在吃东西\n"; }
};
class FlyingBird : public Bird {
public:
virtual void fly() { std::cout << "鸟在飞翔\n"; }
};
class Penguin : public Bird { // 企鹅是一种鸟,但不会飞
public:
void swim() { std::cout << "企鹅在游泳\n"; }
};
// 使用示例
void letBirdFly(Bird& bird) {
// 如果传入 Penguin,这里会出问题
// bird.fly(); // 编译错误!Bird 没有 fly 方法
}
void letFlyingBirdFly(FlyingBird& bird) {
bird.fly(); // 安全,因为 FlyingBird 一定会飞
}
关键洞察: 不是所有鸟都会飞,所以"会飞"不应该成为 Bird 类的接口。正确的做法是将"会飞"提取到 FlyingBird 子类中。
四、is-a 关系的实践检验法
在设计继承关系时,可以通过以下测试来验证 is-a 关系是否成立:
4.1 "是一个"测试
Derived 是一个 Base 吗?
- 学生是一个人?是的。 -> public 继承合理
- 正方形是一个矩形?几何上是,但程序行为上不是。 -> 需要重新考虑
- 企鹅是一种鸟?是的。 -> 但"会飞"不是鸟的普遍属性
4.2 替换测试
cpp
// 如果以下代码对所有 Derived 对象都应该正确工作,
// 那么 Derived public 继承 Base 是合理的
void testSubstitution(Base& base) {
// 调用 Base 的所有公有接口
base.someMethod();
// Derived 对象传入后,行为应该符合预期
// 不能出现:
// - 抛出意外异常
// - 产生不一致的状态
// - 违反 Base 的契约
}
4.3 需求分析表
| 关系 | is-a? | 建议 |
|---|---|---|
| Dog -> Animal | 是 | public 继承 |
| Cat -> Animal | 是 | public 继承 |
| Car -> Vehicle | 是 | public 继承 |
| Engine -> Car | 否(has-a) | 组合/成员变量 |
| Square -> Rectangle | 行为上否 | 重新设计或组合 |
| Penguin -> FlyingBird | 否 | 继承自更抽象的 Bird |
五、实际应用场景
场景1:GUI 框架中的控件继承
cpp
// Qt 风格的控件继承体系
class QWidget {
public:
virtual void show() = 0;
virtual void hide() = 0;
virtual void paintEvent() = 0;
virtual QSize sizeHint() const = 0;
};
class QAbstractButton : public QWidget {
public:
virtual void click() = 0;
virtual void setText(const QString& text) = 0;
virtual QString text() const = 0;
};
class QPushButton : public QAbstractButton {
// QPushButton 是一种 QAbstractButton
// 所有按钮的属性和行为都适用于 QPushButton
public:
void click() override;
void setText(const QString& text) override;
void paintEvent() override;
};
class QCheckBox : public QAbstractButton {
// QCheckBox 也是一种 QAbstractButton
// 但它还有额外的状态:checked/unchecked
public:
bool isChecked() const;
void setChecked(bool checked);
void click() override; // 切换 checked 状态
};
分析: QPushButton is-a QAbstractButton,QCheckBox is-a QAbstractButton。所有按钮的通用行为(点击、设置文本)都适用于这两种具体按钮。
场景2:游戏开发中的角色体系
cpp
class GameEntity {
public:
virtual ~GameEntity() = default;
virtual void update(float deltaTime) = 0;
virtual void render() = 0;
virtual void takeDamage(int amount) = 0;
Vec3 getPosition() const { return position_; }
void setPosition(const Vec3& pos) { position_ = pos; }
protected:
Vec3 position_;
int health_ = 100;
bool alive_ = true;
};
class Character : public GameEntity {
public:
virtual void move(const Vec3& direction) = 0;
virtual void attack(GameEntity& target) = 0;
void takeDamage(int amount) override {
health_ -= amount;
if (health_ <= 0) {
alive_ = false;
onDeath();
}
}
protected:
virtual void onDeath() {}
int level_ = 1;
};
class Player : public Character {
public:
void update(float deltaTime) override;
void render() override;
void move(const Vec3& direction) override;
void attack(GameEntity& target) override;
void gainExperience(int exp);
void equipItem(Item& item);
protected:
void onDeath() override {
std::cout << "玩家死亡!游戏结束。\n";
}
private:
int experience_ = 0;
std::vector<Item> inventory_;
};
class NPC : public Character {
public:
void update(float deltaTime) override;
void render() override;
void move(const Vec3& direction) override;
void attack(GameEntity& target) override;
void setAIBehavior(AIBehavior* behavior);
protected:
void onDeath() override {
std::cout << "NPC 死亡。\n";
dropLoot();
}
private:
AIBehavior* ai_ = nullptr;
std::vector<Item> lootTable_;
};
分析:
Player is-a Character:玩家是一种角色,可以移动、攻击、受到伤害。NPC is-a Character:NPC 也是一种角色,同样可以移动、攻击、受到伤害。- 所有对
Character的操作都适用于Player和NPC。
场景3:金融系统中的账户类型
cpp
class Account {
public:
Account(const std::string& id, double balance)
: accountId_(id), balance_(balance) {}
virtual ~Account() = default;
virtual void deposit(double amount) {
balance_ += amount;
}
virtual bool withdraw(double amount) {
if (balance_ >= amount) {
balance_ -= amount;
return true;
}
return false;
}
double getBalance() const { return balance_; }
std::string getAccountId() const { return accountId_; }
protected:
std::string accountId_;
double balance_;
};
class SavingsAccount : public Account {
public:
SavingsAccount(const std::string& id, double balance, double rate)
: Account(id, balance), interestRate_(rate) {}
void applyInterest() {
double interest = balance_ * interestRate_;
deposit(interest);
}
private:
double interestRate_;
};
class CheckingAccount : public Account {
public:
CheckingAccount(const std::string& id, double balance, double overdraftLimit)
: Account(id, balance), overdraftLimit_(overdraftLimit) {}
bool withdraw(double amount) override {
if (balance_ + overdraftLimit_ >= amount) {
balance_ -= amount;
return true;
}
return false;
}
private:
double overdraftLimit_;
};
// 使用:所有账户都可以统一处理
void processMonthlyStatement(Account& account) {
std::cout << "账户 " << account.getAccountId()
<< " 余额: " << account.getBalance() << "\n";
}
六、常见陷阱与最佳实践
6.1 不要混淆 is-a 和 has-a
cpp
// 错误:汽车是一种引擎?
class Car : public Engine { // 错误!
};
// 正确:汽车有一个引擎
class Car {
private:
Engine engine_; // has-a 关系用组合
};
6.2 不要混淆 is-a 和 is-implemented-in-terms-of
cpp
// 错误:Set 是一个 List?
template<typename T>
class Set : public std::list<T> { // 危险!
// List 允许重复元素,Set 不允许
// List 的接口不完全适用于 Set
};
// 正确:Set 根据 List 实现出来
// 使用 private 继承(见条款39)
template<typename T>
class Set : private std::list<T> {
public:
void insert(const T& item) {
if (std::find(this->begin(), this->end(), item) == this->end()) {
this->push_back(item);
}
}
// ...
};
6.3 虚析构函数的重要性
cpp
class Base {
public:
// 如果类设计为多态基类,必须有虚析构函数
virtual ~Base() = default;
};
class Derived : public Base {
public:
~Derived() override {
// 清理 Derived 特有的资源
}
private:
std::vector<int> data_;
};
// 安全的使用方式
Base* ptr = new Derived();
delete ptr; // 正确:先调用 ~Derived(),再调用 ~Base()
七、总结
| 要点 | 说明 |
|---|---|
| public 继承 = is-a | 这是不可违背的语义契约 |
| Liskov 替换原则 | 子类必须能够替换父类而不改变程序行为 |
| 行为一致性 | 子类不能弱化父类的行为契约 |
| 接口继承 | 子类继承父类的所有公有接口 |
| 设计前思考 | 先问"Derived is-a Base?",再写继承代码 |
请记住:
- "public 继承"意味 is-a。适用于 base classes 身上的每一件事情一定也适用于 derived classes 身上,因为每一个 derived class 对象也都是一个 base class 对象。
- 在设计继承体系之前,先用里氏替换原则检验:所有使用基类的地方,是否都能安全地使用派生类替代?
- 如果答案是否定的,那么 public 继承不是正确的选择,考虑组合或其他设计模式。
public 继承是 C++ 中最强大的代码复用机制,但也是最危险的。正确使用它,你的代码将优雅而强大;误用它,你将陷入维护的泥潭。始终牢记:is-a 不是语法规则,而是语义契约。
参考:《Effective C++》第三版,Scott Meyers 著
相关条款:条款33(避免遮掩继承而来的名字)、条款34(区分接口继承和实现继承)、条款38(通过复合塑模出 has-a)