为什么C语言程序坚若磐石?

831 查看

2008年11月11号(Single Day~~~)

C语言,在今天来说是一种特殊的编程语言。只有极少数人真的可以用C进行编程,而且我们中很大一部分人都对C有自己的看法。缓冲区溢出,栈溢出,整型数据溢出,C有很多广为人知缺陷,而这些缺陷被人们随意传播,甚至那些不熟悉C的人们。我自己已经有10念没有接触C了,由于这样或那样的原因。开始的额时候,编译器是很昂贵的(在免费的UNIX被发布之前)而且很慢,那时的环境是很糟的。而且,所有关于C的恐怖故事让我觉得我这么一个小小的普通程序员怎么可以写出可靠的C程序。

撇过一些我直接从别的地方复制粘贴过来的很多小的C模块不说,我自己写的第一个C程序是Converge VM。其中有两件事情让我惊呆了:-o 。第一,写C程序原来不是那么难。事后我才知道我年轻的时候浪费时间写汇编代码这件事在心理上给我了很大的支持,毕竟C是高级一点的汇编语言。一旦一个人理解了像指针(可以说是低级语言中最微妙的概念,因为真实世界中没有相对应的比喻)这样的概念。第二件事情是,Converge VM没有像我期待那样满是bug。

实际上,忽略可能在任何编程语言上都存在的逻辑错误,到目前为止在Converge VM中引发实际问题的只有两个只针对C才会有的错误(主意,我肯定还有很多潜伏的bug,但是我情形还没有碰上太多)。第一个错误是,一个list没有以\0(C中经典的错误),这个问题花了很长时间去调试。另一个错误则神奇的多了,花了我好几个月时间。Converge 垃圾回收器可以谨慎地根据指针回收随意分配的内存空间。在所有的现在结构中,指针都指的是字和字对齐的边界。然而,已经分配的内存块在长度上常常不是字和字对齐的。 (In all modern architectures, pointers have to live on word-aligned boundaries.However, malloc'd chunks of memory are often not word-aligned in length.) 所以有时候垃圾回收器会在一个内存块位置为4的地方尝试读取4bytes,即使那个内存块是5bytes长。换句话来说,垃圾回收器尝试读入一块数据的1bytes和内存中理论上没有权限的3bytes随机数据。罕见和神奇的是,这导致的错误几乎没法解释。但是不夸张的说,在多少编程语言中一个人可以递归地加上垃圾回收器?

我和Converge VM的经历不怎么不符合我之前的偏见。我已经慢慢承认C程序会随机出现segfault,丢数据,而且常常会像Vikings(维京海盗)去Lindisfarne一样。对比来看,用高级语言编写的程序会按照正常逻辑和可以预料的模式报错。渐渐地,这些问题在我日常使用的我可以信任的这些用C写的程序中,我都碰到了。我不记得上次这些程序发生大问题的时候了。这些不会崩溃,也会优雅的处理次要的错误。就算,我对这些软件(我使用OpenBSD9年了,所以没有比这些质量更好的软件了)极度挑剔,还有一些明显的原因以至于为什么它为什么如此可靠:它被很多人使用,而这些人帮助我们找出bug。软件已经被开发出来很长时间了,所以之前的版本都存在bug。并且,坦诚一点,只有相当能干的程序员首选会倾向于C。但是,仍然存在一个根本的问题:为什么用C写的程序坚如磐石?

过了写论文这段黑暗的时期之后,我最近做了一点C编程。对于长时间没有着手写C程序的人,想妥妥地发封邮件都没谱了。这些年,我都是通过ssh在远程机器用sendmail发送邮件的。这解决了很多问题(比如黑名单),在很多网络中它也有问题(尤其是无线网络),一个过多的网络连接会被抛掉。检查邮件是否发送是很烦人的过程。所以仔细检查它的设计后,我打算写一个简单的工具集来稳妥地通过ssh发送邮件。最终的程序 - extsmail - 比我之前所期待的有更多的功能,但是最基础的思想就是通过外部的命令比如ssh简单的重试发送邮件,直到成功发送。我还想让这个工具集尽可能占用资源少还实用,还可移植。这必然决定应该用C写extsmail。然后我决定尝试尽可能地写这个程序,就当是实验吧。用传统的UNIX方式,只依赖可靠的UNIX分发版所提供的功能,而且容错能力强。在做的过程中,做了两份关于用C写程序的新手观察资料。

第一个观察不是太明显。因为用C写的程序有无数多种错误方式,我比平时更加细心。特别的,任何内存块的的操作都会引发非常危险的缓冲溢出类型错误。然而,在一个高级语言中,我可能会比较懒,心想“嗯,我索引数组的时候是不是应该给这个值减一?先跑一遍看看”。在C中,我会想“OK,坐下来想想原因”。讽刺的是,跑程序和发现问题所花的时间和坐下来思考的时间是不一样的,除了坐下来思考更加耗费脑力。

第二个观察,是我之前从没有碰到过的,在C中没有异常处理。如果,比如说extsmail,要提高容错能力,就得自己不得不处理所有可能的错误。从一方面来说,这是非常痛苦的,extsmail有很大一部分比例(大概40%)都在检查和去除错误,虽然UNIX系统方法已经很仔细的处理出错的情况了。换句话说,当在C中调用一个方法,比如stat的时候,文档会列出所有失败的情况。用户可以很容易的选择应该在程序中修复哪个情况,哪些致命错误应该进一步处理(在extsmail中,内存不足就是致命错误)。这就是在思维模式上基于语言的异常处理方式巨大的不同点,经典的哲学是正常地写代码,仅在少数情况下写try ... catch语句块来处理特定错误(很少碰到的错误)。Java,用受控制的异常,以不同的方式告诉用户“当调用这个方法的时候,你需要try catch特定的异常”。

我明白了一件事情,当希望软件足够的强壮(鲁棒性)的时候,基于异常的软件设计是不合适的。而需要明确的是知道一个方法返回的或者抛出的错误或者异常,然后根据情况去处理。在现在的IDE中,可以根据写的方法自动显示会抛出哪些异常,最多也就只能做到这一点了。理论上,面向对象中的子类和多态意味着预编译的库不能根据写的代码确定会不会抛异常(因为子类会重写方法,会抛出不同的错误)。从实用方面来说,我怀疑这么多方法都会抛出很多不同的异常,这会让用户懵了。对比UNIX中的方法,就非常注意,它们会尽量减少返回给用户的错误的数量,和一些内部错误,或者收集归类错误。进一步,我也怀疑那些依赖异常处理的很多库需要大幅度的进行重写来减少抛出的异常数量直到一个合理的数值。再进一步,方法的调用者应该决定什么错误应该次要的,可以恢复的,哪些会导致重要的问题,甚至导致程序结束;受控制的异常,被调用者强制处理的异常,就忘了这一点。

Henry Spencer 说,“那些不懂UNIX的人注定要可怜地重新发明轮子”。这就是为什么这么多用C写的程序比我们所提出的偏见更加坚固,UNIX文化,在计算机主流里,最古老和最明智的文化,已经发现很多把C的局限和缺陷变成优势的方法。就像我经历的,慢慢地我才明白了这点。综上,如果没有经过大量的思考,我不建议使用C。如果使用C,那最终的软件坚如磐石,但开发会花费大量人力。

Source:http://tratt.net/laurie/blog/entries/how_can_c_programs_be_so_reliable