C 编程语言诞生于 20 世纪 70 年代初期,是新兴 Unix 操作系统的系统实现语言。它源自无类型语言 BCPL,并发展出一种类型结构;它诞生于一台微型机器上,作为改善简陋编程环境的工具,如今已成为当今的主流语言之一。本文研究了 C 语言的演变。
介绍
注:*版权所有 1993 美国计算机协会。本电子重印本由作者免费提供。如需进一步的出版权,请联系 ACM 或作者。本文于 1993 年 4 月在马萨诸塞州剑桥举行的第二届编程语言史会议上发表。
随后,它被收录在会议论文集: 编程语言史-II 版,Thomas J. Bergin, Jr. 和 Richard G. Gibson, Jr. ACM Press(纽约)和 Addison-Wesley(马萨诸塞州雷丁),1996 年;ISBN 0-201-89502-1。
本文主要讲述 C 语言的发展、影响以及诞生的条件。为了简洁起见,我省略了对 C 语言本身、其父语言 B [Johnson 73] 和其祖父语言 BCPL [Richards 79] 的完整描述,而是集中讨论每种语言的典型元素及其演变过程。
C 语言诞生于 1969-1973 年间,与 Unix 操作系统的早期开发同步;最具创造性的时期发生在 1972 年。另一波变化在 1977 年至 1979 年间达到顶峰,当时 Unix 系统的可移植性得到了展示。在第二个时期的中期,出现了该语言的第一个广泛使用的描述: C 编程语言, 通常称为“白皮书”或“K&R”[Kernighan 78]。最后,在 20 世纪 80 年代中期,该语言由 ANSI X3J11 委员会正式标准化,该委员会进行了进一步的修改。直到 20 世纪 80 年代初,尽管存在适用于各种机器架构和操作系统的编译器,但该语言几乎只与 Unix 有关;最近,它的使用范围越来越广,如今它已成为整个计算机行业最常用的语言之一。
c语言在线书籍:54笨鸟
程序员导航网址:编程网
历史:背景
20 世纪 60 年代末是贝尔电话实验室计算机系统研究的动荡时期 [Ritchie 78] [Ritchie 84]。该公司退出了 Multics 项目 [Organick 75],该项目最初是麻省理工学院、通用电气和贝尔实验室的合资项目;到 1969 年,贝尔实验室管理层,甚至研究人员都开始相信 Multics 的承诺实现得太晚,而且成本太高。甚至在 GE-645 Multics 机器从实验室移走之前,一个由 Ken Thompson 领导的非正式小组已经开始研究替代方案。
汤普森希望使用一切可用手段,按照自己的设计创建一个舒适的计算环境。回想起来,他的计划显然融合了 Multics 的许多创新方面,包括将进程作为控制点的明确概念、树形文件系统、作为用户级程序的命令解释器、文本文件的简单表示以及对设备的通用访问。他们排除了其他方面,例如对内存和文件的统一访问。此外,一开始,他和我们其他人就推迟了 Multics 的另一个开创性(尽管不是原创)元素,即几乎完全用高级语言编写。Multics 的实现语言 PL/I 不太符合我们的口味,但我们也在使用其他语言,包括 BCPL,我们很遗憾失去了用高于汇编语言级别的语言编写程序的优势,例如易于编写和易于理解。当时我们并没有太重视可移植性;后来才对此产生兴趣。
汤普森当时面临的硬件环境非常狭窄和简陋:1968 年,他开始使用 DEC PDP-7,这是一台只有 8K 18 位字内存的机器,没有对他有用的软件。虽然他想使用更高级的语言,但他用 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;它评估表达式并输出相应的位。没有库,没有加载器或链接编辑器:程序的整个源代码都呈现给汇编器,出现的输出文件(具有固定名称)可直接执行。(这个名字 a.out解释了一点 Unix 词源;它是汇编器的输出。即使在系统获得了链接器和明确指定另一个名称的方法之后,它仍被保留为编译的默认可执行结果。)
1969 年,Unix 首次在 PDP-7 上运行后不久,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 在 20 世纪 60 年代中期访问麻省理工学院时设计的,并在 20 世纪 70 年代初用于几个有趣的项目,其中包括牛津大学的 OS6 操作系统 [Stoy 72] 和施乐 PARC 的 Alto 开创性工作的部分内容 [Thacker 79]。我们之所以熟悉它,是因为 Richards 所参与的 MIT CTSS 系统 [Corbato 62] 曾用于 Multics 的开发。最初的 BCPL 编译器由 Rudd Canaday 和贝尔实验室的其他人 [Canaday 69] 移植到 Multics 和 GE-635 GECOS 系统;在 Multics 在贝尔实验室的最后挣扎期间以及之后,它成为了后来参与 Unix 的一群人的首选语言。
BCPL、B 和 C 都完全符合 Fortran 和 Algol 60 所代表的传统过程语言系列。它们特别适合系统编程,体积小、描述紧凑,并且易于通过简单的编译器进行翻译。它们“接近机器”,因为它们引入的抽象很容易基于传统计算机提供的具体数据类型和操作,并且它们依靠库例程进行输入输出和与操作系统的其他交互。虽然不太成功,但它们也使用库过程来指定有趣的控制结构,例如协同程序和过程闭包。同时,它们的抽象处于足够高的级别,只要小心谨慎,就可以实现机器之间的可移植性。
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 ...
其中,由命令表示的程序文本包含整个过程。由 和连接的子声明 同时发生,因此名称 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 声明以 auto 或 static之类的说明符开头,后跟名称列表,而 C 不仅遵循这种风格,还通过将其类型关键字放在声明的开头来对其进行修饰。
Richards 的书 [Richards 79] 中记录的 BCPL 语言与 B 之间的差异并非都是故意的;我们从 BCPL 的早期版本 [Richards 67] 开始。例如, 当我们在 20 世纪 60 年代学习 BCPL switchon 语句时,该语言中还没有退出的 endcase ,因此重载break 关键字以退出 B 和 C switch 语句,这归因于发散演化,而不是有意识的改变。
与 B 创建期间发生的普遍语法变化相反,BCPL 的核心语义内容(其类型结构和表达式求值规则)保持不变。这两种语言都是无类型的,或者说只有一种数据类型,即“字”或“单元”,一种固定长度的位模式。这两种语言中的内存由这种单元的线性数组组成,单元内容的含义取决于所应用的运算。 例如, + 运算符只是使用机器的整数加法指令将其操作数相加,其他算术运算同样不知道其操作数的实际含义。因为内存是一个线性数组,所以可以将单元中的值解释为该数组中的索引,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 位槽中导致的字符串长度限制,部分原因是根据我们的经验,维护计数似乎不如使用终止符方便。
BCPL 字符串中的各个字符通常通过将字符串展开到另一个数组中(每个单元格一个字符),然后重新打包来操作;B 提供了相应的例程,但人们更多时候使用其他库函数来访问或替换字符串中的各个字符。
更多历史
在 TMG 版本的 B 运行之后,Thompson 重写了 B 本身(一个引导步骤)。在开发过程中,他不断与内存限制作斗争:每次添加语言都会使编译器膨胀到几乎无法容纳,但每次重写都会利用该功能减小其大小。例如,B 引入了广义赋值运算符,使用 x=+y 将 y添加 到 x。该符号来自 Algol 68 [Wijngaarden 75],由 McIlroy 编写,他将其纳入了他的 TMG 版本。(在 B 和早期的 C 中,运算符拼写为 =+ 而不是 += ;这个错误在 1976 年得到修复,是由 B 的词法分析器中处理第一种形式的一种诱人的简单方法引起的。)
Thompson 更进一步发明了 ++ 和 -- 运算符,它们用于递增或递减;它们的前缀或后缀位置决定了改变是在记录操作数的值之前还是之后发生。它们不是 B 的最早版本,而是在开发过程中出现的。人们经常猜测它们是为了使用 DEC PDP-11 提供的自动递增和自动递减地址模式而创建的,C 和 Unix 正是在这种模式下首次流行起来的。这在历史上是不可能的,因为在开发 B 时还没有 PDP-11。然而,PDP-7 确实有一些“自动递增”存储单元,其特性是通过它们进行的间接内存引用会增加该单元。这个特性可能让 Thompson 想到了这样的运算符;将它们同时用作前缀和后缀是他自己的概括。事实上,自动递增单元并未直接用于运算符的实现,创新的更强大动机可能是他观察到 ++x的翻译 小于 x=x+1的翻译。
PDP-7 上的 B 编译器不生成机器指令,而是生成“线程代码”[Bell 72],这是一种解释方案,其中编译器的输出由执行基本操作的代码片段的地址序列组成。这些操作通常(特别是对于 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]。
B 的问题
我们最初使用 BCPL 和随后使用 B 的机器都是字寻址的,这些语言的单一数据类型“单元”与硬件机器字相等。PDP-11 的出现暴露了 B 语义模型的几个不足之处。首先,它的字符处理机制(几乎不加改动地继承自 BCPL)很笨拙:使用库过程将打包的字符串分散到单个单元中然后重新打包,或者访问和替换单个字符,在面向字节的机器上开始感觉很别扭,甚至很愚蠢。
其次,尽管最初的 PDP-11 不提供浮点运算,但制造商承诺很快就会提供。在我们的 Multics 和 GCOS 编译器中,通过定义特殊运算符,浮点运算已添加到 BCPL 中,但该机制之所以可行,只是因为在相关机器上,单个字足以容纳浮点数;但在 16 位 PDP-11 上并非如此。
最后,B 和 BCPL 模型在处理指针时隐含了开销:语言规则通过将指针定义为字数组中的索引,强制将指针表示为字索引。每个指针引用都会生成从指针到硬件所需的字节地址的运行时比例转换。
出于所有这些原因,似乎需要一种类型方案来处理字符和字节寻址,并为即将到来的浮点硬件做好准备。其他问题,特别是类型安全和接口检查,当时似乎并不像后来那么重要。
除了语言本身的问题之外,B 编译器的线程代码技术生成的程序比汇编语言生成的程序慢得多,因此我们排除了用 B 重新编码操作系统或其核心实用程序的可能性。
1971 年,我开始扩展 B 语言,增加了一个字符类型,并重写了它的编译器,使其生成 PDP-11 机器指令而不是线程代码。因此,从 B 到 C 的过渡与编译器的创建是同步的,该编译器能够快速生成程序,并且程序小到足以与汇编语言相媲美。我将稍微扩展的语言称为 NB,即“新 B”。
胚胎C
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的声明 省略了大小,以断言不应自动分配存储空间。在程序中,该语言对指针的解释与对数组变量的解释相同:指针声明会创建一个单元,与数组声明的区别仅在于程序员需要分配一个引用对象,而不是让编译器分配空间并初始化单元。
存储在绑定到数组和指针名称的单元中的值是相应存储区域的机器地址(以字节为单位)。因此,通过指针进行间接寻址意味着无需在运行时将指针从字缩放为字节偏移量。另一方面,数组下标和指针算法的机器代码现在取决于数组或指针的类型:计算 iarray[i] 或 ipointer+i 意味着将加数 i 按引用对象的大小缩放。
这些语义代表了从 B 的简单过渡,我试验了几个月。当我尝试扩展类型符号时,问题变得明显,尤其是添加结构化(记录)类型。结构似乎应该以直观的方式映射到机器的内存中,但在包含数组的结构中,没有好的地方来存储包含数组基数的指针,也没有任何方便的方式来安排初始化它。例如,早期 Unix 系统的目录条目可能在 C 中描述为
struct { int inumber; char name[14]; };
我希望结构不仅能表征抽象对象,还能描述可以从目录中读取的位集合。编译器可以在哪里隐藏 语义所要求的名称指针 ?即使结构被更抽象地考虑,并且指针的空间可以以某种方式隐藏,我该如何处理在分配复杂对象(也许是指定包含包含任意深度结构的数组的结构)时正确初始化这些指针的技术问题?
该解决方案是无类型 BCPL 和类型化 C 之间的进化链中的关键一跃。它消除了存储中指针的物化,而是在表达式中提到数组名称时创建指针。该规则在当今的 C 中仍然存在,即当数组类型的值出现在表达式中时,它们会被转换为指向组成数组的第一个对象的指针。
这项发明使大多数现有的 B 语言代码能够继续工作,尽管语言语义发生了根本性变化。少数程序将新值赋给数组名称以调整其来源(在 B 和 BCPL 中可能,在 C 中毫无意义)很容易修复。更重要的是,新语言保留了对数组语义的连贯且可行的(尽管不寻常)解释,同时为更全面的类型结构开辟了道路。
第二个创新是 C 与其前辈最明显的区别,即更完整的类型结构,尤其是其在声明语法中的表达。NB 提供了基本类型 int 和 char,以及它们的数组和指向它们的指针,但没有进一步的组合方式。需要泛化:给定任何类型的对象,应该可以描述一个新对象,该对象将多个对象聚集到一个数组中,从函数中生成它,或者是指向它的指针。
对于这种组合类型的每个对象,已经有一种方法可以提及底层对象:索引数组、调用函数、对指针使用间接运算符。类比推理导致名称的声明语法与名称通常出现的表达式语法相似。因此,
int i,*pi,**ppi;
声明一个整数、一个指向整数的指针、一个指向指向整数的指针的指针。这些声明的语法反映了以下观察: i、 *pi和 **ppi 在表达式中使用时 均会产生 int类型。类似地,
int f(),*f(),(*f)();
声明一个返回整数的函数、一个返回指向整数的指针的函数、一个指向返回整数的函数的指针;
int *api[10],(*pai)[10];
声明一个指向整数的指针数组和一个指向整数数组的指针。在所有这些情况下,变量的声明类似于其在表达式中的使用,表达式的类型是声明开头指定的类型。
C 所采用的类型组合方案在很大程度上归功于 Algol 68,尽管它的形式可能不是 Algol 的追随者所认可的。我从 Algol 中捕捉到的核心概念是基于原子类型(包括结构)的类型结构,由数组、指针(引用)和函数(过程)组成。Algol 68 的联合和强制类型转换概念也产生了后来出现的影响。
在为新语言创建了类型系统、相关语法和编译器之后,我觉得它应该有一个新名字;NB 似乎不够独特。我决定遵循单字母风格,将其命名为 C,至于这个名字是代表字母表的进展还是 BCPL 中的字母的进展,则留待以后讨论。
新生儿C
语言命名后,快速的变化仍在继续,例如引入了 && 和 || 运算 符。在 BCPL 和 B 中,表达式的求值取决于上下文:在 if和其他将表达式的值与零进行比较的条件语句中,这些语言对and ( & ) 和 or ( | ) 运算符 进行了特殊解释 。在普通上下文中,它们按位运算,但在 B 语句中
if (e1 & e2) ...
编译器必须评估 e1 ,如果它非零,则评估 e2,如果它也非零,则详细说明依赖于 if 的语句。该要求在e1 和 e2中递归地递归到 & 和 | 运算符 。布尔运算符在这种“真值”上下文中的短路语义似乎是可取的,但运算符的重载很难解释和使用。在 Alan Snyder 的建议下,我引入了 && 和 || 运算 符,以使机制更加明确。
它们的迟缓引入解释了 C 的优先规则的不恰当。在 B 中有人写道
if (a==b & c) ...
检查 a是否 等于 b 且 c 是否非零;在这样的条件表达式中, & 的 优先级最好低于 ==。在从 B 转换为 C 时,人们希望 在这样的语句中用 && 替换 & ;为了使转换不那么痛苦,我们决定保持 & 运算符相对于 == 的优先级 相同 ,并且仅 将 &&的优先级与 & 略有不同 。今天 , 似乎最好移动 & 和 ==的相对优先级,从而简化常见的 C 习惯用法:要将屏蔽值与另一个值进行比较,必须编写
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 和我出版了 《C 编程语言》 [Kernighan 78]。尽管它没有描述一些很快变得普遍的附加功能,但这本书一直作为语言参考,直到十多年后采用正式标准。尽管我们在本书的编写过程中密切合作,但分工却很明确:Kernighan 撰写了几乎所有的说明性材料,而我负责包含参考手册的附录以及有关与 Unix 系统接口的章节。
在 1973-1980 年间,该语言有所发展:类型结构增加了无符号、长整型、联合和枚举类型,结构几乎成为一等对象(只缺少文字符号)。其环境和相关技术也出现了同样重要的发展。用 C 编写 Unix 内核让我们对该语言的实用性和效率有足够的信心,因此我们开始重新编码系统的实用程序和工具,然后将其中最有趣的部分转移到其他平台。如 [Johnson 78a] 所述,我们发现传播 Unix 工具的最困难问题不在于 C 语言与新硬件的交互,而在于适应其他操作系统的现有软件。因此,史蒂夫·约翰逊开始研究 pcc,这是一种旨在轻松重新定位到新机器的 C 编译器 [Johnson 78b],而他、汤普森和我开始将 Unix 系统本身转移到 Interdata 8/32 计算机。
这一时期的语言变化,尤其是 1977 年左右的变化,主要集中在可移植性和类型安全性的考虑上,旨在解决我们在将大量代码迁移到新的 Interdata 平台时预见和观察到的问题。当时的 C 语言仍然表现出其无类型起源的强烈迹象。例如,在早期的语言手册或现存代码中,指针与整数内存索引几乎没有区别;字符指针和无符号整数的算术属性相似,让人难以抗拒识别它们的诱惑。 添加了无符号 类型,使无符号算术可用,而不会将其与指针操作混淆。同样,早期的语言允许整数和指针之间的赋值,但这种做法开始受到阻止;发明了一种类型转换符号(从 Algol 68 的例子中称为“强制转换”)来更明确地指定类型转换。早期的 C 受到 PL/I 示例的欺骗,没有将结构指针与它们指向的结构牢固地绑定在一起,并且允许程序员 几乎不考虑 指针的类型而编写指针->成员;这种表达式被不加批判地视为对指针指定的内存区域的引用,而成员名称仅指定了偏移量和类型。
尽管 K&R 的第一版描述了将 C 的类型结构变为现在形式的大部分规则,但许多以较旧、较宽松的风格编写的程序仍然存在,容忍它的编译器也是如此。为了鼓励人们更多地关注官方语言规则,检测合法但可疑的构造,并帮助找到简单的单独编译机制无法检测到的接口不匹配,Steve Johnson 改编了他的 pcc 编译器以生成 lint [Johnson 79b],它可以扫描一组文件并注释可疑的构造。
使用量增长
我们在 Interdata 8/32 上进行的可移植性实验的成功,很快促使 Tom London 和 John Reiser 在 DEC VAX 11/780 上进行了另一次实验。这台机器比 Interdata 更受欢迎,Unix 和 C 语言开始迅速传播,无论是在 AT&T 内部还是外部。尽管到 20 世纪 70 年代中期,Unix 已被贝尔系统内的各种项目以及我们公司以外的一小部分以研究为导向的工业、学术和政府组织使用,但它的真正发展是在实现可移植性之后才开始的。特别值得注意的是 AT&T 新兴计算机系统部门基于公司开发和研究小组的工作而推出的系统 III 和系统 V 版本,以及加州大学伯克利分校发布的源自贝尔实验室研究组织的 BSD 系列版本。
在 20 世纪 80 年代,C 语言的使用范围广泛,几乎所有机器架构和操作系统都提供了编译器;尤其是它作为个人计算机的编程工具而广受欢迎,无论是对于这些机器的商业软件制造商,还是对编程感兴趣的最终用户。在本世纪初,几乎所有编译器都基于 Johnson 的 pcc;到 1985 年,已经有许多独立生产的编译器产品。
标准化
到 1982 年,很明显 C 需要正式标准化。最接近标准的 K&R 第一版不再描述实际使用的语言;特别是,它既没有提到 void类型 也 没有提到枚举 类型。虽然它预示了结构的新方法,但只有在发布之后,该语言才支持对结构进行赋值、将它们传递给函数和从函数传递,以及将成员的名称与包含它们的结构或联合紧密关联。尽管 AT&T 分发的编译器采用了这些更改,并且大多数不基于 pcc的编译器供应商 也很快采用了它们,但仍然没有对该语言的完整、权威的描述。
K&R 的第一版在语言的许多细节上也不够精确,将 pcc 视为“参考编译器”变得越来越不切实际;它甚至没有完美地体现 K&R 描述的语言,更不用说后续的扩展了。最后,C 在商业和政府合同项目中的初期使用意味着官方标准的认可很重要。因此(在 MD McIlroy 的敦促下),ANSI 于 1983 年夏天在 CBEMA 的指导下成立了 X3J11 委员会,目标是制定 C 标准。X3J11 于 1989 年底发布了报告 [ANSI 89],随后该标准被 ISO 接受为 ISO/IEC 9899-1990。
从一开始,X3J11 委员会就对语言扩展持谨慎、保守的态度。令我非常满意的是,他们认真对待自己的目标:“为 C 编程语言制定一个清晰、一致、明确的标准,该标准将 C 的现有通用定义编纂成法典,并促进用户程序在 C 语言环境中的可移植性。”[ANSI 89] 委员会意识到,仅仅颁布标准并不能改变世界。
X3J11 只对语言本身进行了一项真正重要的更改:它使用从 C++ [Stroustrup 86] 借用的语法,将形式参数的类型合并到函数的类型签名中。在旧样式中,外部函数声明如下:
double sin();
只说 sin是一个返回double (即双精度浮点)值的 函数 。在新样式中,这更好地呈现了
double sin(double);
使参数类型明确,从而鼓励更好的类型检查和适当的转换。即使这一添加,虽然它产生了明显更好的语言,也带来了困难。委员会有理由认为,简单地取缔“旧式”函数定义和声明是不可行的,但也同意新形式更好。不可避免的妥协已经达到了最好的效果,尽管语言定义因允许两种形式而变得复杂,而便携式软件的编写者必须应对尚未达到标准的编译器。
X3J11 还引入了许多较小的添加和调整,例如类型限定符 const 和 volatile,以及略有不同的类型提升规则。尽管如此,标准化过程并没有改变语言的特性。特别是,C 标准并没有试图正式指定语言语义,因此可能会对细节产生争议;尽管如此,它成功地解释了自原始描述以来用法的变化,并且足够精确,可以以此为基础实现。
因此,核心 C 语言几乎毫发无损地逃脱了标准化过程,而标准更像是一种更好、更谨慎的编纂,而不是一项新发明。语言的周围发生了更重要的变化:预处理器和库。预处理器使用与语言其余部分不同的约定执行宏替换。它与编译器的交互从未得到很好的描述,而 X3J11 试图纠正这种情况。结果明显优于 K&R 第一版中的解释;除了更全面之外,它还提供了以前只能通过偶然实现的操作(如标记连接)。
X3J11 正确地认为,对标准 C 库的完整而仔细的描述与语言本身的工作同样重要。C 语言本身不提供输入输出或与外界的任何其他交互,因此依赖于一组标准程序。在 K&R 发布时,C 被认为主要是 Unix 的系统编程语言;尽管我们提供了旨在轻松移植到其他操作系统的库例程示例,但人们隐含地理解了 Unix 的底层支持。因此,X3J11 委员会花费了大量时间来设计和记录一组库例程,这些例程必须在所有符合要求的实现中提供。
根据标准流程规则,X3J11 委员会的当前活动仅限于发布对现有标准的解释。但是,最初由 Rex Jaeschke 召集的非正式小组 NCEG(数值 C 扩展组)已被正式接受为子组 X3J11.1,他们继续考虑对 C 进行扩展。顾名思义,许多可能的扩展旨在使该语言更适合数字用途:例如,边界动态确定的多维数组、合并用于处理 IEEE 算术的工具,以及使该语言在具有矢量或其他高级架构功能的机器上更有效。并非所有可能的扩展都是专门针对数字的;它们包括结构文字的符号。
继任者
C 甚至 B 都有几个直接的后代,尽管它们在产生后代方面无法与 Pascal 相媲美。一个侧枝很早就发展起来了。1972 年,史蒂夫·约翰逊在休假期间访问滑铁卢大学时,他带来了 B。它在那里的霍尼韦尔机器上流行起来,后来又衍生出 Eh 和 Zed(加拿大人对“B 之后是什么?”的回答)。1973 年,约翰逊回到贝尔实验室时,他不安地发现,他带到加拿大的语言种子已经在国内发展起来了;甚至他自己的 yacc 程序也被艾伦·斯奈德用 C 语言重写了。
C 语言最近的衍生语言包括 Concurrent C [Gehani 89]、Objective C [Cox 86]、C* [Thinking 90],尤其是 C++ [Stroustrup 86]。该语言还被广泛用作各种编译器的中间表示(本质上是一种可移植的汇编语言),既用于 C++ 等直接衍生语言,也用于 Modula 3 [Nelson 91] 和 Eiffel [Meyer 88] 等独立语言。
批判
在同类语言中,C 语言有两个最典型的特征:数组和指针之间的关系,以及声明语法模仿表达式语法的方式。它们也是 C 语言最常被批评的特征之一,并且经常成为初学者的绊脚石。在这两种情况下,历史事故或错误都加剧了它们的难度。其中最重要的是 C 编译器对类型错误的容忍度。从上述历史中可以清楚地看出,C 语言是从无类型语言发展而来的。对于其最早的用户和开发人员来说,它并不是突然出现的具有自己规则的全新语言;相反,我们必须随着语言的发展不断调整现有程序,并考虑到现有的代码体。(后来,标准化 C 语言的 ANSI X3J11 委员会也面临同样的问题。)
1977 年及之后的编译器并没有抱怨诸如在整数和指针之间赋值或使用错误类型的对象引用结构成员之类的用法。尽管 K&R 第一版中提出的语言定义在处理类型规则方面相当(但并不完全)连贯,但该书承认现有编译器没有强制执行这些规则。此外,一些旨在简化早期转换的规则导致了后来的混乱。例如,函数声明中的空方括号
int f(a) int a[]; { ... }
是一个活化石,是 NB 声明指针方式的残余; a 仅在这种特殊情况下在 C 中被解释为指针。这种表示法保留下来,部分是为了兼容性,部分是因为它允许程序员向读者传达将 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 的声明方法仍然是合理的,并且我对此感到满意;它是一个有用的统一原则。
C 语言的另一个特征是它对数组的处理,从实际角度来看,它更令人怀疑,尽管它也有真正的优点。虽然指针和数组之间的关系不常见,但它是可以学习的。此外,该语言表现出相当大的能力来描述重要的概念,例如,长度在运行时变化的向量,只需要一些基本的规则和约定。特别是,字符串的处理机制与任何其他数组相同,再加上一个约定,即空字符终止字符串。将 C 的方法与两种几乎同时出现的语言 Algol 68 和 Pascal 的方法进行比较是很有趣的 [Jensen 74]。Algol 68 中的数组要么有固定的边界,要么是“灵活的”:语言定义和编译器都需要相当的机制来适应灵活的数组(并不是所有的编译器都完全实现了它们)。原始的 Pascal 只有固定大小的数组和字符串,这被证明是有限的。后来,这个问题得到了部分修复,但最终的语言还没有普及。
C 将字符串视为通常以标记结尾的字符数组。除了有关通过字符串文字初始化的特殊规则外,字符串的语义完全被管理所有数组的更通用的规则所涵盖,因此,与将字符串作为唯一数据类型的语言相比,该语言更易于描述和翻译。这种方法会产生一些成本:某些字符串操作比其他设计更昂贵,因为应用程序代码或库例程必须偶尔搜索字符串的结尾,因为可用的内置操作很少,并且因为字符串的存储管理负担更多地落在用户身上。尽管如此,C 处理字符串的方法仍然很有效。
另一方面,C 对数组(不仅仅是字符串)的处理方式对优化和未来的扩展都有不利影响。C 程序中指针的普遍性(无论是明确声明的还是来自数组的指针)意味着优化器必须谨慎,并且必须使用谨慎的数据流技术才能获得良好的结果。复杂的编译器可以理解大多数指针可能改变什么,但一些重要的用法仍然难以分析。例如,带有从数组派生的指针参数的函数很难在向量机上编译成高效的代码,因为很少能够确定一个参数指针不与另一个参数引用的数据重叠,或者外部可访问的数据重叠。更根本的是,C 的定义如此具体地描述了数组的语义,以至于将数组视为更原始的对象并允许对它们进行整体操作的变化或扩展变得难以适应现有语言。即使扩展以允许声明和使用其大小动态确定的多维数组也并非完全简单 [MacDonald 89] [Ritchie 90],尽管它们会使用 C 编写数值库变得更加容易。因此,C 通过统一而简单的机制涵盖了实践中出现的字符串和数组的最重要用途,但为高效实现和扩展留下了问题。
当然,除了上面讨论的那些之外,该语言及其描述中还存在许多较小的缺陷。还有一些超越细节的一般批评。其中最主要的是,该语言及其普遍预期的环境对编写非常大的系统几乎没有帮助。命名结构只提供两个主要级别,“外部”(随处可见)和“内部”(在单个过程中)。中间级别的可见性(在单个数据和过程文件中)与语言定义的联系很弱。因此,对模块化的直接支持很少,项目设计者被迫创建自己的约定。
类似地,C 本身提供了两种存储持续时间:当控制位于过程中或过程之下时存在的“自动”对象,以及在整个程序执行过程中存在的“静态”对象。栈外动态分配的存储仅由库例程提供,管理它的负担落在程序员身上:C 不利于自动垃圾收集。
c 为何成功?
C 的成功程度远远超出了人们的早期预期。哪些特质促成了它的广泛使用?
毫无疑问,Unix 本身的成功是最重要的因素;它让成千上万的人可以使用该语言。当然,相反,Unix 使用 C 语言以及因此而可移植到各种机器上,对系统的成功至关重要。但该语言对其他环境的入侵表明了更根本的优点。
尽管有些方面对于初学者甚至是熟练者来说都很神秘,但 C 仍然是一种简单而小巧的语言,可以使用简单而小巧的编译器进行翻译。它的类型和操作以真实机器提供的类型和操作为基础,对于熟悉计算机工作原理的人来说,学习生成时间和空间高效的程序的习惯用法并不困难。同时,该语言充分抽象了机器细节,可以实现程序的可移植性。
同样重要的是,C 及其中央库支持始终与真实环境保持联系。它不是孤立地设计来证明某个观点或作为示例,而是作为编写可执行有用操作的程序的工具;它始终旨在与更大的操作系统交互,并被视为构建更大工具的工具。简约、务实的方法影响了 C 的形成:它满足了许多程序员的基本需求,但不会试图提供太多。
最后,尽管自首次发布描述以来,C 语言经历了多次变化,但这种变化无疑是非正式的和不完整的,数百万使用不同编译器的用户所看到的 C 语言与同样广泛使用的语言(例如 Pascal 和 Fortran)相比仍然非常稳定和统一。C 语言有不同的方言 - 最明显的是旧版 K&R 和新版标准 C 所描述的方言 - 但总体而言,C 语言比其他语言更不受专有扩展的影响。也许最重要的扩展是“远”和“近”指针限定,旨在处理某些 Intel 处理器的特性。尽管 C 语言最初设计时并非以可移植性为主要目标,但它成功地在从最小的个人计算机到最强大的超级计算机的各种机器上表达了程序,甚至包括操作系统。
C 语言古怪、有缺陷,但取得了巨大的成功。虽然历史的偶然因素确实起了一定作用,但它显然满足了人们对系统实现语言的需求,这种语言足够高效,可以取代汇编语言,同时又足够抽象和流畅,可以描述各种环境中的算法和交互。