C专家编程

978-7-115-17180-1
作者: 【美】Perter Van Der Linde
译者: 徐波
编辑: 付飞傅道坤
分类: C语言

图书目录:

详情

《专家编程》展示了最优秀的C程序员所使用的编码技巧,并专门开辟了一章对C++的基础知识进行了介绍。 书中C的历史、语言特性、声明、数组、指针、链接、运行时、内存以及如何进一步学习C++等问题进行了细致的讲解和深入的分析。全书撷取几十个实例进行讲解,对C程序员具有非常高的实用价值,可以帮助有一定经验的C程序员成为C编程方面的专家。

图书摘要

版权信息

书名:C专家编程

ISBN:978-7-115-17180-1

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

• 著    Peter Van Der Linden

  译    徐 波

  责任编辑 付 飞

• 人民邮电出版社出版发行  北京市丰台区成寿寺路11号

  邮编 100164  电子邮件 315@ptpress.com.cn

  网址 http://www.ptpress.com.cn

• 读者服务热线:(010)81055410

  反盗版热线:(010)81055315


《C专家编程》展示了最优秀的C程序员所使用的编码技巧,并专门开辟了一章对C++的基础知识进行了介绍。

书中C的历史、语言特性、声明、数组、指针、链接、运行时、内存以及如何进一步学习C++等问题进行了细致的讲解和深入的分析。全书撷取几十个实例进行讲解,对C程序员具有非常高的实用价值。

本书可以帮助有一定经验的C程序员成为C编程方面的专家,对于具备相当的C语言基础的程序员,本书可以帮助他们站在C的高度了解和学习C++。


解决方案

计算机日期

这个练习的结果在不同的PC和UNIX系统上有所差异,而且它依赖于time_t的存储形式。在Sun的系统中,time_t是long的typedef形式。我们所尝试的第一个解决方案如下:

这是一个输出结果:

显然,这不是正确的结果。ctime()函数把参数转换为当地时间,它跟世界统一时间UTC(格林尼治时间)并不一致,取决于你所在的时区。本书写作地是加利福尼亚,比伦敦晚8个小时,而且现在的年份跟最大时间值的年份相差甚远。

事实上,我们应该采用gmtime()函数来取得最大的UTC时间值。这个函数并不返回一个可打印的字符串,所以不得不用asctime()函数来获取一个这样的字符串。权衡各方面情况后,修订过的程序如下:

它给出了如下的结果:

看!这样就挤出了8个小时。

但是,我们并未大功告成。如果你采用的是新西兰的时区,你就会又多出13个小时,前提是它在2038年仍然采用夏令时。他们在1月份时采用的是夏令时,因为新西兰位于南半球。但是,由于新西兰的最东端位于日界线的东面,在那里它应该比格林尼治时间晚10小时而不是早14小时。这样,新西兰由于其独特的地理位置,不幸成为该程序的第一个Bug的受害者。

即使像这样简单的问题也可能在软件中潜伏令人吃惊的隐患。如果有人觉得对日期进行编程是小菜一碟,一次动手便可轻松搞定,那么他肯定没有深入研究问题,程序的质量也可想而知。

C代码。C代码运行。运行码运行…请!

——Barbara Ling

#include < stdio.h>
#include < time.h>
int main() {
  time_t biggest= 0x7FFFFFFF;
  printf(“biggest = %s \n”, ctime(&biggest));
  return 0;
}

所有的C程序都做同一件事,观察一个字符,然后啥也不干。

——Peter Weinberger

  biggest = Mon Jan 18 19:14:07 2038

你是否注意到市面上存有大量的C语言编程书籍,它们的书名具有一定的启示性,如:C Traps and Pitfalls(本书中文版《C陷阱与缺陷》已由人民邮电出版社出版), The C Puzzle Book, Obfuscated C and Other Mysteries,而其他的编程语言好像没有这类书。这里有一个很充分的理由!

C语言编程是一项技艺,需要多年历练才能达到较为完善的境界。一个头脑敏捷的人很快就能学会C语言中基础的东西。但要品味出C语言的细微之处,并通过大量编写各种不同程序成为C语言专家,则耗时甚巨。打个比方说,这是在巴黎点一杯咖啡与在地铁里告诉土生土长的巴黎人该在哪里下车之间的差别。本书是一本关于ANSI C编程语言的高级读本。它适用于已经编写过C程序的人,以及那些想迅速获取一些专家观点和技巧的人。

编程专家在多年的实践中建立了自己的技术工具箱,里面是形形色色的习惯用法、代码片段和灵活掌握的技巧。他们站在其他更有经验的同事的肩膀上,或是直接领悟他们的代码,或是在维护其他人的代码时聆听他们的教诲,随着时间的推移,逐步形成了这些东西。另外一种成为C编程高手的途径是自省,在认识错误的过程中进步。几乎每个C语言编程新手都曾犯过下面这样的书写错误:

if(i = 3)
#include<stdio.h>
#include<time.h>
int main() {
  time_t  biggest = 0x7FFFFFFF;
  printf(“biggest = %s \n”, asctime(gmtime(&biggest)));
  return 0;
}

正确的应该是:

if(i == 3)
  biggest = Tue Jan 19 03:14:07 2038

一旦有过这样的经历,这种痛苦的错误(需要进行比较时误用了赋值符号)一般不会再犯。有些程序员甚至养成了一种习惯,在比较式中先写常数,如:if(3 == i)。这样,如果不小心误用了赋值符号,编译器就会发出“attempted assighnment to literal(试图向常数赋值)”的错误信息。虽然当你比较两个变量时,这种技巧起不了作用。但是,积少成多,如果你一直留心这些小技巧,迟早会对你有所帮助的。

1993年春天,在SunSoft的操作系统开发小组里,我们接到了一个“一级优先”的Bug报告,是一个关于异步I/O库的问题。如果这个Bug不解决,将会使一桩价值2000万美元的硬件产品生意告吹,因为对方需要使用这个库的功能。所以,我们顶着重压寻找这个Bug。经过几次紧张的调试,问题被圈定在下面这条语句上:

x == 2;

这是个打字错误,它的原意是一条赋值语句。程序员的手指放在“=”键上,不小心多按了一下。这条语句成了将x与2进行比较,比较结果是true或者false,然后丢弃这个比较结果。

C语言的表达能力也实在是强,编译器对于“求一个表达式的值,但不使用该值”这样的语句竟然也能接受,并且不发出任何警告,只是简单地把返回结果丢弃。我们不知道是应该为及时找到这个问题的好运气而庆幸,还是应该为这样一个常见的打字错误可能付出高昂的代价而痛心疾首。有些版本的长整数程序已经能够检测到这类问题,但人们很容易忽视这些有用的工具。

本书收集了其他许多有益的故事。它记录了许多经验丰富的程序员的智慧,避免读者再走弯路。当你来到一个看上去很熟的地方,却发现许多角落依然陌生,本书就像是一个细心的向导,帮助你探索这些角落。本书对一些主要话题如声明、数组/指针等作了深入的讨论,同时提供了许多提示和记忆方法。本书从头到尾都采用了ANSI C的术语,在必要时我会用日常用语来诠释。

编程挑战

小启发

样例框

我们设置了“编程挑战”这个小栏目,像这样以的形式出现。

框中会列出一些对你所编写的程序的建议。

另外,我们还设置了“小启发”这个栏目,也是以的形式出现的。

“小启发”里出现的是在实际工作中所产生一些想法、经验和指导方针。你可以在编程中应用它们。当然,如果你觉得你已经有了更好的指导原则,也完全可以不理会它们。

我们所采用的一个约定是用蔬菜和水果的名字来代表变量的名字(当然只适用于小型程序片段,现实中的程序不可如此):

char pear[40];
double peach;
int mango = 13;
long melon = 2001;

这样就很容易区分哪些是关键字,哪些是程序员所提供的变量名。有些人或许会说,你不能拿苹果和桔子作比较。但为什么不行呢?它们都是在树上生长、拳头大小、圆圆的可食之物。一旦你习惯了这种用法,你就会发现它很有用。另外还有一个约定,有时我们会重复某个要点,以示强调。

