1.1 C 是什么样的语言

1.1.1 比喻

在 Donald C. Gause 和 Gerald M. Weinberg 合著的《你的灯亮着吗?》[1]一书*中,有这样一节(根据需要,我做了必要的删减)。

* 这本书的副标题为“发现问题的真正所在”,它通过一些趣闻轶事来告诉世人“不要急于寻找问题的答案,而是应该先去考虑当前的问题是什么”。

某计算机制造商开发了一种新型打印机。

技术小组在如何保证打印精度的问题上非常苦恼,每次进行新的测试时,工程师都不得不花很长的时间测量打印机的输出结果来追求精确性。

丹(Dan Daring)是这个小组中最年轻但或许是最聪明的工程师。他发明了一种工具,即每隔 8 英寸就在铝条上嵌上小针。使用这个工具,可以很快地找到打印机输出位置的误差。

这个发明显著地提高了生产效率,丹的上司非常高兴,提议给丹颁发一个公司的特别奖赏。他从车间里拿了这个工具,带回办公室,这样他写报告的时候还可以仔细地研究一下。

这个上司显然还用不惯这个工具,当他把这个工具放在桌子上的时候,将针尖朝上了。更不幸的是,当丹的上司的上司友好地坐到桌角上,打算谈谈给丹颁发奖励时,部门内的所有人都听到了他痛苦的尖叫声——他的屁股上被扎了两个相距 8 英寸的孔。

C 语言就恰如这个工具。也就是说,它是一门

  • 为了解决眼前问题,由开发现场的人发明的,

  • 虽然使用方便,

  • 但看上去不怎么顺眼,

  • 如果不熟悉的人糊里糊涂地使用了它,难免会带来“悲剧”的语言。

1.1.2 C的发展历程

众所周知,C 原本是为了开发 UNIX 操作系统而设计的语言。

如此说来,好像 C 应该比 UNIX 更早问世,可惜事实并非如此,最早的 UNIX 是用汇编来写的。

因为厌倦了总是苦哈哈地使用汇编语言进行编程,UNIX 的开发者 Ken Tompson 开发了一种称为“B”的语言。B 语言是 1967 年剑桥大学的 Martin Richard 开发的 BCPL(Basic CPL)的精简版本。BCPL 的前身是 1963 年剑桥大学和伦敦大学共同研究开发的 CPL(Combined Programming Lanugage)语言。

B 语言不直接生成机器码,而是由编译器生成栈式机(Stack Machine)用的中间代码,中间代码通过解释器(interpreter)执行(类似 Java 和早期的 Pascal)。因此,B 语言的执行效率非常低,结果,在后来的 UNIX 开发过程中人们放弃了使用 B 语言。

在这之后的 1971 年,Ken Tompson 的同事 Dennis Ritchie 对 B 语言做了改良,追加了 char 数据类型,并且让 B 语言可以直接生成 PDP-11*的机器代码。曾经在很短的时间内,大家将这门语言称为 NB(New B)。

* 现在已经不存在的 DEC(美国数字设备公司)生产的微型电脑。

之后,NB 改称为 C 语言——这就是 C 语言的诞生。

后来,主要是为了满足使用 UNIX 的程序员的需要,C 语言一边接受来自各方面的建议,一边摸着石头过河般地进行着周而复始的功能扩展。

1978 年出版了被称为 C 语言宝典的 The C Programming Language 一书。

此书取了两位作者(Brian Kernighan 和 Dennis Ritchie)的姓氏首字母,简称为 K&R。在后面提到的 ANSI 标准制定之前,此书一直作为 C 语言语法的参考书被人们广泛使用。

听说这本书在最初发行的时候,Prentice-Hall 出版社制订了对于当时存在的 130 个 UNIX 站点平均每个能卖 9 本的销售计划(相比 Lift With UNIX[2])。

当然了,哪怕是初版 K&R 的销售量,也以 3 位数的数量级超过了 Prentice-Hall 出版社最初的销售计划*。原本只是像“丹的工具”一样为了满足自用的 C 语言,历经坎坷,最终成为全世界广泛使用的开发语言。

* 我手头这本 K&R 是在 1997 年 5 月 1 日出版的第二版(翻译修订版),已经是第 211 次印刷了。这个行业的图书能有这样的业绩,确实惊人。

补充 B 是什么样的语言?

在 C 语言的入门书籍中,经常提到 C 是 B 语言的进化版本。但几乎所有的书对 B 语言的介绍都只有这么多,没有具体说明 B 语言究竟是一门什么样的语言。

正如前面描述的那样,B 是在虚拟机上运行的、解释型开发语言。B 语言没有像 Java 那样想要去实现“到处运行”的宏伟目标,它只是因为当时运行 UNIX 的 PDP-7 硬件环境的限制,而只能采用解释器这样的实现方式。

