接口先行——先设计接口,再写实现

接口有两种形态(函数导出 / 类封装)。但还有一个更深层的问题:接口应该先于实现存在吗? 接口先行的答案是「是」——先定义「能做什么」,再写「怎么做」。接口先行本身只需要函数声明与空实现就足够了;虚函数是另一个问题的产物——当多套接口(多套实现)同时存在时,运行时怎么决定调用哪一套。不要把两者混为一谈。


一、先有实现,后有接口

最初的编程方式是先写实现,再给使用者看头文件:

1
2
3
4
5
6
7
8
9
10
11
// 先写实现
// search.c
ScanResult search_pattern(const uint8_t* data, size_t len,
const uint8_t* pattern, size_t pat_len) {
// 具体搜索算法
}

// 后有接口
// search.h
ScanResult search_pattern(const uint8_t* data, size_t len,
const uint8_t* pattern, size_t pat_len);

头文件只是实现的「说明书」——它描述了已有的实现,不是独立的设计对象。

1
流程:实现 → 接口(接口是实现的附属品)

这在小项目里没问题。但当项目变大,问题出现了:

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
// 先定义接口(函数声明 + 空实现,足以支撑接口先行)
// search.h
ScanResult search_pattern(const uint8_t* data, size_t len,
const uint8_t* pattern, size_t pat_len);

// search.c(空实现 / 桩实现 stub)
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; // 实现 A:文件搜索
};

class MemorySearcher : public ISearcher {
public:
SearchResult find(const uint8_t* pattern, size_t len) override; // 实现 B:内存搜索
};
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
接口先行:
先定义接口(函数声明 + 空实现),再写实现
接口是独立的设计对象,解耦实现时间/方式/实现者
函数声明 + 空实现已然足够,不依赖虚函数

虚函数:
应对「多套接口同时存在」的问题
运行时按实际类型绑定实现(多态)
不是接口先行的必要条件

和其他线的关系:
接口形态 = 用函数还是用类
接口先行 = 先定义接口还是先写实现
虚函数 = 多套实现共存时运行时选哪套
依赖接口 = 依赖接口还是依赖实现
依赖注入 = 实例怎么共享

五条线交叉但各自独立。

接口先行回答「接口和实现谁先谁后」——接口先;虚函数回答「多套实现同时存在时调哪套」——运行时绑定。不要让虚函数背接口先行的锅。