« 让 Erlang 的控制台支持中文 | 返回首页 | The New C Standard »

一个 C 接口设计的问题

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 的处理复杂度(其实也不复杂啦)。

有兴趣的同学可以看这里

Comments

超级受教了!感谢!

开年以来读到的第一篇雄文,如饮烈酒,甘冽有劲。

strdup是posix的, 不是c库.呵呵

哈哈,我们有共同点语言,我大胆地猜测,风云大哥一定更喜欢使用写codegen来解决语言的不足,而不是去依赖某一语言的特性。

方法三稍微修改,函数内部静态空间每线程一个,与当前线程(ID)捆绑。实现上以线程ID为key,在数组或HashMap中存放缓存,实现颇为简单,性能几乎丝毫不损,也可解决文中大部分问题,唯是不可重入。不过正如云风所说,这个问题可能不是问题。

C 语言标准并没有特别限制返回值不能是什么,所以 C 是允许返回struct 作为一种返回复杂数据的方案的。

不知道被以前看过的哪本书误导,我觉得返回 struct 是 C++ 对 C 的一个扩展。(其实不是)

感谢指出这个错误的朋友,我今天仔细核对了 ISO C90 和 ISO C99 标准。:D

您的语言表达能力有待提高啊。。。

"这让我想到了 MySQL 的 C 语言接口。很多初学 C++ 的程序员,很喜欢把那些 C 接口“封装”成“漂亮”的 C++ 接口。直接返回 vector 套 map 的多层模板实例。不知道有多少人干过?前几年我带实习生的时候反正见过不少。如果同学你现在醒悟了,明白这是件巨傻X 的事情,那么握握手,我们有共同理念;否则(C++ 封装以后不是很“酷”吗?),我们暂时没有共同语言了。"
呵呵,我也这样干过。不过,没办法,谁叫咱,那时候刚学会
c++,不管什么东西,都要用我的c++这把小斧头试试啊。

windows api的调用历来就是这么做的

为什么说C函数不能返回结构?

为什么不看看xdr? sun rpc中的数据序列化方式,如果嫌它在线程安全上问题太多,那么看看cdr?C++和java都在用它。corba的idl和sun rpc的xdr就是两个很好的用DSL定义数据然后交给C语言去解析的例子。我觉得你所讨论的这些,在它们的实现上全有答案。
至于allocator,我只在loki中看见它为小对象设计过,专用于小于64bits的小对象的分配。我觉得这方面是花力未必讨好的事情。有时间可以做点别的优化。哦,可以借其做内存泄露的调试器。
至于数据接收区的大小,我觉得stl的stringstream以及string/vector这些都做的很好,根据需求自动增长,还可以让程序员可以灵活的预留空间。但是可惜没有realloc。不知道你的应用具体是什么情形,如果每次都是delete/new而没有realloc,对于性能影响大不。

感觉就是在说fgets嘛

这篇文章提了一个问题:如何用C语言接收被调用函数返回的数据块?
答案是:调用者分配内存,然后传指针给调用函数。被调用者往里面填数据。
简单的说就是:去超市买东西得自备购物袋。

当初我以为指针的数值等于指针指向的地址

后来才发现这两是不等的。
我到现在还是没搞清楚这两者的关系。

只是 知道 指针的数值的地址包含有个指针头数据才到指针真正的数据的地址。

有没有语法或函数得到一个指针指向数据的长度?

感觉写得很混乱,看得不是很明白,说的是对2进制数据的描述?

bioware的很多游戏文件都是2进制描述的,比如.tlk,从博德1开始发展到现在也没淘汰,可以描述一段对话的文本(字符串),配音文件(字符串),时间轴(浮点),颜色什么的,实现也简单,像元数据,先用一个数据结构描述数据结构的结构,然后就是按字节依次填充了

我们公司的模块间数据接口多数是这种类似fread的方案。不过看RADVISON的代码就喜欢传分配器了。

GLib就有点用C写C++的感觉。

传分配器也可以"要求解析器生成的数据结构放在一块连续的内存空间上"啊,只不过,真的有点怪,明明传了个分配器,却又约定只能调用一次-_-!或者传一个类似于realloc的分配器?

呵呵,最终的解决办法 省心啊, 时间真的很宝贵,省出的时间可以泡泡妞,灌灌水, 这才叫生活

pool 是一个内部解决方案,能不暴露出来就不暴露出来。否则,就需要记住两个东西:1. 结构的地址, 2. pool . 并且要记住两者的关系。

如果要用的话,最好和别的部分正交化。比如,先创建后 pool 传递 buffer 指针进去。

至于 pool 的伸展能力,在这里不是必须的。文中已有讲述。遵循能减则减的原则,就可以去掉了。

其实在堆上还是在栈上的问题,即使传分配器,也是都可以兼顾到的 :) 只要愿意弄点奇技淫巧的话。

传分配器的大问题是,复杂的数据结构是多次分配的,而最终需要一次释放。这限制了分配器的设计(必须设计成 pool 那种,而不能是 C 标准的 malloc )

虽然内存管理器等方案也不算坏,但从KISS上说,传内存确实用起来最简单,也灵活。(注意云风多次强调,内存可以在堆里分,也可以在栈上分,似乎要达到这个目地传内存就是唯一的方法了)

哈哈,新年好,特别谢谢上个星期云风大哥帮我抽中的豆浆机,豆浆很好喝,呵呵。

平时也一直在用c语言,说些我的看法吧:
1、c是用来做一些底层和性能要求较高的事情,如果不是很注重性能的话,没必要用它。
2、c是可以传结构体的。c中对于不定的结构体,可以用void指针。
4、对于像nginx这类代码,可以借鉴一下它里面的pool的概念。也就是对于不同的生存期的对象定义不同的pool,在对象开始时,分配一定的内存,在对象存活时,可以从这个pool中分配内存,不够的话,扩大这个pool。在对象消亡时,释放这个对象的pool。pool底层的实现其实都是malloc来做的。不过它的特定环境是session这类有特定生存期的对象,与程序的原有设计关系很大,如果是通用的内存分配,像这类东西就没法做到了。

GLib很多模块都是传构造和析构函数指针进去,或者至少传析构指针

至于说的引入析构函数指针的问题,一般情况下,内存都由内存分配器管理,不同生命期的对象使用不同的管理器,如果只牵涉的内存的话,我觉得要注册析构的情况是很少的

我觉得传入一个内存管理器的方式挺好的啊,比如像nginx这样的内存管理,有点类似于Apache的apr_pool,但更适合一般的应用,除了性能的提升,更重要的是使用起来更简单,最终统一释放,也不容易产生内存泄露。

不懂?学习!

Post a comment

非这个主题相关的留言请到:留言本