接口形态演化——状态决定接口形式
库或组件对外暴露接口,但接口有两种本质不同的形态:无状态的用函数导出,有状态的用类封装。这不是风格偏好,而是由「有没有状态」这个客观事实决定的。
一、函数导出:无状态的接口
最简单的接口形式——导出一个函数:
1 2 3
| int add(int a, int b); double sqrt(double x);
|
调用者只需要知道函数名和参数,不需要知道内部实现。函数没有状态——每次调用都是独立的,不依赖上次调用的结果。
1 2 3
| 调用者 → add(1, 2) → 3 调用者 → add(3, 4) → 7 两次调用之间没有任何关联
|
函数导出的特征:
1 2 3 4
| ✅ 无状态:每次调用独立 ✅ 无需初始化:直接调用 ✅ 无需生命周期管理:调用完就结束 ✅ 简单直接:调用者负担最小
|
函数导出的前提:无状态。
二、当状态出现
需求变了——需要一个「搜索器」,它打开一个文件,在文件中搜索多次:
1 2 3 4
| Searcher* searcher_open(const char* path); SearchResult searcher_find(Searcher* s, const uint8_t* pattern); void searcher_close(Searcher* s);
|
这里有状态了——Searcher* 就是状态(文件句柄、已读取的数据、当前位置)。函数导出必须通过参数传递这个状态:
1 2 3 4
| Searcher* s = searcher_open("file.bin"); SearchResult r1 = searcher_find(s, pattern1); SearchResult r2 = searcher_find(s, pattern2); searcher_close(s);
|
三个函数共享同一个 Searcher*——这就是「有状态的接口」。调用者必须自己管理状态的生命周期:
1 2 3 4
| 必须记得调用 searcher_close() 如果忘记 → 资源泄漏 如果重复调用 → 双重释放 如果 use-after-close → 崩溃
|
函数导出处理状态的方式:把状态作为第一个参数传进去。 能用,但调用者负担重。
三、类封装:状态和操作绑在一起
C++ 用类把状态和操作它的函数包在一起:
1 2 3 4 5 6 7 8 9
| class Searcher { public: Searcher(const char* path); ~Searcher(); SearchResult find(const uint8_t* pattern); private: File file_; size_t position_; };
|
1 2 3 4
| Searcher s("file.bin"); SearchResult r1 = s.find(pat1); SearchResult r2 = s.find(pat2);
|
类封装的特征:
1 2 3 4
| ✅ 状态自动管理:构造创建,析构释放 ✅ 不需要手动传状态:调用更简单 ✅ 生命周期明确:对象作用域 = 状态生命周期 ❌ 实现固定:换实现要改调用者代码
|
类封装的好处:状态的生命周期自动管理。
四、两种形态的对比
1 2 3 4 5 6 7 8 9
| 函数导出 类封装 ──────────────────────────────────────── 状态 无状态 有状态 初始化 不需要 构造函数 释放 不需要 析构函数 状态传递 手动传参 自动(this) 生命周期 无 对象作用域 调用方式 func(args) obj.method() 实现替换 不涉及 固定(不换)
|
选择依据:
五、框架化 vs 接口化的接口形态
回到「框架化 vs 接口化」的讨论:
1 2 3 4 5 6 7 8 9
| 接口化组件(无状态): → 函数导出就够了 → 调用者直接调用,不需要初始化 → 例:log_info(), file_read(), add(), sqrt()
框架化组件(有状态): → 必须用类封装 → 调用者需要创建对象 → 例:EventLoop, Searcher, AuthService
|
框架化 = 有状态 → 类封装。接口化 = 无状态 → 函数导出。
五·五、库 vs 框架 vs 接口:能力的谱系
「接口」「库」「框架」不是互斥的概念——一个库可以同时包含接口和框架。越往上,越容易从「提供能力的接口」变成「规定你怎么组织程序的框架」:
1 2 3 4
| OpenGL / Vulkan → API / 接口(如何向 GPU 提交操作) SDL → 库 / 平台抽象(窗口/输入/音频/图形上下文) Qt → 应用框架(规定启动/事件循环/控件/生命周期/协作) Unity / Unreal → 游戏引擎/框架(规定整个游戏的组织方式)
|
判断标准是谁控制程序主流程:
1 2 3
| 库(library): 你控制主流程,库只提供能力(你决定何时 Update/Render) 框架(framework):框架控制主流程,你按框架的约定填空(规定启动/循环/生命周期) 接口(interface):一个能力的调用方式(解耦、替换)
|
raylib 就是典型库而不是框架——主循环和游戏结构由你控制:
1 2 3 4 5 6 7 8
| InitWindow(1280, 720, "Game"); while (!WindowShouldClose()) { UpdateGame(); BeginDrawing(); DrawGame(); EndDrawing(); } CloseWindow();
|
raylib 提供 Window/Input/Graphics/Texture/Font/Audio/Camera/碰撞工具/数学类型,但不替你决定 Player/Enemy/World/System 怎么设计——用 raylib 学游戏架构反而很合适,能清楚看到「库提供了什么」和「游戏自己应该设计什么」的边界。
C 语言怎么表达接口形态
C 没有 class,但可以用 struct + 函数 + opaque pointer 实现同样的三种接口形态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
typedef struct Player Player; Player* player_create(void); void player_move(Player* p, float dx, float dy); void player_destroy(Player* p);
struct Player { float x, y; }; Player* player_create(void) { Player* p = malloc(sizeof(Player)); p->x = 0; p->y = 0; return p; } void player_move(Player* p, float dx, float dy) { p->x += dx; p->y += dy; } void player_destroy(Player* p) { free(p); }
|
外部只能通过函数操作,不知道内部结构——实现了「实现隐藏 + 稳定 API」,等价于 C++ 的类封装。
甚至可以用函数指针表模拟虚函数(多套接口同时存在时,见 02-接口先行):
1 2 3 4 5 6 7 8
| typedef struct Renderer Renderer; typedef struct { void (*begin)(Renderer*); void (*draw)(Renderer*, Texture*); void (*end)(Renderer*); } RendererAPI; struct Renderer { RendererAPI api; void* impl; }; renderer->api.draw(renderer, texture);
|
不同实现(OpenGL/Vulkan/DX12)都挂到同一 API 上,接近 C++ 的 IRenderer { virtual void draw(...) = 0; }。
1 2
| C: struct + pointer + function + function pointer → 构造 对象/封装/接口/多态(自己建立约定) C++: class → 语言直接提供这些机制
|
结论:C 可以模拟封装、对象、接口、虚函数;C++ 是语言原生机制。 接口形态(无状态函数导出 / 有状态类封装)与语言无关,C 只是换了一种写法。
六、总结
1 2 3 4 5 6 7 8 9
| 接口形态演化:
无状态 → 函数导出(直接调用,无需初始化) 有状态 → 类封装(构造创建,析构释放,状态自动管理)
选择依据:有没有状态?
框架化组件 = 有状态 → 类封装 接口化组件 = 无状态 → 函数导出
|
这是接口的两种基本形态。但「怎么定义接口」和「怎么让别人依赖接口」是两回事——接口先行(02,本主题内)是先定义接口再写实现(函数声明+空实现已足够);虚函数是应对多套接口同时存在时的另一个问题。