如何用 C 语言做项目?

April 16, 2019

C 语言是一门老掉牙的语言,它有各种各样的缺点让工程化协作变得不那么的友好。其中最明显的特征就是缺少组织工程所需的 package 和层级关系管理。我们可以在一些 C 工程里面看到各类基础类库和操作 string 的基础库。他们都是在为标准库的缺失和 C 语言缺乏工程化组织的语言特性在买单。
C 语言只为模块化提供了 .h 头文件这一简单有效的工程机制,对于依赖的管理和编译的开关你需要自己做好管理。模块与模块之间需要人为控制调用链路和层级关系。在我们平常的思维方式里面 A -> B -> C 的链路可以很轻易的转换成 A -> C 。在组织工程方面你千万不能直接这样做,必须要控制好 A -> B -> C 的调用链路以及不能反过来调用,否则代码在过了几个月之后会变得像乱麻一样难以维护。 比较好的方式是按照自顶向下的层级进行划分,越底层外部依赖越小,高层可以随意调用底层,反之则不行。 顺着代码分层的思路,我们阅读 C 语言的开源项目会容易许多。你将会发现大部分有名的开源项目都是一定程度上严格按照层级来划分模块。
C 没有提供面向对象的支持,这一点并不完全是坏处,面向对象不是解决所有问题的“万灵药”。没有面向对象的支持可以让我们在设计接口时不必过多拘泥于“对象”层级的抽象,也不需要过多的考虑 has_a(组合) 还是 is_a (继承)的问题。我们只需要把问题边界和定义以最清晰的方式描述出来,以此来设计接口,控制好哪些是用户应该看到的,哪些是用户应该操心的,其他的一律全部隐藏掉。我的做法就是尽可能的把 .h 文件做得无比简洁,让它没有任何外部依赖去声明一组接口,让 .c 文件去实现所有的细节。如下代码所示:

struct Foo;

struct Foo*
New(char *opts);

int 
AddFoo(struct Foo *f, struct Foo *f1);

int FooBar(struct Foo *f, int bar);

这就是基于对象的接口封装,它简单却十分有效。你必须要很清晰的去定义好问题的边界和代码的层级,提高单个模块的内聚性。由于缺乏一些高级语言特性的支持,用 C 语言开发软件就要求我们必须非常清晰的去思考我们的问题是什么,哪些是需要解决的,哪些是不需要解决的,以及该用什么用的数据结构。至于工程内不断的重复造轮子实际上并不需要花过多的时间。毕竟设计上的失误比代码要更加难以修改。
C 语言没有方便的 string 类,每当我们要操作一段字符串时必须要考虑字符串的生存周期管理。对于一些固定的常量字符串我们可以把它直接放在代码的静态区直接引用(如错误信息)。非固定的字符串我们应该考虑以模块化的方式去管理内存,尽量不要去做细粒度的堆上内存分配,释放内存过于繁琐很容易出现内存泄漏的问题。对于临时需要使用的一段字符串我们可以直接分配一个固定的字符串数组进行操作即可。 C 语言对编解码操作非常擅长,它可以让单个字符的操作达到最优效率。在一些编解码处理的情况下,静态代码 hard code 是必要的(比如 HTTP 的状态码和字符串返回信息的对应)。 为保证模块的内聚性和责任清晰,我们应当尽量不要让堆上的内存交由外部用户去释放或者删除,即使需要这样做也应该提供一个相应的 release 接口来对本模块分配的内存进行释放。
C 语言从 C89 以后就没有更多的改变,它是一门精简的无可再精简的语言。在现在这个年代如果还用 C 语言做开发那一定意味着对跨平台是有一定的要求的。我的做法是直接使用 C89 作为代码标准,并且屏蔽掉 C98 的语言特性和除 posix 库以外的标准库,这样做并没有损失什么,为代码一致性提供良好的支持。对于变量我们应当把一切的值以 0 做为初始化,struct 使用 memset(&s, 0, sizeof(struct s)) 这样的形式初始化,字符串常量数组始终以 char str[32]={0} 这种形式初始化。模块的初始化我们应当提供一个模块级的 init 函数去做。
在 C 语言里面全局变量不是一个好东西,它通常意味着影响整个程序运行期间的状态。比较好的做法是把全局变量定义在 .c 文件中,其他的 .c 文件的依赖可以使用 extern 的方式来引用,这样让它的作用域降低到整个模块的内部。当然对于一些特殊含义的全局变量我们是需要它放在 .h 文件里面让全局可见比如 linux 里面的 init_proc 和 jiffies 这样的变量。全局变量并不是绝对不好的东西,有时我们需要这样的全局实时更新的状态变量。
从以上我们可以看出实际上 go 语言并没有做太多的事情,它只是改进了所有 C 语言有缺陷的地方,并在语言层面提供方便的支持。我们可以把这些特性列出来就能知道 go 语言对应于 C 语言的改进。
GC- 你再也不用考虑栈上还是堆上分配内存,也不用考虑为每个工程设计一个内存分配/释放器。
内置 string 类 - 不再被残缺的 ‘\0’ 结尾的 char * 所累,也可以方便的在函数内返回 string。
struct, interface - 提供组合式面向对象支持,interface 规避了继承的复杂性。
package - 提供默认的 init 函数,并为接口和数据的暴露提供了良好的支持。
goroutine/channel - 让并发编程变得像同步编程一样简单并内置并发调度,提供简单的同步机制。
error handle/defer - 简单的异常处理机制和 defer 资源释放接口(在 C 里面要做到 defer 的效果必须要使用 goto)。
标准库 - 提供功能齐全的标准库,让开发尽量不用过多依赖外部 package,可以尽量组合手中的小轮子。