B 是“没有类型”的语言。虽然 C 里面有 charshortintfloatdouble 等很多的数据类型,但是 B 使用的类型只有 word(你可以认为和 int 差不多)。作为本书的主题——指针,在 B 里面和整数一样使用。因为无论怎么说,指针无非就是内存中的地址,对于机器来说,它是可以和整型同样对待的(关于这点,在本章中会详细说明)。

NB 是具有类型概念的语言。为了把指针和整数纠葛不清的 B 移植到 NB,Dennis Ritchie 在指针运用的设计上下了很大的工夫。C 的指针让人感觉很难理解,可能也有这方面的原因。

关于 B 语言,如果你想知道更详细的内容,可以浏览 Dennis Ritchie 的网页:http://cm.bell-labs.com/cm/cs/who/dmr/index.html 中的“Users' Reference to B”[3]等内容。

1.1.3 不完备和不统一的语法

C 语言是开发现场的人们根据自身的需要开发出来的语言,所以具备极高的实用性。但反过来从人类工程学的角度来看,它就不是那么完美了。

比如:

  1. if (a = 5) { ←本来应该写成==的地方却写成了=

相信大家都犯过这样的错误吧。

在日语键盘上,“-”和“=”在同一按键上,因此经常会发生下面的问题:

  1. for (i - 0; i < 100; i++) ←忘了和[shift]键一起按下

就连这样的错误,编译器也往往无法察觉。现在的编译器倒是可以给出警告,可是早期的编译器对这样的错误是完全忽略的。

使用 switch case 的时候,也经常发生忘了写 break 的错误。

幸运的是,如今的编译器,对于容易犯的语法错误,在很多地方可以给我们警告提示。因此,不但不能无视这些编译器的警告,相反应该提高编译器的警告级别,让编译器替我们指出尽可能多的错误*

* 尽管如此……假设无视了有返回值的函数返回的值,lint 会给出一个警告。为了消除这个讨厌的警告,你特地使用 (void)printf(…) 打印返回的值,这么干是不是就有点过了? C 原本就是“本性恶劣”的语言,警告级别过高,会否定一些既存的程序写法,反而带来不好的后果。此外,如果最大限度地提高警告级别,有些应用程序自身包含的头文件也会引起警告。总之,这些问题还是比较麻烦的。

换句话说,如果编译器向我们提示错误或者警告,不应该以怨报德:“什么呀,这个混蛋!”相反应该奉上一句感谢:“谢谢你,编译器先生!”然后去认真地清除眼前的 bug。

要 点

提高编译器的警告级别。

不可无视或者制止编译器的警告。

C 语言是在使用中成长起来的语言。因此,由于很多历史原因遗留了一些“奇怪的”问题。具有代表性的有位运算符“&”和“|”的优先顺序问题。

通常,如“==”的比较运算符的优先级要低于那些做计算的运算符。因此,

  1. if (a < b + 3)

这样的条件表达式中,虽然可以不使用括号来写,但是当使用了位运算符的时候,就行不通了。

想要进行“将 a 和 MASK 进行按位与运算后的结果,再和 b 做比较运算”,

  1. if (a & MASK == b)

按照上面的写法,因为&运算符的优先级低于==运算符,所以被解释成了下面这样:

  1. if (a & (MASK == b))

这是因为在没有“&&”和“||”运算符的时代,使用“&”和“|”来代替而留下的后遗症。

1.1.4 ANSI C

即使在 K&R 出版之后,C 仍在不断地扩展。

比如关于结构体的一次性赋值,在 K&R 的初版里面并没有记述,其实这个功能在 K&R 出版之前,就已经在 Dennis Ritchie 的 C 编译器里实现了。从某种意义上来说,K&R 的第一版刚出版就已经过时了。这在计算机图书界是常有的事。

另外,K&R 的记述也不一定就是严密的,由于运行环境的不同,程序运行也存在差异。

鉴于这些原因,经过一番纷争,终于在 1989 年,ANSI(American National Standard Institute,美国国家标准学会)通过了 C 语言的标准规范,这就是通常被称为 ANSI C 的 C 语言。目前使用的 C 程序,大部分都是基于 ANSI C 编写的。

顾名思义,ANSI 是美国的标准。难道不存在 C 语言的国际标准吗?那是不可能的。ANSI C 后来被 ISO 采用,目前 C 的真正标准应该是 ISO 的 C。另外,原始的 ANSI 标准说明书的章节编号和 ISO 不同。

ISO-C 标准的名称为“ISO-IEC 9899-1990”,当然这是用英语命名的。JIS(日本工业标准)标准(JIS X3010)原样采用了 ISO-C 标准,所以英语不太好的人(比如我),也可以获取日语版的标准文档。1

1 国内读者可参考中国国家标准 GB/T 15272-94,它即为 ISO-IEC 9899-1900 的中文翻译版。——译者注

从日本工业标准协会可以得到 JIS X3010 标准,以及 JIS X3010 的手册。另外,《信息处理:编程语言篇》里面也包含了这个标准*,此书可以从书店订购。如果是那些大型的书店,也许在书架上就能发现这本书。

* 2001 年,JIS 手册全面修订,删除了《信息处理:编程语言篇》中已经存在的内容。如果在所有的标准中都记录相同的信息会引起不必要的麻烦。

在本书后面的内容中,提到的“标准”都是指 JIS X3010。

1.1.5 C的宝典——K&R

之前已经介绍过,Brian Kernighan 和 Dennis Ritchie(C 语言之父)合著的 The C Programming Language 被称为 K&R。在制订 ANSI C 之前,K&R 是 C 语言语法的使用标准。

人们把 ANSI 之前的 C 称为“K&R C”,这可能会引起一些误解。但无论怎样,制订了 ANSI C 标准之后,追随 ANSI C 的 K&R 就紧跟着出版了第 2 版[4]

在本书中,提起 ANSI C 之前的 C,我们还是尊重事实,称之为“ANSI C 之前的 C”。

此外,本书中提及 K&R 时,是指日语版的《编程语言 C》的第 2 版修订版*

* 最初的 K&R 第 2 版, 因为翻译质量的问 题,恶评如潮。重新 翻译后再次出版了修 订版(原书相同)。第 2 版旧翻译版的封面 为绿色,新翻译版的 封面为白色。2

2 中文版请参考《C 程序设计语言(第 2 版·新版)》。——译者注

顺便提一下,通过

http://www.cs.bell-labs.com/cm/cs/cbook/index.html

可以看到 K&R 的网页,各语种的 K&R 封面排列在一起,颇为壮观。

补充 新的 C

C 语言的功能扩展并没有随着 ANSI C 的发布而停下脚步。ISO 通过 ISO C9X 这个代号名称,计划制定具备更多扩展功能的 C 语言规范。

从 ISO C9X 这个代号名称可以看出来,大家都很期待在 20 世纪 90 年代完成新标准的制订,标准文档封面上的日期是 1999 年 12 月 1 日——好险呀!!

作为 ISO C99 的扩展功能,除了提供对复数类型的支持之外,还包括“可以用变量定义本地数组变量的元素个数”、“将数组作为结构体的成员进行声明时元素个数可以不定义(只需写[])”等功能。这些功能看上去都和本书的主题有重合的部分。

尽管如此,这个“新的 C”在今后究竟能使用多久,我们现在不得而知。所以,本书不参照 ISO C99。

1.1.6 C的理念

ANSI C 标准,附有 Rationale(理论依据)。

可以通过下面的地址在线获取 Rationale。

ftp://ftp.uu.net/doc/standards/ansi/x3.159-1989/

Rationale 中有“keep the spirit of C”(保持 C 的精神)一节,关于“C 的精神”是这样介绍的:

  • 请信任程序员(Trust the programmer)

  • 不要阻止程序员去做需要做的工作(Don't prevent the programmer from doing what needs to be done)

  • 保持语言的小巧和简单(Keep the language small and simple)

  • 为每一种操作只提供一种方法(Provide only way to do an operation)

  • 就算不能保证可移植性,也要追求运行效率(Make it fast, even if it is not guaranteed to be portable)

前面两点最重要——这样不负责任的话它还真敢说!

C 是危险的语言,其中随处可见那些可以让丹的上司的上司在不留心的时候,屁股上扎两个洞的陷阱。

尤其是,在几乎所有的 C 语言实现中,运行时的检查总是不充分的。比如,数组越界写入的时候,有些语言是可以当场给出错误提示的(如 Java),但是在 C 的大部分处理中,总是悄悄地将数据写入,从而破坏了完全不相关的内存区域。

C 是抱着“程序员万能”的理念设计出来的。在 C 的设计中,优先考虑的是:

  • 如何才能简单地实现编译器(而不是让使用 C 的人们能够简单地编程)

  • 如何才能让程序员写出能够生成高效率执行代码的程序(而不是考虑优化编译器,使编译器生成高效率的执行代码)

而安全性问题被完全忽略了。但无论怎样,C 语言原本就是“仅仅为了自己使用”而开发出来的语言。

幸运的是,如今的操作系统可以替我们停止那些已经明显出现奇怪动作的应用程序。在 UNIX 环境下,可以提示“Segmentation fault”、“Bus Error”等信息。Windows 也可以向我们提示“当前应用程序正在进行异常操作,所以需要强制关闭”这样的信息。

同样,这个时候也不能抱有“什么呀,这个混蛋!”的想法,而是应该奉上一声感谢:“谢谢,操作系统先生!”然后埋头开始调试工作。

虽说操作系统可以在应用程序发生明显错误的时刻为我们关闭应用,但是在超出数值型的字节长度或数组的边界写入数据的时候,追踪 bug 还是很困难的,因为这些错误症状很少能马上显现。

本书第 2 章将说明 C 是怎样使用内存的。理解了这一点,对消灭这些不易发现的 bug 大有裨益。

要 点

很幸运,操作系统可以帮助我们停止应用程序。

糟糕的是,如果操作系统不能替我们终止执行应用程序,就会上演内存区域被破坏的悲剧。

1.1.7 C的主体

我在这里想考考你。

下面的单词中,哪些是 C 语言中规定的保留字。

  1. if printf main malloc sizeof

答案是 ifsizeof

printfmalloc 不必多说,连 main 也不是 C 的保留字吗?”有这样想法的读者,请查一查手头的 C 语言参考书。相信大部分的 C 入门书籍中都有 C 语言保留字的列表。

C 以前的很多语言,把输入输出作为语言自身功能的一部分。比如在 Pascal 中与 C 的 printf()的功能相当的,是使用 write()这样的标准规范。它在 Pascal 的语法规则中受到了特别对待*

* 根据 JIS X3008 6.6.4.1 的备注内容,“标准规范或标准函数不一定遵从标准和函数的一般规则”。

相对这种方式,C 语言将 printf()这样的输入输出功能从语言的主体部 分分离出来,让它单纯地成为库函数。对于编译器来说,printf()函数和其 他由普通程序员写的函数并没有什么不同*

* 偶尔也有可以帮我们检查 printf()的参数个数的编译器……

从程序员的角度来看,printf()操作一下子就完成了。其实为了完成这个操作,需要在幕后做诸如向操作系统进行各种各样的请求等非常复杂的处理。C 语言并没有把这种复杂的处理放在语言主体部分,而将它们全部规划在函数库中。

很多编译型的语言会将被称为“run-time routine”(运行时例程)的机器码“悄悄地”嵌入到编译(链接)后的程序中,输入输出这样的功能就是包含在 run-time routine 之中的。C 语言基本上没有必须要“悄悄地”嵌入运行时的复杂功能*。由于稍微复杂一点的功能被全部规划到了库中,程序员只需要去显式地调用函数。

* 当初基于 PDP-11 的应用程序,处理 32 位的乘除运算,以及函数入口和出口的运行时是被“悄悄地”嵌入的。

诚然,这种方式有它的缺点,但是也有它的优点。正因为这个优点,才可能使 C 语言的程序开发和学习变得容易一些。

1.1.8 C是只能使用标量的语言

对于标量(scalar)这个词,大家可能有些陌生。

简单地说,标量就是指 charintdouble 和枚举型等数值类型,以及指针。相对地,像数组、结构体和共用体这样的将多个标量进行组合的类型,我们称之为聚合类型(aggregate)。

早期的 C 语言一度只能使用标量

经常听到初学者有以下的提问:

  1. if (str == "abc")

这样的代码为什么不能执行预期的动作呢?确实已经将“abc”放到了 str 中,条件表达式的值却不为真。这是为什么?

对于这样的疑问,通常给出的答案是“这个表达式不是在比较字符串的内容,它只是在比较指针”,其实还可以给出另外一个答案:

字符串其实就是 char 类型的数组,也就是说它不是标量,当然在 C 里面不能用==进行比较了。

C 就是这样的语言,一门“不用说对于输入输出,就连数组和结构体也放弃了通过语言自身进行整合利用”的语言。

但是,如今的 C(ANSI C)通过以下几个追加的功能,已经能够让我们整合地使用聚合类型了。

  • 结构体的一次性赋值

  • 将结构体作为函数参数值传递

  • 将结构体作为函数返回值返回

  • auto 变量的初始化

当然,这些都是非常方便的功能,如今已经可以积极地使用了(不如说应该去使用)。可是在早期的 C 语言里,它们是不存在的。为了理解 C 语言的基本原则,了解早期的 C 语言也不是什么坏事。

特别要提出来的是,即使是 ANSI C,也还不能做到对数组的整合利用。将数组赋值给另外一个数组,或者将数组作为参数传递给其他函数等手段,在 C 语言中是不存在的。

但是,因为结构体是可以被整合利用的,所以在实际的编程中,应该积极地使用其可用的功能。直到现在,还经常能看到使用 memcpy() 来进行结构体一次性赋值的例子,真是做无用功。如果想要复制结构体,还是让我们使用结构体一次性赋值这个功能吧。