接口先行——先设计接口,再写实现
接口有两种形态(函数导出 / 类封装)。但还有一个更深层的问题:接口应该先于实现存在吗? 接口先行的答案是「是」——先定义「能做什么」,再写「怎么做」。接口先行本身只需要函数声明与空实现就足够了;虚函数是另一个问题的产物——当多套接口(多套实现)同时存在时,运行时怎么决定调用哪一套。不要把两者混为一谈。
一、先有实现,后有接口
最初的编程方式是先写实现,再给使用者看头文件:
1 2 3 4 5 6 7 8 9 10 11
|
ScanResult search_pattern(const uint8_t* data, size_t len, const uint8_t* pattern, size_t pat_len) { }
ScanResult search_pattern(const uint8_t* data, size_t len, const uint8_t* pattern, size_t pat_len);
|
头文件只是实现的「说明书」——它描述了已有的实现,不是独立的设计对象。
这在小项目里没问题。但当项目变大,问题出现了:
1 2 3 4 5 6 7 8
| 问题 1:A 模块要调用 B 模块,但 B 还没写完 → A 无法编译
问题 2:A 模块和 B 模块同时开发,接口不一致 → 对接时大量修改
问题 3:要换 B 的实现,A 的代码要改 → 耦合太紧
|
二、接口先行:先定义「能做什么」
接口先行的思想:先把接口定义好,再写实现。 接口不依赖任何实现,它是独立的设计对象。
接口先行需要什么?只需要函数声明与空实现。
1 2 3 4 5 6 7 8 9 10 11
|
ScanResult search_pattern(const uint8_t* data, size_t len, const uint8_t* pattern, size_t pat_len);
ScanResult search_pattern(const uint8_t* data, size_t len, const uint8_t* pattern, size_t pat_len) { (void)data; (void)len; (void)pattern; (void)pat_len; return (ScanResult){ .found = false }; }
|
1 2 3 4
| 接口先行 = 函数声明 + 空实现(stub) → 声明定义「能做什么」 → 空实现保证「能编译、能链接」 → 真正的实现后面再填
|
接口先行不依赖虚函数。 C 语言里用头文件声明 + 空实现就完全足够——A 模块可以依赖这个接口编译,B 模块在实现填好后直接对接,不需要任何多态机制。
1
| 流程:接口(声明 + 空实现)→ 实现(接口是独立的设计对象)
|
三、接口先行解决了什么
1 2 3 4 5 6 7 8 9 10 11
| 问题 1:A 模块要调用 B 模块,但 B 还没写完 → 先定义 B 的接口(声明 + 空实现),A 依赖它编译 → B 填实现时,A 不需要改任何代码
问题 2:A 模块和 B 模块同时开发,接口不一致 → 先约定接口,双方各自开发 → 对接时不用修改
问题 3:要换 B 的实现 → A 只依赖接口声明,不知道具体实现 → 换实现(重新链接)不需要改 A
|
接口先行 = 提前约定能力边界,解耦实现时间、实现方式、实现者。 它解决的是「接口和实现谁先谁后」的问题——答案:接口先。
四、虚函数是另一个问题:多套接口同时存在
虚函数不是接口先行的产物。接口先行用函数声明 + 空实现就足够了。虚函数应对的是另一个独立的问题:
当多套接口(多套实现)同时存在时,运行时怎么决定调用哪一套?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class ISearcher { public: virtual ~ISearcher() = default; virtual SearchResult find(const uint8_t* pattern, size_t len) = 0; };
class FileSearcher : public ISearcher { public: SearchResult find(const uint8_t* pattern, size_t len) override; };
class MemorySearcher : public ISearcher { public: SearchResult find(const uint8_t* pattern, size_t len) override; };
|
1 2 3 4 5 6
| 调用者手里是一个 ISearcher*,运行时才知道它到底是: FileSearcher(实现 A)还是 MemorySearcher(实现 B)
虚函数 = 运行时绑定 → 调用 find() 时,通过虚表找到当前对象真正对应的实现 → 编译时不知道调用哪一套,运行时才知道
|
虚函数解决的是「多套接口同时存在时怎么选」的问题——运行时多态、按实际类型绑定实现。这是接口先行之外的独立动机:
1 2 3 4 5 6 7
| 接口先行:接口和实现解耦(先设计接口,再写实现) → 只需要函数声明 + 空实现 → 解决「谁先谁后」
虚函数:多套实现共存时运行时选择 → 需要继承 + 虚表 → 解决「多套都存在时调哪套」
|
1 2 3 4 5
| 没有多套实现 → 根本不需要虚函数 → 接口先行 + 函数声明 + 空实现就足够
有多套实现 → 才需要虚函数 → 运行时决定调用哪一套
|
一个常见的误区:以为「接口先行 = C++ 虚函数」。实际上,接口先行在 C 语言里用函数声明 + 空实现就完整成立了;虚函数是当系统里同时存在多套接口实现、需要运行时多态时才引入的——两者是不同的问题。
五、接口先行的演化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| 阶段 1:实现先行(C 风格) → 先写实现,后有头文件 → 接口是实现的附属品 → 换实现要改调用者
阶段 2:接口先行 → 先定义接口(函数声明 + 空实现),后写实现 → 接口是独立的设计对象 → 换实现不改调用者 → 这一步不需要虚函数
阶段 3:接口先行 + 多套实现共存 → 系统里同时存在多套接口实现 → 引入虚函数(运行时绑定)来选调用哪一套 → 接口可替换 + 实例可共享
阶段 4:接口先行 + 依赖注入 → 实现通过注入传入 → 实例可共享
阶段 5:接口先行 + 依赖注入 + 容器 → 接口定义在上层 → 实现注册在容器中 → 容器自动创建和注入 → 框架级的解耦
|
注意阶段 2 到阶段 3 的边界:接口先行本身(阶段 2)不需要虚函数;虚函数是进入「多套实现共存」这个新问题(阶段 3)才引入的。
六、接口先行的代价
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 代价 1:过度设计 → 如果实现确定不会替换,接口先行就是浪费 → 简单函数不需要接口先行
代价 2:空实现维护 → 接口先行阶段有空实现 stub,需要维护占位 → 实现填好后 stub 要移除
代价 3:设计复杂度 → 需要先想清楚接口长什么样 → 小项目可能不值得
代价 4:理解成本 → 新人需要理解接口-实现的分离 → 增加认知负担
|
接口先行不是银弹。 只有在「实现可能替换」「多人协作」「需要测试」时才值得。
七、什么时候接口先行
1 2 3 4 5 6 7 8 9 10 11
| 应该接口先行: → 多人协作(先约定接口再各自开发) → 需要测试(Mock 依赖) → 多平台(换实现不改调用者) → 插件系统(动态加载实现) → 框架设计(使用者只依赖接口)
不应该接口先行: → 简单工具函数(函数导出够了) → 实现确定不会替换(类封装够了) → 原型阶段(快速验证优先)
|
什么时候需要虚函数(在接口先行的基础上额外引入):
1 2 3 4 5 6 7 8
| 需要虚函数: → 多套接口实现同时存在,运行时按类型选择 → 插件系统(运行时加载不同实现) → 回调/观察者(一个接口,多个接收者)
不需要虚函数: → 只有一套实现(接口先行 + 函数声明 + 空实现就够) → 编译期就能确定调用哪一套
|
八、总结
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| 接口先行: 先定义接口(函数声明 + 空实现),再写实现 接口是独立的设计对象,解耦实现时间/方式/实现者 函数声明 + 空实现已然足够,不依赖虚函数
虚函数: 应对「多套接口同时存在」的问题 运行时按实际类型绑定实现(多态) 不是接口先行的必要条件
和其他线的关系: 接口形态 = 用函数还是用类 接口先行 = 先定义接口还是先写实现 虚函数 = 多套实现共存时运行时选哪套 依赖接口 = 依赖接口还是依赖实现 依赖注入 = 实例怎么共享
五条线交叉但各自独立。
|
接口先行回答「接口和实现谁先谁后」——接口先;虚函数回答「多套实现同时存在时调哪套」——运行时绑定。不要让虚函数背接口先行的锅。