和精美食谱一样,《C专家编程》准备了许多可口的东西,以实例的样式奉献给读者。每一章都被分成几个彼此相关而又独立的小节。无论是从头到尾认真阅读,还是随意翻开一章选一个单独的主题细细品味,都是相当容易的。许多技术细节都蕴藏于C语言在实际编程中的一些真实故事里。幽默对于学习新东西是相当重要的,所以我在每一章都以一个“轻松一下”的小栏目结尾。这个栏目包含了一个有趣的C语言故事,或是一段软件轶闻,让读者在学习的过程中轻松一下。

读者可以把本书当作C语言编程的思路集锦,或是C语言提示和习惯用法的集合,也可以从经验丰富的编译器作者那里汲取营养,更轻松地学习ANSI C。总之,它把所有的信息、提示和指导方针都放在一个地方,让你慢慢品味。所以,请赶紧翻开书,拿出笔,舒舒服服在坐在电脑前,开始快乐之旅吧!

偶尔,在C和UNIX中,有些方面是令人感觉相当轻松的。只要出发点合理,什么样的奇思妙想都不为过。IBM/Motorola/Apple PowerPC架构具有一种E.I.E.I.O指令[1],代表“Enforce In-Order Execution of I/O”(在I/O中实行按顺序执行的方针)。与这种思想相类似,在UNIX中也有一条称作tunefs的命令,高级系统管理员用它修改文件系统的动态参数,并优化磁盘中文件块的布局。

和其他的Berkeley[2]命令一样,在早期的tunefs在线手册上,也是以一个标题为“Bugs”的小节来结尾。内容如下:

Bugs:

这个程序本来应该在安装好的(mounted)和活动的文件系统上运行,但事实上并非如此。因为超级块(superblock)并不是保持在高速缓冲区中,所以该程序只有当它运行在未安装好的(dismounted)文件系统中时才有效。如果运行于根文件系统,系统必须重新启动。

你可以优化一个文件系统,但不能优化一条鱼。

更有甚者,在文字处理器的源文件中有一条关于它的注释,警告任何人不得忽视上面这段话!内容如下:

如果忽视这段话,你就等着烦吧。一个UNIX里的怪物会不断地纠缠你,直到你受不了为止。

当SUN和其他一些公司转到SVr4 UNIX平台时,我们就看不到这条警训了。在SVr4的手册中没有了“Bugs”这一节,而是改名为“注意”(会不会误导大家?)。“优化一条鱼”这样的妙语也不见了。作出这个修改的人现在一定在受UNIX里面怪物的纠缠,自作自受!

编程挑战

计算机日期

关于time_t,什么时候它会到达尽头,重新回到开始呢?

写一个程序,找出答案。

1.查看一下time_t的定义,它位于文件/user/include/time.h中。

2.编写代码,在一个类型为time_t的变量中存放time_t的最大值,然后把它传递给ctime()函数,转换成ASCII字符串并打印出来。注意ctime()函数同C语言并没有任何关系,它只表示“转换时间”。

如果程序设计者去掉了程序的注释,那么多少年以后,他不得不担心该程序会在UNIX平台上溢出。请修改程序,找出答案。

1.调用time()获得当前的时间。

2.调用difftime()获得当前时间和time_t所能表示的最大时间值之间的差值(以秒计算)。

3.把这个值格式化为年、月、周、日、小时、分钟的形式,并打印出来。

它是不是比一般人的寿命还要长?

[1] 可能是由一个名叫McDonald的老农设计的。

[2] 加州大学伯克利分校,UNIX系统的许多版本都是在那里设计的。 ——译者注


最近,我逛了一家书店,当我看到大量枯燥乏味的C和C++书籍时,心情格外沮丧。我发现极少有作者想向读者传达这样一个信念:任何人都可以享受编程。在冗长而乏味的阅读过程中,所有的奇妙和乐趣都烟消云散了。如果你硬着头皮把它啃完,或许会有长进。但编程本来不该是这个样子的呀!

编程应该是一项精妙绝伦、充满生机、富有挑战的活动,而讲述编程的书籍也应时时迸射出激情的火花。本书也是一本教学性质的书籍,但它希望重新把快乐融入编程之中。如果本书不合你的口味,请把它放回到书架上,但务必放到更显眼的位置上,这里先谢过了。

好,听了这个开场白,你不免有所疑问:关于C语言编程的书可以说是不胜枚举,那么这本书又有什么独到之处呢?

《C专家编程》应该是每位程序员的第二本学习C语言的书。这里所提到的绝大多数教程、提示和技巧都是无法在其他书上找到的,即使有的话,它们通常也是作为心得体会手工记录在手册的书页空白处或旧打印纸的背面。作者以及Sun公司编译器和操作系统小组的同事们在多年C语言编程实践中,积累了大量的知识和经验。书中讲述了许多有趣的C语言故事和轶闻,诸如连接到因特网上的自动售货机、太空软件中存在的问题,以及一个C语言的缺陷怎样使整个AT&T长途电话网络瘫痪等。本书的最后一章是C++语言的轻松教程,帮助你精通这门日益流行的从C语言演化而来的语言。

本书讲述的是应用于PC和UNIX系统上的ANSI标准C语言。对C语言中与UNIX平台复杂的硬件结构(如虚拟内存等)相关的特性作了详细描述。对于PC的内存模型和Intel 8086系列对C语言产生影响的部分也作了全面介绍。C语言基础相当扎实的人很快就会发现书中充满了很多程序员可能需要多年实践才能领会的技巧、提示和捷径。它覆盖了许多令C程序员困惑的主题:

如果你对这些问题不是很有把握,很想知道C语言专家是如何处理它们的,那么请继续阅读!即使你对这些问题已经了如指掌,对C语言的其他细节也是耳熟能详,那么也请阅读本书,继续充实你的知识。如果觉得不好意思,就告诉书店职员“我是为朋友买书。”

Peter Van Der Linden于加州硅谷


C诡异离奇,缺陷重重,却获得了巨大的成功。

—— Dennis Ritchie

听上去有些荒谬,C语言的产生竟然源于一个失败的项目。1969年,通用电气、麻省理工学院和贝尔实验室联合创立了一个庞大的项目——Multics工程。该项目的目的是创建一个操作系统,但显然遇到了麻烦:它不但无法交付原先所承诺的快速而便捷的在线系统,甚至连一点有用的东西都没有弄出来。虽然开发小组最终勉强让Multics开动起来,但他们还是陷入了泥淖,就像IBM在OS/360上面一样。他们试图建立一个非常巨大的操作系统,能够应用于规模很小的硬件系统中。Multics成了总结工程教训的宝库,但它同时也为C语言体现“小即是美”铺平了道路。

当心灰意冷的贝尔实验室的专家们撤离Multics工程后,他们又去寻找其他任务。其中一位名叫Ken Thompson的研究人员对另一个操作系统很感兴趣,他为此好几次向贝尔管理层提议,但均遭否决。在等待官方批准时,Thompson和他的同事Dennis Ritchie自娱自乐,把Thompson的“太空旅行”软件移植到不太常用的PDP-7系统上。太空旅行软件模拟太阳系的主要星体,把它们显示在图形屏幕上,并创建了一架航天飞机,它能够飞行并降落到各个行星上。与此同时,Thompson加紧工作,为PDP-7编写了一个简易的新型操作系统。它比Multics简单得多,也轻便得多。整个系统都是用汇编语言编写的。Brian Kernighan在1970年给它取名为UNIX,自嘲地总结了从Multics中获得的那些不应该做的教训。图1-1描述了早期C、UNIX和相关硬件系统的关系。

图1-1 早期C、UNIX和相关的硬件系统

是先有C语言还是先有UNIX呢?说起这个问题,人们很容易陷入先有鸡还是先有蛋的套套中。确切地说,UNIX比C语言出现得早(这也是为什么UNIX的系统时间是从1970年1月1日起按秒计算的,它就是那时候产生的啊)。然而,我们这里讨论的不是家禽趣闻,而是编程故事。用汇编语言编写UNIX显得很笨拙,在编制数据结构时浪费了大量的时间,而且系统难以调试,理解起来也很困难。Thompson想利用高级语言的一些优点,但又不想像PL/I[1]那样效率低下,也不想碰见在Multics中曾遇到过的复杂问题。在用Fortran进行了一番简短而又不成功的尝试之后,Thompson创建了B语言,他把用于研究的语言BCPL[2]作了简化,使B的解释器能常驻于PDP-7只有8KB大小的内存中。B语言从来不曾真正成功过,因为硬件系统的内存限制,它只允许放置解释器,而不是编译器,由此产生的低效阻碍了使用B语言进行UNIX自身的系统编程。

