C语言如何编写接口?(非常详细)
C 语言常用于开发函数库。除了标准 C 函数库,我们熟知的很多基础库也是用 C 语言开发的。通过简洁的 API(Application Programming Interface,应用程序编程接口),这些基础库为应用程序的开发提供了使用特定功能的便利手段。
由于 C 语言被广泛应用于各种计算机系统,因此就算某些函数库内部使用 Rust 或 C++ 编程语言开发,最终也仍然要为应用程序提供使用 C 语言描述的编程接口。
然而,如何在 C 语言语境下设计出完备、自洽、易用且合乎使用习惯的编程接口,对绝大多数 C程序员来讲是一项巨大的挑战。主要原因在于鲜有图书或文章从这一角度来审视或阐述接口的设计方法。
接口的设计方法具有极其重要的价值:
所谓的“好接口”,就是一看就知道如何使用的接口。这句话很简单,但蕴含了如下 4 个关键点:
除了以上显而易见的关键点,还需要注意的是,隐藏所有不需要暴露给调用者的实现细节。这可以达到如下两个目的:
在 C 标准库中,不乏一些好的接口。比如 POSIX 标准围绕文件描述符的打开、关闭、读取和写入接口:
除了用于打开文件的接口,其他所有和文件读写相关的接口,也都需要传递一个已经打开(或创建)的文件描述符。因此,我们可以看到 read()、write() 和 close() 接口均使用 int fd 作为第一个参数。在 read() 接口中,调用者需要传入一个缓冲区以及想要读取的字节数,因此我们看到 read() 系统调用的第二个参数类型为 void *,而表示欲读取字节数的参数类型为 size_t。
注意,read() 接口的返回值类型为 ssize_t,这是因为该接口可能返回 -1 来表示读取失败,非负值则表示读取到的字节数。这里不能通过返回 0 来表示读取失败,因为当我们从套接字读取数据的时候,没有数据可读也是一种合理情形。
执行与 read() 接口相反操作的 write() 接口几乎具有和 read() 接口一模一样的原型。唯一的不同在于 write() 接口的第二个参数 buf 类型为 const void *。这表明参数 buf 指向的缓冲区对 write() 接口而言是只读的;也就是说,write() 接口的实现代码不会尝试向参数 buf 指向的内存区域写入任何内容,而只会读取其中的内容。
显然,对接口来讲,函数的名称、参数的类型、参数的顺序、返回值类型等,都是构成接口的重要组成部分。要设计一个好的接口,以上这些因素都要考虑到,而不能只考虑其中的几个方面。
为加深理解,我们来看看 STDIO(标准 C 库的输入输出模块)定义的文件操作相关接口:
1) STDIO 使用 FILE 结构体来表示一个打开的文件,后续读取、写入或关闭这个文件时,均需要传递指向 FILE 结构体的指针,也就是 fopen() 的返回值。这一设计和 POSIX 的文件读写接口有明显的不同,POSIX 使用的是整数的文件描述符。
出现这一差异的主要原因在于,在 UNIX 类操作系统中,运行在用户态的进程不能访问内核的地址空间。因此,一个由内核打开的文件,在内核看来可能对应一个结构体,但这个结构体的地址及内容对用户态进程而言没有任何意义:
正因为 STDIO 提供了带有缓冲区的读写接口,而缓冲区本身又位于当前进程的地址空间中,故而用于表述已打开文件的数值,就没必要再使用索引值了,一个指向结构体的指针足矣。使用索引值需要有一次从索引值到具体的结构体的查询过程,这会让事情变复杂,意义不大。因此,我们看到 STDIO 接口用于表述已打开文件的数值从 POSIX 的文件描述符变成了指向 FILE 结构体的指针。
2) 在 POSIX 的 read() 和 write() 接口中,调用者只需要传递缓冲区地址和内容的长度两个参数;而在 STDIO 的 fread() 和 fwrite() 接口中,针对内容的长度,调用者需要传递两个值(size 和 nmemb)来分别表示要读取的数据项的长度和数量。
之所以如此设计,是为了方便以结构体为单位读写文件,使得当文件中没有期望数量的数据项可读时,也可以返回已经读取到的数据项数量,而不需要执行额外的错误处理。另一个好处是,以单个数据项的长度(由参数 nmemb 指定)为单位读取文件,在缓冲区的帮助下,可在一定程度上实现读写性能的提升。
3) fread()、fwrite() 的返回值类型和 read()、write() 的不同,没有使用 ssize_t。这是因为 STDIO 的文件读写接口主要用于文件,而 POSIX 的文件读写接口还可以用于管道或套接字。前者只需要知道读写操作是否达到预期,通过返回值是否小于 size 即可判断;而后者,尤其是从管道或套接字读取数据时,需要使用错误值(−1)来表示一些特殊的状态,比如被信号中断,或者管道或套接字的另一头已经不存在等。此时,结合 errno 即可判断错误原因,进而做出适当的后续处理。
通过对比分析 POSIX 和 STDIO 的文件读写接口,我们可以知悉:即便完成类似的功能,也可能设计出具有明显差异的接口。这取决于很多因素,比如需求、实现上的要求或者限制,甚至设计者的喜好等。
最后,给出下面两个基本的接口设计原则:
由于 C 语言被广泛应用于各种计算机系统,因此就算某些函数库内部使用 Rust 或 C++ 编程语言开发,最终也仍然要为应用程序提供使用 C 语言描述的编程接口。
然而,如何在 C 语言语境下设计出完备、自洽、易用且合乎使用习惯的编程接口,对绝大多数 C程序员来讲是一项巨大的挑战。主要原因在于鲜有图书或文章从这一角度来审视或阐述接口的设计方法。
接口的设计方法具有极其重要的价值:
- 一方面,良好的接口可以帮助使用接口的开发者节约大量的学习成本;
- 另一方面,优秀的接口设计可以帮助我们解开模块之间的耦合,从而提升整个系统的架构稳定性以及可维护性。
所谓的“好接口”,就是一看就知道如何使用的接口。这句话很简单,但蕴含了如下 4 个关键点:
- 调用者友好。比如函数的命名合理,应用开发者很容易通过函数的名称了解其功能或作用。
- 符合习惯,学习成本低。比如函数的参数名称、类型以及返回值,应符合常见的设计规则,从而降低应用开发者的学习成本。
- 稳定且保持向后(backward)兼容性。如果函数库的升级导致接口发生变化,进而不得不让应用开发者重写相关代码,这将变成一场灾难——因为这种改动还意味着要重新进行测试。因此,保持接口的稳定性至关重要。但有时候,我们不得不对某些接口提供一些增强的设计。为此,我们需要掌握如何设计接口,在提供增强功能的同时,保持向后兼容性。
- 没有让普通应用开发者难以驾驭的过度设计。有时候,开发者为了提供某种灵活性,可能会在接口的设计中掺杂很多难以运用的设计技巧。我们称这种情况为“过度设计”。在一般性的应用程序接口中,应避免过度设计,始终坚持简洁、易用的原则。
除了以上显而易见的关键点,还需要注意的是,隐藏所有不需要暴露给调用者的实现细节。这可以达到如下两个目的:
- 调用者不需要了解接口之外的任何信息,而只需要知道传入什么样的参数,以及会得到哪些预期的结果即可。
- 倒逼设计者对接口做适当的抽象,这一方面可以帮助我们解开模块间的耦合,另一方面也为实现上的扩展、优化和调整打下了基础。
在 C 标准库中,不乏一些好的接口。比如 POSIX 标准围绕文件描述符的打开、关闭、读取和写入接口:
int open(const char *pathname, int flags); ssize_t read(int fd, void *buf, size_t count); ssize_t write(int fd, const void *buf, size_t count); int close(int fd);将打开的文件(包括套接字)用一个整数的文件描述符来表示,这是 UNIX 操作系统的一大发明,类似的还有进程描述符、用户标识符等。
除了用于打开文件的接口,其他所有和文件读写相关的接口,也都需要传递一个已经打开(或创建)的文件描述符。因此,我们可以看到 read()、write() 和 close() 接口均使用 int fd 作为第一个参数。在 read() 接口中,调用者需要传入一个缓冲区以及想要读取的字节数,因此我们看到 read() 系统调用的第二个参数类型为 void *,而表示欲读取字节数的参数类型为 size_t。
注意,read() 接口的返回值类型为 ssize_t,这是因为该接口可能返回 -1 来表示读取失败,非负值则表示读取到的字节数。这里不能通过返回 0 来表示读取失败,因为当我们从套接字读取数据的时候,没有数据可读也是一种合理情形。
执行与 read() 接口相反操作的 write() 接口几乎具有和 read() 接口一模一样的原型。唯一的不同在于 write() 接口的第二个参数 buf 类型为 const void *。这表明参数 buf 指向的缓冲区对 write() 接口而言是只读的;也就是说,write() 接口的实现代码不会尝试向参数 buf 指向的内存区域写入任何内容,而只会读取其中的内容。
显然,对接口来讲,函数的名称、参数的类型、参数的顺序、返回值类型等,都是构成接口的重要组成部分。要设计一个好的接口,以上这些因素都要考虑到,而不能只考虑其中的几个方面。
为加深理解,我们来看看 STDIO(标准 C 库的输入输出模块)定义的文件操作相关接口:
#include <stdio.h> FILE *fopen(const char *pathname, const char *mode); size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream); size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream); int fclose(FILE *stream);上述两组文件打开、关闭、读取、写入的接口,有如下 3 个明显的区别:
1) STDIO 使用 FILE 结构体来表示一个打开的文件,后续读取、写入或关闭这个文件时,均需要传递指向 FILE 结构体的指针,也就是 fopen() 的返回值。这一设计和 POSIX 的文件读写接口有明显的不同,POSIX 使用的是整数的文件描述符。
出现这一差异的主要原因在于,在 UNIX 类操作系统中,运行在用户态的进程不能访问内核的地址空间。因此,一个由内核打开的文件,在内核看来可能对应一个结构体,但这个结构体的地址及内容对用户态进程而言没有任何意义:
- 一方面,当内核使用一个数组表示一个进程打开的所有文件时,使用针对这个数组的索引值来代表已打开的文件,便成了一种简单而直接的选择。这便是文件描述符的由来;
- 另一方面,和 POSIX 的文件读写操作不同,出于各种因素的考虑,比如读写性能以及我们熟知的字符串格式化功能等,标准 C 库的 STDIO 接口是带有缓冲区的读写接口。也就是说,当我们从一个文件中使用 fopen() 读取 1 字节的数据时,fopen() 会尝试一次读取 4096 字节或 8192 字节,下次调用 fread() 读取 1 字节的数据时,便可直接从缓冲区中返回下个字符的内容而无须再调用操作系统的 read() 函数。这样,通过降低调用 read()(系统调用)的频次,就提高了读取的性能,代价便是多了一些内存被占用,包括缓冲区本身以及用于管理缓冲区的数据。
正因为 STDIO 提供了带有缓冲区的读写接口,而缓冲区本身又位于当前进程的地址空间中,故而用于表述已打开文件的数值,就没必要再使用索引值了,一个指向结构体的指针足矣。使用索引值需要有一次从索引值到具体的结构体的查询过程,这会让事情变复杂,意义不大。因此,我们看到 STDIO 接口用于表述已打开文件的数值从 POSIX 的文件描述符变成了指向 FILE 结构体的指针。
2) 在 POSIX 的 read() 和 write() 接口中,调用者只需要传递缓冲区地址和内容的长度两个参数;而在 STDIO 的 fread() 和 fwrite() 接口中,针对内容的长度,调用者需要传递两个值(size 和 nmemb)来分别表示要读取的数据项的长度和数量。
之所以如此设计,是为了方便以结构体为单位读写文件,使得当文件中没有期望数量的数据项可读时,也可以返回已经读取到的数据项数量,而不需要执行额外的错误处理。另一个好处是,以单个数据项的长度(由参数 nmemb 指定)为单位读取文件,在缓冲区的帮助下,可在一定程度上实现读写性能的提升。
3) fread()、fwrite() 的返回值类型和 read()、write() 的不同,没有使用 ssize_t。这是因为 STDIO 的文件读写接口主要用于文件,而 POSIX 的文件读写接口还可以用于管道或套接字。前者只需要知道读写操作是否达到预期,通过返回值是否小于 size 即可判断;而后者,尤其是从管道或套接字读取数据时,需要使用错误值(−1)来表示一些特殊的状态,比如被信号中断,或者管道或套接字的另一头已经不存在等。此时,结合 errno 即可判断错误原因,进而做出适当的后续处理。
通过对比分析 POSIX 和 STDIO 的文件读写接口,我们可以知悉:即便完成类似的功能,也可能设计出具有明显差异的接口。这取决于很多因素,比如需求、实现上的要求或者限制,甚至设计者的喜好等。
最后,给出下面两个基本的接口设计原则:
- 完备(completeness)和自洽(self-consistency),这表示接口要完整且无逻辑漏洞。和上面的文件读写接口一样,C 语言的接口通常一组组出现,很少有单个接口涵盖所有功能的情形。因此,我们在设计接口的时候,要确保接口是完整的,接口之间的配合逻辑要能自圆其说,也就是所谓的自洽。
- 不要给调用者暴露不必要的实现细节,这可以起到两个作用。第一,类似于文件描述符,可有效降低接口的学习和使用成本;第二,通过隐藏实现细节,可解除接口定义和具体的实现方案之间的耦合,从而为将来的功能扩展、优化打下一定的基础。