10、继承体系重构(下):以委托取代继承、以继承取代委托、提炼接口
好,咱们接着聊继承体系重构的下半部分。上一章我们讲了怎么把继承体系拆开、合并、搬移方法。这一章,我们聚焦三个更“狠”的重构手法——以委托取代继承、以继承取代委托、提炼接口。
这三个手法,说白了就是在“继承”和“委托”之间来回切换。你可能会问:为什么要来回折腾?嗯,我在项目里见过太多人把继承用成了“万能钥匙”,结果代码越改越僵。其实,继承和委托各有各的适用场景,选对了,代码就活了。
核心原则:继承是“is-a”关系,委托是“has-a”关系。别搞混了。
10.1 以委托取代继承(Replace Inheritance with Delegation)
先说说最常见的问题:继承滥用。我见过一个项目,一个基类叫Vehicle,然后派生出了Car、Truck、Boat,这没问题。但后来有人为了复用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 知识体系图
下面这张图,帮你理清这三种手法的关系和应用场景:
10.6 避坑指南
最后,分享几个我踩过的坑:
- 不要为了复用而继承。 我曾经为了复用一段排序代码,让
Student继承了Sorter。结果学生类里多了一堆排序相关的方法,调用方一脸懵。后来改成委托,清爽多了。 - 接口要小,职责要单一。 提炼接口时,别想着“一步到位”。我习惯先提炼一个最小接口,等后续有需要再扩展。接口太大,实现方负担就重。
- 委托和继承可以共存。 不是非此即彼。一个类可以继承一个基类,同时委托给其他对象。C 语言里用函数指针结构体,可以轻松实现这种组合。
- 重构后一定要跑测试。 这三种手法都会改变类的对外接口。重构完,记得跑一遍所有测试用例,确保行为没变。
我曾经踩过的坑:有一次,我用“以委托取代继承”重构了一个网络协议栈的类。重构完,发现性能下降了 20%。原因是委托引入了额外的函数调用开销。后来我改用内联函数和宏优化,才把性能追回来。所以,性能敏感的场景,要谨慎使用委托。
好了,这一章的内容就到这里。三种手法——以委托取代继承、以继承取代委托、提炼接口——都是重构继承体系的利器。记住,没有银弹,选对手法比会用手法更重要。