软件信条

编译器设计者的金科玉律:效率(几乎)就是一切

在编译器中,效率几乎就是一切。当然还有一些其他需要关心的东西,如有意义的错误信息、良好的文档和产品支持。但与用户需要的速度相比,这些因素就黯然失色了。编译器的效率包括两个方面:运行效率(代码的运行速度)和编译效率(产生可执行代码的速度)。除了一些开发和学习环境之外,运行效率起决定性作用。

有很多编译优化措施会延长编译时间,但却能缩短运行时间。还有一些优化措施(如清除无用代码和忽略运行时检查等)既能缩短编译时间,又能减少运行时间,同时还能减少内存的使用量。这些优化措施的不利之处在于可能无法发现程序中无效的运行结果。优化措施本身在转换代码时是非常谨慎的,但如果程序员编写了无效的代码(如:越过数组边界引用对象,因为他们“知道”附近有他们需要的变量)就可能引发错误的结果。

这就是为什么说效率几乎就是一切但也并不是绝对的道理。如果得到的结果是不正确的,那么效率再高又有什么意义呢?编译器设计者通常会提供一些编译器选项。这样,每个程序员可以选择自己想要的优化措施。B语言不算成功,而Dennis Ritchie所创造的注重效率的“New B”却获得了成功,充分证明了编译器设计者的这条金科玉律。

B语言通过省略一些特性(如嵌套过程和一些循环结构),对BCPL语言作了简化,并发扬了“引用数组元素相当于对指针加上偏移量的引用”这个想法。B语言同时保持了BCPL语言无类型这个特点,它仅有的操作数就是机器的字。Thomposon发明了++和--操作符,并把它加入到PDP-7的B编译器中。它们在C语言中依然存在,很多人天真地以为这是由于PDP-11存在对应的自动增/减地址模型,这种想法是错误的!自动增/减机制的出现早于PDP-11硬件系统的出现。尽管在C语言中,拷贝字符串中的一个字符的语句:

*p++ = *s++;

可以极其有效地被编译为PDP-11代码:

moveb (r0)+, (r1)+

这使得许多人错误地以为前者的语句形式是根据后者特意设计的。

当1970年开发平台转移到PDP-11以后,无类型语言很快就显得不合时宜了。这种处理器以硬件支持几种不同长度的数据类型为特色,而B语言无法表达不同的数据类型。效率也是一个问题,这也迫使Thompson在PDP-11上重新用汇编语言实现了UNIX。Dennis Ritchie利用PDP-11的强大性能,创立了能够同时解决多种数据类型和效率的“New B”(这个名字很快变成了“C”)语言,它采用了编译模式而不是解释模式,并引入了类型系统,每个变量在使用前必须先声明。

增加类型系统的主要目的是帮助编译器设计者区分新型PDP-11机器所拥有的不同数据类型,如单精度浮点数、双精度浮点数和字符等。这与其他一些语言如Pascal形成了鲜明的对比。在Pascal中,类型系统的目的是保护程序员,防止他们在数据上进行无效的操作。由于设计哲学不同,C语言排斥强类型,它允许程序员需要时可以在不同类型的对象间赋值。类型系统的加入可以说是事后诸葛,从未在可用性方面进行过认真的评估和严格的测试。时至今日,许多C程序员仍然认为“强类型”只不过是增加了敲击键盘的无用功。

除了类型系统之外,C语言的许多其他特性是为了方便编译器设计者而建立的(为什么不呢?开始几年C语言的主要客户就是那些编译器设计者啊)。根据编译器设计者的思路而发展形成的语言特性有:

为了C编译器设计者的方便而建立的其他语言特性还有很多。这本身不是一件坏事,它大大简化了C语言本身,而且通过回避一些复杂的语言要素(如Ada中的泛型和任务,PL/I中的字符串处理,C++中的模板和多重继承),C语言更容易学习和实现,而且效率非常高。

和其他大多数语言不同,C语言有一个漫长的进化过程。在目前这个形式之前,它经历了许多中间状态。它历经多年,从一个实用工具进化为一种经过大量试验和测试的语言。第一个C编译器大约出现在1970年,距今20多年了[3]。时光荏苒,作为它的根基的UNIX系统得到了广泛使用,C语言也随之茁壮成长。它对直接由硬件支持的底层操作的强调,带来了极高的效率和移植性,反过来也帮助UNIX获得了巨大的成功。

软件信条

C并非Algol

70年代后期,Steve Bourne在贝尔实验室编写UNIX第7版的shell(命令解释器)时,决定采用C预处理器使C语言看上去更像Algol-68。早年在英国剑桥大学时,Steve曾编写过一个Algol-68编译器。他发现如果代码中有显式的“结束语句”提示,诸如if ... fi或者case ... esac等,调试起来会更容易。Steve认为仅仅一个“}”是不够的,因此他建立了许多预处理定义:

这样,他就可以像下面这样编写代码:

再看一下相应的C代码:

Bourne shell的影响远远超出了贝尔实验室的范围,这也使得这种类似Algol-68的C语言变型名声大噪。但是,有些C程序员对此感到不满。他们抱怨这种记法使别人难以维护代码。时至今日,BSD 4.3 Bourne shell(保存于/bin/sh)依然是这种记法写的。

我有一个特别的理由反对Bourne Shell,在我的书桌上堆满了针对它的Bug报告!我把它们发给Sam,我们都发现了这样的Bug:这个shell不使用malloc,而是使用sbrk自行负责堆存储的管理。在维护这类软件时,每解决两个问题通常又会引入一个新问题。Steve解释说他之所以采用这种特制的内存分配器,是为了提高字符串处理的效率,他从来不曾想到其他人会阅读他的代码。

C编译器不曾实现的一些功能必须通过其他途径实现。在C语言中,它们在运行时进行处理,既可以出现在应用程序代码中,也可以出现在运行时函数库(runtime library)中。在许多其他语言中,编译器会植入一些代码,隐式地调用运行时支持工具,这样程序员就无须操心它们了。但在C语言中,绝大多数库函数或辅助程序都需要显式调用。例如,在C语言中(必要时),程序员必须管理动态内存的使用,创建各种大小的数组,测试数组边界,并自己进行范围检测。

与此类似,C语言原先并没有定义I/O,而是由库函数提供。后来,这实际上成了标准机制。可移植的I/O由Mike Lesk编写,最初出现在1972年左右,可在当时存在的3个平台上通用。实践经验表明,它的性能低于预期值。所以,人们对它又进行了优化和裁剪,后来成为标准I/O函数库。

C预处理器大约也是在这个时候被加入的,倡议者是Alan Snyder。它所实现的3个主要功能是:

    #define STRING char *
    #define IF if(
    #define THEN ){
    #define ELSE }else(
    #define FI ;}
    #define WHILE while(
    #define DO ){
    #define OD ;}
    #define INT int
    #define BEGIN {
    #define END }
#define a(y)  a_expanded(y)
a(x);
    INT compare(s1, s2)
        STRING s1;
        STRING s2;
    BEGIN
        WHILE *s1++ == *s2
        DO IF *s2++ == 0
            THEN return(0);
            FI
        OD
           return(*--s1 - *s2);
    END

被扩展为:

a_expanded(x);
    int compare(s1, s2)
        char *s1, *s2;
    {
        while(*s1++ == *s2){
                if(*s2++ == 0) return(0);
        }
        return (*--s1 - *s2);
    }

而:

#define a (y)   a_expanded (y)
a(x);

则被扩展为:

(y)    a_expanded (y)(x)

它们所表示的意思风马牛不相及。你可能会以为在宏里面使用花括号就像在C语言的其他部分一样,能把多条语句组合成一条复合语句,但实际上并非如此。

这里对C语言的预处理器并不作太多的讨论。这反映了这样一个观点:对于宏这样的预处理器,只应该适量使用,所以无须深入讨论。C++在这方面引入了一些新的方法,使得预处理器几乎无用武之地。

Bourne创立的这种C语言变型事实上促成了异想天开的国际C语言混乱代码大赛(The International Obfuscated C Code Competition),比赛要求参赛的程序员尽可能地编写神秘而混乱的程序来压倒对手(关于这个比赛,以后还有更详尽的说明)。

