P33
Bugs are by far the largest and most successful class of entity, with nearly a million known species. In this respect they outnumber all the other known creatures about four to one.
—Professor Snopes' Encyclopedia of Animal Life
P34
作者再一次以无比严谨的八卦态度考证了程序员经典都市传奇之:Fortran对identifier的宽松约束导致的NASA轨道计算错误
基本上凡是见到malloc(strlen(str))都可以判定是个错误,应该是malloc(strlen(str)+1),要留下最后终止符的空间
But, as my grandmother used to say, you can't run a super-conducting supercollider without smashing a few atoms, and you can't analyze C without looking at the flaws as well as the high points.
作者的奶奶是什么神人啊!!!
为什么C++这么令人失望:不但没有改进C的问题,反而把问题变得更复杂了
口诀:
The one "l" NUL ends an ASCII string,
The two "l" NULL points to nothing.
C语言的设计哲学(程序员们都不会乱来,编译器作者越轻松越好,包袱都丢给程序员)导致它少有runtime error checking,最多对指针的dereference进行检查,但碰上MS-DOS这样的就连这点检查都没有了,因为它不支持虚拟内存,就算dereference了超出虚拟内存的指针也无法处理(不过这个年代已经有很多让MS-DOS用上“虚拟内存”的extension了~)
P37
早期的编译器允许在switch的{后面(第一个case的前面)声明变量,分配内存,给下面的case用。虽然在这里初始化是没有用的,因为初始化是个statement,而switch只会在找到匹配的case或者default后才开始执行statement
在计算机内存以K计的年代,常见在一个block里的开头临时声明变量的做法,这样离开block就被销毁,可以节约空间,当然编译器可以无视它们,直接一次性打包分配空间。C++更进一步,不一定要放到block开头,夹在一堆statement中间照样可以,还有for(int i = 0; ... ; )这样的形式
P38
switch里啥东西都能被label,然后goto过去,这很容易出问题。还有一个问题是如果把default打成defau1t之类长得很像的东西,编译器不会报错,它会把这玩意儿当成一个label,于是这样一来default的处理就没掉了,只留下个奇怪的没用的label,还很难看出来这个BUG……
由于switch的case后面只能跟一个真正的constant,万一谁把const修饰的东西真的当成了一个constant拿去用,就会报错。
P39
用switch时不加break就会无视case一直往下执行的设计,即fall through,相当于给出一堆语句,根据case的情况跳过前面的一部分,然后全部执行下去,这样所有的case都会有一部分相同的收尾工作。
可以类比一下初中的化学课介绍过的简易净水器,从上到下分别有卵石,石英砂,活性炭,纱布,棉花,根据水里具有的不同的杂质,让需要处理的水分别从卵石层,或者石英砂层,或者活性炭层灌进去,无论如何这些水都会经过纱布和棉花层的处理,并且会相应地经过本层下面的处理。break的作用相当于在某一层直接把处理过的水引走,不经过下面的处理。
但其实switch的这一设计是很有问题的,因为大部分时间都不会用上fall through,作者用Sun的编译器举例,前端的244个switch语句里只有3%用上了fall through,而编译器会用上fall through的情形普通程序就更少碰上了。由于fall through的用法属于少数,这种默认的设计在用上的时候反而还要当做特例注释一下,并且连累常见的用法多写一堆break,真是DT……
As the Red Queen said to Alice in Through the Looking Glass, you can't deny that even if you used both hands.
又引用了一个奇妙的梗,类比起来就是虽然两种用法都派得上用场,但不代表这种设计是正确的(还要看比例撒)。
P40
一个在switch里用错的break(以为可以用来break掉case里的if,结果直接break出了switch)引发的连锁反应砸掉了AT&T 114年的招牌,造成全美范围内9个小时的电话瘫痪……
P41
ANSI C引入的新特性:相邻的字符串会被自动合并,不需要逗号分隔,因此如果在初始化一个字符串数组的时候,如果漏掉了元素间的逗号,这两个元素就会被自动合并,带来一些潜在的问题
早期的C允许在数组定义的最后一个元素后留一个看上去很多余的逗号,ANSI C依然允许这种做法,作者吐槽既然如此为什么有只有数组这一种comma-separated list才能这么干,赤果果的歧视啊口胡。
P42
演示了一种利用static来简便地实现第一次调用和其他调用结果不同的函数,比如打印一个数列,要用逗号分开,也就是第一个元素前不需要逗号,后面都先打印一个逗号再把逗号打印出来,那么只需要设一个static char变量,初始化为空字符,第一次打印后,再设为逗号,这样从第二次打印开始,初始化语句会被无视掉,这个改为了逗号的变量就会被打印出来。这种“第一遍和其他不同”的做法比“最后一遍和其他不同”的做法更容易利用static实现,代码也更干净,不需要多余的if或者switch。
ANSI C对允许数组的initializer list最后那个逗号的辩护理由是这样生成代码方便一些,作者用上面这个例子吐槽了这个理由,将第一个而不是最后一个元素作为例外才是更好的思维方式。这种奇怪的initializer只会把本来就很诡异的语法搞得更加诡异
P43
C的函数们是默认global的,也就是默认和加了extern一样,需要将它限定在一个文件里使用得特别在开头写上static,又因为群众普遍比较懒(……),一般都不会特意写个static,于是满世界都是glboal的函数,这显然是与Principle of least privilege相抵触的。在这种大家都喜欢犯懒的例子里,标准应该适应人,而不是人去适应标准,一个好的设计应该让人们只需要least effort就能选择正确的做法
乖乖马克:interpositioning—I should learn more about that
当两个库需要共享一个东西的时候,唯一的解决方案是将它变成global的,结果大家都能看到了,这种all-or-nothing visibility是C的一大槽点……
C不允许嵌套函数,所以只能把一个函数里附带的那些小函数们都弄到外头去,又因为地球人都懒得写static,于是这些小函数就成了global的了,实在是¥#@%!%
P44
C的运算符和关键字们一词多义的情况太多了,很容易把初学者搞晕。比如:
static:①放在一个函数里,表示用来在多次调用中保存一个值;②放在函数声明前,表示只有当前文件才准用这个函数
extern:①放在一个函数里,表示声明在这,定义在其他地方;②放在函数声明前,表示其他文件也可以用这个函数
void:①作为函数的return type,表示啥都不返回;②放在指针的声明里,泛指任意类型的指针;③放在parameter list里表示啥都不接受
*:①乘法运算符;②用来dereference一个指针;③用来声明一个指针(这个初学的时候真的很坑爹……)
作者举的其他例子我觉得还好……
P45
sizeof并不是一个函数,而是一个运算符,但在对一个类型而不是一个变量使用的时候必须加括号,所以让一些人误解它是个函数
apple = sizeof (int) * p; //右边是int的大小乘以p
连K&R都说过C的一些运算符优先级很有问题……=_____,=
P46
一些反直觉的优先级例子
*p.f相当于*(p.f),显然一般人不是想这么用的,所以发明了->
*ap[] = *(ap[])
*fp() = *(fp())
val&mask != 0 相当于 val & (mask != 0)
c=getchar() != EOF相当于 c = (getchar() != EOF)
msb<<4 + lsb 相当于 msb<<(4+lsb)
i = 1,2相当于(i=1),2,注意逗号运算符的作用是从左到右求值,也就是整个式子的值是最右边那个东西的值
P47
直接放了dmr在net.lang.c上关于运算符优先级的一条post(拜之)
&和|的不科学优先级是一个历史原因导致的问题,早期的C继承了B和BCPL的做法,只有&和|,它们包括了&&和||的功能,根据语境(whether Boolean value was expected)来判断是用来做位运算还是用来做逻辑运算,然后你走你的阳光道我走我的独木桥。后来在Alan Snyder的催促下,分裂出了&&和||,这样两种运算分得清楚一些,但是由于以前的很多代码都没特意加括号,如果要改&和|的优先级,它们就废掉了,所以dmr就觉着,算了吧,不科学就不科学了,不改了……
P48
又来了,蛋疼的undefined……如果在一个运算式子调用多个函数,编译器不能保证返回值先要被运算的那个函数就能先调用,可能最后面才拿来运算的返回值的函数反而是最先调用的,如果你在一个式子里调用一堆相互影响的函数,那就……听天由命吧……again,这也是为了编译器好写而留下来的自由,这样人家可以看着怎么方便怎么调用函数们,不用管顺序
pascal的做法是所有逻辑运算和数学计算混合的式子都要乖乖写括号,有些人推荐在C里只要记得乘除先于加减就好了,其他的一律加括号,作者表示赞赏……
associativity是什么(我也一直觉得很蛋疼的问题……):tie-breaker,在precedence相同的时候,进一步解决谁先算谁后算
所有带赋值功能的运算符都是先算右边再算左边
所有precedence相同的运算符,associativity也必然是相同的,不然大家混在一起又不知道先算谁了……
如果写一个式子的时候发现自己还要琢磨associativity ,不如直接写成两个命令算了
P49
在逻辑运算里函数调用好歹不是unspecified的,为了实现短路,一定从左到右调用求值
备注:C的&&相当于if嵌套,||相当于else if嵌套,可以以此理解短路
不过蛋疼的是,逻辑运算里那些函数传进去的参数如果又是调用其他函数的话……又unspecified了……
经典的gets()的缓冲区溢出漏洞……当gets()把多出来的字符继续塞进堆栈的时候,只要精心安排好这些字符,就能让函数返回后跳到邪恶的地方,用execv()函数开一个shell,然后为所欲为……
为了安全,请使用fgets()……然后作者又吐槽了下没删掉gets()的ANSI C
P51
写argument parsing的时候不要想当然,不要偷懒……
UNIX和ANSI C均不能事先判断命令行参数是文件名还是选项,不过一些操作系统如VAX/VMS可以
P52
bash渣渣没完全看懂,马克先……
为什么不要在unix/linux下用带奇怪符号的名字:用bash进行文件操作的时候会很蛋疼……对于文件名开头是“-”的文件(真是蛋疼的命名)进行操作,文件名容易被看成是参数,一个解决方法是直接给完整路径,这样开头就不是蛋疼的“-”了
P53
有些C程序员会约定用“--”表示此后的参数均非选项,作者表示更好的解决方法应该是让操作系统自己搞个参数处理器,让用户省点心。
虽然目前分析argv的机制很有问题,但又是因为应用太广,改进不能……╮(╯▽╰)╭
用“\”将physical lines合并成一个logical line的做法并不好,如果不小心在行尾的\后面加了一个空格,这个空格很难debug出来,但它会影响结果(多加一个空格)。这种做法常常被拿去对macro进行多行定义,如果真的要用,先确定你的编译器会提醒你这个行尾“\”后的空格(根据个人经验,g++是会提醒地……)
ANSI C为了解决这个问题,改用前面提到过的临近字符串自动合并的做法,结果丢了个芝麻又捡了个芝麻……
如果写一个带++的式子又不在双目运算符两边加空格,比如z = y+++x,按照ANSI C的标准(maximal munch strategy http://en.wikipedia.org/wiki/Maximal_munch)编译器会在解析下一个token的时候自动选择最长的,y++长于y,所以是y++ + x而不是y + ++x。
接下来,问题出现了,z = y+++++x,解析出z = y++之后又有一堆加号,那么++比+长,于是就成了y++ ++ + x,于是中间就出现了个没头没尾的++,编译报错……
当然,某些优化过的编译器可能尽量选择那些有意义的解释,于是上面发现那个奇怪的++行不通之后,再换一个,最后解析成z = y++ + ++x,不过依赖编译器实在太浮云了……
除此之外/和*放在一块儿的时候也有问题,比如*x/*y,这回更糟糕了,人家连运算符都不是,直接变注释……当然如果用上了语法高亮可能会提醒你一下,不过还是得注意
P54
前面来了个不小心开了个注释(/*)的例子,下面就是一个不小心忘记结束注释(*/)的黑历史……
有一个ANSI C的编译器(作者很厚道地没说是哪个……)的symbol table使用的hash function会先算大概从什么地方开始找,这个算法被编译器作者非常认真地注释了一遍,连出自哪本书都很龟毛地写了进去,然后……他忘记写*/了……而且下一个*/在略远的地方,于是初始计算直接被注释掉了,所有symbol都直接从头找到尾,因为查找结果不受影响(不知算好事还是坏事……),在测试的时候完全没找到这个蛋疼的bug,还是后来改另一个bug的时候无意中被发现的,赶紧在该结束注释的地方加上*/拯救初始值计算之后,效率直接提升15%……这可是查找symbol这种频繁的操作,对性能影响必然不是一点半点,之前用这个编译器的人是不是会有想哭的冲动~ 所以有些比较人性化的编译器如果在一个/**/注释里发现有/*的话,会好心提醒你一下是不是漏了*/……
嗯更可怕的,配合C++的//注释:
a //*
//*/ b
在C里是a//b,在C++是a……
P55
作者真是黑C语言其乐无穷啊……
The bug described in this section is a perfect example of how easy it is to write something in C that happily compiles, but produces garbage at runtime. This can be done in any language (e.g., simply divide by zero), but few languages offer quite so many fruitful and inadvertent opportunities as C.
又来揭人黑历史了,Sun在这本书里真是被黑得渣都不剩2333
这次是Sun的pascal compiler里有个函数返回了一个automatic的char数组,导致结果出现一堆奇怪的字符~
在离开函数之后那些local的数据不一定会被立刻复写成其他东西,要看这些数据放在栈的哪里和当前函数声明了什么新的变量,那些旧函数的数据所在的栈空间被新的函数拿去用掉了才会变,所以会出现书上的char数组有几个字符还跟上一个函数里一样的情况
P56
如果要在C里让函数返回一个字符串该怎么做:
1. 返回一个string literal,也就是直接return "xxxxxx",但如果需要先通过运算处理才能得到字符串内容,而不是事前就知道xxxxxxx是什么,这个方法就行不通了。此外万一编译器如果是把这些string literal存在只读内存里,而caller试图修改得到的字符串的时候……嗯……
2. 用全局数组,简单方便,但是容易不小心被其他函数改掉,而且太长又暂时用不上的话浪费内存
3.用static数组,这样只有调用了那个函数的地方才能修改得到的字符串,但是caller如果想调用超过两次,就必须将上一次的结果先保存到另一个数组,比如:
char *func(int param) { static char buffer[200]; ... return buffer; }
如果不保存就调用两次,比如
a = func(aparam);
b = func(bparam);
这样a和b的结果是一样的,因为是同一个数组,或者某种意义上来说,同一个指针。因此需要
strcpy(a, func(aparam));
b = func(bparam);
或者
strcpy(a, func(aparam));
strcpy(b, func(bparam));
不过这个策略和用全局数组一样,有浪费内存的风险
4.直接在函数里分配内存,比如:
char *func(int param) { char *buffer = malloc(200); .... return buffer; }
效果和用static数组差不多,但是因为每次都是一个新的数组,就不需要每次都保存上次调用的结果,而且多线程程序也可以用。不过缺点也很明显,调用的人要记得free掉这些内存,而在大型程序里,要么容易不小心被别人或被某个记性不好的人提前free掉然后又拿去用,结果产生无比头痛的bug,要么就是直接忘记free了,于是memory leak……
5.可能是最好的办法,向fgets()学习,接收一个指针和一个size,然后将结果写进给出的指针,利用传下来的size做必要的保护,比如这样:
void func(int param, char *buffer, size_t size) { char result[size]; .... // 对result做各种运算处理,注意size strcpy(buffer, result); }
这样就不是函数调用malloc,caller调用free,而是调用的人自己两个都写,如此一来写的人就比较容易记得将malloc和free成对调用。
P57
对于返回local数组的问题,lint能够帮忙解决一部分,产生警告(warning: function returns pointer to automatic),但无论是lint和编译器都不能保证揪出所有的类似问题, 因为它们可能会被层层的间接使用掩盖住
P58
在C语言滴童年时期,它的粑粑麻麻们做出了一个决定:将错误检查与编译器分离出来写成一个叫lint的程序,这样编译器可以更小更轻更快~反正程序员们都是很聪明的嘛,我们可以放心他们不会乱来的!嗯哼!(所谓大牛的神逻辑:会用我的东西的人也都是大牛!是也)
作者对这个决定猛烈吐槽了一番,然后表示好在当时的很多编辑器已经开始意识到这一点加入了错误检查了,顺便吐槽关于函数使用的检查明明被群众认为是编译器而不是lint干的工作,你看人家Ada所有的编译器都有这功能,怎么还有这么多C编译器不赶紧加上~麻利点~~
接下来是作者所在的SunOS development team的lint血泪史……当他们从BSD UNIX转到SVR4的时候决定对SVR4的内核进行lint检查,结果怒生成了1W2+条警告,每条都要人工处理,于是修改了将近750个源代码文件,被悲伤的大牛们称作the lint merge from hell……除掉那些不太严重的地方,真正的bug包括函数调用中变味的参数类型,不小心少传几个参数于是编译器怒从栈里抓俩垃圾传进去(……年轻人表示很震惊当年的编译器居然不管这个),以及没有初始化就使用的变量。在经过了地狱般的lint洗礼之后,他们决定以后所有的修改都必须通过lint和cstyle的检查。估计这也是为什么作者如此怨念lint不是编译器一部分的原因……
P59
虽然有人觉得把lint合并进编译器会拖慢速度而且很烦人,但是作者表示实践证明如果将lint与编译器分离开,只能是让大家都懒得用lint,于是出来的代码更加不负责……
(顺便马克一个句型:sb. object to the idea of ... on the grounds that ...)
软件开发的一个准则:bug越早发现,修复成本越低。所以让lint(或者编译器,如果它们肯检查的话)发现bug好过让debugger发现bug,让debugger发现bug好过让测试团队发现bug,最糟糕的结果,就是让用的人发现bug……
P60
作者在这一章开头提到的FORTRAN的都市传说经常被错以为是发生在Mariner 1项目里的(其实是Mercury),不过Mariner 1本身也确实发生过重大的软件问题,给程序员的追踪算法是手抄的,结果抄写错误(……),平均速度(R上面画一条杠)的杠被漏掉了,结果程序员就当做雷达直接返回的速度写进了程序里。这个程序属于自动控制的部分,前几次飞行中都是人工控制的,所以没有问题,结果有一次天线故障,就启用了自动控制程序,然后火箭的速度出现了一点小波动,进入负反馈处理,接下来各种餐具……最后结果是火箭飞离轨道,不得不被摧毁,探测计划失败…………(参见wiki,http://en.wikipedia.org/wiki/Mariner_1,The most expensive hyphen in history,笑傻了2333)
作者表示那些写实时控制程序的程序员应该做飞船的第一批乘客,比如写life support system的就该让他被发射到太空去,这样他就会在发射前拼命debug……绝对能大幅度提高代码质量23333
作者爆了一个神一般的故事,并且一再强调,这是真实故事:有次飞船发射前,dock master上去检查飞船各项物品的重量,然后震惊地发现,泥煤软件怎么没重量?!人家明明在内存里的!于是loading dock和the computer center还争论了半天(我去……),最后终于决定让这个0重量的软件登船……(= =|||)(虽然根据相对论,信息也是有质量的……但作者表示我们就别破坏这个故事的笑点了,咳咳)