Unix痛恨者手册第三集
时间:2006-01-18 来源:welcome008
第九章 编程
张贴者: pengchengzou (veteran)
张贴日期 01/17/03 22:59 第九章 编程 “牛牛别怕,不疼的。” 别惹Unix,它弱不禁风,动不动就吐核(core dump) ——无名氏 如果你是通过在Unix上写C代码而学会的编程,那么可能会觉得这一章有些别扭。不幸的是,Unix如此广泛地被应用到了科研教育领域,很少有学生能意识到Unix的许多设计并不是严瑾合理的。 例如,听了我们关于有许多语言和环境比C/Unix要好的说法后,一个Unix爱好者是这么为Unix和C辩护的: 日期: 1991 Nov 9
发信人: [email protected] (Thomas M. Breuel) Scheme, Smalltalk和Common Lisp这些语言确实提供了强大的编程环境。但是Unix内核,shell和C语言则针对的是更为广泛的问题空间,而这些问题不是上面那些语言所擅长的(有的根本就无法处理)。 这些问题空间包括内存管理和局部性(locality)(在进程的产生和终止中实现),、持续性(persistency)(使用文件存储数据结构),并行性(parallelism)(通过管道,进程和进程通讯机制来实现),保护和恢复(通过独立的地址空间实现),以及可直观读取的数据表现方式(使用文本文件实现)。从实用的角度来看,Unix能很好地处理这些问题。 Thomas Breuel夸奖Unix能够解决复杂的计算机科学问题。幸运的是,这不是其他科学领域用来解决问题的方法。 日期: Tue, 12 Nov 91 11:36:04 -0500
发信人: [email protected]
收信人: UNIX-HATERS
主题: Random Unix similes (随机的Unix笑脸) 通过控制进程的产生与终止来进行内存管理,这就如同通过控制人的生死来对付疾病——这忽视了真正问题。 通过Unix文件获得持续性就如同把你所有的衣服仍进衣柜,幻想着能从里面找到需要的衣服(不幸的是,我正是这么去做的)。 通过管道,进程和进程通讯机制来实现并行化?Unix进程的代价是如此之高,以至于并行化得不偿失。就象是鼓励员工多生孩子,以解决公司人力资源短缺问题。 不错,Unix当然可以处理文本。他还能处理文本。嗯,还有,我有没有提到过Unix能够很好地处理文本? ——Mark 蔚为壮观的Unix编程环境 Unix狂热分子们总在宣扬Unix的所谓“编程环境”。他们说Unix提供了丰富的工具,能够使得编程工作更为容易。这是Kernighan和Mashey在《Unix编程环境》一文中的说法: Unix环境最能提高编程效率,这归功于众多的又小又有用的程序——工具,这些工具为日常的编程工作提供帮助。下面列举的这些程序被认为是其中最为有用的。我们在下文中将以他们为例说明其他观点。 wc files —— 统计文件中的行数,字数和字符数。
pr files —— 打印文件,支持标题和多栏打印。
lpr files —— 打印文件
grep pattern files —— 找到符合某种模式的文件行。 许多程序员的工作就是用它们和一些其他相关程序完成的。例如: wc *.c 用于对所有C源代码文件进行代码量统计; grep goto *.c 用于找到所有的goto语句。 这些就是“最为有用的”?!?! 有道理。这就是程序员的日常工作。事实上,今天我就用了不少时间来统计我的C代码量,以至于没有多少时间去做其他事情。等一下,我想我还得再数一遍。 同一期《IEEE计算机》上还有一篇文章,是Warren Teitelman和Larry Masinter写的《Interlisp编程环境》.Interlisp是个极为复杂的编程环境。1981年Interlisp就有了Unix程序员到了1984还在梦想的工具。 Interlisp环境的设计者们使用的是完全不同的方法。他们决定开发一个复杂的工具,需要花不少时间来掌握,好处是一旦学会了,极大地提高编程效率。听上去有些道理。 悲哀的是,今天很少有程序员能体会使用这类环境的感觉了。 在柏拉图的洞穴里编程 我总有一种感觉,计算机语言设计和工具开发的目标应该是提高编程效率而不是降低。 ——comp.lang.c++上的一个贴子 计算机以外的其他产业早就体会到了自动化的意义。当人们走进快餐点,他们需要的是一致标准的东西,而不是什么法国大菜。大规模地提供一致的一般食物,这比小批量的精耕细作要赚钱得多。 ——netnews上一个技术人员的回复 Unix不是世界上最好的软件环境——它甚至不是一个好的环境。Unix编程工具又简陋又难用;Unix调试器和PC上的没法比;解析器(interpreters)仍然是富人的玩具;修改日志(change log)和审记(audit trail)总是想起来才去做。可Unix仍然被当成程序员的梦。也许它只能让程序员梦到了效率的提高,而不是真的提高效率。 Unix程序员有点象数学家。你能从他们身上观察到一个神秘现象,我们称之为“空头编程”(Programming by Implication)。一次我们和一个Unix程序员聊天,谈到需要这样一个工具,能够回答诸如“函数foo被谁调用过?”或者“那个函数改变过全局变量bar”之类的问题。他也认为这个工具会很有用,提议到,“你们可以自己写一个。” 公平地说,他之所以只是说“你们可以自己写一个”而不是真正写一个,这是因为C语言的一些特性和Unix“编程环境”的强强联手,使得写这样的程序难于上青天。 使用yacc进行解析(parsing with yacc) "Yacc"就是我用过yacc(1)之后想喊的。 ——匿名 "YACC"是再一个编译编译器的编译器(Yet Another Compiler Compiler)的意思。它接受与上下文无关(context-free)的语法,构造用于解析的下推自动机(pushdown automaton)。运行这个自动机,就得到了一个特定语言的解析器。这一理论是很成熟的,因为以前计算机科学的一个重要课题就是如何减少编写编译器的时间。 这个方法有个小问题:许多语言的语法不是与上下文无关的。这样yacc的使用者不得不在每一个状态转换点上加上相关代码,以处理和上下文有关的部分(类型检查一般就是这么处理的)。许多C编译器使用的都是yacc生成的解析器;GCC 2.1的yacc语法有1650行之多 (如果不用yacc,GCC应该能成为自由软件基金会不错的作品)。由yacc生成的代码就更多了。 有些编程语言的语法比较容易解析。比如,Lisp能够用一个递归下降解析器进行解析。“递归下降”是一个计算机术语,含义是“喝杯可乐的功夫就能实现”。作为试验,我们写了一个Lisp递归下降解析器,只用了250行C代码。如果是用Lisp写的,那么一页纸也用不了。 在上面提到的那个计算机科学原始时代,这本书的编辑还没有生出来呢。计算机房是恐龙的天下,“真正的人”都在用仪表盘上的开关来编程。今天,社会学家和历史工作者想破脑袋也无法理解为什么理智的程序员却设计、实现和传播了如此难解析的语言。也许他们那时候极需一个困难的研究项目,设计一个难于解析的语言似乎是个不错的课题。 一直想知道他们在那个时代吃的是什么药。 上面提到的那个工具类似于一个C编译器的前端。C编译器前端是个极其复杂的东西,这是C的复杂语法和yacc的使用造成的。没有人真正动手去写一个这样的工具,这还有什么奇怪的么? 死硬的Unix分子会说你不需要这么一个程序,因为有grep就足够了。而且,你还能在shell管道中使用grep。有一天,我们想找出BSD内核源码中所有使用min函数的地方。这是其中一个结果: % grep min netinet/ip_icmp.c
icmplen = oiplen + min(8, oip->ip_len);
* that not corrupted and of at least minimum length.
* If the incoming packet was addressed directly to us,
* to the incoming interface.
* Retrieve any source routing from the incoming packet;
% 挺不错的吧,grep找到了所有的min函数调用,而且还不止这些。 “不知道怎么做爱。我撤。”("Don't know how to make love. Stop.") 理想的编程工具应该是这样的,它能让简单的问题保持简单,让复杂的问题有解决的可能。不幸的是,许多Unix工具过分追求通用性,而忽视了简洁。 Make就是这样一个典型。从抽象意义而言,make的输入是一个倚赖关系的描述。倚赖图上的每个节点都对应这一组命令,当节点过期时(由它所倚赖的节点来决定),这些命令会被执行。节点和文件相关,文件的修改时间决定了节点是否过期。下面是一个简单的倚赖关系图,也就是Makefile: program: source1.o source2.o
cc -o program source1.o source2.o source1.o: source1.c
cc -c source1.c source2.o: source2.c
cc -c source2.c 这里program, source1.o, source2.o, source1.c,source2.c就是关系图上的节点。节点program倚赖于source1.o和source2.o。 如果source1.o或source2.o比program要新,make便会运行命令cc -o program source1.o source2.o重新生成program。当然,如果修改了source1.c,那么source1.o和program都会过时,所以make会重新进行编译和链接。 尽管make的模型很通用,可惜设计者从没有考虑过简单性。不过,许多Unix新手都能体会到make能多么简单地“钻”(screw)了他们。 继续我们上面的那个例子,假定有个程序员Dennis想调试source1.c,于是要编译使用调试选项。他修改了一下Makefile: program: source1.o source2.o
cc -o program source1.o source2.o # I'm debugging source1.c
source1.o: source1.c
cc -c source1.c
source2.o: source2.c
cc -c source2.c "#"打头的那行是注释,会被make忽略。可怜的Dennis运行了一下make,这是它得到的: Make: Makefile: Must be a speparator on line 4.
Stop make歇菜了。Dennis盯着Makefile看了有好几分钟,又看了几小时,还是不明白哪儿出错了。他觉得是注释行的问题,可不是很肯定。 毛病出在当他加入注释行时,他不小心在第二行开始的制表符(tab)前敲入了一个空格。制表符是Makefile语法的一个重要部分。所有的命令行(例子中cc开始的行)必须以制表符打头。这就是Dennis的Makefile不工作的原因。 “那又怎样?”你可能会说,“这有什么不对的?” 它本身没什么不对。不过如果你想一下其他Unix编程工具的工作方式,就会觉得制表符语法就好象《地雷战》里的头发丝雷,看上去一马平川,踩上去呜呼哀哉。 你知道,制表符、空格符和换行符一般被统称为“白字符”(whitespacecharacters)。“白字符”意味着“你可以放心大胆地忽略它”许多程序正是这么做的,对空格和制表符一视同仁。就make孤芳自赏桀骜不驯鹤立鸡群冰清玉洁众人皆醉唯我独醒。于是我们这位Dennis兄弟恐怕只能给自己脑袋来一枪,告别这悲惨的Unix世界。 可怜的Dennis最终也没有找到自己那个Makefile的毛病,他现在落魄到只好去给一个中西部州立大学维护sendmail配置文件。默哀三分钟。 头文件 C语言有个东西叫头文件,里面是一些说明信息,在编译时被源文件使用。和Unix上的其他玩意一样,如果只有一个两个,可以工作得很好,多了就没戏了。 要知道你的源文件该使用那个头文件,这可不是件容易事。头文件是C预处理器(preprocessor)根据#include指令(directive)加载的。这个指令有两个用法: #include <header1.h> 和 #include "header2.h" 这两种用法的区别和各个C预处理器的实现有关,也就是说,任何实现都可以大着胆子撒着欢儿由着性子乱来。 让我们来看看Dennis的朋友Joey,Joey也是个Unix新手。Joey有个C程序foo.c,使用了foo.h中定义的一些数据结构,foo.c和foo.h放在了同一个目录下。你可能已经知道"foo"是程序员常用的名字。Joey机器上的系统程序员也做了一个foo.h文件,并把它放到了缺省系统头文件目录/usr/include 倒霉蛋Joey编译了foo.c,得到一堆语法错误。他迷惑不解,编译器总在他定义的一些数据结构处报错,可是这些数据结构在foo.h里被定义的好好的呀。 你我估计能猜到Joey的问题在哪儿,他一定是这么加载头文件的: #include <foo.h> 而不是写成: #include "foo.h" 可Joey不知道这个。也可能他确实是用的引号方式,只是他的编译器的查找方式有些特别。不管怎样,Joey是被干掉了,很无辜地被干了。 维护很多头文件是件挺头疼的事,不幸的是,如果你写个有用点儿的C程序,这是不可避免的。头文件一般 于定义数据结构,一个头文件往往倚赖于其他一?头文件。去把那些头文件的倚赖关系整理一下,你这回可不愁没事儿做了。 当然,编译器会帮你的。如果你把倚赖关系搞错了,编译器会毫不留情地指出语法错误。记住,编译器是个很忙很有身份的程序,它没时间去区分未定义的数据结构和输入错误的区别。事实上,即使你只是忘了敲个分号,C编译器也会恼羞成怒,立马撂挑子不干了。 在编译器社区,这一现象被称为“错误雪崩”,或者按照编译器自己的说法:“我完蛋了,起不来了。” 缺个分号会把解析器彻底搞晕,狂吐不止。这个解析器很可能是用yacc写成的,yacc对语法正确的程序(很少见的一种情况)处理得很好,但要让它生成健壮容错自动恢复的解析器,这就有点儿勉为其难了。有经验的C程序员都知道只有第一条解析错误才是有意义的。 工具程序和Man手册 Unix工具是自成一体的;可以任意解释命令行参数。这样的自由有些烦人;别以为学会了一套命令行规则就一劳永逸了,你必须去读每个命令的Man手册,才能知道如何去使用。 知道有那么多清楚明白的Man手册供你参考,你一定很开心吧。 看一下下面这个例子。“摘要”一栏总结得挺不错的,是不是? LS(1) Unix程序员手册 LS(1) 名称
ls - 列出目录内容 摘要
ls [ -acdfgilqrstu1ACLFR ] 名称 ... 描述
对于每个目录参数,ls列举那个目录的内容;对于每个文件参数,
ls 给出文件名以及要求的其他信息。缺省情况下,输出将按照字
母顺序排列。如果没有参数,则列举当前目录的内容。如果有不只
一个参数,这些参数首先会被适当排序,但是文件参数总是会被排
在目录参数前面。 ls有很多选项: [ ... ] BUGS
文件名中的换行符和制表符会被可打印字符 输出设备会被假设有80列宽 输出会根据输出设备的不同而不同,比如"ls -s"的结果和"ls -s| lpr"的结果不一样。这是不正确的,然而如果不这么做,一些倚赖这个功能的旧有shell脚本就会完蛋。 如果你想玩个游戏,不妨读一下每个Man手册的BUGS部分,然后想像一下每个bug是如何造成的。看一下这个shell的man手册: SH(1) Unix程序员手册 SH(1) 名称
sh, for, case, if, while, :, ., break, continue, cd,
eval, exec, exit, export, login, read, readonly, set,
shift, times, trap, umask, wait - 命令语言 摘要
ls [ -ceiknrstuvx ] [参数] ... 描述
Sh是一个命令程序语言,它执行来自终端或文件的命令。下面是各
个选项的说明。 [ ... ] BUGS 如果把使用<<提供的标准的输入提供给使用&运行起来的非同步的进程,shell会搞不清楚输入文档的名字。会生成一个垃圾文件/tmp/sh*,shell会抱怨找不到使用另外一个名字的文档。 我们用了好几分钟也没搞明白这个bug究竟是他妈什么意思。一个Unix专家看过之后说:“我边看边挠脑袋,有写这段BUGS的功夫,估计足够这家伙改掉这个吊玩意了。” 不幸的是,修改bug几乎是不可能的,因为它会随着每个新发布的操作系统而卷土重来。在80年代早期,在这些bug还没有被Unix信徒奉为神圣以前,一个BBN的程序员真的修改了伯克利make的这个制表符bug。这不是很难,也就是几行代码的事儿。 和所有责任感的公民一样,BBN的骇客们把补丁发给了伯克利,希望能把它加入主Unix代码中。一年过后,伯克利发布了新版本的Unix,make的这个bug还是存在。BBN的骇客第二次做了修改,又把补丁交给了伯克利。 ....然而伯克利的第三次发布还是老样子,BBN的程序员彻底失望了。他们没有再提交补丁,而是把他们所有的Makefile中空格打头的行替换成了制表符。毕竟BBN雇佣他们是来写新程序的,而不是反复修改同一个bug。 (据说,Stu Felman(make的作者)一开始就查觉到了这个问题,他没有修改,因为那时已经有10个用户开始用了。) 源码就是文档。哇~~ 牛逼! 如果我写着不容易,那么你理解起来就不应该容易。 —— 一个Unix程序员 我们在《文档》一章里提到Unix程序员认为操作系统的源代码是最好的文档。一个著名的Unix历史学家曾经指出:“毕竟,操作系统自己也是靠读源代码来知道下一步该干嘛的。” 可是通过阅读源代码来理解Unix,这就如同开着Ken Thompson的老爷车(对,就是闪着大红问号的那辆)周游世界。 Unix内核源码(更准确的说,是ftp.uu.net上发布的伯克利网络磁带2版的代码)几乎没有注释,充斥这大"段"没有空行的代码,goto随处可见,绞尽脑汁给妄图读懂它的人制造麻烦。有个骇客感叹到:“阅读Unix代码就好象走在伸手不见五指的巷子里。我总是停下来摸摸口袋,脑子里回响着一个声音‘老天,我就要遭劫了。’” 当然,内核代码有它自己的警报系统。四处散布着这样的小小注释: /* XXX */ 意思是有什么东西不太对劲儿。你应该知道哪儿出事儿了。 这绝不可能是bug,我的Makefile需要它! BBN的程序员应该算是另类。大部分Unix程序员是不去修改bug的:他们没有源代码。即使修改了也于事无补。这就是为什么Unix程序员遇到bug的第一个反应不是修了它,而是绕过它。 于是我们看到了悲惨的一幕:为什么不一劳永逸地解决问题,而是一错再错?也许早期的Unix程序员是尼采“永恒轮回”思想的信徒。 对于调试方法,存在着两个截然不同的派别:一个是“外科手术派”,包括流行于早期ITS和Lisp系统,程序运行过程中始终有调试器参与,如果程序崩溃了,调试器(也就是所谓外科大夫)会对问题进行诊断医治。 Unix是属于更古老的“尸体解剖派”。Unix下如果一个程序崩溃了,会遗留下一个core文件,从各个方面看这都和尸体没什么两样。Unix调试器然后会找出死因。有趣的是,Unix程序常常和人一样,死于本可治疗的疾病、事故以及疏忽。 对付Core 如果你的程序吐核(core)了,你首先要做的是找到它。这不该太困难,因为core文件总是很大——4, 8, 甚至12兆。 core文件之所以这么大,是因为它包括了所有用来调试的信息:堆栈,数据,代码指针等等,无所不包,除了程序的动态状态。如果你在调试一个网络程序,在你的程序吐核的时候,已经为时太晚了;程序的网络连接已经没有了,更致命的一击是,所有打开的文件现在都被关上了。 不幸的是,在Unix上只能如此。 例如,不能把调试器作为命令解析器,或者在内核发生异常时把控制交给调试器。如果想让调试器在程序崩溃时进行接管,那你只能在调试器里面运行所有程序(是的,有的Unix版本让你用调试器接管一个运行中的进程,但是你手边必须有一个还有符号的程序文件)。如果你想调试中断代码,你的调试器必须截获每个中断,然后把合适的中断返回给程序。你能想像emacs里每敲一键都发生3个进程切换(context switch)的感觉么?显然,例程调试(routine debugging)思想和Unix哲学是格格不入的。 日期: Wed, 2 Jan 91 07:42:04 PST
发信人: Michael Tiemann <[email protected]>
收信人: UNIX-HATERS
主题: Debuggers (调试器) 想过Unix调试器为什么这么蹩脚么?这是因为如果它想提供什么功能,那一定会跟来一堆bug,如果有bug,它一定会吐核(dump core),如果它吐核,靠,你用来调试的那个core文件就会被覆盖。如果能让程序来控制如何吐核,何时吐核,以及吐在哪里,那就太好了。 bug骨灰盒 和其他操作系统不同,Unix把bug供奉为标准操作。之所以那么多Unix bugs得不到修正,这里有个不可告人的原因——如果修正了,那么已有的一些程序就会死逼了。然而,荒唐的是,Unix程序员在增加新功能时却从来不去考虑向下兼容。 考虑到这些,Michael Tiemann给出了Unix调试器覆盖core文件的10个理由: 日期: Thu, 17 Jan 91 10:28:11 PST
发信人: Michael Tiemann <[email protected]>
收信人: UNIX-HATERS
主题: Unix Debuggers (Unix调试器) David Letterman (美国著名晚间脱口秀主持人)的10个最佳理由是: 10. 这会破坏已有代码。
9. 这需要修改文档。
8. 太难实现了。
7. 这怎么是调试器的活儿?为什么不写个“工具”做它?
6. 如果调试器吐了核,你应该丢开你自己的程序,开始调试调试器。
5. 太难理解了。
4. 哪儿有饼干?
3. 为什么非得现在做?
2. Unix也不是神仙。
1. 哪儿有问题? Unix程序员总是打着“这会破坏已有代码”的幌子,不愿意修正bug。可这里面还有内幕,修正bug不但会破坏已有代码,还必须修改简单完美的Unix接口,而这正是Unix教众们的命根子。至于这个接口是否工作,这并不重要。Unix教众们不去提出更好的接口,也不去修正bug,而是齐声高唱“Unix接口好简洁,好简洁。Unix接口就是美,就是美!Unix无罪!Unix有理!”。 不幸的是,绕过bug是个很恶劣的行为,它使得错误成为了操作系统规范的一部分。你越是等,就越难以修正,因为越来越多的程序会尽力绕过bug,以至于没有了bug反而活不了了。同理,修改操作系统接口带来的影响更大,因为更多的程序必须根据这个正确的新接口进行修改。(这解释了为什么ls有那么多的选项来完成几乎一样的工作)。 如果你把一只青蛙仍到开水里,它会马上跳出来。它知道开水很烫。可是,如果你把青蛙放到冷水里,再慢慢地加热,青蛙感觉不到什么,直到最后被烫死。 Unix接口已经开锅了。以前,输入/输出的全部接口只包括open, close, read和write。网络支持给Unix添了一大把柴禾。现在,至少有五种方法向一个文件句柄输入数据:write, writev, send, sendto和sendmsg。每个都在内核中有不同的实现,这意味着有五倍的可能出现bug,有五种不同的性能结果需要考虑。读文件也一样(read, recv, recvfrom和recvmsg)。等死吧,青蛙们。 文件名扩展 Unix“所有程序自成一体”的规定有一个例外。Unix程序经常要处理一个或多个文件。Unix shells提供了命名一组文件的方法,shell会把这组文件展开,做为一个文件列表传递给各个命令。 例如,假设你的目录下有文件A, B和C。如果象删除所有这些文件,你可以运行rm *。shell会把"*"扩展成为"A B C",并把他们做为rm的参数传递给它。这个方法有不少问题,这在上一章已经提到过了。不过,你应该知道让shell来扩展文件名不是偶然的:而是精心设计的结果。在Kernighan和Mashey发表的《Unix编程环境》一文中(IEEE计算机杂志,1981年四月),他们指出:“把这个作为shell的一个机制,这避免了各个程序的重复劳动,而且保证了为所有程序提供一致的输入。” (Unix的一个理想是让任何人能够运行任何shell。现在你没法运行任何shell;你的shell必须提供文件名扩展)。 别忙。标准输入/输出库(Unix所谓的stdio)不就能“为所有程序提供一致的输入”么?提供一个用于扩展文件名的库函数不就成了?这些家伙没有听说过链接库么?那些关于性能的说法也是无稽之谈,因为他们无法提供任何的性能数据,他们甚至没有说明“性能指标”是什么。指的是开发一个小程序会快一些?还是指能高性能地把一个新手的所有文件一扫而光? 大多数情况下,让shell进行文件名扩展也无所谓,因为这和程序自己扩展的结果没什么不同。可是,和Unix上的许多玩意一样,它早晚会咬你一口,而且不轻。 假设你是个Unix新手,目录下有两个文件A.m和B.m。你习惯了MS-DOS,想把它们的名字换成A.c和B.c。嗯~~ 没找到rename命令,不过mv命令似乎差不多。于是你执行mv *.m *.c。shell将这个命令扩展为 mv A.m B.m,你辛辛苦苦写了几小时的B.m就这么被干掉了。 再好好思考一下上面这个问题,你就会发现理论上你完全不可能提供一个和MS-DOS "rename"一样的功能。对于软件工具,就扯这么多吧。 健壮性,或者说“所有输入行必须小于80个字符” 1990年11月份的《ACM通讯》上登了Miller Fredriksen等人写的一篇精采文章,题目是《Unix工具的稳定性的经验性研究》。他们使用一些随机数据作为Unix工具的输入,发现有24-33%(不同的Unix发布结果有所不同)的工具崩溃了。有时候甚至整个系统都完蛋了。 文章是以一个笑话开头的。其中一位作者曾使用一个极差的电话连接工作,发现许多工具都垮掉了。于是他决定针对这一现象进行更系统的调查研究。 许多bug都可以归因于C语言的陈规陋习。事实上,Unix的许多内在脑损伤都是C语言造成的。Unix的核心以及所有的工具程序都是用C语言写的。著名语言学家Benjamin Whorf说过:语言决定思想。Unix有深深的C烙印。C语言使得程序员根本无法想像能写出健壮的程序。 C语言是极小的。它被设计成能在各种硬件上快速地进行编译,所以它有着和硬件类似的结构。 Unix诞生之初,使用高级语言编写操作系统是个革命性的想法。现在则应该考虑使用一种有错误检查的语言了。 C是个最为底层的语言,诞生于硬件更为底层的时代。PDP-11没有的,C语言也不会有。过去几十年的编程语言研究表明,语言中加入错误处理,自动内存管理和抽象数据类型等功能,会使得开发出的程序更为健壮可靠。你在C里面找不到这些东西。C语言太流行了,没人去考虑给它增加诸如数据标记或硬件垃圾回收支持等功能。即使硬件提供了垃圾回收功能,也只是多费了一些硅片罢了,因为许多C语言编写的程序根本无法使用它。 回想一下,C是无法处理整数溢出的。解决方法是使用超过问题需要的整数大小,希望这个大小在你有生之年足够用。 C也没有真正意义上的数组,它有个象是数组的东西,实际不过是一个指向一块内存的指针。数组定位表达式(array[index])不过是表达式(*(array+index))的简写版。所以你甚至可以说index[array],这和表达式(*(array+index))是一个意思。聪明吧?在字符处理时经常能见到这个用法。数组变量和指针变量经常可以互换。 举个例子,假设你有: char *str = "bugy"; 于是下面的这些语句都是一样的: 0[str] == 'b'
*(str+1) == 'u'
*(2+str) == 'g'
str[3] == 'y' C语言够伟大的吧? 这个做法的问题是C根本不做任何自动数组边界检查。为什么该C去做呢?数组在C里只是个指针而已,你可以把指针指向内存的任何地方,是不是?不过,一般你不想在内存里乱写乱画,特别在是一些关键的地方,比如程序的堆栈。 这把我们引到了Miller的文章里提到的一类bug。许多程序是在读取输入到堆栈上的一块字符缓冲区时崩溃的。许多C程序是这么做的;下面的C程序把一行输入读到堆栈上的一个数组里,然后调用do_it函数进行处理。 a_function()
{
char c, buff[80];
int i = 0; while ((c = getchar()) != '\n')
buff[i++] = c;
buff[i] = '\000';
do_it(buff);
} 这类代码把Unix搞得臭不可闻。知道为什么缓冲区被定为80个字符么?这是因为许多Unix文件每行最多有80个字符。知道为什么没有边界检查,也没有文件尾检查么?这是因为这个程序员喜欢把c = getchar()这样的赋值语句嵌入到while循环中。信不信,有些人还推崇C的这种缩简写法,管他妈什么可读性可维护性。最后,调用do_it(),数组摇身一变成了指针,作为第一个参数传了进去。 作为练习:如果在一行当中到达了文件尾,这个程序的结果是什么? 当Unix用户查觉到这个内置的限制后,他们想到的不是去修正这个bug,而是想方设法躲过它。比如,Unix的磁带备份工具(tape archiver)tar不能处理超过100个字符的路径名(包括目录)。解决方法是:不要备份目录到磁带,或者使用dump。更好的办法是:不要建立太深的目录,这样文件的绝对路径就不会超过100个字符。 2038年1月18日上午10点14分07秒,Unix马虎编程将在这一刻上演精采的一幕,那时Unix的32位timeval将耗尽... 再回到我们前面那个例子,假设输入行有85个字符。这个函数毫无问题地接受了这个输入,可问题是最后那5个字符会被放到哪里呢?答案是它们会占据任何排放在数组后面的5个字节。之前那里放着的是什么呢? c和i这两个变量可能会被分配在字符数组之后,所以有可能会被85字符长的输入冲垮。如果输入了850个字符呢?则可能会毁掉堆栈上的重要的C运行环境系统信息,比如返回地址等。毁掉这些信息的最好结果是程序可能崩溃。 我们说“可能崩溃”是因为程序的编写者从没想到过你竟能毁掉堆栈。想像一下我们的这个程序读入了很长的一行,约有2,000个字符,这行字符被用来覆盖堆栈上的返回地址以及其他环境信息,它将调用2,000个字符里埋藏的一段代码。这段代码可能会做一些很有用的事情,比如执行(exec)出一个shell,运行一些命令。 Robert T. Morris的著名Unix蠕虫病就是使用了这个机制(以及其他一些技巧)黑进Unix主机的。我不知道其他人为什么还会这么做,真的不知道,嘻嘻。 日期: Thu, 2 May 91 18:16:44 PDT
发信人: Jim McDonald <jlm%[email protected]>
收信人: UNIX-HATERS
主题: how many fingers on your hands? (你共有几根手指?) :( 下面是给我的上司的一个报告: 一个用来更新Make文件的程序使用了一个指针,对它的访问毁掉了一个存放倚赖关系的数组,这个倚赖关系被用来生成Makefile。直接后果是生成的错误Makefile不能用于编译任何东西,没有生成所需的对象文件(.o),所以编译最终失败了。一天的工作就这么付之东流了,只是因为一个傻瓜认为10个头文件足够所有人使用了,然后对它进行了极其危险的优化以在1毫秒内生成所有的Make文件! 网络化的坏处是,你没法再闯进某人的办公室里把他的心给挖出来。 (关于堆栈溢出攻击,可参考经典论文href=http://www.phrack.org/phrack/49/P49-14> Smashing The Stack For Fun And Profit --me) 异常处理 编写健壮程序的最大挑战是如何正确处理错误和其他异常。不幸的是,C几乎没有为此提供什么帮助。今天在学校里学会编程的人里很少有谁知道异常是什么。 异常是函数无法正常运行时所产生的一个状态。异常经常发生在请求系统服务时,比如分配内存,打开文件等。由于C没有提供异常处理支持,程序员必须自己在服务请求时加入异常处理代码。 例如,下面是所有C语言课本中推荐的使用malloc()分配内存的方法: struct bpt *another_function()
{
struct bpt *result; result = malloc(sizeof(struct bpt));
if (result == 0) {
fprintf(stderr, "error: malloc: ???\n"); /* recover gracefully from the error */
[...]
return 0;
}
/* Do something interesting */
[...]
return result;
} another_function函数分配了一个类型为bpt的结构,返回了一个指向这一结构的指针。这段代码说明了如何分配内存给这个结构。因为C没有显式的异常处理支持,C程序员必须自己去做这件事(就是粗体的那些代码)。 当然你可以不这么干。许多C程序员认为这是小事一桩,从来不做异常处理。他们的程序往往是这样的: struct bpt *another_function()
{
struct bpt *result = malloc(sizeof(struct bpt)); /* Do something interesting */
[...]
return result;
} 多么简单,多么干净,大多数系统服务请求都会成功的,是不是?这样的程序在大多数场合运行良好,直到它们被应用到复杂特殊的地方,往往就会神秘地失效。 Lisp的实现总是包括一个异常处理系统。异常条件包括OUT-OF-MEMORY这样的名称,程序员可以为特定的异常提供异常处理函数。这些处理函数在异常发生时被自动调用——程序员不需要介入,也不需要做特殊的检查。适当地使用,可以让程序更为健壮。 CLU这样的编程语言也有内置的异常处理。每个函数定义都有一系列可以发出的异常条件。对异常的显式支持可以帮助编译器检查那些未被处理的异常。CLU程序总是十分健壮,因为编译器逼着CLU程序员去考虑异常处理问题。C程序是个什么样子呢: 日期: 16 dec 88 16:12:13 GMT
主题: Re: GNU Emacs
发信人: [email protected] <448@myab.se> [email protected] (Lars Pensy)>写到:
... 所有的程序都应该检查系统调用(如write)的返回结果,这非常重要。 同意,可不幸的是很少有程序在进行读(read)写(write)时这么做。 Unix工具程序一般会检查open系统调用的返回值,假设所有随后的read,write和close总会成功。 原因很明显:程序员很懒,不做错误处理程序会显得更小更快。(这样你的系统会有更优异的性能表现)。 这封信的作者继续指出,由于大部分系统工具不对write()等系统调用的返回值进行检查,系统管理员就必须保证文件系统时时刻刻都有足够的空间。正是如此:许多Unix程序假设它们可以写任何成功打开的文件,想写多少就写多少。 读到这里你可能会皱眉头,"嗯~~”一下。最为可怕的是,就在《Unix工具的稳定性的经验性研究》这篇文章的前几页,登载了一份报告,说明休斯顿外层空间中心的飞船控制实时数据采集系统是如何转型为Unix系统的。"嗯~~” 捕捉bug是社会所不能接收的 不去检查和报告bug,这会使制造商生产的系统显得似乎更为健壮和强大。更重要的是,如果Unix系统报告每一个错误,那么就根本不会有人去用它!这是活生生的现实。 日期: Thu, 11 Jan 90 09:07:05 PST
发信人: Daniel Weise <[email protected]>
收信人: UNIX-HATERS
主题: Now, isn't that clear? (现在明白了么?) 惠普做了一些工作,这样我们的惠普Unix系统能够报告一些可能会影响它的网络错误。这些惠普系统和SUN, MIPS, DEC工作站共享一个网络。我们经常会发现其他机器所引发的问题,可是当我们通知给那些机器的主人时(因为这些系统不报告错误,他们不知道自己的机器有一半时间是用在重发数据包上了),他们往往反称是我们这里的问题,因为只有我们这里报出了错误。 “两国相争,不斩来使”,不过在Unix世界里,你最好别当信使。 修不了?重启! 如果某个关键软件不能适当处理错误的数据和操作条件,那么系统管理员该如何是好呢?嗯~~,如果它能在一段时间里正常工作,你就能通过不断重启它来凑合着运行。这个法子不是很可靠,也不具有扩展性,不过足够让Unix苟 硬写 一阵子了。 下面就是这么一个例子,说明如何在named程序不稳定的情况下提供邮件服务: 日期: 14 May 91 05:43:35 GMT
发信人: [email protected] (Theodore Ts'o) (著名的Ted Ts'o? --me)
主题: Re: DNS performance metering: a wish list for bind 4.8.4
(DNS性能测试:bind 4.8.4的期待功能表)
收信人: comp.protocols.tcp-ip.domains 我们现在是这么解决这个问题的:我写了一个叫"ninit"的程序以非精灵(deamon)模式(nofork)运行named,然后等待它退出。当named退出时,ninit重新启动一个新的named。另外,每隔五分钟,ninit会醒来一次发给named一个SIGIOT信号,named接到这个信号后会包一些状态信息写入/usr/tmp/named.stats文件中。每隔60秒钟,ninit会用本地named进行一次域名解析。如果短时间内没有得到结果,它会杀掉named,重新启动一个新的。 我们在MIT的名称服务器上和我们的邮件网关(mailhub)上运行了这个程序。我们发现它很有用,能够捕捉named的神秘死亡或僵死。这在我们的邮件网关上更是不可缺少,因为即使域名解析中断一小会儿,我们的邮件队列也会给撑炸了。 当然,这类办法会引发这样的问题:如果ninit有bug,那么该怎么办呢?难道也要写一个程序不断重启ninit么?如果写了,你又如何保证那个正常工作呢? 对于软件错误的这种态度并不少见。下面这个man手册最近出现在我桌上。我们还不能肯定这是不是个玩笑。BUGS部分很是发人深省,因为那里列举的bug是Unix程序员总也无法从代码中剔除的: NANNY(8) Unix程序员手册 NANNY(8) 名称
nanny - 奶妈,运行所有服务的服务 摘要
/etc/nanny [switch [argument]] [...switch [argument]] 描述
许多系统都为用户提供各种服务(server)功能。不幸的是,这些服务经常不明不白地罢工,造成用户无法获得所需要的服务。Nanny(奶妈)的作用就是照看(babysit)好这些服务,避免关键服务的失效,而不需要系统管理员的随时监视。 另外,许多服务使用日志文件作为输出。这些数据常会很讨厌地充满磁盘。可是,这些数据又是重要的跟踪记录,应该尽量保存。Nanny会定期把日志数据重定向到新文件。这样,日志数据被化整为零,旧的日志文件就能被任意转移走,而不对服务构成影响。(现在这成了logrotate的任务 --me) 最后,nanny还提供了一些控制功能,使得系统管理员能够对nanny以及它所照看的服务进行运行时操作。 选项
... BUGS
有个服务在nanny中做分离fork(detaching fork)。nanny会错误地认为这个服务死掉了,不断重启它。 到目前为止,nanny还不能容忍配置文件的错误,如果配置文件的路径不对或者内容有错误,nanny必死无疑。 不是所有的选项都被实现了。 Nanny倚赖系统提供的网络功能进行进程间通讯。如果网络代码有错误,nanny将无法处理这些错误,可能僵死或是死循环。 对不稳定软件经常重启,这已经成了MIT雅典娜计划(Project Athena)的日常工作,现在他们每星期天的凌晨4点都会重启AFS(Andrew File System, 一种网络文件系统)服务器。但愿没有人周末熬夜赶写下周一要交的作业... 怎么样,Unix编程很有趣吧?惊险,刺激,痛并快乐!该回家了,休息一下,大麻没劲了,有海洛英;C用腻了,我们还有C++!放心,离死不远了。 第十章 C++
90年代的COBOL 问:"C"和"C++"的名字是怎么来的?
答:这是他们的成绩 ——Jerry Leichter 再没有比C++更能体现Unix“绝不给用户好脸”的哲学思想的了。 面向对象编程可以追溯到60年代的Simula语言,在70年代的Smalltalk语言上得到极大发展。许多书会告诉你面向对象语言如何能提高编程效率,使代码更健壮,和减少维护费用。不过你甭想在C++里得到这些。 这是因为C++根本就没理解面向对象的实质。非但没有简化什么,反而增加了更多的复杂性。和Unix一样,C++从没被好好设计过,它从一个错误走向另一个错误,是件打满补丁的破衣服。连自己的语法都没有严格定义(没一个语言敢这样),所以你甚至无法知道一行代码是不是合法。 把C++比做COBOL,其实是对COBOL的污辱。COBOL在那个时代的技术条件下,是做出了很不同凡响的贡献的。如果有谁用C++做成过什么事,那就算是很不同凡响了。幸运的是,很多不错的程序员知道必须尽量避免C++的伤害,他们只用C,对那些荒唐费解的功能敬而远之。通常,这意味着他们必须自己写个非面向对象的工具,以获得自己想要的功能。当然,他们的代码会显得极为怪异,失去兼容性,难于理解和重用。不过只要有一点儿C++的味道,就足够说服头头批准他们的项目。 许多公司已经被多年遗留下来的混乱难懂的COBOL代码搞得焦头烂额了。那些转而使用C++的公司刚刚意识到自己上了当。当然,这已经太晚了。软件灾难的种子已经播下了,浇水施肥,得到悉心照料,就等着十几年后长成参天大树了。等着瞧吧! 面向对象的汇编语言 C++没有一丝一毫高层次语言的特性。为什么这么说?让我们看看高层次语言应该具备那些特性: 优雅:在表示方式及其所表达的概念之间有着简单易懂的关系
抽象:高层次语言的每个表达式只表示一个概念。概念能够被独立表达并能自由使用
强大:高层次语言的能够对任何精确完整的程序行为进行提供直接了当的表述方式
高层次语言使程序员能够采用问题空间的方式描述解决方案。高层次的程序很容易维护,因为它们的目的性(intent)十分明确。根据一行高层次程序代码,现代编译器能够为各种平台生成高效的代码,所以高层次程序的可移植性和可重用性自然会很强。 使用低层次语言则需要对考虑无数细节,其中大部分是和机器内部操作有关的东西,而不是要解决的问题本身。这不但造成代码难于理解,而且很容易过时。现在几乎每隔今年就要更新系统,过时的必须花费很高代价修改低层代码或者彻底重写。 对不起,你的内存泄漏了 高层次语言对于常见问题有内置解决方案。例如,众所周知内存管理是产生错误最多的地方。在使用一个对象之前,你必须为它分配内存,适当进行初始化,小心跟踪使用,并正确释放。当然,每件事儿都异常乏味而且很容易出错,极小的一个错误可能会导致灾难性后果。定位和修改这类错误是臭名昭著的困难,因为它们对于配置或使用方式的变化极其敏感。 使用未分配内存的结构指针会造成程序崩溃。使用未正确初始化的结构也会使你的程序崩溃,不过不一定立刻完蛋。如果未能好好跟踪结构的使用情况,则很可能释放一块还在使用中的内存。还是崩溃。最好再分配一些结构用来跟踪那些结构对象。不过如果你太保守,不去释放那些不很肯定未在使用的内存,那么你可要小心了。内存中很快就会充斥着无用的对象,直到内存耗尽,程序崩溃。这就是恐怖的“内存泄漏”。 如果你的内存空间碎片太多,那该怎么办呢?解决办法是通过移动对象对内存重新归整,不过在C++里没戏——如果你忘了更新对象的所有引用(reference),那么你就会搞乱程序,结果还是崩溃。 很多真正的高层次语言提供了解决办法——那就是垃圾回收(garbage collector)。它记录跟踪所有的对象,如果对象用完了会加以回收,永远不会出错。如果你使用有垃圾回收功能的语言,会得到不少好处: 大量的bug立马无影无踪。是不是很爽呀? 代码会变得更短小更易读,因为它不必为内存管理的细节操心。 代码更有可能在不同平台和不同配置下高效运行。 唉,C++用户必须自己动手去拣垃圾。他们中的许多人被洗了脑子,认为这样会比那些专家提供的垃圾回收工具更为高效,如果要建立磁盘文件,他们估计不会使用文件名,而更愿意和磁道扇区打交道。手动回收垃圾可能会对一两中配置显得更高效些,不过你当然不会这么去使用字处理软件。 你不必相信我们这里说的。可以去读一下B. Zorn的《保守垃圾回收的代价测量》(科罗拉多大学Boulder分校,技术报告CU-CS-573-92),文中对程序员用C手动优化的垃圾回收技术和标准垃圾回收器进行了性能比较,结果表明C程序员自己写的垃圾回收器性能要差一些。 OK,假设你是个幡然醒悟的C++程序员,想使用垃圾回收。你并不孤单,很多人认为这是个好主意,决定写一个。老天爷,猜猜怎么着?你会发现根本没法在C++中提供其他语言内置的那样好的垃圾回收。其中一个原因是,(惊讶!)C++里的对象在编译后和运行时就不再是对象了。它们只是一块十六进制的烂泥巴。没有动态类型信息——垃圾回收器(还有调试器)没法知道任何一块内存里的对象究竟是什么,类型是什么,以及是否有人此时正在使用它。 另一个原因是,即使你能写个垃圾回收器,如果你用了其他未使用垃圾回收功能的代码,你还是会被干掉。因为C++没有标准的垃圾回收器,而且很有可能永远也不会有。假设我写了一个使用了我的垃圾回收功能的数据库程序,你写了一个使用你自己的垃圾回收功能的窗口系统。但你关闭一个装有我的数据记录的窗口,你的窗口不会去通知我的数据记录,告诉它已经没有人引用它了。这个对象将不会被释放,直到内存耗尽——内存泄漏,老朋友又见面了。 学起来困难?这就对了 C++和汇编语言很相象——难学难用,要想学好用好就更难了。 日期: Mon, 8 Apr 91 11:29:56 PDT
发信人: Daniel Weise <[email protected]>
收信人: UNIX-HATERS
主题: From their cradle to our grave (从他们的摇篮到我们的坟墓) 造成Unix程序如此脆弱的一个原因是C程序员从启蒙时期就是这么被教育的。例如,Stroustrup(C++之父)的《C++编程语言》第一个完整程序(就是那个300K大小的"hello world"程序之后的那个)是一个英制/公制转换程序。用户用结尾"i"表示英制输入,用结尾"c"表示公制输入。下面是这个程序的概要,是用真正的Unix/C风格写的: #include <stream.h> main() {
[变量声明]
cin >> x >> ch;
;; A design abortion.
;; 读入x,然后读入ch。 if (ch == 'i') [handle "i" case]
else if (ch == 'c') [handle "c" case]
else in = cm = 0;
;; 好样的,决不报告错误。
;; 随便做点儿什么就成了。 [进行转换] 往后翻13页(第31页),给了一个索引范围从n到m的数组(而不是从0到m)的实现例子。如果程序员使用了超出范围的索引,这个程序只是笑嬉嬉地返回数组的第一个元素。Unix的终极脑死亡。 语法的吐根糖浆(Syrup of Ipecac,一种毒药) 语法糖蜜(Syntactic sugar)是分号癌症的罪魁祸首。 ——Alan Perlis 在使用C编程语言中所能遇到的所有语法错误几乎都能被C++接受,成功编译。不幸的是,这些语法错误并不总能生成正确的代码,这是因为人不是完美的,他们总是敲错键盘。C一般总能在编译是发现这些错误。C++则不然,它让你顺利通过编译,不过如果真的运行起来,就等着头痛吧。 C++的语法形成也和它自身的发展密不可分。C++从来没有被好好设计过:它只是逐步进化。在进化过程中,一些结构的加入造成了语言的二义性。特别的规则被用于解决这些二义性,这些难懂的规则使得C++复杂难学。于是不少程序员把它们抄在卡片上以供不时之需,或者根本就不去使用这些功能。 例如,C++有个规则说如果一个字符串既可以被解析为声明也可以被解析为语句,那么它将被当做声明。解析器专家看到这个规则往往会浑身发冷,他们知道很难能正确实现它。AT&T自己甚至都搞不对。比如,当Jim Roskind想理解一个结构的意思时(他觉得正常人会对它有不同的理解),他写了段测试代码,把它交给AT&T的"cfront"编译器。Cfront崩溃了。 事实上,如果你从ics.uci.edu上下载Jim Roskind的开放C++语法,你会发现ftp/pub目录里的c++grammar2.0.tar.Z有这样的说明:“注意我的语法和cfront不一定保持一致,因为 a) 我的语法内部是一致的(这源于它的规范性以及yacc的确证。b) yacc生成的解析器不会吐核(core dump)。(这条可能会招来不少臭鸡蛋,不过...每次当我想知道某种结构的语法含义是,如果ARM(Annotated C++ Reference Manual, 带注释的C++参考手册)对它的表述不清楚,我就会拿cfront来编译它,cfront这时总是吐核(core dump))” 日期: Sun, 21 May 89 18:02:14 PDT
发信人: tiemann (Michael Tiemann)
收信人: [email protected]
抄送: UNIX-HATERS
主题: C++ Comments (C++注释) 日期: 21 May 89 23:59:37 GMT
发信人: [email protected] (Scott Meyers)
新闻组: comp.lang.c++
组织: 布朗大学计算机系 看看下面这行C++代码: //********************** C++编译器该如何处理它呢?GNU g++编译器认为这是一行由一堆星星(*)组成的注释,然而AT&T编译器认为这是一个斜杠加上一个注释开始符(/*)。我想知道哪个是正确解析方式,可是Stroustrup的书(《C++编程语言》)里面却找不到答案。 实际上如果使用-E选项进行编译,就会发现是预处理器(preprocessor)搞的鬼,我的问题是: 这是否AT&T预处理器的bug?如果不是,为什么?如果是bug,2.0版是否会得到修正?还是只能这么下去了? 这是否GNU预处理器的bug?如果是,为什么? Scott Meyers [email protected] UNIX解析中有个古老的规则,尽量接受最长的语法单元(token)。这样'foo'就不会被看成三个变量名('f', 'o'和'o'),而只被当成一个变量'foo'。看看这个规则在下面这个程序中是多么的有用(还有选择'/*'作为注释开始符是多么的明智): double qdiv (p, q)
double *p, *q;
{
return *p/*q;
} 为什么这个规则没有被应用到C++中呢?很简单,这是个bug。 Michael 糟糕的还在后头,C++最大的问题是它的代码难读难理解,即使对于每天都用它的人也是如此。把另一个程序员的C++的代码拿来看看,不晕才怪。C++没有一丝品位,是个乱七八糟的丑八怪。C++自称为面向对象语言,却不愿意承担任何面向对象的责任。C++认为如果有谁的程序复杂到需要垃圾回收,动态加载或其他功能,那么说明他有足够的能力自己写一个,并且有足够的时间进行调试。 C++操作符重载(operator overloading)的强大功能在于,你可以把一段明显直白的代码变成能和最糟糕的APL, ADA或FORTH代码相媲美的东西。每个C++程序员都能创建自己的方言(dialect),把别的C++程序员彻底搞晕。 不过——嘿——在C++里甚至标准的方言也是私有的(private)。 抽象些什么? 你可能会觉得C++语法是它最糟糕的部分,不过当你开始学习C++时,就会知道你错了。一旦你开始用C++编写一个正式的大型软件,你会发现C++的抽象机制从根儿上就烂了。每本计算机科学教材都会这样告诉你,抽象是良好设计之源。 系统各个部分的关联会产生复杂性。如果你有一个100,000行的程序,其中每一行都和其他行代码的细节相关,那你就必须照应着10,000,000,000种不同的关联。抽象能够通过建立清晰的接口来减少这种关联。一段实现某种功能的代码被隐藏在模块化墙壁之后发挥作用。 类(class)是C++的核心,然而类的实现却反而阻碍着程序的模块化。类暴露了如此多的内部实现,以至于类的用户强烈倚赖着类的具体实现。许多情况下,对类做一点儿改变,就不得不重新编译所有使用它的代码,这常常造成开发的停滞。你的软件将不再“柔软”和“可塑”了,而成了一大块混凝土。 你将不得不把一半代码放到头文件里面,以对类进行声明。当然,类声明所提供的public/private的区分是没有什么用的,因为“私有”(private)信息就放在了头文件里,所以成了公开(public)信息。一旦放到头文件里,你就不大愿意去修改它,因为这会导致烦人的重编译。程序员于是通过修补实现机制,以避免修改头文件。当然还有其他一些保护机制,不过它们就象是减速障碍一样,可以被心急的家伙任意绕过。只要把所有对象都转换(cast)成void*,再也没有了讨厌的类型检查,这下世界清净了。 其他许多语言都各自提供了设计良好的抽象机制。C++丢掉了其中一些最为重要的部分,对于那些提供的部分也叫人迷惑不解。你是否遇到过真正喜欢模板(template)的人?模板使得类的实现根据上下文不同而不同。许多重要的概念无法通过这种简单的方式加以表达;即使表达出来了,也没法给它一个直接的名字供以后调用。 例如,名空间(namespace)能够避免你一部分代码的名字和其他部分发生冲突。一个服装制造软件可能有个对象叫做"Button"(钮扣),它可能会和一个用户界面库进行链接,那里面也有个类叫做"Button"(按钮)。如果使用了名空间,就不会有问题了,因为用法和每个概念的意思都很明确,没有歧义。 C++里则并非如此。你无法保证不会去使用那些已经在其他地方被定义了的名字,这往往会导致灾难性后果。你唯一的希望是给名称都加上前缀,比如ZjxButton,并但愿其他人不会用同一个名字。 日期: Fri, 18 Mar 94 10:52:58 PST
发信人: Scott L. Burson <[email protected]>
主题: preprocessor (预处理器) C语言迷们会告诉你C的一个最好的功能是预处理器。可事实上,它可能一个最蹩脚的功能。许多C程序由一堆蜘蛛网似的#ifdef组成 (如果各个Unix之间能够互相兼容,就几乎不会弄成这样)。不过这仅仅是开始。 C预处理器的最大问题是它把Unix锁在了文本文件的监牢里,然后扔掉了牢 旁砍 。这样除了文本文件以外,C源代码不可能以任何其他方式存储。为什么?因为未被预处理的C代码不可能被解析。例如: #ifdef BSD
int foo() {
#else
void foo() {
#endif
/* ... */
} 这里函数foo有两种不同的开头,根据宏'BSD'是否被定义而不同。直接对它进行解析几乎是不可能的 (就我们所知,从来没实现过)。 这为什么如此可恶?因为这阻碍了我们为编程环境加入更多智能。许多Unix程序员从没见过这样的环境,不知道自己被剥夺了什么。可是如果能够对代码进行自动分析,那么就能提供很多非常有用的功能。 让我们再看一个例子。在C语言当道的时代,预处理器被认为是唯一能提供开码(open-coded,是指直接把代码嵌入到指令流中,而不是通过函数调用)的方式。对于每个简单常用的表达式,开码是一个很高效的技术。比如,取小函数min可以使用宏实现: #define min(x,y) ((x) < (y) ? (x) : (y)) 假设你想写个工具打印一个程序中所有调用了min的函数。听上去不是很难,是不是?但是你如果不解析这个程序就无法知道函数的边界,你如果不做经过预处理器就无法进行解析,可是,一旦经过了预处理,所有的min就不复存在了!所以,你的只能去用grep了。 使用预处理器实现开码还有其他问题。例如,在上面的min宏里你一定注意到了那些多余的括号。事实上,这些括号是必不可少的,否则当min在另一个表达式中被展开时,结果可能不是你想要的。(老实说,这些括号不都是必需的——至于那些括号是可以省略的,这留做给读者的练习吧)。 min宏最险恶的问题是,虽然它用起来象是个函数调用,它并不真是函数。看这个例子: a = min(b++, c); 预处理器做了替换之后,变成了: a = ((b++) < (c) ? (b++) : (c)) 如果'b'小于'c','b'会被增加两次而不是一次,返回的将是'b'的原始值加一。 如果min真是函数,那么'b'将只会被增加一次,返回值将是'b'的原始值。 C++对于C来说,就如同是肺癌对于肺 “如果说C语言给了你足够的绳子吊死自己,那么C++给的绳子除了够你上吊之外,还够绑上你所有的邻居,并提供一艘帆船所需的绳索。” ——匿名 悲哀的是,学习C++成了每个计算机科学家和严肃程序最为有利可图的投资。它迅速成为简历中必不可少的一行。在过去的今年中,我们见过不少C++程序员,他们能够用C++写出不错的代码,不过... ...他们憎恶它。 程序员进化史 初中/高中 10 PRINT "HELLO WORLD"
20 END 大学一年级 program Hello(input, output);
begin
writeln('Hello world');
end. 大学四年级 (defun hello ()
(print (list 'HELLO 'WORLD))) 刚参加工作 #include <stdio.h> main (argc, argv)
int argc;
char **argv; {
printf ("Hello World!\n");
} 老手 #include <stream.h> const int MAXLEN = 80; class outstring;
class outstring {
private:
int size;
char str[MAXLEN]; public:
outstring() { size=0; }
~outstring() { size=0; }
void print();
void assign(char *chrs);
}; void outstring::print() {
int in;
for (i=0; i<size; i++)
cout << str[i];
cout << "\n";
} void outstring::assign(char* chrs) {
int i;
for (i=0; chars[i]!='\0'; i++)
str[i] = chrs[i];
size=i;
} main (int argc, char **argv) {
outstring string;
string.assign("Hello World!");
string.print();
} 老板 “乔治,我需要一个能打印'Hello World!'的程序” 好了,换个角度想想,C++可能是你最好的朋友,C++之父Stroustrup之所以设计C++,其实http://www.chunder.com/text/ididit.html正是为了我们这些程序员啊,当然如果你真的发誓不当C++程序员了,而且一时半会儿也当不了老板,你还可以考虑做系统管理员,叫人羡慕的sysadmin。
张贴日期 01/17/03 22:59 第九章 编程 “牛牛别怕,不疼的。” 别惹Unix,它弱不禁风,动不动就吐核(core dump) ——无名氏 如果你是通过在Unix上写C代码而学会的编程,那么可能会觉得这一章有些别扭。不幸的是,Unix如此广泛地被应用到了科研教育领域,很少有学生能意识到Unix的许多设计并不是严瑾合理的。 例如,听了我们关于有许多语言和环境比C/Unix要好的说法后,一个Unix爱好者是这么为Unix和C辩护的: 日期: 1991 Nov 9
发信人: [email protected] (Thomas M. Breuel) Scheme, Smalltalk和Common Lisp这些语言确实提供了强大的编程环境。但是Unix内核,shell和C语言则针对的是更为广泛的问题空间,而这些问题不是上面那些语言所擅长的(有的根本就无法处理)。 这些问题空间包括内存管理和局部性(locality)(在进程的产生和终止中实现),、持续性(persistency)(使用文件存储数据结构),并行性(parallelism)(通过管道,进程和进程通讯机制来实现),保护和恢复(通过独立的地址空间实现),以及可直观读取的数据表现方式(使用文本文件实现)。从实用的角度来看,Unix能很好地处理这些问题。 Thomas Breuel夸奖Unix能够解决复杂的计算机科学问题。幸运的是,这不是其他科学领域用来解决问题的方法。 日期: Tue, 12 Nov 91 11:36:04 -0500
发信人: [email protected]
收信人: UNIX-HATERS
主题: Random Unix similes (随机的Unix笑脸) 通过控制进程的产生与终止来进行内存管理,这就如同通过控制人的生死来对付疾病——这忽视了真正问题。 通过Unix文件获得持续性就如同把你所有的衣服仍进衣柜,幻想着能从里面找到需要的衣服(不幸的是,我正是这么去做的)。 通过管道,进程和进程通讯机制来实现并行化?Unix进程的代价是如此之高,以至于并行化得不偿失。就象是鼓励员工多生孩子,以解决公司人力资源短缺问题。 不错,Unix当然可以处理文本。他还能处理文本。嗯,还有,我有没有提到过Unix能够很好地处理文本? ——Mark 蔚为壮观的Unix编程环境 Unix狂热分子们总在宣扬Unix的所谓“编程环境”。他们说Unix提供了丰富的工具,能够使得编程工作更为容易。这是Kernighan和Mashey在《Unix编程环境》一文中的说法: Unix环境最能提高编程效率,这归功于众多的又小又有用的程序——工具,这些工具为日常的编程工作提供帮助。下面列举的这些程序被认为是其中最为有用的。我们在下文中将以他们为例说明其他观点。 wc files —— 统计文件中的行数,字数和字符数。
pr files —— 打印文件,支持标题和多栏打印。
lpr files —— 打印文件
grep pattern files —— 找到符合某种模式的文件行。 许多程序员的工作就是用它们和一些其他相关程序完成的。例如: wc *.c 用于对所有C源代码文件进行代码量统计; grep goto *.c 用于找到所有的goto语句。 这些就是“最为有用的”?!?! 有道理。这就是程序员的日常工作。事实上,今天我就用了不少时间来统计我的C代码量,以至于没有多少时间去做其他事情。等一下,我想我还得再数一遍。 同一期《IEEE计算机》上还有一篇文章,是Warren Teitelman和Larry Masinter写的《Interlisp编程环境》.Interlisp是个极为复杂的编程环境。1981年Interlisp就有了Unix程序员到了1984还在梦想的工具。 Interlisp环境的设计者们使用的是完全不同的方法。他们决定开发一个复杂的工具,需要花不少时间来掌握,好处是一旦学会了,极大地提高编程效率。听上去有些道理。 悲哀的是,今天很少有程序员能体会使用这类环境的感觉了。 在柏拉图的洞穴里编程 我总有一种感觉,计算机语言设计和工具开发的目标应该是提高编程效率而不是降低。 ——comp.lang.c++上的一个贴子 计算机以外的其他产业早就体会到了自动化的意义。当人们走进快餐点,他们需要的是一致标准的东西,而不是什么法国大菜。大规模地提供一致的一般食物,这比小批量的精耕细作要赚钱得多。 ——netnews上一个技术人员的回复 Unix不是世界上最好的软件环境——它甚至不是一个好的环境。Unix编程工具又简陋又难用;Unix调试器和PC上的没法比;解析器(interpreters)仍然是富人的玩具;修改日志(change log)和审记(audit trail)总是想起来才去做。可Unix仍然被当成程序员的梦。也许它只能让程序员梦到了效率的提高,而不是真的提高效率。 Unix程序员有点象数学家。你能从他们身上观察到一个神秘现象,我们称之为“空头编程”(Programming by Implication)。一次我们和一个Unix程序员聊天,谈到需要这样一个工具,能够回答诸如“函数foo被谁调用过?”或者“那个函数改变过全局变量bar”之类的问题。他也认为这个工具会很有用,提议到,“你们可以自己写一个。” 公平地说,他之所以只是说“你们可以自己写一个”而不是真正写一个,这是因为C语言的一些特性和Unix“编程环境”的强强联手,使得写这样的程序难于上青天。 使用yacc进行解析(parsing with yacc) "Yacc"就是我用过yacc(1)之后想喊的。 ——匿名 "YACC"是再一个编译编译器的编译器(Yet Another Compiler Compiler)的意思。它接受与上下文无关(context-free)的语法,构造用于解析的下推自动机(pushdown automaton)。运行这个自动机,就得到了一个特定语言的解析器。这一理论是很成熟的,因为以前计算机科学的一个重要课题就是如何减少编写编译器的时间。 这个方法有个小问题:许多语言的语法不是与上下文无关的。这样yacc的使用者不得不在每一个状态转换点上加上相关代码,以处理和上下文有关的部分(类型检查一般就是这么处理的)。许多C编译器使用的都是yacc生成的解析器;GCC 2.1的yacc语法有1650行之多 (如果不用yacc,GCC应该能成为自由软件基金会不错的作品)。由yacc生成的代码就更多了。 有些编程语言的语法比较容易解析。比如,Lisp能够用一个递归下降解析器进行解析。“递归下降”是一个计算机术语,含义是“喝杯可乐的功夫就能实现”。作为试验,我们写了一个Lisp递归下降解析器,只用了250行C代码。如果是用Lisp写的,那么一页纸也用不了。 在上面提到的那个计算机科学原始时代,这本书的编辑还没有生出来呢。计算机房是恐龙的天下,“真正的人”都在用仪表盘上的开关来编程。今天,社会学家和历史工作者想破脑袋也无法理解为什么理智的程序员却设计、实现和传播了如此难解析的语言。也许他们那时候极需一个困难的研究项目,设计一个难于解析的语言似乎是个不错的课题。 一直想知道他们在那个时代吃的是什么药。 上面提到的那个工具类似于一个C编译器的前端。C编译器前端是个极其复杂的东西,这是C的复杂语法和yacc的使用造成的。没有人真正动手去写一个这样的工具,这还有什么奇怪的么? 死硬的Unix分子会说你不需要这么一个程序,因为有grep就足够了。而且,你还能在shell管道中使用grep。有一天,我们想找出BSD内核源码中所有使用min函数的地方。这是其中一个结果: % grep min netinet/ip_icmp.c
icmplen = oiplen + min(8, oip->ip_len);
* that not corrupted and of at least minimum length.
* If the incoming packet was addressed directly to us,
* to the incoming interface.
* Retrieve any source routing from the incoming packet;
% 挺不错的吧,grep找到了所有的min函数调用,而且还不止这些。 “不知道怎么做爱。我撤。”("Don't know how to make love. Stop.") 理想的编程工具应该是这样的,它能让简单的问题保持简单,让复杂的问题有解决的可能。不幸的是,许多Unix工具过分追求通用性,而忽视了简洁。 Make就是这样一个典型。从抽象意义而言,make的输入是一个倚赖关系的描述。倚赖图上的每个节点都对应这一组命令,当节点过期时(由它所倚赖的节点来决定),这些命令会被执行。节点和文件相关,文件的修改时间决定了节点是否过期。下面是一个简单的倚赖关系图,也就是Makefile: program: source1.o source2.o
cc -o program source1.o source2.o source1.o: source1.c
cc -c source1.c source2.o: source2.c
cc -c source2.c 这里program, source1.o, source2.o, source1.c,source2.c就是关系图上的节点。节点program倚赖于source1.o和source2.o。 如果source1.o或source2.o比program要新,make便会运行命令cc -o program source1.o source2.o重新生成program。当然,如果修改了source1.c,那么source1.o和program都会过时,所以make会重新进行编译和链接。 尽管make的模型很通用,可惜设计者从没有考虑过简单性。不过,许多Unix新手都能体会到make能多么简单地“钻”(screw)了他们。 继续我们上面的那个例子,假定有个程序员Dennis想调试source1.c,于是要编译使用调试选项。他修改了一下Makefile: program: source1.o source2.o
cc -o program source1.o source2.o # I'm debugging source1.c
source1.o: source1.c
cc -c source1.c
source2.o: source2.c
cc -c source2.c "#"打头的那行是注释,会被make忽略。可怜的Dennis运行了一下make,这是它得到的: Make: Makefile: Must be a speparator on line 4.
Stop make歇菜了。Dennis盯着Makefile看了有好几分钟,又看了几小时,还是不明白哪儿出错了。他觉得是注释行的问题,可不是很肯定。 毛病出在当他加入注释行时,他不小心在第二行开始的制表符(tab)前敲入了一个空格。制表符是Makefile语法的一个重要部分。所有的命令行(例子中cc开始的行)必须以制表符打头。这就是Dennis的Makefile不工作的原因。 “那又怎样?”你可能会说,“这有什么不对的?” 它本身没什么不对。不过如果你想一下其他Unix编程工具的工作方式,就会觉得制表符语法就好象《地雷战》里的头发丝雷,看上去一马平川,踩上去呜呼哀哉。 你知道,制表符、空格符和换行符一般被统称为“白字符”(whitespacecharacters)。“白字符”意味着“你可以放心大胆地忽略它”许多程序正是这么做的,对空格和制表符一视同仁。就make孤芳自赏桀骜不驯鹤立鸡群冰清玉洁众人皆醉唯我独醒。于是我们这位Dennis兄弟恐怕只能给自己脑袋来一枪,告别这悲惨的Unix世界。 可怜的Dennis最终也没有找到自己那个Makefile的毛病,他现在落魄到只好去给一个中西部州立大学维护sendmail配置文件。默哀三分钟。 头文件 C语言有个东西叫头文件,里面是一些说明信息,在编译时被源文件使用。和Unix上的其他玩意一样,如果只有一个两个,可以工作得很好,多了就没戏了。 要知道你的源文件该使用那个头文件,这可不是件容易事。头文件是C预处理器(preprocessor)根据#include指令(directive)加载的。这个指令有两个用法: #include <header1.h> 和 #include "header2.h" 这两种用法的区别和各个C预处理器的实现有关,也就是说,任何实现都可以大着胆子撒着欢儿由着性子乱来。 让我们来看看Dennis的朋友Joey,Joey也是个Unix新手。Joey有个C程序foo.c,使用了foo.h中定义的一些数据结构,foo.c和foo.h放在了同一个目录下。你可能已经知道"foo"是程序员常用的名字。Joey机器上的系统程序员也做了一个foo.h文件,并把它放到了缺省系统头文件目录/usr/include 倒霉蛋Joey编译了foo.c,得到一堆语法错误。他迷惑不解,编译器总在他定义的一些数据结构处报错,可是这些数据结构在foo.h里被定义的好好的呀。 你我估计能猜到Joey的问题在哪儿,他一定是这么加载头文件的: #include <foo.h> 而不是写成: #include "foo.h" 可Joey不知道这个。也可能他确实是用的引号方式,只是他的编译器的查找方式有些特别。不管怎样,Joey是被干掉了,很无辜地被干了。 维护很多头文件是件挺头疼的事,不幸的是,如果你写个有用点儿的C程序,这是不可避免的。头文件一般 于定义数据结构,一个头文件往往倚赖于其他一?头文件。去把那些头文件的倚赖关系整理一下,你这回可不愁没事儿做了。 当然,编译器会帮你的。如果你把倚赖关系搞错了,编译器会毫不留情地指出语法错误。记住,编译器是个很忙很有身份的程序,它没时间去区分未定义的数据结构和输入错误的区别。事实上,即使你只是忘了敲个分号,C编译器也会恼羞成怒,立马撂挑子不干了。 在编译器社区,这一现象被称为“错误雪崩”,或者按照编译器自己的说法:“我完蛋了,起不来了。” 缺个分号会把解析器彻底搞晕,狂吐不止。这个解析器很可能是用yacc写成的,yacc对语法正确的程序(很少见的一种情况)处理得很好,但要让它生成健壮容错自动恢复的解析器,这就有点儿勉为其难了。有经验的C程序员都知道只有第一条解析错误才是有意义的。 工具程序和Man手册 Unix工具是自成一体的;可以任意解释命令行参数。这样的自由有些烦人;别以为学会了一套命令行规则就一劳永逸了,你必须去读每个命令的Man手册,才能知道如何去使用。 知道有那么多清楚明白的Man手册供你参考,你一定很开心吧。 看一下下面这个例子。“摘要”一栏总结得挺不错的,是不是? LS(1) Unix程序员手册 LS(1) 名称
ls - 列出目录内容 摘要
ls [ -acdfgilqrstu1ACLFR ] 名称 ... 描述
对于每个目录参数,ls列举那个目录的内容;对于每个文件参数,
ls 给出文件名以及要求的其他信息。缺省情况下,输出将按照字
母顺序排列。如果没有参数,则列举当前目录的内容。如果有不只
一个参数,这些参数首先会被适当排序,但是文件参数总是会被排
在目录参数前面。 ls有很多选项: [ ... ] BUGS
文件名中的换行符和制表符会被可打印字符 输出设备会被假设有80列宽 输出会根据输出设备的不同而不同,比如"ls -s"的结果和"ls -s| lpr"的结果不一样。这是不正确的,然而如果不这么做,一些倚赖这个功能的旧有shell脚本就会完蛋。 如果你想玩个游戏,不妨读一下每个Man手册的BUGS部分,然后想像一下每个bug是如何造成的。看一下这个shell的man手册: SH(1) Unix程序员手册 SH(1) 名称
sh, for, case, if, while, :, ., break, continue, cd,
eval, exec, exit, export, login, read, readonly, set,
shift, times, trap, umask, wait - 命令语言 摘要
ls [ -ceiknrstuvx ] [参数] ... 描述
Sh是一个命令程序语言,它执行来自终端或文件的命令。下面是各
个选项的说明。 [ ... ] BUGS 如果把使用<<提供的标准的输入提供给使用&运行起来的非同步的进程,shell会搞不清楚输入文档的名字。会生成一个垃圾文件/tmp/sh*,shell会抱怨找不到使用另外一个名字的文档。 我们用了好几分钟也没搞明白这个bug究竟是他妈什么意思。一个Unix专家看过之后说:“我边看边挠脑袋,有写这段BUGS的功夫,估计足够这家伙改掉这个吊玩意了。” 不幸的是,修改bug几乎是不可能的,因为它会随着每个新发布的操作系统而卷土重来。在80年代早期,在这些bug还没有被Unix信徒奉为神圣以前,一个BBN的程序员真的修改了伯克利make的这个制表符bug。这不是很难,也就是几行代码的事儿。 和所有责任感的公民一样,BBN的骇客们把补丁发给了伯克利,希望能把它加入主Unix代码中。一年过后,伯克利发布了新版本的Unix,make的这个bug还是存在。BBN的骇客第二次做了修改,又把补丁交给了伯克利。 ....然而伯克利的第三次发布还是老样子,BBN的程序员彻底失望了。他们没有再提交补丁,而是把他们所有的Makefile中空格打头的行替换成了制表符。毕竟BBN雇佣他们是来写新程序的,而不是反复修改同一个bug。 (据说,Stu Felman(make的作者)一开始就查觉到了这个问题,他没有修改,因为那时已经有10个用户开始用了。) 源码就是文档。哇~~ 牛逼! 如果我写着不容易,那么你理解起来就不应该容易。 —— 一个Unix程序员 我们在《文档》一章里提到Unix程序员认为操作系统的源代码是最好的文档。一个著名的Unix历史学家曾经指出:“毕竟,操作系统自己也是靠读源代码来知道下一步该干嘛的。” 可是通过阅读源代码来理解Unix,这就如同开着Ken Thompson的老爷车(对,就是闪着大红问号的那辆)周游世界。 Unix内核源码(更准确的说,是ftp.uu.net上发布的伯克利网络磁带2版的代码)几乎没有注释,充斥这大"段"没有空行的代码,goto随处可见,绞尽脑汁给妄图读懂它的人制造麻烦。有个骇客感叹到:“阅读Unix代码就好象走在伸手不见五指的巷子里。我总是停下来摸摸口袋,脑子里回响着一个声音‘老天,我就要遭劫了。’” 当然,内核代码有它自己的警报系统。四处散布着这样的小小注释: /* XXX */ 意思是有什么东西不太对劲儿。你应该知道哪儿出事儿了。 这绝不可能是bug,我的Makefile需要它! BBN的程序员应该算是另类。大部分Unix程序员是不去修改bug的:他们没有源代码。即使修改了也于事无补。这就是为什么Unix程序员遇到bug的第一个反应不是修了它,而是绕过它。 于是我们看到了悲惨的一幕:为什么不一劳永逸地解决问题,而是一错再错?也许早期的Unix程序员是尼采“永恒轮回”思想的信徒。 对于调试方法,存在着两个截然不同的派别:一个是“外科手术派”,包括流行于早期ITS和Lisp系统,程序运行过程中始终有调试器参与,如果程序崩溃了,调试器(也就是所谓外科大夫)会对问题进行诊断医治。 Unix是属于更古老的“尸体解剖派”。Unix下如果一个程序崩溃了,会遗留下一个core文件,从各个方面看这都和尸体没什么两样。Unix调试器然后会找出死因。有趣的是,Unix程序常常和人一样,死于本可治疗的疾病、事故以及疏忽。 对付Core 如果你的程序吐核(core)了,你首先要做的是找到它。这不该太困难,因为core文件总是很大——4, 8, 甚至12兆。 core文件之所以这么大,是因为它包括了所有用来调试的信息:堆栈,数据,代码指针等等,无所不包,除了程序的动态状态。如果你在调试一个网络程序,在你的程序吐核的时候,已经为时太晚了;程序的网络连接已经没有了,更致命的一击是,所有打开的文件现在都被关上了。 不幸的是,在Unix上只能如此。 例如,不能把调试器作为命令解析器,或者在内核发生异常时把控制交给调试器。如果想让调试器在程序崩溃时进行接管,那你只能在调试器里面运行所有程序(是的,有的Unix版本让你用调试器接管一个运行中的进程,但是你手边必须有一个还有符号的程序文件)。如果你想调试中断代码,你的调试器必须截获每个中断,然后把合适的中断返回给程序。你能想像emacs里每敲一键都发生3个进程切换(context switch)的感觉么?显然,例程调试(routine debugging)思想和Unix哲学是格格不入的。 日期: Wed, 2 Jan 91 07:42:04 PST
发信人: Michael Tiemann <[email protected]>
收信人: UNIX-HATERS
主题: Debuggers (调试器) 想过Unix调试器为什么这么蹩脚么?这是因为如果它想提供什么功能,那一定会跟来一堆bug,如果有bug,它一定会吐核(dump core),如果它吐核,靠,你用来调试的那个core文件就会被覆盖。如果能让程序来控制如何吐核,何时吐核,以及吐在哪里,那就太好了。 bug骨灰盒 和其他操作系统不同,Unix把bug供奉为标准操作。之所以那么多Unix bugs得不到修正,这里有个不可告人的原因——如果修正了,那么已有的一些程序就会死逼了。然而,荒唐的是,Unix程序员在增加新功能时却从来不去考虑向下兼容。 考虑到这些,Michael Tiemann给出了Unix调试器覆盖core文件的10个理由: 日期: Thu, 17 Jan 91 10:28:11 PST
发信人: Michael Tiemann <[email protected]>
收信人: UNIX-HATERS
主题: Unix Debuggers (Unix调试器) David Letterman (美国著名晚间脱口秀主持人)的10个最佳理由是: 10. 这会破坏已有代码。
9. 这需要修改文档。
8. 太难实现了。
7. 这怎么是调试器的活儿?为什么不写个“工具”做它?
6. 如果调试器吐了核,你应该丢开你自己的程序,开始调试调试器。
5. 太难理解了。
4. 哪儿有饼干?
3. 为什么非得现在做?
2. Unix也不是神仙。
1. 哪儿有问题? Unix程序员总是打着“这会破坏已有代码”的幌子,不愿意修正bug。可这里面还有内幕,修正bug不但会破坏已有代码,还必须修改简单完美的Unix接口,而这正是Unix教众们的命根子。至于这个接口是否工作,这并不重要。Unix教众们不去提出更好的接口,也不去修正bug,而是齐声高唱“Unix接口好简洁,好简洁。Unix接口就是美,就是美!Unix无罪!Unix有理!”。 不幸的是,绕过bug是个很恶劣的行为,它使得错误成为了操作系统规范的一部分。你越是等,就越难以修正,因为越来越多的程序会尽力绕过bug,以至于没有了bug反而活不了了。同理,修改操作系统接口带来的影响更大,因为更多的程序必须根据这个正确的新接口进行修改。(这解释了为什么ls有那么多的选项来完成几乎一样的工作)。 如果你把一只青蛙仍到开水里,它会马上跳出来。它知道开水很烫。可是,如果你把青蛙放到冷水里,再慢慢地加热,青蛙感觉不到什么,直到最后被烫死。 Unix接口已经开锅了。以前,输入/输出的全部接口只包括open, close, read和write。网络支持给Unix添了一大把柴禾。现在,至少有五种方法向一个文件句柄输入数据:write, writev, send, sendto和sendmsg。每个都在内核中有不同的实现,这意味着有五倍的可能出现bug,有五种不同的性能结果需要考虑。读文件也一样(read, recv, recvfrom和recvmsg)。等死吧,青蛙们。 文件名扩展 Unix“所有程序自成一体”的规定有一个例外。Unix程序经常要处理一个或多个文件。Unix shells提供了命名一组文件的方法,shell会把这组文件展开,做为一个文件列表传递给各个命令。 例如,假设你的目录下有文件A, B和C。如果象删除所有这些文件,你可以运行rm *。shell会把"*"扩展成为"A B C",并把他们做为rm的参数传递给它。这个方法有不少问题,这在上一章已经提到过了。不过,你应该知道让shell来扩展文件名不是偶然的:而是精心设计的结果。在Kernighan和Mashey发表的《Unix编程环境》一文中(IEEE计算机杂志,1981年四月),他们指出:“把这个作为shell的一个机制,这避免了各个程序的重复劳动,而且保证了为所有程序提供一致的输入。” (Unix的一个理想是让任何人能够运行任何shell。现在你没法运行任何shell;你的shell必须提供文件名扩展)。 别忙。标准输入/输出库(Unix所谓的stdio)不就能“为所有程序提供一致的输入”么?提供一个用于扩展文件名的库函数不就成了?这些家伙没有听说过链接库么?那些关于性能的说法也是无稽之谈,因为他们无法提供任何的性能数据,他们甚至没有说明“性能指标”是什么。指的是开发一个小程序会快一些?还是指能高性能地把一个新手的所有文件一扫而光? 大多数情况下,让shell进行文件名扩展也无所谓,因为这和程序自己扩展的结果没什么不同。可是,和Unix上的许多玩意一样,它早晚会咬你一口,而且不轻。 假设你是个Unix新手,目录下有两个文件A.m和B.m。你习惯了MS-DOS,想把它们的名字换成A.c和B.c。嗯~~ 没找到rename命令,不过mv命令似乎差不多。于是你执行mv *.m *.c。shell将这个命令扩展为 mv A.m B.m,你辛辛苦苦写了几小时的B.m就这么被干掉了。 再好好思考一下上面这个问题,你就会发现理论上你完全不可能提供一个和MS-DOS "rename"一样的功能。对于软件工具,就扯这么多吧。 健壮性,或者说“所有输入行必须小于80个字符” 1990年11月份的《ACM通讯》上登了Miller Fredriksen等人写的一篇精采文章,题目是《Unix工具的稳定性的经验性研究》。他们使用一些随机数据作为Unix工具的输入,发现有24-33%(不同的Unix发布结果有所不同)的工具崩溃了。有时候甚至整个系统都完蛋了。 文章是以一个笑话开头的。其中一位作者曾使用一个极差的电话连接工作,发现许多工具都垮掉了。于是他决定针对这一现象进行更系统的调查研究。 许多bug都可以归因于C语言的陈规陋习。事实上,Unix的许多内在脑损伤都是C语言造成的。Unix的核心以及所有的工具程序都是用C语言写的。著名语言学家Benjamin Whorf说过:语言决定思想。Unix有深深的C烙印。C语言使得程序员根本无法想像能写出健壮的程序。 C语言是极小的。它被设计成能在各种硬件上快速地进行编译,所以它有着和硬件类似的结构。 Unix诞生之初,使用高级语言编写操作系统是个革命性的想法。现在则应该考虑使用一种有错误检查的语言了。 C是个最为底层的语言,诞生于硬件更为底层的时代。PDP-11没有的,C语言也不会有。过去几十年的编程语言研究表明,语言中加入错误处理,自动内存管理和抽象数据类型等功能,会使得开发出的程序更为健壮可靠。你在C里面找不到这些东西。C语言太流行了,没人去考虑给它增加诸如数据标记或硬件垃圾回收支持等功能。即使硬件提供了垃圾回收功能,也只是多费了一些硅片罢了,因为许多C语言编写的程序根本无法使用它。 回想一下,C是无法处理整数溢出的。解决方法是使用超过问题需要的整数大小,希望这个大小在你有生之年足够用。 C也没有真正意义上的数组,它有个象是数组的东西,实际不过是一个指向一块内存的指针。数组定位表达式(array[index])不过是表达式(*(array+index))的简写版。所以你甚至可以说index[array],这和表达式(*(array+index))是一个意思。聪明吧?在字符处理时经常能见到这个用法。数组变量和指针变量经常可以互换。 举个例子,假设你有: char *str = "bugy"; 于是下面的这些语句都是一样的: 0[str] == 'b'
*(str+1) == 'u'
*(2+str) == 'g'
str[3] == 'y' C语言够伟大的吧? 这个做法的问题是C根本不做任何自动数组边界检查。为什么该C去做呢?数组在C里只是个指针而已,你可以把指针指向内存的任何地方,是不是?不过,一般你不想在内存里乱写乱画,特别在是一些关键的地方,比如程序的堆栈。 这把我们引到了Miller的文章里提到的一类bug。许多程序是在读取输入到堆栈上的一块字符缓冲区时崩溃的。许多C程序是这么做的;下面的C程序把一行输入读到堆栈上的一个数组里,然后调用do_it函数进行处理。 a_function()
{
char c, buff[80];
int i = 0; while ((c = getchar()) != '\n')
buff[i++] = c;
buff[i] = '\000';
do_it(buff);
} 这类代码把Unix搞得臭不可闻。知道为什么缓冲区被定为80个字符么?这是因为许多Unix文件每行最多有80个字符。知道为什么没有边界检查,也没有文件尾检查么?这是因为这个程序员喜欢把c = getchar()这样的赋值语句嵌入到while循环中。信不信,有些人还推崇C的这种缩简写法,管他妈什么可读性可维护性。最后,调用do_it(),数组摇身一变成了指针,作为第一个参数传了进去。 作为练习:如果在一行当中到达了文件尾,这个程序的结果是什么? 当Unix用户查觉到这个内置的限制后,他们想到的不是去修正这个bug,而是想方设法躲过它。比如,Unix的磁带备份工具(tape archiver)tar不能处理超过100个字符的路径名(包括目录)。解决方法是:不要备份目录到磁带,或者使用dump。更好的办法是:不要建立太深的目录,这样文件的绝对路径就不会超过100个字符。 2038年1月18日上午10点14分07秒,Unix马虎编程将在这一刻上演精采的一幕,那时Unix的32位timeval将耗尽... 再回到我们前面那个例子,假设输入行有85个字符。这个函数毫无问题地接受了这个输入,可问题是最后那5个字符会被放到哪里呢?答案是它们会占据任何排放在数组后面的5个字节。之前那里放着的是什么呢? c和i这两个变量可能会被分配在字符数组之后,所以有可能会被85字符长的输入冲垮。如果输入了850个字符呢?则可能会毁掉堆栈上的重要的C运行环境系统信息,比如返回地址等。毁掉这些信息的最好结果是程序可能崩溃。 我们说“可能崩溃”是因为程序的编写者从没想到过你竟能毁掉堆栈。想像一下我们的这个程序读入了很长的一行,约有2,000个字符,这行字符被用来覆盖堆栈上的返回地址以及其他环境信息,它将调用2,000个字符里埋藏的一段代码。这段代码可能会做一些很有用的事情,比如执行(exec)出一个shell,运行一些命令。 Robert T. Morris的著名Unix蠕虫病就是使用了这个机制(以及其他一些技巧)黑进Unix主机的。我不知道其他人为什么还会这么做,真的不知道,嘻嘻。 日期: Thu, 2 May 91 18:16:44 PDT
发信人: Jim McDonald <jlm%[email protected]>
收信人: UNIX-HATERS
主题: how many fingers on your hands? (你共有几根手指?) :( 下面是给我的上司的一个报告: 一个用来更新Make文件的程序使用了一个指针,对它的访问毁掉了一个存放倚赖关系的数组,这个倚赖关系被用来生成Makefile。直接后果是生成的错误Makefile不能用于编译任何东西,没有生成所需的对象文件(.o),所以编译最终失败了。一天的工作就这么付之东流了,只是因为一个傻瓜认为10个头文件足够所有人使用了,然后对它进行了极其危险的优化以在1毫秒内生成所有的Make文件! 网络化的坏处是,你没法再闯进某人的办公室里把他的心给挖出来。 (关于堆栈溢出攻击,可参考经典论文href=http://www.phrack.org/phrack/49/P49-14> Smashing The Stack For Fun And Profit --me) 异常处理 编写健壮程序的最大挑战是如何正确处理错误和其他异常。不幸的是,C几乎没有为此提供什么帮助。今天在学校里学会编程的人里很少有谁知道异常是什么。 异常是函数无法正常运行时所产生的一个状态。异常经常发生在请求系统服务时,比如分配内存,打开文件等。由于C没有提供异常处理支持,程序员必须自己在服务请求时加入异常处理代码。 例如,下面是所有C语言课本中推荐的使用malloc()分配内存的方法: struct bpt *another_function()
{
struct bpt *result; result = malloc(sizeof(struct bpt));
if (result == 0) {
fprintf(stderr, "error: malloc: ???\n"); /* recover gracefully from the error */
[...]
return 0;
}
/* Do something interesting */
[...]
return result;
} another_function函数分配了一个类型为bpt的结构,返回了一个指向这一结构的指针。这段代码说明了如何分配内存给这个结构。因为C没有显式的异常处理支持,C程序员必须自己去做这件事(就是粗体的那些代码)。 当然你可以不这么干。许多C程序员认为这是小事一桩,从来不做异常处理。他们的程序往往是这样的: struct bpt *another_function()
{
struct bpt *result = malloc(sizeof(struct bpt)); /* Do something interesting */
[...]
return result;
} 多么简单,多么干净,大多数系统服务请求都会成功的,是不是?这样的程序在大多数场合运行良好,直到它们被应用到复杂特殊的地方,往往就会神秘地失效。 Lisp的实现总是包括一个异常处理系统。异常条件包括OUT-OF-MEMORY这样的名称,程序员可以为特定的异常提供异常处理函数。这些处理函数在异常发生时被自动调用——程序员不需要介入,也不需要做特殊的检查。适当地使用,可以让程序更为健壮。 CLU这样的编程语言也有内置的异常处理。每个函数定义都有一系列可以发出的异常条件。对异常的显式支持可以帮助编译器检查那些未被处理的异常。CLU程序总是十分健壮,因为编译器逼着CLU程序员去考虑异常处理问题。C程序是个什么样子呢: 日期: 16 dec 88 16:12:13 GMT
主题: Re: GNU Emacs
发信人: [email protected] <448@myab.se> [email protected] (Lars Pensy)>写到:
... 所有的程序都应该检查系统调用(如write)的返回结果,这非常重要。 同意,可不幸的是很少有程序在进行读(read)写(write)时这么做。 Unix工具程序一般会检查open系统调用的返回值,假设所有随后的read,write和close总会成功。 原因很明显:程序员很懒,不做错误处理程序会显得更小更快。(这样你的系统会有更优异的性能表现)。 这封信的作者继续指出,由于大部分系统工具不对write()等系统调用的返回值进行检查,系统管理员就必须保证文件系统时时刻刻都有足够的空间。正是如此:许多Unix程序假设它们可以写任何成功打开的文件,想写多少就写多少。 读到这里你可能会皱眉头,"嗯~~”一下。最为可怕的是,就在《Unix工具的稳定性的经验性研究》这篇文章的前几页,登载了一份报告,说明休斯顿外层空间中心的飞船控制实时数据采集系统是如何转型为Unix系统的。"嗯~~” 捕捉bug是社会所不能接收的 不去检查和报告bug,这会使制造商生产的系统显得似乎更为健壮和强大。更重要的是,如果Unix系统报告每一个错误,那么就根本不会有人去用它!这是活生生的现实。 日期: Thu, 11 Jan 90 09:07:05 PST
发信人: Daniel Weise <[email protected]>
收信人: UNIX-HATERS
主题: Now, isn't that clear? (现在明白了么?) 惠普做了一些工作,这样我们的惠普Unix系统能够报告一些可能会影响它的网络错误。这些惠普系统和SUN, MIPS, DEC工作站共享一个网络。我们经常会发现其他机器所引发的问题,可是当我们通知给那些机器的主人时(因为这些系统不报告错误,他们不知道自己的机器有一半时间是用在重发数据包上了),他们往往反称是我们这里的问题,因为只有我们这里报出了错误。 “两国相争,不斩来使”,不过在Unix世界里,你最好别当信使。 修不了?重启! 如果某个关键软件不能适当处理错误的数据和操作条件,那么系统管理员该如何是好呢?嗯~~,如果它能在一段时间里正常工作,你就能通过不断重启它来凑合着运行。这个法子不是很可靠,也不具有扩展性,不过足够让Unix苟 硬写 一阵子了。 下面就是这么一个例子,说明如何在named程序不稳定的情况下提供邮件服务: 日期: 14 May 91 05:43:35 GMT
发信人: [email protected] (Theodore Ts'o) (著名的Ted Ts'o? --me)
主题: Re: DNS performance metering: a wish list for bind 4.8.4
(DNS性能测试:bind 4.8.4的期待功能表)
收信人: comp.protocols.tcp-ip.domains 我们现在是这么解决这个问题的:我写了一个叫"ninit"的程序以非精灵(deamon)模式(nofork)运行named,然后等待它退出。当named退出时,ninit重新启动一个新的named。另外,每隔五分钟,ninit会醒来一次发给named一个SIGIOT信号,named接到这个信号后会包一些状态信息写入/usr/tmp/named.stats文件中。每隔60秒钟,ninit会用本地named进行一次域名解析。如果短时间内没有得到结果,它会杀掉named,重新启动一个新的。 我们在MIT的名称服务器上和我们的邮件网关(mailhub)上运行了这个程序。我们发现它很有用,能够捕捉named的神秘死亡或僵死。这在我们的邮件网关上更是不可缺少,因为即使域名解析中断一小会儿,我们的邮件队列也会给撑炸了。 当然,这类办法会引发这样的问题:如果ninit有bug,那么该怎么办呢?难道也要写一个程序不断重启ninit么?如果写了,你又如何保证那个正常工作呢? 对于软件错误的这种态度并不少见。下面这个man手册最近出现在我桌上。我们还不能肯定这是不是个玩笑。BUGS部分很是发人深省,因为那里列举的bug是Unix程序员总也无法从代码中剔除的: NANNY(8) Unix程序员手册 NANNY(8) 名称
nanny - 奶妈,运行所有服务的服务 摘要
/etc/nanny [switch [argument]] [...switch [argument]] 描述
许多系统都为用户提供各种服务(server)功能。不幸的是,这些服务经常不明不白地罢工,造成用户无法获得所需要的服务。Nanny(奶妈)的作用就是照看(babysit)好这些服务,避免关键服务的失效,而不需要系统管理员的随时监视。 另外,许多服务使用日志文件作为输出。这些数据常会很讨厌地充满磁盘。可是,这些数据又是重要的跟踪记录,应该尽量保存。Nanny会定期把日志数据重定向到新文件。这样,日志数据被化整为零,旧的日志文件就能被任意转移走,而不对服务构成影响。(现在这成了logrotate的任务 --me) 最后,nanny还提供了一些控制功能,使得系统管理员能够对nanny以及它所照看的服务进行运行时操作。 选项
... BUGS
有个服务在nanny中做分离fork(detaching fork)。nanny会错误地认为这个服务死掉了,不断重启它。 到目前为止,nanny还不能容忍配置文件的错误,如果配置文件的路径不对或者内容有错误,nanny必死无疑。 不是所有的选项都被实现了。 Nanny倚赖系统提供的网络功能进行进程间通讯。如果网络代码有错误,nanny将无法处理这些错误,可能僵死或是死循环。 对不稳定软件经常重启,这已经成了MIT雅典娜计划(Project Athena)的日常工作,现在他们每星期天的凌晨4点都会重启AFS(Andrew File System, 一种网络文件系统)服务器。但愿没有人周末熬夜赶写下周一要交的作业... 怎么样,Unix编程很有趣吧?惊险,刺激,痛并快乐!该回家了,休息一下,大麻没劲了,有海洛英;C用腻了,我们还有C++!放心,离死不远了。 第十章 C++
90年代的COBOL 问:"C"和"C++"的名字是怎么来的?
答:这是他们的成绩 ——Jerry Leichter 再没有比C++更能体现Unix“绝不给用户好脸”的哲学思想的了。 面向对象编程可以追溯到60年代的Simula语言,在70年代的Smalltalk语言上得到极大发展。许多书会告诉你面向对象语言如何能提高编程效率,使代码更健壮,和减少维护费用。不过你甭想在C++里得到这些。 这是因为C++根本就没理解面向对象的实质。非但没有简化什么,反而增加了更多的复杂性。和Unix一样,C++从没被好好设计过,它从一个错误走向另一个错误,是件打满补丁的破衣服。连自己的语法都没有严格定义(没一个语言敢这样),所以你甚至无法知道一行代码是不是合法。 把C++比做COBOL,其实是对COBOL的污辱。COBOL在那个时代的技术条件下,是做出了很不同凡响的贡献的。如果有谁用C++做成过什么事,那就算是很不同凡响了。幸运的是,很多不错的程序员知道必须尽量避免C++的伤害,他们只用C,对那些荒唐费解的功能敬而远之。通常,这意味着他们必须自己写个非面向对象的工具,以获得自己想要的功能。当然,他们的代码会显得极为怪异,失去兼容性,难于理解和重用。不过只要有一点儿C++的味道,就足够说服头头批准他们的项目。 许多公司已经被多年遗留下来的混乱难懂的COBOL代码搞得焦头烂额了。那些转而使用C++的公司刚刚意识到自己上了当。当然,这已经太晚了。软件灾难的种子已经播下了,浇水施肥,得到悉心照料,就等着十几年后长成参天大树了。等着瞧吧! 面向对象的汇编语言 C++没有一丝一毫高层次语言的特性。为什么这么说?让我们看看高层次语言应该具备那些特性: 优雅:在表示方式及其所表达的概念之间有着简单易懂的关系
抽象:高层次语言的每个表达式只表示一个概念。概念能够被独立表达并能自由使用
强大:高层次语言的能够对任何精确完整的程序行为进行提供直接了当的表述方式
高层次语言使程序员能够采用问题空间的方式描述解决方案。高层次的程序很容易维护,因为它们的目的性(intent)十分明确。根据一行高层次程序代码,现代编译器能够为各种平台生成高效的代码,所以高层次程序的可移植性和可重用性自然会很强。 使用低层次语言则需要对考虑无数细节,其中大部分是和机器内部操作有关的东西,而不是要解决的问题本身。这不但造成代码难于理解,而且很容易过时。现在几乎每隔今年就要更新系统,过时的必须花费很高代价修改低层代码或者彻底重写。 对不起,你的内存泄漏了 高层次语言对于常见问题有内置解决方案。例如,众所周知内存管理是产生错误最多的地方。在使用一个对象之前,你必须为它分配内存,适当进行初始化,小心跟踪使用,并正确释放。当然,每件事儿都异常乏味而且很容易出错,极小的一个错误可能会导致灾难性后果。定位和修改这类错误是臭名昭著的困难,因为它们对于配置或使用方式的变化极其敏感。 使用未分配内存的结构指针会造成程序崩溃。使用未正确初始化的结构也会使你的程序崩溃,不过不一定立刻完蛋。如果未能好好跟踪结构的使用情况,则很可能释放一块还在使用中的内存。还是崩溃。最好再分配一些结构用来跟踪那些结构对象。不过如果你太保守,不去释放那些不很肯定未在使用的内存,那么你可要小心了。内存中很快就会充斥着无用的对象,直到内存耗尽,程序崩溃。这就是恐怖的“内存泄漏”。 如果你的内存空间碎片太多,那该怎么办呢?解决办法是通过移动对象对内存重新归整,不过在C++里没戏——如果你忘了更新对象的所有引用(reference),那么你就会搞乱程序,结果还是崩溃。 很多真正的高层次语言提供了解决办法——那就是垃圾回收(garbage collector)。它记录跟踪所有的对象,如果对象用完了会加以回收,永远不会出错。如果你使用有垃圾回收功能的语言,会得到不少好处: 大量的bug立马无影无踪。是不是很爽呀? 代码会变得更短小更易读,因为它不必为内存管理的细节操心。 代码更有可能在不同平台和不同配置下高效运行。 唉,C++用户必须自己动手去拣垃圾。他们中的许多人被洗了脑子,认为这样会比那些专家提供的垃圾回收工具更为高效,如果要建立磁盘文件,他们估计不会使用文件名,而更愿意和磁道扇区打交道。手动回收垃圾可能会对一两中配置显得更高效些,不过你当然不会这么去使用字处理软件。 你不必相信我们这里说的。可以去读一下B. Zorn的《保守垃圾回收的代价测量》(科罗拉多大学Boulder分校,技术报告CU-CS-573-92),文中对程序员用C手动优化的垃圾回收技术和标准垃圾回收器进行了性能比较,结果表明C程序员自己写的垃圾回收器性能要差一些。 OK,假设你是个幡然醒悟的C++程序员,想使用垃圾回收。你并不孤单,很多人认为这是个好主意,决定写一个。老天爷,猜猜怎么着?你会发现根本没法在C++中提供其他语言内置的那样好的垃圾回收。其中一个原因是,(惊讶!)C++里的对象在编译后和运行时就不再是对象了。它们只是一块十六进制的烂泥巴。没有动态类型信息——垃圾回收器(还有调试器)没法知道任何一块内存里的对象究竟是什么,类型是什么,以及是否有人此时正在使用它。 另一个原因是,即使你能写个垃圾回收器,如果你用了其他未使用垃圾回收功能的代码,你还是会被干掉。因为C++没有标准的垃圾回收器,而且很有可能永远也不会有。假设我写了一个使用了我的垃圾回收功能的数据库程序,你写了一个使用你自己的垃圾回收功能的窗口系统。但你关闭一个装有我的数据记录的窗口,你的窗口不会去通知我的数据记录,告诉它已经没有人引用它了。这个对象将不会被释放,直到内存耗尽——内存泄漏,老朋友又见面了。 学起来困难?这就对了 C++和汇编语言很相象——难学难用,要想学好用好就更难了。 日期: Mon, 8 Apr 91 11:29:56 PDT
发信人: Daniel Weise <[email protected]>
收信人: UNIX-HATERS
主题: From their cradle to our grave (从他们的摇篮到我们的坟墓) 造成Unix程序如此脆弱的一个原因是C程序员从启蒙时期就是这么被教育的。例如,Stroustrup(C++之父)的《C++编程语言》第一个完整程序(就是那个300K大小的"hello world"程序之后的那个)是一个英制/公制转换程序。用户用结尾"i"表示英制输入,用结尾"c"表示公制输入。下面是这个程序的概要,是用真正的Unix/C风格写的: #include <stream.h> main() {
[变量声明]
cin >> x >> ch;
;; A design abortion.
;; 读入x,然后读入ch。 if (ch == 'i') [handle "i" case]
else if (ch == 'c') [handle "c" case]
else in = cm = 0;
;; 好样的,决不报告错误。
;; 随便做点儿什么就成了。 [进行转换] 往后翻13页(第31页),给了一个索引范围从n到m的数组(而不是从0到m)的实现例子。如果程序员使用了超出范围的索引,这个程序只是笑嬉嬉地返回数组的第一个元素。Unix的终极脑死亡。 语法的吐根糖浆(Syrup of Ipecac,一种毒药) 语法糖蜜(Syntactic sugar)是分号癌症的罪魁祸首。 ——Alan Perlis 在使用C编程语言中所能遇到的所有语法错误几乎都能被C++接受,成功编译。不幸的是,这些语法错误并不总能生成正确的代码,这是因为人不是完美的,他们总是敲错键盘。C一般总能在编译是发现这些错误。C++则不然,它让你顺利通过编译,不过如果真的运行起来,就等着头痛吧。 C++的语法形成也和它自身的发展密不可分。C++从来没有被好好设计过:它只是逐步进化。在进化过程中,一些结构的加入造成了语言的二义性。特别的规则被用于解决这些二义性,这些难懂的规则使得C++复杂难学。于是不少程序员把它们抄在卡片上以供不时之需,或者根本就不去使用这些功能。 例如,C++有个规则说如果一个字符串既可以被解析为声明也可以被解析为语句,那么它将被当做声明。解析器专家看到这个规则往往会浑身发冷,他们知道很难能正确实现它。AT&T自己甚至都搞不对。比如,当Jim Roskind想理解一个结构的意思时(他觉得正常人会对它有不同的理解),他写了段测试代码,把它交给AT&T的"cfront"编译器。Cfront崩溃了。 事实上,如果你从ics.uci.edu上下载Jim Roskind的开放C++语法,你会发现ftp/pub目录里的c++grammar2.0.tar.Z有这样的说明:“注意我的语法和cfront不一定保持一致,因为 a) 我的语法内部是一致的(这源于它的规范性以及yacc的确证。b) yacc生成的解析器不会吐核(core dump)。(这条可能会招来不少臭鸡蛋,不过...每次当我想知道某种结构的语法含义是,如果ARM(Annotated C++ Reference Manual, 带注释的C++参考手册)对它的表述不清楚,我就会拿cfront来编译它,cfront这时总是吐核(core dump))” 日期: Sun, 21 May 89 18:02:14 PDT
发信人: tiemann (Michael Tiemann)
收信人: [email protected]
抄送: UNIX-HATERS
主题: C++ Comments (C++注释) 日期: 21 May 89 23:59:37 GMT
发信人: [email protected] (Scott Meyers)
新闻组: comp.lang.c++
组织: 布朗大学计算机系 看看下面这行C++代码: //********************** C++编译器该如何处理它呢?GNU g++编译器认为这是一行由一堆星星(*)组成的注释,然而AT&T编译器认为这是一个斜杠加上一个注释开始符(/*)。我想知道哪个是正确解析方式,可是Stroustrup的书(《C++编程语言》)里面却找不到答案。 实际上如果使用-E选项进行编译,就会发现是预处理器(preprocessor)搞的鬼,我的问题是: 这是否AT&T预处理器的bug?如果不是,为什么?如果是bug,2.0版是否会得到修正?还是只能这么下去了? 这是否GNU预处理器的bug?如果是,为什么? Scott Meyers [email protected] UNIX解析中有个古老的规则,尽量接受最长的语法单元(token)。这样'foo'就不会被看成三个变量名('f', 'o'和'o'),而只被当成一个变量'foo'。看看这个规则在下面这个程序中是多么的有用(还有选择'/*'作为注释开始符是多么的明智): double qdiv (p, q)
double *p, *q;
{
return *p/*q;
} 为什么这个规则没有被应用到C++中呢?很简单,这是个bug。 Michael 糟糕的还在后头,C++最大的问题是它的代码难读难理解,即使对于每天都用它的人也是如此。把另一个程序员的C++的代码拿来看看,不晕才怪。C++没有一丝品位,是个乱七八糟的丑八怪。C++自称为面向对象语言,却不愿意承担任何面向对象的责任。C++认为如果有谁的程序复杂到需要垃圾回收,动态加载或其他功能,那么说明他有足够的能力自己写一个,并且有足够的时间进行调试。 C++操作符重载(operator overloading)的强大功能在于,你可以把一段明显直白的代码变成能和最糟糕的APL, ADA或FORTH代码相媲美的东西。每个C++程序员都能创建自己的方言(dialect),把别的C++程序员彻底搞晕。 不过——嘿——在C++里甚至标准的方言也是私有的(private)。 抽象些什么? 你可能会觉得C++语法是它最糟糕的部分,不过当你开始学习C++时,就会知道你错了。一旦你开始用C++编写一个正式的大型软件,你会发现C++的抽象机制从根儿上就烂了。每本计算机科学教材都会这样告诉你,抽象是良好设计之源。 系统各个部分的关联会产生复杂性。如果你有一个100,000行的程序,其中每一行都和其他行代码的细节相关,那你就必须照应着10,000,000,000种不同的关联。抽象能够通过建立清晰的接口来减少这种关联。一段实现某种功能的代码被隐藏在模块化墙壁之后发挥作用。 类(class)是C++的核心,然而类的实现却反而阻碍着程序的模块化。类暴露了如此多的内部实现,以至于类的用户强烈倚赖着类的具体实现。许多情况下,对类做一点儿改变,就不得不重新编译所有使用它的代码,这常常造成开发的停滞。你的软件将不再“柔软”和“可塑”了,而成了一大块混凝土。 你将不得不把一半代码放到头文件里面,以对类进行声明。当然,类声明所提供的public/private的区分是没有什么用的,因为“私有”(private)信息就放在了头文件里,所以成了公开(public)信息。一旦放到头文件里,你就不大愿意去修改它,因为这会导致烦人的重编译。程序员于是通过修补实现机制,以避免修改头文件。当然还有其他一些保护机制,不过它们就象是减速障碍一样,可以被心急的家伙任意绕过。只要把所有对象都转换(cast)成void*,再也没有了讨厌的类型检查,这下世界清净了。 其他许多语言都各自提供了设计良好的抽象机制。C++丢掉了其中一些最为重要的部分,对于那些提供的部分也叫人迷惑不解。你是否遇到过真正喜欢模板(template)的人?模板使得类的实现根据上下文不同而不同。许多重要的概念无法通过这种简单的方式加以表达;即使表达出来了,也没法给它一个直接的名字供以后调用。 例如,名空间(namespace)能够避免你一部分代码的名字和其他部分发生冲突。一个服装制造软件可能有个对象叫做"Button"(钮扣),它可能会和一个用户界面库进行链接,那里面也有个类叫做"Button"(按钮)。如果使用了名空间,就不会有问题了,因为用法和每个概念的意思都很明确,没有歧义。 C++里则并非如此。你无法保证不会去使用那些已经在其他地方被定义了的名字,这往往会导致灾难性后果。你唯一的希望是给名称都加上前缀,比如ZjxButton,并但愿其他人不会用同一个名字。 日期: Fri, 18 Mar 94 10:52:58 PST
发信人: Scott L. Burson <[email protected]>
主题: preprocessor (预处理器) C语言迷们会告诉你C的一个最好的功能是预处理器。可事实上,它可能一个最蹩脚的功能。许多C程序由一堆蜘蛛网似的#ifdef组成 (如果各个Unix之间能够互相兼容,就几乎不会弄成这样)。不过这仅仅是开始。 C预处理器的最大问题是它把Unix锁在了文本文件的监牢里,然后扔掉了牢 旁砍 。这样除了文本文件以外,C源代码不可能以任何其他方式存储。为什么?因为未被预处理的C代码不可能被解析。例如: #ifdef BSD
int foo() {
#else
void foo() {
#endif
/* ... */
} 这里函数foo有两种不同的开头,根据宏'BSD'是否被定义而不同。直接对它进行解析几乎是不可能的 (就我们所知,从来没实现过)。 这为什么如此可恶?因为这阻碍了我们为编程环境加入更多智能。许多Unix程序员从没见过这样的环境,不知道自己被剥夺了什么。可是如果能够对代码进行自动分析,那么就能提供很多非常有用的功能。 让我们再看一个例子。在C语言当道的时代,预处理器被认为是唯一能提供开码(open-coded,是指直接把代码嵌入到指令流中,而不是通过函数调用)的方式。对于每个简单常用的表达式,开码是一个很高效的技术。比如,取小函数min可以使用宏实现: #define min(x,y) ((x) < (y) ? (x) : (y)) 假设你想写个工具打印一个程序中所有调用了min的函数。听上去不是很难,是不是?但是你如果不解析这个程序就无法知道函数的边界,你如果不做经过预处理器就无法进行解析,可是,一旦经过了预处理,所有的min就不复存在了!所以,你的只能去用grep了。 使用预处理器实现开码还有其他问题。例如,在上面的min宏里你一定注意到了那些多余的括号。事实上,这些括号是必不可少的,否则当min在另一个表达式中被展开时,结果可能不是你想要的。(老实说,这些括号不都是必需的——至于那些括号是可以省略的,这留做给读者的练习吧)。 min宏最险恶的问题是,虽然它用起来象是个函数调用,它并不真是函数。看这个例子: a = min(b++, c); 预处理器做了替换之后,变成了: a = ((b++) < (c) ? (b++) : (c)) 如果'b'小于'c','b'会被增加两次而不是一次,返回的将是'b'的原始值加一。 如果min真是函数,那么'b'将只会被增加一次,返回值将是'b'的原始值。 C++对于C来说,就如同是肺癌对于肺 “如果说C语言给了你足够的绳子吊死自己,那么C++给的绳子除了够你上吊之外,还够绑上你所有的邻居,并提供一艘帆船所需的绳索。” ——匿名 悲哀的是,学习C++成了每个计算机科学家和严肃程序最为有利可图的投资。它迅速成为简历中必不可少的一行。在过去的今年中,我们见过不少C++程序员,他们能够用C++写出不错的代码,不过... ...他们憎恶它。 程序员进化史 初中/高中 10 PRINT "HELLO WORLD"
20 END 大学一年级 program Hello(input, output);
begin
writeln('Hello world');
end. 大学四年级 (defun hello ()
(print (list 'HELLO 'WORLD))) 刚参加工作 #include <stdio.h> main (argc, argv)
int argc;
char **argv; {
printf ("Hello World!\n");
} 老手 #include <stream.h> const int MAXLEN = 80; class outstring;
class outstring {
private:
int size;
char str[MAXLEN]; public:
outstring() { size=0; }
~outstring() { size=0; }
void print();
void assign(char *chrs);
}; void outstring::print() {
int in;
for (i=0; i<size; i++)
cout << str[i];
cout << "\n";
} void outstring::assign(char* chrs) {
int i;
for (i=0; chars[i]!='\0'; i++)
str[i] = chrs[i];
size=i;
} main (int argc, char **argv) {
outstring string;
string.assign("Hello World!");
string.print();
} 老板 “乔治,我需要一个能打印'Hello World!'的程序” 好了,换个角度想想,C++可能是你最好的朋友,C++之父Stroustrup之所以设计C++,其实http://www.chunder.com/text/ididit.html正是为了我们这些程序员啊,当然如果你真的发誓不当C++程序员了,而且一时半会儿也当不了老板,你还可以考虑做系统管理员,叫人羡慕的sysadmin。
相关阅读 更多 +