继承还是组合:先定关系,再写类
C++是一门可以用来完成面对对象的OOP语言。您瞧!多么朴实的断言。
是的,所以这一张开始呢,咱们准备讲解一下C++中是如何完成对“继承”和“组合”的实现的。
诶诶!先把VSCode,或者是您炫酷的neovim放下,咱们写代码的时候,其实更加需要的是想清楚一个重要的问题。这个重要的问题就是设计。笔者见过很多,甚至包括自己,在学习的初期非常迷恋OOP。甚至干个啥事情,想都没想好先搞个接口了。最后盘点工程的时候发现完全多余的设计,造成了极大的代码膨胀。甚至更加可怕的是——一旦设计错了,项目就要面临重构。该用继承的时候不用继承,该组合的时候又不组合,最后接口写的难受,您好骂一句编程真是令人烦躁,那就不愉快了。
LLM点评到:什么时候该用继承?语法写错了,编译器当场拦下;关系定错了,编译器一声不吭,代码照样编译、照样跑,直到某天需求一变,继承体系改不动了,才发现根子上就选错了工具。这类错误编译器帮不了咱们,只能靠设计判断。这个我认为不错,同样摘抄下来放在这里
组合:您早就在用的方式
咱们拿机器人举例:它有两个电机,左轮一个、右轮一个。电机是一个类,机器人把电机作为成员持有:
class Motor {
public:
void set_speed(int rpm);
int speed() const;
};
class Robot {
Motor left_;
Motor right_;
public:
void move(int rpm)
{
left_.set_speed(rpm);
right_.set_speed(rpm);
}
};Robot 和 Motor 的关系是"机器人有一个电机",也就是 has-a。组合的全部机制就是成员对象:外层类持有成员,需要成员的能力时调用它的公有接口。没有新语法,move 里那两行委托您在第 6 章、第 7 章早就写熟了。
组合的耦合天然就低。Robot 只经过 Motor 的公有接口使用它,Motor 内部怎么实现、甚至将来换成另一个型号,Robot 的代码都不用动。这份松耦合,是后面咱们和继承对比时的一枚重要筹码。强调的是,笔者几乎从来都使用组合,只有在最必要的场景下,使用继承。为什么需要写代码慢慢感悟~,这就属于设计模式和软件工程的范畴了。不走远~
继承登场:is-a 关系一瞥
咱们再看另一类关系。学生是一种人,轿车是一种交通工具——"是一种",也就是 is-a。C++ 表达它用继承:
class Person { /* 姓名、年龄…… */ };
class Student : public Person { /* 学号、学校…… */ };class Student : public Person 这一行的意思是:Student 是一种 Person。语法细节(构造顺序、访问权限、对象切片)下一篇展开,这里咱们只盯住语义——继承买到的东西不是"少写几行代码",而是类型层次:Student 对象可以被当作 Person 使用,接受 Person 的地方都能收下 Student。后面的多态就建立在这上面,这也是继承和组合最本质的区别:组合复用的是实现,继承声明的是可替换性。
正因为继承声明了可替换性,它的耦合才天生就重:基类的接口和实现细节一变,所有派生类跟着变。所以"该不该继承"这个问题,比"怎么写继承"重要得多,咱们把它摆在语法前面讲,就是这个原因。
光有 is-a 还不够:行为得替换得了
您可能觉得判断标准已经清楚了:is-a 用继承,has-a 用组合。但有一个经典反例,咱们先看代码:
class Ellipse {
public:
void set_axes(double width, double height); // 两根轴独立可调
// ……
};
class Circle : public Ellipse { // "圆是一种椭圆"?
public:
// set_axes 原封不动地继承了过来
};数学上,圆确实是椭圆的特例,这条 is-a 咱们看着觉得天经地义。可 Ellipse 对外承诺了"两根轴可以独立设置",圆的半径却只有一根:调用 circle.set_axes(2, 3) 之后,这个对象既不再是圆,椭圆"两轴独立"的约定也被打破了。继承来的接口和派生类必须维持的约束打起架来,凡是按"这是个椭圆"写的代码,拿到这个圆都会出错。
这就是里氏替换原则(Liskov Substitution Principle)说的事,我很需要把他用引用胡起来:
凡是接受基类的地方,换成派生类对象,程序的行为必须仍然正确。is-a 是必要条件,行为可替换才是完整判据。判断的时候咱们得盯着基类接口的每一条约定问一遍:派生类守得住吗?守不住,继承就是错的,哪怕语义上"确实是一种"。
为了复用实现而继承:最常见的错用
新手滥用继承,多半不是分不清 is-a 和 has-a,而是动机就偏了:看上了基类的某段实现,想"继承过来省得重写"。咱们把这种写法摆出来看:
class Printer {
public:
void print(const std::string& text);
};
// 为了"复用" print 而继承
class TicketMachine : public Printer {
public:
void issue(const std::string& content)
{
print("ticket: " + content); // 直接用上了基类的实现
}
};咱们问一句:售票机是一种打印机吗?不是。这个继承真正想表达的只有一句话:用上 print 这段现成的代码。关系一建立,任何接受 Printer& 的函数都能收下一台售票机,而它为此付出的代价是把自己挂在 Printer 的继承树上——基类一改,它跟着遭殃;它想换一种打印方式,也得在这个继承关系里折腾。
同一个需求,用组合写是这样的:
class TicketMachine {
Printer printer_; // 有一台打印机,而不是"是一种"打印机
public:
void issue(const std::string& content)
{
printer_.print("ticket: " + content);
}
};复用一点没少,关系还摆对了。这就是咱们说"组合优于继承"的具体含义:想复用实现,用组合加委托;继承只留给"确实是一种、且要被当作基类统一操作"的场景。
一条判断顺序
把前面的结论串成一条可操作的判断路径。想在一个新类和已有类之间建立关系时,咱们按这个顺序问:
先问能不能用组合:想要的只是对方的某个能力?成员加委托就够,这一步能帮咱们挡掉大多数情况,组合是默认选项。确实需要"被当作基类统一操作"(多态,下一篇的虚函数把这层能力补上),再问 is-a 成立吗:新类确实是已有类的一种?成立了还不够,最后问行为可替换吗:基类接口的每条约定,新类都守得住?三关全过,才轮到 public 继承。
还有一个稳定性的视角可以帮咱们佐证:继承留给本质的、稳定的关系(圆形是一种图形,这个事实不会变),组合处理偶然的、可能变化的关系(图形带颜色、带边框,这些是后加上去的属性)。关系越可能变,越该用组合——本章末尾的实战篇里,ColoredShape 会把这个判断原样演一遍。
| 继承 | 组合 | |
|---|---|---|
| 语义 | is-a(是一种) | has-a(有一个) |
| 耦合 | 高,派生类依赖基类接口和实现细节 | 低,只经过成员的公有接口 |
| 适合的关系 | 本质的、稳定的 | 偶然的、可能变化的 |
| 复用的是什么 | 可替换性(类型层次) | 实现(成员加委托) |
到这里判断方法立住了,下一篇咱们就把继承本身讲清楚:语法怎么写、构造和析构按什么顺序执行、对象切片是怎么回事。本章后面的抽象类、多继承、实战篇,都会反复回到这一篇的判断上来。
动手试试
练习一:is-a 还是 has-a
请您判断下面四组关系,哪些是 is-a(该考虑继承),哪些是 has-a(该用组合),需要注意的是,他们很有可能完全没有标准答案~。
- 无人机(Uav)与飞行器(FlyingMachine)
- 无人机(Uav)与 GPS 模块(GpsModule)
- 矩形(Rectangle)与多边形(Polygon)
- 多边形(Polygon)与顶点列表(
std::vector<Point>)
练习二:把错误的继承改成组合
下面的代码哪里错了?请您把它改成正确的写法:
class UartDriver {
public:
void send(const std::uint8_t* data, std::size_t n);
};
class TemperatureSensor : public UartDriver {
public:
double read();
// read 的实现里直接调用 send,把请求帧发出去
};