宏最好只用于命名常量,并为一些适当的结构提供简捷的记法。宏名应该大写,这样便很容易与函数调用区分开来。千万不要使用C预处理器来修改语言的基础结构,因为这样一来C语言就不再是C语言了。

软件信条

一个非比寻常的Bug

C语言从Algol-68中继承了一个特性,就是复合赋值符。它允许对一个重复出现的操作数只写一次而不是两次,给代码生成器一个提示,即操作数寻址也可以类似地紧凑。这方面的一个例子是用b+=3作为b=b+3的缩写。复合赋值符最初的写法是先写赋值符,再写操作符,就像:b=+3。在B语言的词法分析器里有一个技巧,使实现=op这种形式要比实现目前所使用的op=形式更简单一些。但这种形式会引起混淆,它很容易把

搞混淆。

因此,这个特性被修改为目前所使用的这种形式。作为修改的一部分,代码格式器程序indent也作了相应修改,用于确定复合赋值符的过时形式,并交换两者的位置,把它转换为对应的标准形式。这是个非常糟糕的决定,任何格式器都不应该修改程序中除空白之外的任何东西。令人不快的是,这种做法会引入一个Bug,就是几乎任何东西(只要不是变量),如果它出现在赋值符后面,就会与赋值符交换位置。

如果你运气好,这个Bug可能会引起语法错误,如:

会被交换成:

这条语句将无法通过编译器,你马上就能发现错误。但一条源语句也可能是这样的:

会悄无声息地交换成:

这条语句同样能够通过编译,但它的作用与源语句明显不同,它并不改变valve的值。

在后面这种情况下,这个Bug会潜伏下来,并不会被马上检测到。在赋值后面加个空格是很自然的事,所以随着复合赋值符的过时形式越来越罕见,人们也逐渐忘记了indent程序曾经被用于“改进”这种过时的形式。这个由indent程序引起的 Bug直到20世纪80年代中期才在各种C编译器中销声匿迹。这是一个应被坚决摒弃的东西!

到了20世纪70年代中期,C语言已经很接近目前这种我们所知道和喜爱的形式了。更多的改进仍然存在,但大部分都只是一些细节的变化(比如允许函数返回结构值)和一些对基本类型进行扩展以适应新的硬件变化的改进。(比如增加关键字unsigned和long)。1978年,Steve Johnson编写了pcc这个可移植的C编译器。它的源代码对贝尔实验室之外开放,并被广泛移植,形成了整整一代C编译器的基础。C语言的演化之路如图1-2所示。

图1-2 后期的C

b=-3;  /* 从b中减去3 */

1978年,C语言经典名著The C Programming Language出版了。这本书受到了广泛的赞誉,其作者Brian Kernighan和Dennis Ritchie也因此名声大噪,所以这个版本的C语言就被称为“K&R C”。出版商最初估计这本书将售出1000册左右。截止到1994年,这本书大约售出了150万册(参见图1-3)。C语言成为最近20年最成功的编程语言之一,可能就是最成功的。但随着C语言的广泛流行,许多人试图从C语言中产生其他变种。

b= -3;  /* 把-3赋给b */

图1-3 像猫王艾尔维斯一样,C语言无处不在

epsilon=.0001;
epsilon.=0001;
valve=!open;   /*valve被设置为open的逻辑反*/
valve!=open;  /*valve与open进行不相等比较*/

到了20世纪80年代初,C语言被业界广泛使用,但存在许多不同的实现和差别。PC的实现者发现了C语言优于BASIC的诸多长处,这一发现更是掀起了C语言的高潮。Mirosoft为IBM PC制作了一个C编译器,引入了几个新的关键字(far, near等)帮助指针处理Intel 80x86芯片不规则的架构。随着其他更多并非基于pcc的编译器的兴起,C语言受到了重复BASIC老路的威胁,也就是可能变成一种多个变种松散相关的语言。

形势渐渐明了,一个正式的语言标准是必需的。幸运的是,在这个领域已经有了相当多的先行者——所有成功的编程语言最终都作了标准化。然而,编写标准手册所存在的问题是:只有当你明白它们讲的是什么,那才是可行的。如果人们用日常语言来编写它们,越想把它们写得精确,就越可能使它们变得冗长、乏味且晦涩。如果用数学概念来定义语言,那么标准手册对于大多数人而言不啻于天书。

多年以来,用于定义编程语言标准的手册变得越来越长,但也越来越容易理解。Algol-60就语言复杂性而言,与C语言不相上下,但它的标准手册——Algol-60 Reference Definition只有18页。Pascal用了35页来描述。Kernighan和Ritchie所作的C语言最初报告用了40页,尽管漏掉了一些东西,但对于许多编译器设计者而言,这些已经足够了。定义ANSI C的手册超过了200页。它部分地对C语言的实际应用作了描述,是对标准文档中有些晦涩文字的补充和说明。

1983年,美国国家标准化组织(ANSI)成立了C语言工作小组,开始了C语言的标准化工作。小组所处理的主要事务是确认C语言的常用特性,但对语言本身也作了一些修改,并引入一些有意义的新特性。对于是否要接受near和far关键字,小组内部进行了旷日持久的争论。最终,它们还是没有被纳入以UNIX为中心的相对谨慎的ANSI C标准。尽管当时世界上大约有5000万台PC,而且它是当时应用范围最广的C语言实现平台,但标准仍然认为(我们认为这是对的)不应该通过修改语言来处理某个特定平台所存在的限制。

小启发

该用哪个版本的C语言呢?

就此而论,任何学习或使用C语言的人都应当使用ANSI C,而不是K&R C。

1989年12月,C语言标准草案最终被ANSI委员会接纳。随后,国际标准化组织ISO也接纳了ANSI C标准(令人不快的是,它删除了非常有用的“Rationale”一节,并作了个虽然很小却让人很恼火的修改,就是把文档的格式和段落编码作了改动)。ISO是一个国际性组织,从技术上讲它更权威一些。所以在1990年初,ANSI重新采纳了ISO C(同样删除了Rationale),取代了原先的版本。因此从原则上说,ANSI所采纳的C语言标准是ISO C,我们日常所说的标准C也应该是ISO C。Rationale这一节是非常有用的,能极大地帮助人们理解标准,它后来作为独立的文档出版。[4]

小启发

哪里能得到C语言标准的一份拷贝

C语言标准的官方名称是:ISO/IEC 9899:1990。ISO/IEC是指国际标准化组织和国际电工组织。标准组织定价$130.00出售C语言标准。在美国,你可以通过给下面的地址写信来获取一份标准的拷贝:

American National Standards Institute

11 West 42ndStreet

New York, NY 10036

Tel.(212)642-4900

在美国以外的地区,你可以向下面的地址写信求购:

ISO Sales

Case postale 56

CH-1211 Genève 20

Switzerland

要指明自己想要的是英语版本。

另一个办法是购买Herbert Schildt所著的The Anootated ANSI C Standard(纽约,Osborne McGraw-Hill,1993)。这本书包含一个压缩了版面,但内容完整的C语言标准。Herbert Schildt的书有两个优势,首先是价格,$39.95的定价不到标准定价的三分之一。其次,不像ANSI或ISO,它可能在你当地的书店里就有售,你可以利用20世纪的先进手段,通过电话订购和信用卡支付。

实际上,在ISO成立第14工作小组(WG14)制定C标准之前,“ANSI C”这个称呼就已被广泛使用。这并没有什么不妥,因为ISO工作小组把最初标准的技术性完善工作留给了ANSI X3J11委员会。在工作接近尾声时,ISO WG14和X3J11一起通力协作,敲定技术细节并确保最终的标准能被两个组织共同接受。事实上,标准的最终形成又推迟了一年,主要是为了修改标准草案以覆盖一些国际化的问题如宽字符和国际区域问题。

这就使得所有几年来一直关心C语言标准的人们将新的标准当成是ANSI C标准。当语言标准最终形成后,所有人都想支持C语言标准。ANSI C同时是一个欧洲标准(CEN 29899)和X/Open标准。ANSI C被采纳为Federal Information Processing Standard(联邦信息处理标准),取名FIPS160,由国家标准和技术局于1991年3月发布,并于1992年8月24日更新。在C语言上的工作仍在继续——据说有可能在C语言中增加复数类型。

不要添乱——立即解散ISO工作小组。

——匿名人士

