C 语言在本质上,参数传递都是值传递。不像 Pascal 和 C++ 可以传引用。这一点,使得 C 语言可以保持简单的设计,但另一方面也颇为人诟病。
因为性能问题,指针不得不被引入。可以说,用 C 语言实现的软件,其实现的 Bug 90% 以上都来至于指针,应该是没有夸大了。当然设计引起的问题或许更为关键一些,那些于指针无关。
纠结于性能问题上,层次比较低。可 C 语言就是一个活跃在较低层次的语言,一旦你选择用它,就不得不关心性能问题。反过来,把 C 模仿成更高级的语言,倒是有点画蛇添足了。好了,让我们来看个实际的涉及参数传递的相关问题,用 C 语言该如何设计。
最近同事在做一个类似 Protocol Buffers 的东西。这个东西做好并不容易,设计上尤为困难。其中的设计难点:设计一个合适的 DSL (领域专用语言) 我们讨论过很久,也分析了好几天,但今天不打算谈了。拣个小东西说:当我们把一个二进制结构化数据块解析出来,传递到 C 语言中,让 C 语言可以方便的访问数据结构时,接口如何设计?
这个问题在目标语言不是 C 而是更高级的语言(尤其是有 gc 机制的语言)时,都不是问题。可 C 语言本身是没有对象概念的。
C 语言有结构,但是不具备描述动态长度的能力;没有字符串,只有定长的字符数组;甚至没有多维数组,只有一维数组的数组。
C 函数的参数及返回值可以是结构,但在接口设计中,或许是因为值传递,以及考虑 ABI 的简洁性的关系,常常使用结构指针。返回结构指针往往有生命期管理的苦恼。即使到了 C++ 里,允许返回结果/对象了,可所谓返回值优化也是件相当让人困扰的事情(如果你打算完全放弃了解语言的细节,无视细微处的性能问题。那么,为什么不考虑使用 Java 或是 Python ,无论什么都比选择 C++ 强)。
对于返回一组复杂数据,通常的办法有些什么?
最常用的方法是,调用者分配空间,传递给处理函数。由处理函数反向填写结构内容。这样的好处是,调用者可以选择把空间分配在栈上还是堆上。一点小提示:在语法上,C 语言允许你把一个数组当成指针来传递。所以你可以定义一个长度为 1 的结构数组类型。用起来好看一些。具体见标准库中的 setjmp 的定义。不过作为我个人的理念来说,不太主张在 C 语言设计的软件中,为了减少几次键盘输入,而使用过多的语言特性。
这个方式的缺点是,你很难让调用者定义不定的数据结构。尤其是在结构里还有对别的结构的引用。
跟这个相似的是接收字符串。最典型的例子是标准库中的 fgets ,提供一个接收缓冲区的地址指针,和一个缓冲区大小。(注:gets 则是一个失败的设计)同样在 Windows 的 API 中,也随处可见这样的例子。
第二,就是由函数自己分配内存,交给调用者去释放。大家只需要约定内存管理的接口即可。标准库中的 strdup 就是这样做的,同样的还有 readline 库中的 readline 。C 语言统一使用 malloc 管理内存,不像 C++ 提供了更灵活(更难控制,更容易出问题?)的 new 操作符重载。所以,给出这个约定并不会增添太多的麻烦。btw, 由于微软 VC 的 CRT 对 malloc 等实现的过于糟糕,导致很多 Windows 的软件自行实现内存管理器。或者在库中开放自定义内存管理器注入的接口。这其实有点越俎代庖了。gcc 提供的 CRT 里, malloc 性能就相当不错了。
缺点呢?内存只能从堆上分配;而且增加了内存泄露的隐患;设计角度上讲,也不太干净。对于复杂数据结构,这个方法也无能为力。C 语言里并没有所谓析构函数的说法。
作为对第二点的一种补充方案,用的人就凤毛麟角了。那就是给你的系统加入 gc 。实际上,就是约定另一种内存管理方法。我们的项目部分模块在用,效果还不错。gc 库已经开源,请参考这里。如果信不过这套东西,可以考虑 COM 的机制:增减引用。COM 旨在建立一种对象模型,可惜 C 语言中没有对象的概念,在 C 的层面使用 COM ,痛苦了一些。对于粒度比较小的东西,性能也将是问题。
第三种,用的人也比较多。就是在函数内部开一块静态空间,用于数据返回。返回的指针指向的数据的生命期可以保证到下次调用同一函数之前。静态空间可以声明在数据段里,也可以在程序初始化时从堆上分配出来,这样利于在空间不够的时候扩展。至于这块静态空间什么时候释放的问题,不用太操心。即使不去释放它们也不用内疚。操作系统会帮你回收的,还会比你干的更出色。C 是为了实现 UNIX 而诞生,而 UNIX 的哲学就是,编写简单的程序专心干好自己的事,让更高层次的程序(通常是 shell 或动态语言)去组合它们,让操作系统去管理它们。在 Windows 上,Unix 编程哲学未必有用,但大原则没错的。
这个方案有另外一个问题,就是函数不可重入,且有线程安全问题。重入问题可以想办法避免。线程安全可以用 TLS 解决。老实说,我个人不看好在 C 语言中使用多线程解决问题。多线程也是违背 Unix 哲学的。如果你有几件事情需要协调起来做,使用多进程;如果你有几百件事情需要同时来做,考虑换个思路,玩玩 Erlang 啥的。
回到今天我们面临的问题。用一种 DSL 来描述一个数据结构(比 C 的结构表达能力更强的),然后生成对应语言的解析库。如果目标语言是 C 的话,我们生成的代码如何返回对 C 程序员友好的结构化数据呢?
这让我想到了 MySQL 的 C 语言接口。很多初学 C++ 的程序员,很喜欢把那些 C 接口“封装”成“漂亮”的 C++ 接口。直接返回 vector 套 map 的多层模板实例。不知道有多少人干过?前几年我带实习生的时候反正见过不少。如果同学你现在醒悟了,明白这是件巨傻X 的事情,那么握握手,我们有共同理念;否则(C++ 封装以后不是很“酷”吗?),我们暂时没有共同语言了。
我不是想说 MySQL 的 C 接口设计的很好,不过是中规中矩。只是 C++ 不是 C ,C 也不是 C++ 。(话说,上面提到的 C++ 封装,我也不认为是正确的使用 C++ )反复提及 C++ ,是因为,我发现今天很大比例的 C 程序员其实是从 C++ 开始启蒙的,而不是相反。把 C++ 当成 C 用的危害其实比不上把 C 当成 C++ 用。前者不过是把汽车开到自行车的速度,至少不怕摔跤了,跑起来还能安全点;而后者,非要把自行车踩到高速公路中间,迟早非撞死不可。
最方便 C 程序使用的莫过于传入一个结构指针,让库去解析数据,填写这个结构了。
但是,如果结构里有字符串、不定长数组(通常会根据前面解析出来的数据决定后面的长度,对于 C 的编程技巧来说,允许把结构体的最后一个数组的长度设为 0 ,假设成不定长的,从而减少一次间接的指针引用。但是对于结构中有多个不定长数组则无法使用这个技巧。)等等的话,就很难避免指针了。
数据中一旦出现指针(间接引用别的数据),就有内存管理问题。
最开始,考虑过一个很 C++ 的方案,传入一个内存管理器。这种设计在 STL 里就有。所有 STL 的容器都可以指定一个 allocator ,供灵活的管理内存。前几年我倒是认为这是个相当巧妙的东西。没有细想,自定义分配器最终有多大的意义?自定义内存管理器,很大程度上是因为效率因素引起的。但性能问题永远不是根本问题。制作软件是为了达到特定的目的,而软件开发的问题更多的是是解决复杂度问题。往往复杂度带来的性能问题更加严重。然后为了解决复杂度带来的性能问题去引入更高的复杂度,出现恶性循环的可能性非常之大。
即使我们传入的内存管理器(或是直接使用 CRT 里的 malloc,但这样就没可能利用堆栈分配空间了),还会面临新问题,如何回收结构中间接引用的数据。引入析构函数指针?OMG 。
后来,我们设想使用一个内部静态空间,所有的解析结果都分配在内部,自我管理。这些空间还可以复用。大部分解析结果也就是临时用用,这样做很方便。而且调用者不用太关心数据的生命期。
但是,一旦调用者需要把结果(一个复杂结构)保存一段时间的话,他就遇到困难。
当然,也可能不是困难。当我们面对这个设计难点时,都应该向上考虑一层,究竟这是一个问题吗?我们需要这么用吗?
调用者可以自己遍历这个数据结构,把他需要的数据,以自己的方式复制出来,组织起来。他们需要的是数据,而不是对数据结构完全的拷贝。
仔细考虑过以后,我们还是发现,保留完整的数据结构是有意义的。不像 C++ ,C 没有对象赋值操作符重载这种语法糖,我也不喜欢用宏去模拟一个出来。增加一个拷贝函数指针其实和增添一个析构函数一样,对 C 来说,不那么漂亮。(当然,同时增加了开发量,我们需要编写更多的代码自动生成器)
最终,我们采用了由调用者传入缓冲区指针的方案。要求解析器生成的数据结构放在一块连续的内存空间上。这样,调用者就可以把指针直接定义成最终方便访问的结构或联合。但是提供更充裕的内存空间,存放那些内部引用的数据(比如字符串)。
因为结果数据区是由调用者提供,就不存在数据复制移动引起的指针调整问题(调用者可以自己先分配好)。
最后一个问题是,如何让调用者估算数据接收区的大小呢?
很多 Windows API 可以通过两次调用来完成,第一次空调用计算需要的缓冲区大小,第二次真的去填写数据。根据实际需求分析过之后,我认为在我们这个模块的应用上,这样做是多余的。我们尽可以让用户随便给一个估算大小去处理数据,一旦空间不够,返回错误信息。让用户自己扩大缓冲区,重新调用一次即可。
btw, 不断重试是我们最终认可的最 KISS 的方案。一开始,我们认为让处理程序自己分配内存,并自己使用 realloc 更好。后来发现,完全是多余的设计。因为,解析二进制流是 O(1) 的操作,不比估算长度慢;而往往调用者都能正确估算接收区应有的长度,即使简单的每次两倍的方法扩展接收区大小,也不会浪费多少处理时间。即使他们需要精确分配结果需要的内存块,尽可以用一个足够大的公用缓冲区接收,然后得到长度信息,重新在特定内存上重来一次即可。
写累了。想表达的也表达完了。今天到此为止。 :D
ps. 前几天写了一篇关于 一种对汉字更环保的 Unicode 编码方案 ,我昨晚花了两小时写了个简单的 C 实现。可以把 UTF-8 或 UTF-16 转换到我自己定义的暂且命名为 UTF-C 的编码上,也可以转回来。代码用的行数比预想的要多一些,因为我低估了 UTF-8 的处理复杂度(其实也不复杂啦)。