10、继承体系重构(下):以委托取代继承、以继承取代委托、提炼接口

好,咱们接着聊继承体系重构的下半部分。上一章我们讲了怎么把继承体系拆开、合并、搬移方法。这一章,我们聚焦三个更“狠”的重构手法——以委托取代继承、以继承取代委托、提炼接口。

这三个手法,说白了就是在“继承”和“委托”之间来回切换。你可能会问:为什么要来回折腾?嗯,我在项目里见过太多人把继承用成了“万能钥匙”,结果代码越改越僵。其实,继承和委托各有各的适用场景,选对了,代码就活了。

核心原则:继承是“is-a”关系,委托是“has-a”关系。别搞混了。

10.1 以委托取代继承(Replace Inheritance with Delegation)

先说说最常见的问题:继承滥用。我见过一个项目,一个基类叫Vehicle,然后派生出了CarTruckBoat,这没问题。但后来有人为了复用Engine的代码,直接把Engine作为基类,让Car继承Engine……你想想看,车是引擎吗?显然不是。

这种时候,就该用委托取代继承。

重构前:滥用继承

// 错误的设计:Car 继承 Engine
typedef struct {
    int horsepower;
    int cylinders;
} Engine;

typedef struct {
    Engine base;  // 继承 Engine
    char* model;
    int year;
} Car;

void car_start(Car* car) {
    // 直接调用 Engine 的方法
    engine_ignite(&car->base);
}

你看,Car 继承了 Engine,但车和引擎之间明明是“拥有”关系,不是“是”关系。这种设计,一旦引擎接口变了,车也得跟着改,耦合太紧。

重构后:使用委托

// 正确的设计:Car 包含 Engine
typedef struct {
    int horsepower;
    int cylinders;
} Engine;

typedef struct {
    Engine* engine;  // 委托:Car 拥有一个 Engine
    char* model;
    int year;
} Car;

void car_start(Car* car) {
    if (car->engine) {
        engine_ignite(car->engine);  // 委托调用
    }
}

我的经验:我在重构一个嵌入式项目时,发现一个Sensor类继承了Timer类,只为了用定时器功能。重构后改成委托,代码清晰多了,而且测试也更容易——你可以 mock 一个 Timer 传给 Sensor。

10.2 以继承取代委托(Replace Delegation with Inheritance)

反过来,有时候委托用得太多,也会让代码变得啰嗦。你想想看,如果一个类只是“包装”了另一个类的所有方法,那还不如直接用继承。

我遇到过这样一个场景:一个Logger类,它内部委托了一个FileWriter,但Logger的每个方法都只是简单调用FileWriter的同名方法。这种“透传”代码,写起来烦,读起来也烦。

重构前:过度委托

typedef struct {
    FileWriter* writer;
} Logger;

void logger_write(Logger* logger, const char* msg) {
    file_writer_write(logger->writer, msg);  // 只是透传
}

void logger_flush(Logger* logger) {
    file_writer_flush(logger->writer);  // 又是透传
}

重构后:使用继承

// Logger 直接继承 FileWriter
typedef struct {
    FileWriter base;  // 继承
    // 可以添加额外字段
} Logger;

// 直接复用 FileWriter 的方法
// 不需要再写透传代码

注意:以继承取代委托的前提是,两个类之间确实存在“is-a”关系。Logger 是一种 FileWriter 吗?如果 Logger 只是用 FileWriter 来写文件,那就不该继承。如果 Logger 本质上就是一个带额外功能的 FileWriter,那继承就合理。

10.3 提炼接口(Extract Interface)

这个手法,说白了就是“把共同行为抽出来,定义成接口”。在 C 语言里,我们没有 interface 关键字,但可以用函数指针结构体来模拟。

为什么要提炼接口?我举个例子:你有一个Printer类,还有一个Scanner类,它们都有start()stop()方法。如果你想让一个AllInOne设备同时支持打印和扫描,怎么办?

嗯,提炼接口就是答案。

定义接口

// 定义可启动设备的接口
typedef struct {
    void (*start)(void* self);
    void (*stop)(void* self);
} StartStopInterface;

// Printer 实现该接口
typedef struct {
    StartStopInterface interface;
    // Printer 特有字段
} Printer;

void printer_start(void* self) {
    Printer* p = (Printer*)self;
    // 启动打印机
}

void printer_stop(void* self) {
    Printer* p = (Printer*)self;
    // 停止打印机
}

// Scanner 也实现该接口
typedef struct {
    StartStopInterface interface;
    // Scanner 特有字段
} Scanner;

void scanner_start(void* self) { /* ... */ }
void scanner_stop(void* self) { /* ... */ }

使用接口

// AllInOne 设备可以同时拥有多个接口
typedef struct {
    StartStopInterface printer_if;
    StartStopInterface scanner_if;
    // 其他接口...
} AllInOne;

void all_in_one_init(AllInOne* device) {
    device->printer_if.start = printer_start;
    device->printer_if.stop = printer_stop;
    device->scanner_if.start = scanner_start;
    device->scanner_if.stop = scanner_stop;
}

我的习惯:提炼接口时,我通常会先写一个“最小接口”——只包含调用方真正需要的方法。不要一开始就把所有方法都塞进去。接口越小,复用性越强。

10.4 三种手法的选择策略

你可能会问:什么时候用委托,什么时候用继承,什么时候提炼接口?我总结了一张表,供你参考:

场景 推荐手法 原因
子类只复用父类的部分行为 以委托取代继承 避免继承所有接口,减少耦合
子类需要父类的全部接口 以继承取代委托 减少透传代码,简化设计
多个类有共同行为,但无继承关系 提炼接口 实现多态,提高灵活性
类需要同时扮演多个角色 提炼接口 + 委托 组合多个接口,避免菱形继承

10.5 知识体系图

下面这张图,帮你理清这三种手法的关系和应用场景:

继承体系重构三种手法 继承体系重构 以委托取代继承 has-a 替代 is-a 以继承取代委托 减少透传代码 提炼接口 抽象共同行为 适用场景 适用场景 适用场景 子类只复用部分行为 继承导致接口污染 需要动态切换行为 子类需要全部接口 委托代码过于啰嗦 存在明确的 is-a 关系 多个类有共同行为 需要多态支持 类需要扮演多个角色 核心原则:优先使用组合(委托),只在明确 is-a 关系时使用继承

10.6 避坑指南

最后,分享几个我踩过的坑:

  • 不要为了复用而继承。 我曾经为了复用一段排序代码,让Student继承了Sorter。结果学生类里多了一堆排序相关的方法,调用方一脸懵。后来改成委托,清爽多了。
  • 接口要小,职责要单一。 提炼接口时,别想着“一步到位”。我习惯先提炼一个最小接口,等后续有需要再扩展。接口太大,实现方负担就重。
  • 委托和继承可以共存。 不是非此即彼。一个类可以继承一个基类,同时委托给其他对象。C 语言里用函数指针结构体,可以轻松实现这种组合。
  • 重构后一定要跑测试。 这三种手法都会改变类的对外接口。重构完,记得跑一遍所有测试用例,确保行为没变。

我曾经踩过的坑:有一次,我用“以委托取代继承”重构了一个网络协议栈的类。重构完,发现性能下降了 20%。原因是委托引入了额外的函数调用开销。后来我改用内联函数和宏优化,才把性能追回来。所以,性能敏感的场景,要谨慎使用委托。

好了,这一章的内容就到这里。三种手法——以委托取代继承、以继承取代委托、提炼接口——都是重构继承体系的利器。记住,没有银弹,选对手法比会用手法更重要。

公众号:蓝海资料掘金营,微信deep3321