ANSI C标准可以说是非常独特的,我们可以从好几个有趣的方面来说明这一点。它定义了下面一些术语,用于描述某种编译器的特点。如果你对这些术语有一个比较好的了解,就有助于你理解什么东西能被语言接受,什么东西不能被语言接受。前两个术语涉及不可移植的代码(unportable code),接下来的两个术语跟坏代码(bad code)有关,而最后两个术语则跟可移植的代码(portable code)有关。

不可移植的代码(unportable code):

由编译器定义的(implementation-defined)——由编译器设计者决定采取何种行动(就是说,在不同的编译器中所采取的行为可能并不相同,但它们都是正确的),并作好文档记录。

例如:当整型数向右移位时,要不要扩展符号位。

未确定的(unspecified)——在某些正确情况下的做法,标准并未明确规定应该怎样做。

例如:参数求值的顺序。

坏代码(bad code):

未定义的(undefined)——在某些不正确情况下的做法,但标准并未规定应该怎样做。你可以采取任何行动,可以什么也不做,也可以发出一条警告信息,或者可以中止程序以及让CPU陷入瘫痪,甚至可以发射核导弹(只要你安装了能发射核弹的硬件系统)。

例如:当一个有符号整数溢出时该采取什么行动。

约束条件(a constraint)——这是一个必须遵守的限制或要求。如果你不遵守,那么你的程序的行为就会变成像上面所说的属于未定义的。这就出现了一种很有意思的情况:分辨某种东西是否是一个约束条件是很容易的,因为标准的每个主题都附有一个“约束(constraint)”小节,列出了所有的约束条件。现在又出现了一个更为有趣的情况:标准规定[5]编译器只有在违反语法规则和约束条件的情况下才能产生错误信息!这意味着所有不属于约束条件的语义规则你都可以不遵循,而且由于这种行为属于未定义行为,编译器可以采取任何行动,甚至不必通知你!

例如:%操作符的操作数必须属于整型。所以,在非整数数据上使用%操作符肯定会引发一条错误信息。

不属于约束条件规则的例子:所有在C语言标准头文件中声明的标识符均保留,所以不能声明一个叫作malloc()的函数,因为在标准头文件里已经有一个函数以此为名。但由于这个规定不是约束条件,因此可以违反它,而且编译器甚至可以不警告你!关于“interpositioning”这一小节的更多内容,参见第5章。

软件信条

未定义的行为在IBM PC中引起CPU瘫痪!

未定义的软件行为引起CPU瘫痪的说法并不像它乍听上去那样牵强。

IBM PC的显示器以显示控制芯片所提供的水平扫描速率工作。回扫变压器(flyback transformer,一种产生高电压的装置,用于加速电子以点亮显示器上的荧光物质)需要保持一个合理的频率。

然而在软件中,程序员有可能把视频芯片的扫描速率设置成零,这样就会产生一个恒定的电压输出到回归变压器的输入端。这就使它起了电阻器的作用,只是把电能转换成热能,而不是传送到屏幕。这会在数秒之内就把显示器烧毁,那就是未定义的软件行为会导致系统瘫痪的理由。

可移植的代码(portable code):

严格遵循标准的(strictly-conforming)—— 一个严格遵循标准的程序应该是:

这样规定的主要目的就是最大限度地保证可移植性。这样,不论你在什么平台上运行严格遵循标准的程序都会产生相同的输出。事实上,在所有遵循标准的程序中,属于这一类的程序并不多。例如,下面这个程序就不是严格遵循标准的:

#include <limits.h>
#include <stdio.h>
int main() { (void)printf("biggest int is %d", INT_MAX); return 0;}

/*并不严格遵循标准:其输出结果是由编译器定义的。*/

在本书的剩余部分,我们通常并不强求例子程序严格遵循标准。因为如果这样做会使文本看上去比较乱,而且不利于理解所讨论的要点。程序的可移植性是非常重要的,所以在你的现实编码中,应该始终要保证加上必要的类型转换、返回值等。

遵循标准的(conforming)——一个遵循标准的程序可以依赖一些某种编译器特有的不可移植的特性。所以,一个程序有可能在一个特定的编译器里是遵循标准的,但在另一个编译器里却是不遵循标准的。它可以进行扩展,但这些扩展不能修改严格遵循标准的程序的行为。但是,这个规则并不是一个约束条件,所以对于你的程序中不遵循标准之处,你不要指望编译器会给出一条警告信息指出你违反了规定!

上面所举的几个程序实例都是遵循标准的。

事实上,ANSI C标准对一个能够成功编译的程序的最小长度作了限制,这是在标准第5.2.4.1节规定的。绝大多数语言都有类似的规定,如一个数据名称(dataname)最多可以有多少个字符,一个多维数组的维数最多能够达到多少。但对语言的某种特性的最小值作出规定,如果不是独此一家,至少也是非比寻常的。标准委员会的成员们评论说这是为了指导编译器选择程序最小能够接受的长度。

每一个ANSI C编译器必须能够支持:

编译器限制通常是一个“编译器质量”的话题。在ANSI C标准中包含它们就是默认如果所有的编译器都设置一些容量上的限制,就会更加有利于代码的移植。当然,一个真正优秀的编译器不应该有预设的限制,而应该只受一些外部因素的限制,如可用的内存或硬盘空间等。这可以通过使用链表或必要时动态扩展表的大小(这个技巧将在第10章解释)来实现。

软件信条

原型的形成

原型的目的是当我们对函数作前向声明(forward declaration)时,在形参类型中增加一些信息(而不仅仅是函数名和返回类型)。这样,编译器就能够在编译时对函数调用中的实参和函数声明中的形参之间进行一致性检查。在K&R C中,这种检查被推迟到链接时,或者干脆不作检查。使用原型以后,原先的:

现在在头文件中的形式如下:

可以省略参数名称,只保留参数类型:

但最好不要省略形参名。尽管编译器并不理睬形参的名称,但它们经常能向程序员们传递一些有用的信息。类似地,函数的定义也从:

变成了:

函数头不再以一个分号结尾,而是在后面紧接一个组成函数体的复合语句。

每次编写新函数时都应该使用原型,并确保它在每次调用时都可见。不要回到K&R C老式的函数声明方法,除非需要使用缺省的类型升级(这个话题在第8章详细讨论)。

如果我们岔开话题,快速浏览一下ANSI C标准的出处和内容,对读者应该是有帮助的。ANSI C标准分成四个主要的部分:

第4节:介绍(共5页)。对术语进行介绍和定义。

第5节:环境(共13页)。描述了围绕和支持C语言的系统,包括在程序启动时发生什么,程序中止时发生什么,以及一些信号和浮点数运算。编译器的最低限制和字符集信息也在这一部分介绍。

char * strcpy();

第6节:C语言(共78页)。标准的这部分是基于Dennis Ritchie数次出版的经典之作“The C Reference Manual”,包括The C Programming Language的附录A。如果对比标准和附录,就会发现大多数标题都是一样的,顺序也相同。标准中的主题用辞生硬,看上去像表1-1那样(空白的子段落被省略)。

表1-1 ANSIC标准段落形式一览

char * strcpy(char *dst, const char *src);

ANSI C标准中段落的一般形式

ANSI C标准中段落举例

段落号 主题

6.4 常量表达式

语法
  语法图

语法
  常量表达式:
    条件表达式:

描述
  语言特性的一般描述

描述
  常量表达式可以在编译时而不是运行时计算,因而可以出现在任何常量可以出现的地方

约束条件
  这里所列的任何规则如果被破坏,编译器应该给出一条错误信息

约束条件
  常量表达式不应该包含赋值、增值、减值、函数调用和逗号操作符,除非它们包含在sizeof的操作数内。每个常量表达式应该计算成一个常量,该常量应该在其类型可以表示的范围之内

语义
  该特性的意思是什么,起什么作用

语义
  计算结果是一个常量的常量表达式为一些上下文环境所需要。如果一个浮点表达式在翻译环境中被计算,计算的精度和…

实例
  一段展示语言特性的代码

...

最初的附录只有40页,但在ANSI C标准中,足足多了一倍。

char * strcpy(char *, const char *);

第7节:C运行库(共81页)。本节提供了一个遵循标准的编译器必须提供的库函数列表,它们是标准所规定的辅助和实用函数,用于提供基本的或有用的功能。ANSI C标准第7节所描述的C运行库是基于/user/group 1984 年的标准,去除了一些UNIX特有的部分。“/user/group”是一个于1984年成立的UNIX国际用户小组。1989年,它更名为“UniForum”,它现在是一个非盈利性行业协会,其宗旨是完善UNIX操作系统。

