前言
继续阅读《C Interfaces and Implementations》的第二章。这一章讲的是“接口与实现”。作为有一定 C 语言基础的读者,我觉得还好,没什么难的,无非就是接口写在头文件里、实现写在源文件里那一套,快速阅读即可。
耦合
可以简单地将耦合理解为下面这句话:
耦合 = 一段代码对另一段代码“知道得有多少、依赖得有多深”。
如果模块 A 知道模块 B 的内部实现,甚至直接依赖或修改其内部逻辑,那么两者之间就可能存在耦合问题。模块 B 一旦改动,模块 A 很可能也要跟着改。
比如有一个 stack 模块。正常情况下,调用者只应该知道:
Stack *stack_new(void);
void stack_push(Stack *s, int value);
int stack_pop(Stack *s);
调用者只依赖这些接口,耦合就比较低。
但如果调用者还知道:
struct Stack {
int data[100];
int top;
};
然后绕过模块提供的接口,直接写:
s->data[s->top++] = 10;
耦合就会变得很高。后续如果更改 Stack 的内部实现,所有直接访问这些成员的模块都可能要跟着修改。
问题在于,调用者不仅知道“怎么使用栈”,还知道栈的内部实现。很多时候,最好只让调用者知道它需要知道的内容,避免调用者依赖或者猜测内部实现。
模块
一个模块由两部分组成:接口和实现。
接口指明模块要做什么,它声明了使用该模块的代码可以使用的标识符、类型和函数;实现则指明模块如何完成接口声明的目标。
一个模块通常只有一个接口,但是可以有多个实现。每个实现可能使用不同的算法和数据结构,不过都必须符合接口给出的使用说明。
接口
对于 C 而言,接口通常写在头文件 *.h 中。
命名规范
对于一个名叫 yr 的模块,其接口最好都带有 yr_ 或类似的前缀。因为 C 语言没有提供很好用的模块级命名空间,多个模块很可能具有相同的功能,并且不约而同地取了同一个名字,最终导致命名冲突。因此,以模块名作为前缀是一个避免命名冲突的好办法。
语义规范
一个接口可以有多种实现,但是对外行为要保持一致。接口的用户不应该对各种实现之间未说明的差异负责,否则就是耦合过重。
比如有一个用于取余的接口:
int my_mod(int x, int y);
那么就要规定好它在各种情况下的结果。有人可能会问,C 语言不是有对应的 % 运算符吗?
printf("-1 %% 5 = %d\n", -1 % 5); // 输出 -1
是的,我们可以约定 my_mod 的行为就是 return x % y。但是对于 x % y,早期的 C 语言程序员却不敢直接假定它的结果。
在 C89/C90 中,负整数除法的舍入方向没有完全统一。假设 i 和 N 都是 int,对于 (i - 1) % N,当 i == 0、N > 1 时,根据平台的不同,结果可能是 -1,也可能是 N - 1。
C99 及之后的标准统一规定整数除法向零截断,因此现在的 -1 % 5 会固定得到 -1。如果 my_mod 只是对 % 的简单包装,那么现在看来确实没有什么用;但在当时,它可以明确规定取余的语义,避免用户对结果作出隐含假设。
实现
对于 C 而言,实现通常写在源文件 *.c 中。
在 C 语言中,一个实现可以由一个或多个 .c 文件提供。实现必须提供其导出接口所指定的功能。
实现文件应该包含自己的接口头文件,以保证函数定义与接口声明一致。不过除此之外,C 语言不会自动检查实现是否满足接口规定的完整行为。
应用举例
下面用 ADT 举个例子。
ADT
ADT 即抽象数据类型。
C 标准库本身没有直接提供栈、链表等数据类型。如果需要这些数据类型,就要使用结构体和函数封装出相应的接口。
如果你用 C 语言写过 CLI 工具等,那么大概率见过标准库 stdio.h 提供的 ADT,也就是 FILE 以及相关的文件操作。
原书建议只提供 ADT 的结构体指针和接口函数,不公开 ADT 结构体的具体实现:
#ifndef STACK_INCLUDED
#define STACK_INCLUDED
typedef struct Stack_T *Stack_T;
extern Stack_T Stack_new(void);
extern int Stack_empty(Stack_T stk);
extern void Stack_push(Stack_T stk, void *x);
extern void *Stack_pop(Stack_T stk);
extern void Stack_free(Stack_T *stk);
#endif
这种方式更适合通过接口动态获取对象。但在嵌入式领域,因为各种限制,很多时候需要静态定义一个数据结构,而不是通过 Stack_new 之类的接口动态获取。
如果调用者需要直接静态定义对象,编译器就必须知道这个数据结构的大小和内存布局。只在头文件中提供结构体指针,而不提供完整定义,调用者就无法直接定义这个对象。
当然,也不是完全没有办法,只是不够优雅。例如可以在头文件中预留一块存储空间:
typedef struct {
uint32_t storage[16];
} Queue;
然后在源文件中定义真正使用的数据结构:
typedef struct {
uint8_t *buffer;
size_t head;
size_t tail;
size_t capacity;
} QueueImpl;
具体实现时,再将 Queue 的地址强制转换为 QueueImpl *:
static QueueImpl *queue_impl(Queue *queue)
{
return (QueueImpl *)(void *)queue;
}
但是这种方式十分麻烦。后续维护不仅涉及指针的强制转换,还要处理存储空间大小、内存对齐和类型访问等问题。而且它仍然暴露了部分信息,用户可能继续猜测内部的内存布局,最终造成耦合。
所以一般不会这么做,而是直接写明完整的数据结构定义,再通过注释说明哪些成员是 internal 的。Zephyr RTOS 中就能看到这种做法。
结语
除了加深了对耦合的理解,这一章我感觉没有学到什么新东西。作者写这本书时,C 语言还没有现在这么完善,其中一些例子放到今天已经不怎么适用了,不过接口与实现的思想还是可以参考的。这里就不多说了。