文章目录
- 引言
- [一、C 的"继承":结构体嵌套](#一、C 的"继承":结构体嵌套)
- [二、C++ 的继承基础语法](#二、C++ 的继承基础语法)
-
- [2.1 最简单的继承](#2.1 最简单的继承)
- [2.2 `public` 继承意味着什么](#2.2
public继承意味着什么)
- [三、is-a 的铁律:何时才能用继承](#三、is-a 的铁律:何时才能用继承)
-
- [3.1 简单的判断标准](#3.1 简单的判断标准)
- [3.2 反例:Square 应该继承 Rectangle 吗?](#3.2 反例:Square 应该继承 Rectangle 吗?)
- 四、继承最常见的误用:为了复用代码
-
- [4.1 一个典型的错误](#4.1 一个典型的错误)
- [4.2 正确的做法:组合](#4.2 正确的做法:组合)
- 五、继承和组合的实战对比
-
- [5.1 场景:设计一个"带颜色的矩形"](#5.1 场景:设计一个"带颜色的矩形")
- [5.2 决策速查表](#5.2 决策速查表)
- 六、继承的其他细节
-
- [6.1 派生类的构造与析构顺序](#6.1 派生类的构造与析构顺序)
- [6.2 派生类中调用基类方法](#6.2 派生类中调用基类方法)
- [6.3 三种继承方式速查](#6.3 三种继承方式速查)
- 总结
本系列为《C++深度修炼:基础、STL源码与多线程实战》第5篇
前置条件:理解 class 封装(第2篇)、构造函数(第3篇)、成员函数(第4篇)
引言
很多 C 程序员学会 C++ 的继承语法之后,第一反应是:"太好了!我有一个现成的类,里面大部分代码我都要用,继承它改几个方法就行了。"
这是个危险的直觉。 把继承当成"代码复用工具",是 C++ 中最常见的面向对象设计错误。
继承的真正含义只有一个:is-a(是一个) 。Dog 继承 Animal,是因为 Dog is an Animal------狗就是动物。不是因为 Animal 里面有一堆好用的函数,省得你自己写。
本文用 C 程序员的视角,从 struct 嵌套开始,解释继承的本质、误用场景,以及什么时候应该用**组合(composition)**替代继承。
一、C 的"继承":结构体嵌套
在 C 中,"复用"一个结构体的方式只有一种:嵌套。
cpp
// embed.c
#include <stdio.h>
struct Person {
char name[32];
int age;
};
struct Employee {
struct Person person; // 嵌套 Person
int employee_id;
double salary;
};
int main() {
struct Employee emp;
snprintf(emp.person.name, 32, "张三");
emp.person.age = 30;
emp.employee_id = 1001;
emp.salary = 50000.0;
printf("%s, %d岁, 工号%d, 月薪%.0f\n",
emp.person.name, emp.person.age, emp.employee_id, emp.salary);
}
这就是组合(Composition) ------ Employee has a Person(员工有一个人信息),不是"员工是一个人"。
在 C++ 中,你有两种方式表达这个关系:继承(is-a)和组合(has-a)。用对还是用错,是区分设计水平的分水岭。
二、C++ 的继承基础语法
2.1 最简单的继承
cpp
#include <iostream>
#include <string>
class Animal {
public:
Animal(const std::string &name) : name_(name) {}
void eat() { std::cout << name_ << " 吃东西\n"; }
void sleep() { std::cout << name_ << " 睡觉\n"; }
std::string name() const { return name_; }
private:
std::string name_;
};
// Dog is-a Animal
class Dog : public Animal {
public:
Dog(const std::string &name) : Animal(name) {} // 委托基类构造
void bark() { std::cout << name() << " 汪汪叫\n"; }
};
int main() {
Dog d("旺财");
d.eat(); // 继承自 Animal
d.sleep(); // 继承自 Animal
d.bark(); // Dog 自己的方法
}
text
$ g++ -std=c++17 dog.cpp && ./a.out
旺财 吃东西
旺财 睡觉
旺财 汪汪叫
2.2 public 继承意味着什么
cpp
class Dog : public Animal { /* ... */ };
// ^^^^^^
public 继承的含义是:"Dog is an Animal" 这个事实对全世界公开。 具体表现为:
| 基类的访问权限 | 在 public 继承后,派生类中 | 外部代码中 |
|---|---|---|
public 成员 |
仍然是 public |
可通过派生类对象访问 |
protected 成员 |
仍然是 protected |
不可访问 |
private 成员 |
不可访问(但存在于对象中) | 不可访问 |
cpp
class Base {
public: int pub_;
protected: int prot_;
private: int priv_; // 派生类也看不见
};
class Derived : public Base {
public:
void test() {
pub_ = 1; // ✅
prot_ = 2; // ✅ 派生类可以访问 protected
// priv_ = 3; // ❌ 派生类也不能访问基类的 private
}
};
int main() {
Derived d;
d.pub_ = 10; // ✅ public 继承后仍然是 public
// d.prot_ = 20; // ❌ protected 对外部始终不可见
// d.priv_ = 30; // ❌ private 永远不可见
}
三、is-a 的铁律:何时才能用继承
3.1 简单的判断标准
用一句话问自己:"派生类是否在任何场景下都可以替换基类?"
如果答案是"是"------用继承。如果答案是"大部分可以,但有些场景不太对"------不能用继承。
cpp
// ✅ 好的继承:Rectangle is a Shape
class Shape {
public:
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
double area() const override { return w_ * h_; }
private:
double w_, h_;
};
// Rectangle 在任何需要 Shape 的场景下都能用------is-a 成立
void print_area(const Shape &s) {
std::cout << "面积: " << s.area() << '\n';
}
3.2 反例:Square 应该继承 Rectangle 吗?
这是最经典的继承陷阱。直觉上"正方形是特殊的矩形",但:
cpp
class Rectangle {
public:
Rectangle(double w, double h) : w_(w), h_(h) {}
virtual void set_width(double w) { w_ = w; }
virtual void set_height(double h) { h_ = h; }
double area() const { return w_ * h_; }
protected:
double w_, h_;
};
class Square : public Rectangle {
public:
Square(double side) : Rectangle(side, side) {}
void set_width(double w) override {
w_ = w;
h_ = w; // 保持正方形:宽高同步
}
void set_height(double h) override {
w_ = h;
h_ = h; // 保持正方形:宽高同步
}
};
// 问题来了:
void stretch(Rectangle &r) {
r.set_width(r.area() / r.w_ * 2); // 假设 w_ 可访问
// 调用者以为 r 是个普通矩形,单独调宽没问题
// 但如果 r 实际是 Square,set_width 会连带改高度------调用者不知道!
}
Square is NOT a Rectangle ------在可变的设定下,正方形不能自由改变宽高,因此不能在需要 Rectangle 的地方无差别地替换。让 Square 继承 Rectangle 是对 is-a 的违反。
正确的做法:两者各自继承 Shape,互不依赖。
四、继承最常见的误用:为了复用代码
4.1 一个典型的错误
cpp
// ❌ 错误示范:为了复用而继承
class CsvWriter {
public:
void open(const std::string &path) { /* ... */ }
void write_row(const std::vector<std::string> &row) { /* ... */ }
void close() { /* ... */ }
};
// 我想要一个写 JSON 的功能,加上 CSV 里 open/close ------ 继承吧?
class JsonWriter : public CsvWriter { // 错!JsonWriter is NOT a CsvWriter
public:
void write_json(const std::string &json) {
// 调用 write_row 来输出?完全用不上......
}
};
JsonWriter 不是 CsvWriter------它们本质上是两个独立的写器。把 CsvWriter 当"工具包"继承过来,就是典型的"为了复用而继承"。
4.2 正确的做法:组合
cpp
// ✅ 正确:组合
class FileHandle { // 提取共享的"文件打开关闭"职责
public:
void open(const std::string &path) { /* ... */ }
void close() { /* ... */ }
};
class CsvWriter {
public:
CsvWriter() : file_(new FileHandle()) {}
void write_row(const std::vector<std::string> &row) { /* 用 file_->write() */ }
private:
std::shared_ptr<FileHandle> file_; // has-a,不是 is-a
};
class JsonWriter {
public:
JsonWriter() : file_(new FileHandle()) {}
void write_json(const std::string &json) { /* 用 file_->write() */ }
private:
std::shared_ptr<FileHandle> file_; // has-a,不是 is-a
};
从"继承以复用"切换到"组合以复用"------这条原则在 GoF 的《设计模式》中被总结为:
"优先使用对象组合而不是类继承。"(Favor object composition over class inheritance.)
五、继承和组合的实战对比
5.1 场景:设计一个"带颜色的矩形"
方案 A:继承
cpp
// ❌ 仅仅为了加个 color 就继承
class ColoredRectangle : public Rectangle {
std::string color_;
public:
ColoredRectangle(double w, double h, const std::string &c)
: Rectangle(w, h), color_(c) {}
};
方案 B:组合
cpp
// ✅ Rectangle 干 Rectangle 的事,Color 是额外的属性
class ColoredRectangle {
Rectangle rect_;
std::string color_;
public:
ColoredRectangle(double w, double h, const std::string &c)
: rect_(w, h), color_(c) {}
double area() const { return rect_.area(); }
std::string color() const { return color_; }
};
判断标准:"带颜色的矩形"是不是一种特殊的矩形? 还是说,它的"矩形性"只是它的一个属性?
如果在你的设计中,矩形的所有操作对"带颜色的矩形"都完全适用,那继承也可以。但如果只是"需要一个矩形 + 一个颜色",组合更简单直接。
5.2 决策速查表
| 场景 | 用继承 | 用组合 |
|---|---|---|
| 新类"是"旧类的特化 | ✅ | --- |
| 新类"有"旧类作为组件 | --- | ✅ |
| 需要基类的所有接口原样传递 | ✅ | --- |
| 只需要基类的部分功能 | --- | ✅ |
| 运行时要切换多种实现 | ✅(多态) | --- |
| 基类的实现细节频繁变化 | --- | ✅ |
| 不确定、说不清楚"是不是" | --- | ✅(安全默认值) |
💡 经验法则 :当你犹豫该用继承还是组合时,先写组合 。如果后来发现到处都在手动转发同样的接口("组合疲劳"),再考虑提取为继承。从组合到继承容易,从继承到组合难。
六、继承的其他细节
6.1 派生类的构造与析构顺序
cpp
#include <iostream>
struct Base {
Base() { std::cout << "Base()\n"; }
~Base() { std::cout << "~Base()\n"; }
};
struct Member {
Member() { std::cout << " Member()\n"; }
~Member() { std::cout << " ~Member()\n"; }
};
class Derived : public Base {
public:
Derived() { std::cout << " Derived()\n"; }
~Derived() { std::cout << " ~Derived()\n"; }
private:
Member m_;
};
int main() {
Derived d;
}
text
$ g++ -std=c++17 order.cpp && ./a.out
Base()
Member()
Derived()
~Derived()
~Member()
~Base()
构造顺序 :基类 → 成员(按声明顺序) → 派生类自身
析构顺序:完全逆序(派生类自身 → 成员 → 基类)
这和"先打地基再盖房子,拆房先拆顶再拆地基"是一个道理。
6.2 派生类中调用基类方法
cpp
class Base {
public:
void foo() { std::cout << "Base::foo\n"; }
};
class Derived : public Base {
public:
void foo() {
std::cout << "Derived::foo\n";
Base::foo(); // 显式调用基类版本
}
};
不加 Base:: 的话,foo() 会递归调用自己------因为派生类的名字遮蔽(hide)了基类的同名函数。
6.3 三种继承方式速查
| 继承方式 | 基类 public → 派生类 | 基类 protected → 派生类 | 典型用途 |
|---|---|---|---|
public |
public | protected | "is-a" 关系(最常用) |
protected |
protected | protected | "对子类公开,对外保密" |
private |
private | private | "用基类来实现"(本质上就是组合的替代语法) |
cpp
// private 继承:等于"用基类的代码,但不承认 is-a 关系"
class MyStack : private std::vector<int> {
public:
void push(int v) { push_back(v); }
void pop() { pop_back(); }
int top() const { return back(); }
using std::vector<int>::empty;
using std::vector<int>::size;
};
// MyStack 不是 vector<int>------它只是用 vector 来实现 stack
// 外部代码不能写: MyStack *p = new std::vector<int>; // ❌
⚠️
private继承在大多数场景下可以用一个私有成员变量替代。优先考虑组合;只有当需要访问基类的protected成员或覆盖虚函数时才用private继承。
总结
继承是 C++ 中最容易被滥用的机制,因为它看起来太方便了------在类名后面加个 : public Base 就能"免费"获得大量代码。
记住这条核心原则:继承表达的是 is-a(是一个),不是 has-a(有一个),更不是"我想要你里面的函数"。
- 用 public 继承表达严格的 is-a 关系------派生类必须能在任何场景下替换基类
- 用组合(成员变量)表达 has-a 关系------这是默认的安全选择
- 有疑虑时,先写组合------从组合迁移到继承比重构错误的继承体系容易得多
- 构造析构顺序是基类→成员→派生类(析构逆序),这决定了对象从"毛坯"到"精装"的搭建过程
第1章到此结束。下一篇开始,我们进入第2章------命名空间、头文件和 C++ 的基础设施升级,让代码从"能编译"进化到"有组织"。
📝 动手练习:
- 设计一个"鸟"的继承体系------
Bird基类,Sparrow、Penguin、Eagle作为派生类。试着给Bird加一个fly()方法,然后发现企鹅不会飞------重新思考设计- 把第2篇的
BankAccount扩展为SavingsAccount(有利率)和CheckingAccount(有透支额度),验证 is-a 是否成立- 找一段自己以前用继承写的代码,审视是否误用了"为了复用而继承"------如果是,用组合改写