UniForum从行为的角度对UNIX进行了成功的定义,这激励了许多有创造性的想法,包括X/Open的可移植性指导方针(第4版,XPG/4出现于1992年12月)、IEEE的POSIX 1003、System Ⅴ Interface Definition(系统5接口定义)以及ANSI C标准函数库。每个人都与ANSI C工作小组协作,确保他们所有的标准草案相互之间保持一致。感谢上帝!

char * strcpy(dst, src)
       char *dst, *src;
{ ... }

ANSI C标准同时附有一些很有用的附录:

附录F:一般警告信息。在许多常见的情况下,诊断信息并非标准所强制要求,但如果有这方面的信息,肯定对程序员有帮助作用。

char * strcpy(char *dst, const char *src)  /* 注意没有分号 */
{ ... }

附录G:可移植性话题。有一些关于可移植性的一般性建议,把遍布标准各处的所有这方面的建议集中在一个地方。它包括未确定的、未定义的和由编译器定义的行为等方面的信息。

软件信条

标准设立后轻易不作变动,即使是修改错误

并不能因为标准是由国际标准组织所撰写的就认定它必然完整、一致乃至正确。IEEE POSIX 1003.1-1998标准(它是一个操作系统标准,定义类似UNIX的行为)就存在一个非常有趣的自相矛盾的地方:

“[一个路径名]...最多由PATH_MAX个字节所组成,包括最后面的‘\0’字符” ——摘自第2.3节。

“PATH_MAX是一个路径名中最多能出现的字节个数(并不是字符串的长度;不包括最后面的‘\0’字符”——摘自第2.9.5节)。

所以,PATH-MAX个字节既包括最后面的‘\0’,又不包括最后面的‘\0’!

看来需要加以解释。答案(IEEE Std 1003.1-1988/INT,1992版,解释编号:15,第36页)认为标准出现了不一致,不过两个结果可以认为都是正确的(这令人很感奇怪,因为一般的观点认为它们不可能两个都是正确的)。

之所以出现这个问题,是由于在修改草案时,所有出现这个词的地方并未得到全部更新。标准化过程非常重视形式,显得僵化。如要更新,只有投票小组批准后才允许对问题进行修改。

这样的错误也曾出现在C标准最早期的脚注里,也就是所附的Rationale文档。事实上,Rationale现在已不属于C标准的一部分,当标准的所有权移交到ISO时,它就被删掉了。

小启发

K&R C和ANSI C之间的区别

阅读本节内容时,我假定你已经完全明白K&R C,对ANSI C也已知道了90%。ANSI C和K&R C的区别分成四大类,按其重要性分列于下:

1. 第一类区别是指一些新的、非常不同的、并且很重要的东西。惟一属于这类区别的特性是原型——把形参的类型作为函数声明的一部分。原型使得编译器很容易根据函数的定义检查函数的用法。

2.第二类区别是一些新的关键字。ANSIC正式增加了一些关键字:enum代表枚举类型(最初出现于pcc的后期版本),const、volatile、signed、void也有各自相关的语义。另外,原先可能由于疏忽而加入到C中的关键字entry则弃之不用。

3.第三类区别被称作“安静的改变”——原先的有些语言特性仍然合法,但它的意思有了一些轻微的改变。这方面的例子很多,但都不是很重要,几乎可以被忽略。在你偶尔漫步于它们之上时,可能由于不注意而被其中一个绊了个趔趄。例如,现在的预处理规则定义得更加严格,有一条新规则,就是相邻的字符串字面值会被自动连接在一起。

4.最后一类区别就是除上面3类之外的所有区别,包括那些在语言的标准化过程中长期争论的东西,这些区别在现实中几乎不可能碰到,如符号粘贴(token-pasting)和三字母词(trigraph)(三字母词就是用3个字符表示一个单独的字符,如果该字符不存在于某种计算机的字符集中,就可以用这3个字符来表示。比如两字母词(digraph)\t表示“tab”,而三字母词??< 则表示“开放的花括号”)。

ANSI C中最重要的新特性就是“原型”,这种特性取自C++。原型是函数声明的扩展,这样不仅函数名和返回类型已知,所有的形参类型也是已知的。这就允许编译器在参数的使用和声明之间检查一致性。把“原型”称作是“带有所有参数的函数名”是不够充分的,它应该被称作“函数签名(function signiture)”,或者像Ada那样称作“函数说明(function specification)”。

把同一种东西用几个不同的术语来称呼,确实有点神秘。就好像药品至少有3种名称一样:化学名、商品名和常用名。

小启发

容易混淆的const

关键字const并不能把变量变成常量!在一个符号前加上const限定符只是表示这个符号不能被赋值。也就是它的值对于这个符号来说是只读的,但它并不能防止通过程序的内部(甚至是外部)的方法来修改这个值。const最有用之处就是用它来限定函数的形参,这样该函数将不会修改实参指针所指的数据,但其他的函数却可能会修改它。这也许就是C和C++中const最一般的用法。

const可以用在数据上,如:

这和其他语言差不多,但当你在等式两边加上指针,就有一定难度了:

这段代码表示limitp是一个指向常量整型的指针。这个指针不能用于修改这个整型数,但是在任何时候,这个指针本身的值却可以改变。这样,它就指向了不同的地址,对它进行解除引用(dereference)操作时会得到一个不同的值!

const和*的组合通常只用于在数组形式的参数中模拟传值调用。它声称“我给你一个指向它的指针,但你不能修改它。”这个约定类似于极为常见的void *的用法,尽管在理论上它可以用于任何情形,但通常被限制于把指针从一种类型转换为另一种类型。

类似地,你可以取一个const变量的地址,并且可以...(唔,我最好不要往大家的脑袋里灌输这种思想)。正如Ken Thompson所指出的那样,“const关键字可能引发一些罕见的错误,只会混淆函数库的接口。”回首往事,const关键字原先如果命名为readonly就好多了。

有时候必须非常专注地阅读ANSI C标准才能找到某个问题的答案。一位销售工程师把下面这段代码作为测试例发给Sun的编译器小组。

1 foo(const char **p) { }
2
3 main(int argc, char **argv)
4 {
5          foo(arvg);
6 }

如果编译这段代码,编译器会发出一条警告信息:

line 5: warning: argument is incompatible with prototype
    const int limit = 10;

(第5行:警告:参数与原型不匹配)。

提交代码的工程师想知道为什么会产生这条警告信息,也想知道ANSI C标准的哪一部分讲述了这方面的内容。他认为,实参char* s与形参const char *p应该是相容的,标准库中所有的字符串处理函数都是这样的。那么,为什么实参char **argv与形参const char **p实际上不能相容呢?

    const int * limitp = &limit;
    int i = 27;
    limitp = &i;

答案是肯定的,它们并不相容。要回答这个问题颇费心机,如果研究一下获得这个答案的整个过程,会比仅仅知道结论更有意义。对这个问题的分析是由Sun的其中一位“语言律师”[6]进行的,其过程如下:

在ANSI C标准第6.3.2.2节中讲述约束条件的小节中有这么一句话:

每个实参都应该具有自己的类型,这样它的值就可以赋值给与它所对应的形参类型的对象(该对象的类型不能含有限定符)。

这就是说参数传递过程类似于赋值。

所以,除非一个类型为char **的值可以赋值给一个const char **类型的对象,否则肯定会产生一条诊断信息。要想知道这个赋值是否合法,就请回顾标准中有关简单赋值的部分,它位于第6.3.16.1节,描述了下列约束条件:

要使上述的赋值形式合法,必须满足下列条件之一:

两个操作数都是指向有限定符或无限定符的相容类型的指针,左边指针所指向的类型必须具有右边指针所指向类型的全部限定符。

正是这个条件,使得函数调用中实参char*能够与形参const char*匹配(在C标准库中,所有的字符串处理函数就是这样的)。它之所以合法,是因为在下面的代码中:

char *cp;
const char *ccp;
ccp = cp;

注意,反过来就不能进行赋值。如果不信,试试下面的代码:

cp = ccp;     /* 结果产生编译警告 */

标准第6.3.16.1节有没有说char **实参与const char **形参是相容的?没有。

