Dennis M. Ritchie
Bell Labs/Lucent Technologies
Murray Hill, NJ 07974 USA
本文专注于 C 编程语言的开发,它受到的影响,和创造它所处的条件。出于简要性的原因,我省略了对 C 语言本身、它的父辈 B 语言[Johnson 73] 和它的祖父辈 BCPL 语言[Richards 79] 的完整描述,而是集中在每种语言的特征性要素和它们是如何演变的。
C 语言形成于 1969-1973 年之间,平行于 Unix 操作系统的早期开发;最活跃的时期发生在 1972。另一次变化涌现在 1977 年和 1979 年之间达到高峰,此时 Unix 系统的可移植性被证实了。在第二个时期的中间,出现了第一个可广泛获得的语言描述: The C Programming Language,它常被称为‘白皮书’或‘K&R’[Kernighan 78]。最后,在 1980 年代中期, ANSI X3J11 委员会正式标准化了这门语言,它做了进一步的改变。直到 1980 年代早期,尽管编译器存在于各种机器体系和操作系统之上,C 语言几乎还是专门的关联于 Unix 操作系统;最近,它的使用传播得更加广泛,今天它是整个计算机工业中最常用的语言之一。
1960 年代晚期对于 Bell 电话实验室的计算机系统研究而言是个喧闹的时期[Ritchie 78] [Ritchie 84]。公司脱离出了 Multics 计划[Organick 75],它是作为 MIT、General Electric 和 Bell Labs 的合资项目发起的;在 1969 年,Bell Labs 管理者甚至是研究者,开始相信履行对 Multics 的承诺太晚了也太昂贵了。甚至在 GE-645 Multics 机器被从前提中去除之前,主要由 Ken Thompson 领导的一个非正式小组就已经开始调研替代者了。
Thompson 希望创造一个舒适的计算环境,依据他自己的设计来构造,使用能用上的任何手段。回顾起来,他的设计结合了很多 Multics 的创新方面,包括作为控制的处所的明确的进程概念,树状结构的文件系统,作为用户级别程序的命令解释器,文本文件的简单表示,和对设备的一般化访问。他排除了其他一些东西,比如对内存和文件的统一的访问。而且在开始时,他和我们中其余的人推延了 Multics 的另一个先驱性(尽管不是首创)的要素,就是基本上完全用高级语言写成。Multics 的实现语言 PL/I 不合我们的胃口,我们还使用了其他高级语言,包括 BCPL 语言,我们遗憾于失去了使用在汇编层次之上的语言的利益,比如易于书写和清晰理解。那时我们没有重视可移植性;对此的兴趣是后来才唤起的。
Thompson 面对的是在当时都是狭促和艰苦的硬件环境: 他在 1968 年起步时用的 DEC PDP-7 是有 8K 18-bit 字的内存而没有可用的软件的机器。尽管想要使用一门高级语言,他还是用 PDP-7 汇编语言写了最初的 Unix 系统。在开始时,他甚至没有在 PDP-7 自身上编程,而是在一个 GE-635 机器上使用 GEMAP 汇编器的一组宏。一个后处理器生成 PDP-7 可读的纸带。
把这些纸带从 GE 机器运送到 PDP-7 上做测试,直到完成了原始的 Unix 内核、编辑器、汇编器、一个简单的 shell(命令解释器)和一些实用工具(例如 Unix 的 rm、cat、cp 命令)。此后,操作系统就自我支持了: 不用借助纸带就可以书写和测试程序了,在 PDP-7 自身上继续开发了。
Thompson 的 PDP-7 汇编器在简单性上甚至胜过了 DEC 的;它求值(evaluate)表达式并表述出(emit)相应的二进制位。这里没有库,没有装载器和连接器: 把程序的全部源代码提供给汇编器,固定名字的输出文件直接就是可执行的。(a.out 这个名字解释了一点 Unix 语源;它是汇编器的输出。即使在系统增加了连接器和明确的指定另一个名字的方式之后,它仍被保留为编译后的缺省的可执行的结果。)
在 Unix 首次在 PDP-7 上运行不久,在 1969 年,Doug McIlroy 建立了新系统的第一个高级语言: McClure 的 TMG [McClure 65]的一个实现。TMG(更一般的说是 TransMoGrifiers)是书写编译器的语言,它采用把上下文无关语法概念和过程性元素组合起来的自顶向下递归下降方式。McIlroy 和 Bob Morris 曾经使用 TMG 为 Multics 写过早期的 PL/I 编译器。
受到 McIlroy 在重新创作 TMG 中表现出的技艺的挑战,Thompson 决定仍没有命名的 Unix 也需要一个系统编程语言。在快速的放弃对 Fortran 的尝试之后,他转而建立自己的语言,他称之为 B 语言。B 语言可以被认为是没有类型的 C 语言;更加准确的说,它是压缩 8K 字节中的并经过 Thompson 的大脑过滤后的 BCPL 语言。它的名字很可能表示 BCPL 的缩写,尽管还有一种说法说它是衍生自 Bon 语言[Thompson 69],它是 Thompson 在 Multics 时日中建立的一种无关的语言。Bon 依次要么命名于他的妻子 Bonnie,要么依据有着呢喃的巫术仪式的一种宗教来命名的(依据在它的手册中的一个百科全书引用)。
BCPL 语言由 Martin Richards 在 1960 年代中期设计,当时他正在访问 MIT,并在 1970 年代早期在一些有趣的项目中使用,包括在 Oxford 的 OS6 操作系统[Stoy 72],和 Xerox PARC 的有重大的影响(seminal)的 Alto 工作中[Thacker 79]。我们熟悉它是因为 Richard 在其上工作的 MIT CTSS 系统[Corbato 62]被用于 Multics 开发。最初的 BCPL 语言编译器被 Bell Labs 的 Rudd Canaday 和其他人运输到 Multics 和 GE-635 GECOS 系统上[Canaday 69];在 Multics 于 Bell Labs 生命的最后剧痛期间和此后不久,它是后来与 Unix 涉及在一起的这组人所选择的语言。
BCPL 语言、B 语言和 C 语言都坚定的归属于以 Fortran 语言和 Algol 60 语言为代表的传统过程式语言家族。他们显著的面向于系统编程,都很小并被简洁的描述,和适合用简单的编译器来翻译。它们都‘贴近于机器’,因为它们介入的抽象都容易的根基于常规的计算机提供的具体数据类型和操作之上,而且它们依靠库例程来来做输入-输出和与操作系统的其他交互。有着小一些的成功,它们还使用库过程来指定有趣的控制构造比如协同例程(coroutine)和过程闭包(closure)。同时,它们的抽象位于充分高的层次上,慎重的完成了在机器间的可移植性。
BCPL 语言、B 语言和 C 语言在很多细节上有语法上的区别,但在宏观上它们是类似的。程序由一系列的全局声明和函数(过程)声明构成。在 BCPL 中过程可以嵌套,但是对在包含过程中定义的非静态对象是不可引用的。B 和 C 语言通过施加更严格的限制来避免这种限制: 根本不允许嵌套的过程。这些语言(除了早期版本的 B 语言)识别分开的编译,并提供包含指名文件的文本的方式。
BCPL 的一些语法和词法机制要比 B 语言或 C 语言更加优雅和正规。例如,BCPL 的过程和数据声明有更加一致的结构,并提供更完整的一组循环构造。尽管 BCPL 程序在概念上提供无界限的字符流,聪明的规则允许省略结束于行边界的语句之后多数分号。B 和 C 语言去除了这种便利,以分号终结多数语句。不管有这些区别,多数 BCPL 语言的语句和操作符都可以直接映射到对应的 B 语言和 C 语言上。
在 BCPL 语言和 B 语言之间的某些结构性区别根源于在中介内存上的限制。例如,BCPL 语言声明采用下列形式
let P1 be command
and P2 be command
and P3 be command
...
这里用 command 表示的程序文本包含完整的过程。子声明用 and 连接并同时出现,所以名字 P3 在过程 P1 内是可知的。类似的,BCPL 语言可以包装一组声明和语句到一个生成一个值的表达式中,例如
E1 := valof ( declarations ; commands ; resultis E2 ) + 1
BCPL 编译器通过在输出结果之前存储和分析整个程序的解析后的表示于内存中来容易的处理这种构造。B 编译器的存储限制需要尽可能快的生成输出的一种一趟技术,而使之可能的语法性重新设计被被转接到 C 语言中。
BCPL 语言的某些较少令人愉快的特征归咎于它自身的技术问题,而在 B 语言设计中被有意的避免了。例如,BCPL 语言为在独立编译的程序之间通信而使用了‘全局向量’机制。在这种方案中,编程者显式的给每个外部可见的过程和数据对象的名字关联上在全局向量中的数值偏移量;连接是在编译后的代码中通过使用这些数值偏移量来完成的。B 语言最初通过坚持把整个程序一次提供给编译器来躲避了这种麻烦。后来的 B 语言实现和所有 C 语言实现,使用常规的连接器来解析在单独编译的文件中出现的外部名字,而不是把分配偏移量的负担转加给编程者。
在从 BCPL 语言到 B 语言的过渡中的其他琐事是作为个人喜好的上事情而引入的,某些仍然有争议,例如决定使用单一字符 = 来替代 := 用做赋值。类似的,B 语言使用 /* */ 来包围注释,而 BCPL 语言使用 //,来忽略直到行末的文本。这是明显的 PL/I 遗迹。(C++ 复兴了 BCPL 语言的注释约定。) Fortran 语言影响了声明的语法: B 语言声明开始于说明符(specifier)比如 auto 或 static,随后是一列名字,C 不只是依从这种风格,而且通过在声明的开始处放置类型关键字来修饰它。
在 Richard 的书[Richards 79]中加以文档的 BCPL 语言和 B 语言之间的区别不都是故意的;我们开始自 BCPL 语言的一个早期版本[Richards 67]。例如,当我们在 1960 年代学习它的时候,从 BCPL 语言的 switchon 语句中退出的 endcase 是不存在的,所以过载(overload)了 break 关键字来从 B 语言和 C 语言的 switch 语句中退出,这归功于分裂演进而不是有意的变革。
与在建立 B 语言期间发生的普遍深入的语法变革相对比,BCPL 语言的核心语义内容,它的类型结构和表达式求值规则,都完整的保留了。两种语言都是无类型的,更准确的说是有一个单一的数据类型,‘字(word)’或者叫‘单元(cell)’, 它是固定长度的位模式(pattern)。在这些语言内存由这种单元的一个线形数组构成,单元的内容的意义依赖于应用在其上的操作。例如 + 操作符使用机器的整数加法指令来加它的操作数,其他算术操作也不去体察它们的操作数的实际意义。因为内存是线性数组,可以把在单元中的值解释为这个数组的索引,BCPL 语言为此提供了一个操作符。在最初的语言中它拼写为 rv,后来是 !,而 B 语言使用了一元的 *。所以,如果 p 是包含另一个单元的索引(地址或指针)的单元,则 *p 引用指向的单元的内容,要么作为表达式中的值要么作为赋值的目标。
因为在 BCPL 语言和 B 语言中的指针只是在内存数组中的整数索引,在它们上的算术操作是有意义的: 如果 p 是一个单元的地址,则 p+1 是下一个单元的地址。这种约定是在两种语言中数组语义的基础。在 BCPL 语言中我们写
let V = vec 10
而在 B 语言中为
auto V[10];
效果是相同的: 分配命名为 V 的一个单元,接着留出另一组 10 个连续的单元,并把其中第一个单元的内存索引放置到 V 中。作为一般规则,B 语言表达式
*(V+i)
加 V 和 i,并引用 V 后面的第 i 个位置。BCPL 语言和 B 语言都增加了特殊符号来美化这种数组访问;在 B 语言中等价的表达式是
V[i]
在 BCPL 中是
V!i
访问数组的这种方式在当时是不同寻常的;C 语言随后以更不常规的方式吸收了它。
BCPL 语言、B 语言和 C 语言都不在语言中强力的支持字符数据;它们都把字符串作为整数的向量那样对待,并通过一些约定来补充上一般规则。在 BCPL 语言和 B 语言中,字符串严格的指示用包装到单元中的字符串字符来初始化的静态区域的地址。在 BCPL 语言中,第一个包装的字节包含字符串中字符的数目;在 B 语言中,没有计数并用特殊字符终结字符串,B 语言把它拼写为‘*e’。做这个变更部分的为了避免在字符串长度上的限制,这是由于在 8 或 9 位槽(slot)中持有计数导致的,部分的原因是按我们的经验维护这个计数好象比使用终结符更不方便。
操纵 BCPL 字符串中的单独字符通常需要把字符串展开另一个数组中,每单元一个字符,并在以后重新包装他们;B 提供了对应例程,但是人们更经常使用访问或替代字符串中的单独字符的其他库函数。
在 TMG 版本的 B 语言工作了之后,Thompson 用 B 语言自身重写了它(引导步骤)。在开发期间,他不断的与内存限制作斗争: 每次语言增加都会膨胀编译器而几乎不能适合内存限制,而利用这些特征的每次重写都缩小它的大小。例如,B 语言介入了通用的赋值操作符,使用 x=+y 做加 y 到 x。这个符号来自 Algol 68 语言[Wijngaarden 75],这是 McIlroy 合并到它的 TMG 版本中的。(在 B 语言和早期的 C 语言中,这个操作符拼写为 =+ 而不是 += ;这个错误在 1976 年被修正了,这是由于在 B 语言的词法分析器中处理第一种形式是容易的方式而导致的。)
Thompson 通过发明表示增加和减少的 ++ 和 -- 操作符而更进了一步;它们在前缀或后缀的位置上,决定变更发生在记录下(note)这个操作数的值之前还是之后。它们在最早版本的 B 语言不存在,但是顺道就出现了。人们经常猜测建立它们是为了使用 C 和 Unix 在其上变得流行的 DEC PDP-11 所提供的自增和自减寻址模式。这在历史上是不可能的,因为在开发 B 语言的时候还没有 PDP-11。但是,PDP-7 确实有一些‘自增’内存单元,带有通过它们的间接内存引用会增加这些单元的特性。这个特征可能向 Thompson 暗示了这种操作符;使其作为前缀和后缀二者的一般化是他自己的想法。实际上,在这个操作符的实现中没有直接使用自增单元,这个创新的更强的动机可能是转译 ++x 比 x=x+1 更小。
在 PDP-7 上的 B 编译器不生成机器指令,而是‘穿线(threaded)代码’[Bell 72],这是一种解释性的方案,编译器的输出由一序列的进行基本操作的代码片断(fragment)的地址构成。这些操作典型的让 B 在简单的栈机器上活动。
在 PDP-7 Unix 系统上,除了 B 语言自身之外只有很少的东西是用 B 语言写的,因为这个机器太小并且太慢来做实验之外更多的事情;用 B 语言重写整个操作系统和实用工具是太昂贵了而不可行的。在某种程度上,Thompson 通过提供一个‘虚拟 B’编译器缓解地址空间紧张,它通过在解释器内分页代码和数据来允许解释程序在解释器内占用多于 8K 字节空间,但是对于实用于公共工具它还是太慢了。尽管如此,还是出现了用 B 语言写的实用工具,包括 Unix 用户熟悉的可变精度计算器 dc 的早期版本[McIlroy 79]。我承担的最有雄心的计划是把 B 语言转换成 GE-635 机器指令而不是穿线代码的真正的交叉编译器。 这是个小特技: 完整的 B 编译器,用它自己的语言写成并生成 36 位主机的代码,它在有 4K 字的用户空间的 18 位机器上运行。这个计划可行只是因为 B 语言的简单性和它的运行时间系统。
尽管我们偶尔考虑实现当时的某个主要语言,象 Fortran、 PL/I, 或 Algol 68,这种计划对于我们的资源而言好像太大了: 需要的是更加简单和小的工具。所有这些语言都影响了我们的工作,但靠我们自己做事情更加好玩了。
在 1970 年,Unix 计划展示出足够的承诺,我们可以要求新 DEC PDP-11。这个处理器是 DEC 供货的第一批,在它的磁盘到来之前就过去了三个月。使用穿线技术使 B 程序在其上运行只要求为操作符写代码片段,和我用 B 语言写的一个简单的汇编器;不久,dc 成为在我们的 PDP-11 上在任何操作系统之前测试的第一解释性程序。在等待磁盘期间,Thompson 用 PDP-11 汇编语言迅速的重新编码 Unix 内核和一些命令。在机器的 24K 字节的内存中,最早的 PDP-11 Unix 系统使用 12K 字节用于操作系统,一个小空间用于用户程序,余下的用于一个 RAM 磁盘。这个版本只用于测试而不是实际工作; 机器通过枚举棋盘上各种大小的闭合的跳马巡回来消磨时间。一旦它的磁盘出现了,我们在转换汇编语言命令到 PDP-11 方言并移植已经用 B 语言写的那些东西之后,快速的迁移到了它上面。
在 1971 年,我们的微型的计算机中心开始有用户了。我们想要更加容易的建造有趣的软件。使用汇编语言很沉闷,不去管 B 语言性能问题,它已经被补充上了有用的服务例程的一个小库,并被越来越多的新程序所使用。这个时期中最显著的成果是 Steve Johnson 的 yacc 分析器生成器的第一个版本[Johnson 79a]。
我们在其上第一次使用 BCPL 和此后的 B 的机器是字寻址的,而这些语言的单一的数据类型‘单元’(cell)舒适的等同于硬件机器字。PDP-11 的出现暴露了 B 语义模型的不充分。首先,它的字符处理机制,继承自 BCPL 语言并有一些改变,是蠢笨的: 为了访问和替代单独的字符,要使用库过程展开包装后的字符串到单独的单元中并接着重新包装它们,在面向字节的机器上显得很笨拙甚至愚蠢。
其次,尽管最初的 PDP-11 不提供浮点算术,制造商承诺不久就能获得。在我们的 Multics 和 GCOS 编译器中通过定义特殊的操作符来向 BCPL 语言增加浮点操作,但是这种机制只在相关的机器上是可能的,因为一个单一字对包含一个浮点数值是足够大的;在 16-bit PDP-11 上是不行的。
最后,B 语言和 BCPL 语言模型暗含了在处理指针上的花费: 定义指针为字的数组的索引,这种语言规则强制指针被表示为字的索引。每次指针引用都要产生运行时间的从指针到硬件期望的字节地址的度量单位转换。
出于这些原因,为了妥善处理字符和字节寻址和将要到来的浮点硬件,类型方案看来是必须的。其他要点,特别是类型安全和整数检查不如它们重要而后来才出现。
除了语言自身的问题,B 编译器的穿线代码技术生成的程序与它们的汇编语言类似物相比太慢了,我们低估 用 B 语言重新编码操作系统或它的主要实用工具的可能性。
在 1971 年我开始通过增加字符类型来扩展 B 语言,并重写了它的编译器来生成 PDP-11 机器码替代穿线代码。所以从 B 语言到 C 语言的过渡是同建立有能力生成足够快和小的程序去与汇编语言竞争的编译器同时期的。我称这个轻微扩展的语言为 NB,意为‘new B’。
NB 存在的如此短暂以至于没有写对它的完整描述。它提供类型 int 和 char、它们的数组、到它们的指针,按下列例子代表的方式来声明
int i, j;
char c, d;
int iarray[10];
int ipointer[];
char carray[10];
char cpointer[];
B 语言和 BCPL 语言的数组语义被完全的保留了: iarray 和 carray 的声明动态的建立单元,分别动态的初始化为指向 10 个整数和字符的序列中第一个位置的值。ipointer 和 cpointer 的声明省略了大小,声称不应当自动分配存储。在过程内,语言对指针的解释等同于数组变量: 只有在编程者希望指派一个指示物(referent),而不让编译器分配空间并初始化这个单元的时候,建立一个单元指针的声明不同于数组声明。
在绑定到数组和指针名字的单元中所存储的值,是对应的存储区域的按字节度量的机器地址。所以,通过指针的间接不暗含着从字到字节偏移量缩放的运行时间开销。在另一方面,数组下标和指针算术的机器代码现在依赖于数组或指针的类型: 要计算 iarray[i] 或 ipointer+i 则暗含着按照所引用的对象的大小来缩放加数 i 。
这些语义表现了从 B 语言的平缓的过渡,我试验了它们几个月。当我尝试扩展类型表示法(notation)的时候问题变得明显了,特别是在增加结构(纪录)类型的时候。结构好像应当以直觉的方式映射到机器的内存上,但是在包含数组的结构中,这里没有好地方来隐藏包含这个数组的基址的指针,也没有安排初始化它的任何方便方式。例如,早期的 Unix 系统的目录条目可以用 C 语言描述为
struct {
int inumber;
char name[14];
};
我希望结构不只是刻画一个抽象的对象,而且还要描述可以从目录中读出的一组数位。编译器能在什么地方隐藏语义所需要的到 name 的指针? 即使结构可以想象的更加抽象,而且可以用某种方式隐藏这种指针,我如何处理在分配复杂的对象的时候适当的初始化这些指针的技术问题,人们指定的结构可能包含着数组,而数组又包含着结构,直到任意的深度?
解决方案形成了在无类型的 BCPL 语言到有类型的 C 语言的进化链上的关键性跳跃。它去除了在存储中指针的具体化,转而导致这个指针在表达式中提及到数组名字的时候建立。这个规则在今天的 C 语言中仍幸存着,就是当数组类型的值出现在表达式中的时候,把它们转换成到组成这个数组的第一个对象的指针。
这个创造确保了多数现存的 B 代码可以继续工作,而不用管在语言语义中下层的转变。很少有程序向数组名字赋予新值来调整它的起源(origin),这在 B 语言和 BCPL 语言中是可能的,但在 C 语言中是无意义的和容易修正的。更加重要的是,新语言保持了数组语义的一致的和可工作(即使是不寻常的)的解释,而且开通了到更加全面的类型结构的道路。
把 C 语言和它的祖先明显的区别开来的第二个创新是更完整的类型结构,特别是它在声明语法的表达上的扩展。NB 提供基本类型 int 和 char,加上它们的数组,和到它们的指针,但是没有复合的进一步方式。普遍化是必须的: 给出任何类型的一个对象,应当有可能描述聚集几个这种对象到一个数组中的一个新对象,从一个函数生成它,或者到它的一个指针。
对于这种复合类型的每个对象,都已经有了提及下层对象的一种方式: 变址(index)这个数组,调用这个函数,在这个指针上使用间接操作符。类比推理导致名字的声明语法借鉴了名字典型在其中出现的表达式语法。所以,
int i, *pi, **ppi;
声明一个整数, 到整数的一个指针,到整数的一个指针的一个指针。这些声明的语法反映了对 i、*pi 和 **ppi 在表达式中都生成一个 int 类型的观察。类似的
int f(), *f(), (*f)();
声明返回一个整数的函数,返回到整数的一个指针的函数,到返回一个整数的函数的一个指针;
int *api[10], (*pai)[10];
声明到整数的指针的一个数组,和到整数的数组的一个指针。在所有这些个例中变量的声明都类似于它们在表达式中语法,它们的类型是在声明头部指名的类型。
C 语言采纳的类型合成(composition)方案在相当程度上归功于 Algol 68,尽管没有显现出 Algol 信徒所赞赏的那种形式。我从 Algol 中捕获的中心想法是基于把原子类型(包括结构)合成(compose)到数组、指针(引用)和函数(过程)内的一种类型结构。Algol 68 的联合(union)和强制(cast)概念也在后来影响。
在建立了类型系统,有关的语法和语言的编译器之后,我觉得它应当得到一个新名字;NB 好象不够有特色。我决定跟从单字母风格而叫它 C 语言,留下了一个公开的问题,这个名字表示在字符表上的递进还是在 BCPL 字符上的递进。
在语言命名之后快速的变更继续着,例如介入了 && 和 || 操作符。在 BCPL 语言和 B 语言中,表达式求值依赖于上下文: 在 if 和其他条件语句中把表达式的值与零做比较,这些语言在and (&) 和 or (|)操作符上寄予了特殊的解释。在普通的上下文中,它们作逐位(bitwise)操作,但是在 B 语言语句
if (e1 & e2) ...
中编译器必须求值 e1,如何它是非零,则求值 e2, 并且如果它也是非零,则执行依赖于 if 的语句。这个要求递归下降到在 e1 和 e2 内的 & 和 | 操作符上。在这种‘真值’上下文中的 Boolean 操作符的短路语义好像是符合人意的,但是操作符过载难于解释和使用。应 Alan Snyder 的建议,我介入了 && 和 || 操作符来使机制更加清晰。
它们的迟缓介入说明了 C 语言优先级规则的不当之处。在 B 语言中写
if (a==b & c) ...
来检查是否 a 等于 b 并且 c 是非零; 在这种条件表达式中 & 有比 == 更低的优先级更好些。在从 B 语言转换到 C 语言中,你希望在这样的语句中用 && 替换 & ;为了使转换更少痛苦,我们决定保持 & 操作符相对于 == 有着相同的优先级,只是把 && 的优先级与 & 轻微的分开来。今天,好像移动 & 和 == 的相对的优先级会更好些,这样就能简化一个常见的 C 习惯用语: 要对另一个值测试一个掩码(masked)值,你必须写
if ((a&mask) == b) ...
这里需要内层的圆括号但很容易忘记。
在 1972-3 年左右出现了很多其他改变,其中最重要的是预处理的介入,部分的是应 Alan Snyder[Snyder 74]的催促,也是对在 BCPL 语言和 PL/I 语言中能获得的文件包含机制的设施的认可。它的最初版本是极其简单的,只提供包含文件和简单的字符串替换: #include 和无参数宏的 #define。从此之后,它主要由 Mike Lesk 接着是 John Reiser 来扩展,结合了带有参数的宏和条件编译。预处理器最初被考虑为对语言自身的可选的附件。实际上在很多年里,除非源程序在它的开始处包含特殊的信号,否则它是不被调用的。这种看法坚持和解释了预处理器语法同语言的其他部分之间的不完全的整合,和在早期的参考手册中的不精确描述。
在 1973 年早期,现代 C 语言的基础完成了。语言和编译器足够强大到允许我们在这一年的夏天用 C 语言为 PDP-11 重写了 Unix 内核。(Thompson 在 1972 年作了生成用结构之前的 C 语言早期版本编码的系统的短暂尝试,但是他放弃了这次努力)。也是在这个时期期间,编译器被重定目标到其他附近的机器上,特别是 Honeywell 635 和 IBM 360/370;语言不能孤立的生存,所以开发了现代的库的原型。特别是,Lesk 写了一个‘可移植的 I/O 包’[Lesk 72],后来重做成为 C 语言‘标准 I/O’例程。在 1978 年 Brian Kernighan 和我出版了 The C Programming Language [Kernighan 78]。尽管它没有描述不久之后就变得常用的一些补充,这本书充当语言参考直到十多年后正式标准被接受。尽管我们为写这本书而紧密的在一起工作,这里有明确的劳动分工: Kernighan 写了几乎所有的说明性的材料,而我负责包含参考手册的附录和关于与 Unix 系统交互的章节。
在 1973-1980 年期间,语言也增长了一些: 类型结构增加了无符号、长整数、联合和枚举类型,而结构几乎成为一等对象(只是缺乏文字上的一种表示法)。同样重要的开发出现在它的环境和配套的技术中。用 C 语言写 Unix 内核带给我们对语言的有用性和有效性的足够的信心,我们开始同样的重新编码系统的实用设施和工具,并接着把主要兴趣转移到把它们移植到其他平台上。按照[Johnson 78a]中的描述,我们发现在传播 Unix 工具中的最艰难的问题不在于 C 语言同新硬件的交互上,而是在于适应其他操作系统的现存软件上。所以 Steve Johnson 开始做 pcc 的工作,它是意图容易的重定目标到新机器上的 C 编译器[Johnson 78b],同时他、Thompson 和我开始转移 Unix 系统自身到 Interdata 8/32 计算机上。
在这个时期期间特别是 1977 年左右,语言的变更主要的集中于对可移植性和类型安全的考虑上,来源于我们应付在把可观的代码团体转移到新的 Interdata 平台中预见和观察到的问题时的努力。C 语言在那时仍强烈的表现出他的无类型起源的迹象。例如,指针几乎不能区分于在早期的语言手册或尚存的代码中的整数的内存索引;字符指针同无符号整数的算术特性的类似性使得把它们等同起来的诱惑难以抗拒。增加了 unsigned 类型来获得无符号算术而不把它同指针操作混淆起来。类似的,早期语言容忍在整数和指针之间的赋值,但是这种实践不被鼓励,发明了类型转换的一种表示法(叫做‘强制’,借鉴自 Algol 68)来更加明确的指定类型转换。受到 PL/I 榜样的诱惑,早期的 C 语言不把结构指针牢固的绑定到它们指向的结构上,允许编程者写 pointer->member 而几乎不用管 pointer 的类型;这样的表达式被不加鉴别的接受为到这个指针指出的内存区域的一个引用,而成员名字只是指定一个偏移量和一个类型。
尽管第一版的 K&R 描述了把 C 语言的类型系统带领到当前形式的多数规则,很多用老的不受约束的风格写的程序继续存在着,所以编译器还要容忍它们。为了鼓励人们更加注意正式的语言规则,去发现合法但可疑的造句,并帮助找到不可检查的协调(interface)不匹配,Steve Johnson 调整他的 pcc 编译器来生成 lint [Johnson 79b],它扫描一组文件并标注有疑义的造句。
我们在 Interdata 8/32 上的移植能力试验的成功马上导致了 Tom London 和 John Reiser 在 DEC VAX 11/780 上的另一次成功。这个机器变得比 Interdata 更加流行,而 Unix 和 C 语言开始在 AT&T 内外快速传播。尽管在 1970 年代中期 Unix 已经用于 Bell 系统内部被各种项目,和我们公司外一小组的面向研究的工业、学术和政府组织,它的真正增长是在可移植性完成了之后才开始的。特别要注意的是 System III 和 System V 版本的系统来自 AT&T 新兴的计算机系统部,基于公司的开发和研究组的工作,而加洲大学 Berkeley 分校的 BSD 系列发行衍生自在 Bell 实验室中的研究组织。
在 1980 年代期间 C 语言广泛的传播,编译器变得可以在几乎所有的机器体系和操作系统上获得;特别是它开始作为个人计算机的编程工具而流行,对于这些机器的商业软件的厂商,和有兴趣编程的最终拥护二者。从这个时代开始,几乎所有的编译器都基于 Johnson 的 pcc; 在 1985 年已经后了很多独立制造的编译器产品。
在 1982 年很明显 C 语言需要正式标准化。最接近于标准的第一版的 K&R,不再描述实际上使用的语言;特别是它没有提及 void 或者 enum 类型。虽然它预示了到结构的新途径,在它出版之后语言才支持赋值它们,传递它们去到和来自函数,和把成员名字牢固的关联到包含它们的结构或联合上。尽管 AT&T 发行的编译器结合了这些变更,多数的不基于 pcc 的编译器承办商迅速的选取了它们,但是仍然没有完整的、权威的语言描述。
第一版的 K&R 在很多语言细节上也不够精确,把 pcc 当作‘参考编译器’变得日益不合实际;它甚至不完善的具体表述 K&R 描述的语言,更不用说后续的扩展。最后,在以商业或政府合同为主题的项目中 C 语言的最初使用意味着官方标准的出台是很重要的。所以(应 M. D. McIlroy 的要求),ANSI 在 1983 年夏天在 CBEMA 的指导下建立了 X3J11 委员会,其目标是产生 C 语言标准。X3J11 在 1989 年底拿出了它的报告[ANSI 89],随后这个标准被 ISO 接受为 ISO/IEC 9899-1990。
从一开始,X3J11 委员会对语言扩展采取了谨慎的、保守的看法。令我非常满意,他们认真的达到了他们的目标:‘为 C 编程语言开发一个清晰的、一致的和无歧义的标准,它系统化 C 语言的常见的、现存的定义,它促进跨越 C 语言环境的用户程序的可移植性’[ANSI 89]。委员会认识到仅仅是一个标准的颁布不能改变世界。
X3J11 只介入了一个真正重要的对语言自身的改变: 它在函数的类型署名(signature)中结合了形式参数的类型,使用了从 C++ [Stroustrup 86]借鉴来的语法。在旧样式下,外部函数按下面这样声明:
double sin();
它只说出了 sin 是返回一个 double(就是双精度浮点)值的一个函数。在新样式下,它更好的呈现为
double sin(double);
这使参数类型明显,并鼓励更好的类型检查和适当的转换。恰恰是这个补充,尽管它产生了一个显著的更好的语言,它导致了困难。委员会合理的觉得简单的废弃‘旧样式’的函数定义和声明是不可行的,但也同意新形式更好。不可避免的妥协同它所能做到的一样,虽然允许两种形式使语言定义更复杂了,可移植软件的作者必须对付还没有遵守标准的编译器。
X3J11 还介入了一大堆小的增补和调整,例如,类型限定符(qualifier) const 和 volatile,和稍微不同的类型提升(promotion)规则。不过标准化过程没有改变语言的特性。特别是,C 语言标准不尝试在形式上指定语言的语义,这会导致在精细要点上的争论;然而,它成功的总结来自从最初描述之后的在用法上的变更,并充分精确来把它为实现的基础。
就这样核心 C 语言从标准化进程中几乎无损的逃逸出来了,而形成的标准是一个更好的细致的法典而不是一个新创造。更重要的变更发生在语言的外围: 预处理器和库。预处理器进行宏替换,使用不同于语言其余部分的约定。它与编译器的交互从未被完善的描述,而 X3J11 尝试改善这个情况。结果是比第一版的 K&R 中的解释显著的更好;除了更加完整之外,它提供了操作,如记号(token)串联(concatenation),以前只在偶尔的实现中能获得。
X3J11 恰当的相信标准 C 库的完全和详细的描述同语言自身上的工作同样重要。C 语言自身不提供输入-输出或与外部时间的任何其他交互,因为必须依靠一组标准过程。在出版 K&R 的时候,C 语言主要被考虑为 Unix 的系统编程语言;尽管我们提供了意图可以容易的运输到其他操作系统的库例程的例子,来自 Unix 的底层支持还是内在的不可理解。所以 X3J11 委员会花费了大量时间设计和文档化要求在所有符合标准的实现中都能获得的一组库例程。
根据标准化进程的规则,X3J11 委员会的当前活动被限定为发布对现存标准的解释。但是,最初由 Rex Jaeschke 召集的一个非正式的组织 NCEG (Numerical C Extensions Group)已经被正式的接受为 X3J11.1 的子组,他们继续考虑扩展 C 语言。如同名字暗示的那样,很多这些可能性扩展都意图使语言更加适合数值性使用: 例如,动态确定边界的多维数组,结合处理 IEEE 算术的设施,并使语言在带有向量或其他高级结构性特征的机器上更加有效率。不是所有可能性扩展都特定于数值;他们包括了结构文字的一种表示法。
C 语言和 B 语言有许多直接的后代,尽管在产生后代上不能同 Pascal 匹敌。有一个早期发展出的旁支。当 Steve Johnson 在 1972 年休假年访问 Waterloo 大学的时候,他带去了 B 语言。它在那里的 Honeywell 机器上变得流行,后来又产生了 Eh 和 Zed(加拿大人对‘B 后面是什么’的回答)。当 Johnson 在 1973 年返回到了 Bell 实验室,他惊慌的发现他把其种子带到加拿大的语言已经演化回到了家里;甚至他自己的 yacc 程序都已经被 Alan Snyder 用 C 语言重写了。
更新近的 C 语言后代恰当的包括 Concurrent C [Gehani 89]、Objective C [Cox 86]、C* [Thinking 90]、特别是 C++ [Stroustrup 86]。C 语言还广泛的用作各种编译器的中介表示(特别是作为可移植的汇编语言),用于直接后代如 C++,和独立的语言如 Modula 3 [Nelson 91] 和 Eiffel [Meyer 88]。
两个想法是 C 语言在它同类语言中最大的标志性特征: 在数组和指针之间的联系,和模仿表达式语法的声明语法方式。它们也是最常受到批评的特征,并经常绊倒初学者。在这两个案例中,历史的偶然事件或错误加剧了这种困难。其中最重要是 C 编译器对类型错误的容忍。这可从上述历史中搞清楚,C 语言演化自无类型语言。它不是作为带有自己规则的全新语言而出现在它的最早的用户和开发者面前的;我们在语言开发的同时必须持续的接受现存的程序,并对现存的代码主体做出宽容。(后来,ANSI X3J11 委员会标准化 C 语言时也面对同样的问题。)
1977 年和相当久之后的编译器,不抱怨比如在整数和指针之间的赋值,或使用使用错误类型的对象来引用结构成员这样的用法。尽管在第一版的 K&R 中提出的语言定义在类型规则的处理上相当的(但不是完全的)一致,这本书容忍现存的编译器不执行它们。此外,某些意图便利早期的过渡的规则导致了后来的迷惑。例如,在函数声明中的空方括号
int f(a) int a[]; { ... }
是一个活化石,声明指针的 NB 方式的遗迹;在 C 语言中只有在这种特殊情况下 a 才被解释为指针。这种幸存的表示法部分的为了兼容性,部分的为了合理化,它允许编程者告知他们的读者一个意图,传递给它生成在一个数组的一个指针 f,而不是到一个单一整数的一个引用。不幸的是,它更多的是迷惑学习者而不是提醒读者。
在 K&R C 中,向函数调用提供正确类型的参数是编程者的责任,现存的编译器不检查类型一致性。最初语言在函数的类型署名中包含参数类型的失误是一个严重的缺点,实际上是需要 X3J11 委员会修正的最突出和最痛苦的创新。这个早期设计可以由我的技术性问题回避,特别是在独立编译的源文件之间的交叉检查,和我对从无类型到有类型语言转移的内涵的不完全消化,来解释(可能不足以辩解)。上面提到过的 lint 程序,尝试减轻这个问题: 同其他功能联合起来,lint 通过扫描一组源文件,把在调用中使用的函数参数的类型和在它们定义中类型做比较,来检查整个程序的一致性和连贯性。
一个语法的意外事件贡献了语言的可察觉的复杂性。在 C 语言中拼写为 * 的间接操作符在语法上是一元前缀操作符,同在 BCPL 语言和 B 语言中一样。这在简单表达式中工作的很好,但在更复杂的情况下,需要圆括号来指引分析。例如,要区别通过调用一个函数返回的值的间接,和调用一个指针指出的函数,你可以分别的写为 *fp() 和 (*pf)()。在表达式中使用的式样被完全的拿到了声明中,所以名字可以被声明为
int *fp();
int (*pf)();
在更加华丽当仍很现实的情况下,事情变得更糟:
int *(*pfp)();
是到返回到一个整数的指针的一个函数的一个指针。这就出现了两种效果。最重要的是,C 语言有一组相对丰富的描述类型的方法(相对于 Pascal)。声明有同 C 语言一样的表达力的语言如 Algol 68,描述对象同样的难于理解,这完全是因为对象自身是复杂的。次要的效果归咎于语法细节。在 C 语言中的声明必须以‘由内至外’的方式来读,很多人都觉得难于掌握[Anderson 80]。Sethi [Sethi 81]观察到如果间接操作符采用后缀操作符而不是前缀操作符,则多数嵌套的声明和表达式可以变得更简单,但是要改变已经太晚了。
不去管它的难度,我相信 C 语言的声明方法仍然是貌似合理的(plausible),我觉得它很舒适;它是一个有用的统一的原则。
C 语言的另一个标志性特征,它对数组的处理,在实际场所更加令人怀疑,虽然它也有真实的功效。尽管在指针和数组之间的联系是不寻常的,它却是可以学会的。此外,语言展示了可观的能力去描述重要的概念,例如,长度在运行时间可变的向量,只带有一些基本的规则和约定。特别是,处理字符串使用同其他数组一样的机制,加上空字符终结字符串的约定。把 C 语言的方式同两个几乎同时期的语言 Algol 68 和 Pascal [Jensen 74]相比较是有趣的。Algol 68 中的数组要么有固定边界,要么是‘灵活的’: 在语言定义和编译器中都需要相当可观的机制来提供可变数组(而且不是所有编译器完全实现了它们)。最初的 Pascal 只有固定大小的数组和字符串,这被证明是局限的[Kernighan 81]。最后,这个问题被部分的修正了,尽管结果的语言仍然不是普遍可获得的。
C 语言把字符串对待为按惯例用一个记号终结的字符的数组。除了关于用字符串文字做初始化的一个特殊规则之外,字符串的语义完全被支配所有数组的更一般规则所包含,作为一个结果,C 语言比把字符串结合成一个唯一的数据类型的语言更加简单的去描述和翻译。一些代价产生自它的实行(approach): 因为应用代码或库例程有时必须查找字符串的结束,能获得很少的内置操作,或者存储管理的负担对于用户很沉重,所以特定字符串操作比其他设计更加破费。尽管如此,C 语言实行字符串的方式工作得很好。
在另一方面,C 的数组的处理一般而言(不只是字符串)、对优化和将来的扩展二者都有令人遗憾的牵连。在 C 程序中指针的流行,不管显式声明还是产生自数组的那些指针,都意味着优化器必须小心和仔细的使用数据流技术来达成好的结果。久经世故的编译器可以理解大多数指针有可能改变的东西,但是某些重要的用法仍旧难于分析。例如,带有导出自数组的指针参数的函数在向量机器上就难于编译到高效代码中,因为没有可能去确定一个参数指针不交叠于也被另一个参数引用的或可从外部访问的数据。更加根本的,C 语言的定义如此特殊的描述数组的语义,以至于改变或扩展数组为更基本的(primitive)对象,和允许在它们上面按一个整体来操作,因为难于适合现存的语言。甚至允许声明和使用其大小是动态确定的多维数组的扩展都不是完全直接了当的[MacDonald 89] [Ritchie 90],尽管它们可以使用 C 语言写数值函数库更加容易。所以 C 语言通过一种统一的和简单的机制覆盖了在实践中出现的最重要的字符串和数组的用法,而留下做更高效率的实现和扩展的问题。
当然了,在语言中存在很多更小的不当,它们的描述不在上述讨论之内。还有一些比细节更关键的一般批评需要提出。其中的最首要的是语言和它的通常预期的环境对于写非常大的系统只提供了很少的帮助。命名结构只提供两个主要级别。‘外部’(在所有地方都可见)和‘内部’(在一个单一过程中可见)。可见性的中介级别(在一个单一的数据和过程文件内可见)被软弱的结合到了语言定义中。所以,对模块化有很少的直接支持,而强迫项目设计者去建立他们自己的约定。
类似的,C 语言自身提供两种存储的生存期:‘自动’对象在控制驻留在一个过程中或其下的时候存在,而‘静态’对象在整个程序执行期间都存在。脱离栈的、动态分配的存储只在一个库例程中提供,管理它的负担抛给了编程者: C 语言敌视自动垃圾收集。
C 语言的成功程度远远超出任何早先的期望。什么品质对它的广泛使用作出了贡献?
毫无疑问 Unix 自身的成功是最主要因素;它使成百上千的人能获得这门语言。当然反过来说,Unix 使用 C 语言因而移植到各种机器上对于系统的成功是很重要的。但是语言对其他环境的入侵显出了更基本的优点。
尽管一些特征对于初学者甚至老手都是神秘的,C 语言保持为简单和小的语言,可以用简单和小的编译器来翻译它。它的类型和操作牢固的根基于真实机器所提供的那些东西,对于习惯了计算机工作方式的人,学习生成时间效率和空间效率的程序是不困难的。同时语言又足够抽象于机器细节之上而达成了程序的可移植性。
同样重要,C 语言和它中心的库支持总是保持着与真实环境的联系。它不是证明观点或充当实例的孤立设计,而是作为写能做有用事情的程序的工具;它总是预想着与一个更大的操作系统交互,并被当作建造更大工具的工具。节俭的注重实效的方式影响了进入 C 语言的东西: 它覆盖多数编程者的基本需要,但不尝试支持过多。
最后,不管从它的首次出版的描述之后所经历的变化,它们无可否认的是不正式和不完整的,相对同样广泛流通的语言如 Pascal 和 Fortran,数以万计的用户通过很多不同的编译器所见到的实际的 C 语言保持着显著的稳定和统一。有不同的 C 方言,最显著的是,旧 K&R 和新标准 C 方言。但是在整体上,C 语言保持着比其他语言更少的专有扩展。最重要的扩展可能是意图处理某些 Intel 处理器的怪癖的‘far’和‘near’指针限定符(qualification)。尽管 C 语言最初设计不是以可移植性作为主要目标,它成功于在范围从最小的个人计算机到最强大的超级计算的机器上表述程序甚至操作系统。
C 语言是一个离奇的、有瑕疵的、和巨大的成功。尽管历史上的事故的确应被避免,它明显的满足足够高效以替代汇编语言作为系统实现语言的需要,而仍然足够抽象和流畅来在各式各样的广泛环境中描述算法和交互。
简要的总结今天的 C 语言的直接贡献者的角色是有价值的。Ken Thompson 在 1969-70 年建立了 B 语言;它直接演化自 Martin Richards 的 BCPL 语言。Dennis Ritchie 在 1971-73 年期间把 B 语言转化成 C 语言。保持了多数 B 语言的语法,增加了类型和其他改变,并写了第一个编译器。Ritchie、Alan Snyder、Steven C. Johnson、Michael Lesk 和 Thompson 在 1972-1977 年期间贡献了语言的一些想法,而 Johnson 的可移植编译器仍在广泛使用。在这个时期期间,成长出了相当可观的一组库例程,感谢在 Bell 实验室的众多的人。在 1978 年,Brian Kernighan 和 Ritchie 写了一本书,它成为多年的语言定义。始于 1983 年,ANSI X3J11 委员会标准化了这门语言。尤其值得注意的是它的官员 Jim Brodie、Tom Plum 和 P. J. Plauger,和后来的草案修订者 Larry Rosler 和 Dave Prosser 在保存思想轨迹上的努力。
我要感谢 Brian Kernighan、Doug McIlroy、Dave Prosser、Peter Nelson、Rob Pike、Ken Thompson 和 HOPL 仲裁人在准备本文时的建议。