Skip to content

继承还是组合:先定关系,再写类 ​

C++是一门可以用来完成面对对象的OOP语言。您瞧!多么朴实的断言。

是的,所以这一张开始呢,咱们准备讲解一下C++中是如何完成对“继承”和“组合”的实现的。

诶诶!先把VSCode,或者是您炫酷的neovim放下,咱们写代码的时候,其实更加需要的是想清楚一个重要的问题。这个重要的问题就是设计。笔者见过很多,甚至包括自己,在学习的初期非常迷恋OOP。甚至干个啥事情,想都没想好先搞个接口了。最后盘点工程的时候发现完全多余的设计,造成了极大的代码膨胀。甚至更加可怕的是——一旦设计错了,项目就要面临重构。该用继承的时候不用继承,该组合的时候又不组合,最后接口写的难受,您好骂一句编程真是令人烦躁,那就不愉快了。

LLM点评到:什么时候该用继承?语法写错了,编译器当场拦下;关系定错了,编译器一声不吭,代码照样编译、照样跑,直到某天需求一变,继承体系改不动了,才发现根子上就选错了工具。这类错误编译器帮不了咱们,只能靠设计判断。这个我认为不错,同样摘抄下来放在这里

组合:您早就在用的方式 ​

咱们拿机器人举例:它有两个电机,左轮一个、右轮一个。电机是一个类,机器人把电机作为成员持有:

C++
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++ 表达它用继承:

C++
class Person { /* 姓名、年龄…… */ };

class Student : public Person { /* 学号、学校…… */ };

class Student : public Person 这一行的意思是:Student 是一种 Person。语法细节(构造顺序、访问权限、对象切片)下一篇展开,这里咱们只盯住语义——继承买到的东西不是"少写几行代码",而是类型层次:Student 对象可以被当作 Person 使用,接受 Person 的地方都能收下 Student。后面的多态就建立在这上面,这也是继承和组合最本质的区别:组合复用的是实现,继承声明的是可替换性。

正因为继承声明了可替换性,它的耦合才天生就重:基类的接口和实现细节一变,所有派生类跟着变。所以"该不该继承"这个问题,比"怎么写继承"重要得多,咱们把它摆在语法前面讲,就是这个原因。

光有 is-a 还不够:行为得替换得了 ​

您可能觉得判断标准已经清楚了:is-a 用继承,has-a 用组合。但有一个经典反例,咱们先看代码:

C++
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,而是动机就偏了:看上了基类的某段实现,想"继承过来省得重写"。咱们把这种写法摆出来看:

C++
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 的继承树上——基类一改,它跟着遭殃;它想换一种打印方式,也得在这个继承关系里折腾。

同一个需求,用组合写是这样的:

C++
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(该用组合),需要注意的是,他们很有可能完全没有标准答案~。

  1. 无人机(Uav)与飞行器(FlyingMachine)
  2. 无人机(Uav)与 GPS 模块(GpsModule)
  3. 矩形(Rectangle)与多边形(Polygon)
  4. 多边形(Polygon)与顶点列表(std::vector<Point>)

练习二:把错误的继承改成组合 ​

下面的代码哪里错了?请您把它改成正确的写法:

C++
class UartDriver {
public:
    void send(const std::uint8_t* data, std::size_t n);
};

class TemperatureSensor : public UartDriver {
public:
    double read();
    // read 的实现里直接调用 send,把请求帧发出去
};

pdf-latest-70-g3de3d0a · 3de3d0a · 2026-09-27