标准第6.1.2.5节中讲述实例的部分声称:

const float *类型并不是一个有限定符的类型——它的类型是“指向一个具有const限定符的float类型的指针”,也就是说const限定符是修饰指针所指向的类型,而不是指针本身。

类似地,const char **也是一个没有限定符的指针类型。它的类型是“指向有const限定符的char类型的指针的指针”。

由于char **和const char **都是没有限定符的指针类型,但它们所指向的类型不一样(前者指向char *,后者指向const char *),因此它们是不相容的。因此,类型为char**的实参与类型为const char**的形参是不相容的,违反了标准第6.3.2.2节所规定的约束条件,编译器必然会产生一条诊断信息。

用这种方式理解这个要点有一定困难。可以用下面这个方法进行理解:

FOO和BAZ所指向的类型是相容的,而且它们本身都没有限定符,所以符合标准的约束条件,两者之间进行赋值是合法的。但FOO2和BAZ2之间的关系又有不同,由于相容性是不能传递的,FOO和BAZ所指向的类型相容并不表示FOO2和BAZ2所指向的类型也相容,所以虽然FOO2和BAZ2都没有限定符,但它们之间不能进行赋值。也就是说,它们都是不带限定符的指针,但它们所指向的对象是不同的,所以它们之间不能进行赋值,也就不能分别作为函数的形参和实参。但是,这个约束条件很令人恼火,也很容易让用户混淆。所以,这种赋值方法目前在基于Cfront的C++翻译器中是合法的(虽然这在将来可能会改变)。

确实,整个标准好像是由一位蹩脚的翻译把它从乌尔都语转译成丹麦语,再转译成英语而来。标准委员会似乎自我感觉良好,所以虽然人们希望语言的规则更简单一些、更清楚一些,但他们觉得这样做会破坏他们的良好感觉,所以拒不采纳。

我感觉,将来还会有许多人产生类似的疑问,而且并不是他们中的每一个人都会仔细揣摩前面详述的推理过程。所以,我们修改了Sun的ANSI C编译器,当它发现不相容的情况时,会打印出更多的警告信息。原先那个例子将会产生的完整信息如下:

Line 6: warning : argument #1 is imcompatible with prototype:
  prototype: pointer to pointer to const char: "barf.c", line 1
  argument: pointer to pointer to char

(第6行:警告:#1实参与原型不相容:

    原型:指向const char的指针的指针。"barf.c", 第1行
    实参:指向char的指针的指针。)

即使程序员不明白为什么会这样,他至少应该明白什么是不相容。

软件信条

一个微妙的Bug

虽然规则作了修改,但微妙的Bug依然存在。在下面这个例子里,变量d比程序所需的下标值小1,这段代码的目的就是处理这种情况。但if表达式的值却不是真。为什么?是不是有Bug:

TOTAL_ELEMENTS所定义的值是unsigned int类型(因为sizeof()的返回类型是无符号数)。if语句在signed int和unsigned int之间测试相等性,所以d被升级为unsigned int类型,-1转换成unsigned int的结果将是一个非常巨大的正整数,致使表达式的值为假。这个bug在ANSI C中存在,而如果K&R C的某种编译器的sizeof()的返回值是无符号数,那么这个Bug也存在。要修正这个问题,只要对TOTAL_ELEMENTS进行强制类型转换即可:

标准所作的修改并非都如原型那样引人注目。ANSI C作了其他一些修改,目的是使C语言更加可靠。例如,“寻常算术转换(usual arithmetic conversion)”在旧式的K&R C和ANSI C中的意思就有所不同。Kernighan和Ritchie当初是这样写的:

第6.6节:算术转换

许多运算符都会引发转换,以类似的方式产生结果类型。这个模式称为“寻常算术转换”。

int array[] = { 23, 34, 12, 17, 204, 99, 16 };
#define TOTAL_ELEMENTS (sizeof(array)/sizeof(array[0]))
 
main( )
{
    int d = -1, x;
    /* ... */
     
    if(d <= TOTAL_ELEMENTS - 2)
        x = array[d+1];
    /* ... */
}

首先,任何类型为char或short的操作数被转换为int,任何类型为float的操作数被转换为double。其次,如果其中一个操作数的类型是double,那么另一个操作数被转换成double,计算结果的类型也是double。再次,如果其中一个操作数的类型是long,那么另一个操作数被转换成long,计算结果的类型也是long。或者,如果其中一个操作数的类型是unsigned,那么另一个操作数被转换成unsigned,计算结果的类型也是unsigned。如果不符合上面几种情况,那么两个操作数的类型都作为int,计算结果的类型也是int。

ANSI C手册重新编写了有关内容,填补了其中的漏洞:

if(d <= (int)TOTAL_ELEMENTS – 2)

第6.2.1.1节 字符和整型(整型升级)

char, short int或者int型位段(bit-field),包括它们的有符号或无符号变型,以及枚举类型,可以使用在需要int或unsigned int的表达式中。如果int可以完整表示源类型的所有值[7],那么该源类型的值就转换为int,否则转换为unsigned int。这称为整型升级。

第6.2.1.5节 寻常算术转换

许多操作数类型为算术类型的双目运算符会引发转换,并以类似的方式产生结果类型。它的目的是产生一个普通类型,同时也是运算结果的类型。这个模式称为“寻常算术转换”。

首先,如果其中一个操作数的类型是long double,那么另一个操作数也被转换为long double。其次,如果其中一个操作数的类型是double,那么另一个操作数也被转换为double。再次,如果其中一个操作数的类型是float,那么另一个操作数也被转换为float。否则,两个操作数进行整型升级(第6.2.1.1节描述整型升级),执行下面的规则:

如果其中一个操作数的类型是unsigned long int,那么另一个操作数也被转换为unsigned long int。其次,如果其中一个操作数的类型是long int,而另一个操作数的类型是unsigned int,如果long int能够完整表示unsigned int的所有值[8],那么unsigned int类型操作数被转换为long int,如果long int不能完整表示unsigned int的所有值[9],那么两个操作数都被转换为unsigned long int。再次,如果其中一个操作数的类型是long int,那么另一个操作数被转换为long int。再再次,如果其中一个操作数的类型是unsigned int,那么另一个操作数被转换为unsigned int。如果所有以上情况都不属于,那么两个操作数都为int。

浮点操作数和浮点表达式的值可以用比类型本身所要求的更大的精度和更广的范围来表示,而它的类型并不因此改变。

采用通俗语言(当然存有漏洞,而且不够精确),ANSI C标准所表示的意思大致如下:

当执行算术运算时,操作数的类型如果不同,就会发生转换。数据类型一般朝着浮点精度更高、长度更长的方向转换,整型数如果转换为signed不会丢失信息,就转换为signed,否则转换为unsigned。

K&R C所采用无符号保留(unsigned preserving)原则,就是当一个无符号类型与int或更小的整型混合使用时,结果类型是无符号类型。这是个简单的规则,与硬件无关。但是,正如下面的例子所展示的那样,它有时会使一个负数丢失符号位。

ANSI C标准则采用值保留(value preserving)原则,就是当把几个整型操作数像下面这样混合使用时,结果类型有可能是有符号数,也可能是无符号数,取决于操作数的类型的相对大小。

下面的程序段分别在ANSI C和K&R C编译器中运行时,将打印出不同的信息:

main(){
  if(-1 < (unsigned char)1
     printf("-1 is less than (unsigned char)1: ANSI semantics ");
  else
     printf("-1 NOT less than (unsigned char)1: K&R semantics");
}

程序中的表达式在两种编译器下编译的结果不同。-1的位模式是一样的,但一个编译器(ANSI C)将它解释为负数,另一个编译器(K&R C)却将它解释为无符号数,也就是变成了正数。

小启发

对无符号类型的建议

尽量不要在你的代码中使用无符号类型,以免增加不必要的复杂性。尤其是,不要仅仅因为无符号数不存在负值(如年龄、国债)而用它来表示数量。

尽量使用像int那样的有符号类型,这样在涉及升级混合类型的复杂细节时,不必担心边界情况(如-1被翻译为非常大的正数)。

只有在使用位段和二进制掩码时,才可以用无符号数。应该在表达式中使用强制类型转换,使操作数均为有符号数或者无符号数,这样就不必由编译器来选择结果的类型。

这听起来是不是有点诡异,是不是令人吃惊?确实如此!用前面一页所说的规则完成上面这个例子。

最后,为了不让The Elements of Programming Style[10]未来的版本把这段代码作为不良风格的实例,我最好解释一下其中的一些代码。我使用了下面这条语句:

#define TOTAL_ELEMENTS  (sizeof(array) / sizeof(array[0]))

而不是:

#define TOTAL_ELEMENTS  (sizeof(array) / sizeof(int))

因为前者可以在不修改#define语句的情况下改变数组的基本类型(比如,把int变成char)。

Sun公司的ANSI C编译器小组认为从“无符号保留”转到“值保留”对于C语言的语义而言完全没有必要,只会让偶尔遇到这方面问题的人感到吃惊和沮丧。因此,在“尽量不让人误会”的原则下,Sun编译器认可并编译ANSI C的特性,除非该特性在K&R C里另有解释。如果碰到后面这种情况,编译器在缺省情况下使用K&R C的标准,并给出一条警告信息。如果碰到上面这个例子,程序员应该使用强制类型转换告诉编译器最终所希望的类型。在Sun公司运行Solaris 2.x的工作站上只要打开编译器的-Xc开关,就可以使编译器严格遵循ANSI C标准的语义。

在K&R C的许多特性中,有许多在ANSI C中进行了更新,包括许多所谓“安静的转变”。在这种情况下,代码在两种编译器里都能通过编译,但具体含义稍有差别。当程序员发现这种情况时,他们的反应可想而知。因此,这种转变事实上应该称作“讨厌的转变”。总的来说,ANSI委员会试图进行尽可能少的改动,与原先存在的但确实需要改进的特性保持一致。

对于ANSI C族系背景知识的讨论已经够多了。因此,在下面的“轻松一下”一节过后,让我们驶向第2章,进入本书的中心内容。

自由软件基金会(Free Software Foundation)是一个独特的组织,它由MIT顶级黑客Richard Stallman所创立。顺便提一下,我们所说的“黑客”,它的原先意思是“天才程序员”。后来这个称呼被媒体所贬损,致使它在局外人眼中成了“邪恶的天才”的代名词。和形容词“bad”一样,“黑客”现在也有两个相反的意思,必须通过上下文才能明白它的确切意思。

Stallman成立自由软件基金会的初衷是:软件应该是免费的,所有人都可以自由使用。FSF的宗旨是“消除在计算机程序拷贝、重发布、理解和修改方面的限制”,它雄心勃勃地想建立一个UNIX的自由软件实现方案,称为GNU(它代表“GNU's Not UNIX”,对,确实如此)。

许多计算机科学研究生和其他人赞同GNU的哲学,他们设计软件产品,由FSF进行打包并免费发布。通过这些甘心奉献的有天赋的程序员们的辛勤劳动,产生了一些优秀的软件作品。FSF最好的作品之一就是GNU C编译器系列。gcc是一个健壮的、在代码优化方面具有创造性的编译器,可以在很多硬件平台使用,有时甚至比编译器厂商的产品更为优秀。gcc并不适合所有的项目,它在维护性和未来版本连续性方面仍存在一些问题。在现实的开发中,除了编译器之外,还需要很多工具。曾有很长一段时间,GNU的调试器无法在共享库中工作。而且在开发时,GNU C偶尔会让人感到眼花缭乱。

在制订ANSI C标准时,引入了pragma指示符,这个指示符来源于Ada。#pragma用于向编译器提示一些信息,诸如希望把某个特定函数扩展为内联函数,或者取消边界的检查。由于它并非C语言所固有,pragma遭到了一个gcc编译器设计者的积极抵制,他把这个“由编译器定义的”的效果做得很搞笑——在gcc 1.34版,如果使用了pragma,将会导致编译器停止编译,而是运行一个计算机游戏!在gcc手册中有如下说明:

在ANSI C标准中,“#pragma”指令会产生一个由编译器定义的任意效果。在GNU C预处理器中,一旦遇见“#pragma”指令,它首先试图运行“rogue”游戏;如果失败,尝试运行“hack”游戏;如果还是失败,它会尝试运行GNU Emacs,显示汉诺塔(Tower of Hanoi)。如果仍然失败,它就报告一个致命错误。总之,预处理过程不会继续下去。

—— GNU C编译器1.34版手册

GNU C编译器中关于预处理器的那部分源代码如下:

/ *
 * #pragma指示符的行为是由编译器定义的。
 * 在GNU C编译器中,它的定义如下:
 * /
do_pragma()
{
    close(0);
    if(open("/dev/tty", O_RDONLY, 0666) != 0)
                         goto nope;
    close(1);
    if(open("/dev/tty", O_WRONLY, 0666) != 1)
                         goto nope;
    exel("/usr/games/hack", "#pragma", 0);
    exel("/usr/games/rogue", "#pragma", 0);
    exel("/usr/new/emacs", "-f", "hanoi", "9", "-kill", 0);
    exel("/usr/local/emacs", "-f", "hanoi", "9", "-kill", 0);
nope:
  fatal("you are in a maze of twisty compiler features, all different");
}

特别好笑的是,用户手册中的描述是错误的,它把“hack”和“rogue”的次序搞反了。

[1] 学习、使用和实现PL/I的困难使一位程序员写了这样一首打油诗:“IBM有个PL/I,语法比JOSS还糟糕,到处都见它踪影,实实在在是垃圾。JOSS是个老古董,它可不是因简单而闻名。”

[2] “BCPL:A Tool for Compiler Writing and System Programming(BCPL,编译器编写和系统编程的工具),” Martin Richards, Proc. AFIPS Spring Joint Computer Conference, 34(1969), pp.557-566。BCPL并非“Before C Programming Language(C前身编程语言)”的首字母缩写,尽管这是个有趣的巧合。它的确切意思是“Basic Combined Programming Language(基本组合编程语言)”。basic的意思是“不花哨”,它是由英国伦敦大学和剑桥大学的研究人员合作开发的。Multics实现了一种BCPL编译器。

[3] 本书原版出于1994年,当时距1970年还不到30年。——译者注

[4] ANSI C Rationale(单独)可通过匿名FTP,从ftp.uu.net下载,位于/doc/standards/ansi/X3.159-1989/(如果你不明白匿名FTP,赶紧到附近的书店买一本关于Internet的书,免得成为信息高速公路上的“跛行的羔羊”)。Rationale的纸版书也已出版,ANSI C Rationale, 新泽西Silicon Press,1990。ANSI C标准本身无法从任何ftp站点下载,因为标准印刷本的营业收入是ANSI的重要收入来源之一。

[5] 如果你想刨根问底,它位于第5.1.1.3段,“Diagnostics(诊断)”。作为一个语言标准,它不会简单地说“在一个不正确的程序里,你必须为每个错误准备一个标志”。作为标准,其用辞必然骈四骊六,仿佛是由靠玩弄文字吃饭的律师所撰写的。它的正式用辞如下:“一个遵循标准的实现应该*至少为每个翻译单元产生一条诊断信息,其中包含了所有违反语法规则或约束的行为。在其他情况下不必产生诊断信息”。
*Brian Scearce+ 所总结的有用规律——如果你听到一个程序员说“应该(shall)”,那么他一定在引用标准里的说法。
+嵌套脚注(nested footnote)的发明者。

[6] The New Hacker's Dictionary把语言律师定义为“能从200多页的手册中提取5句话,拼起来放到你面前,你只要一看就能明白自己问题的答案的人”,嘿!在这个例子的情况下正是如此。

[7] 即int是32位。——译者注

[8] 即long 是32位而int是16位。——译者注

[9] 即long和int均为32位。——译者注

[10] The Elements of Programming Style, Kernighan(对,就是那个Kernighan)和Plauger,纽约,McGraw Hill,1978。这是一本文字流畅、细节真实的优秀作品——非常值得购买,你能从中获益良多。


相关图书

代码审计——C/C++实践
代码审计——C/C++实践
C/C++代码调试的艺术(第2版)
C/C++代码调试的艺术(第2版)
大规模C++软件开发 卷1:过程与架构
大规模C++软件开发 卷1:过程与架构
C/C++程序设计竞赛真题实战特训教程(图解版)
C/C++程序设计竞赛真题实战特训教程(图解版)
C/C++函数与算法速查宝典
C/C++函数与算法速查宝典
C程序设计教程(第9版)
C程序设计教程(第9版)

相关文章

相关课程