C语言本身是用什么★☆★语言写的?
听上去有些荒谬,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和相关硬件系统的关系。
是先有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”)语言,它采用了编译模式而不是解释模式,并引入了类型系统,每个变量在使用前必须先声明。
C语言的早期体验
增加类型系统的主要目的是帮助编译器设计者区分新型PDP-11机器所拥有的不同数据类型,如单精度浮点数、双精度浮点数和字符等。这与其他一些语言如Pascal形成了鲜明的对比。在Pascal中,类型系统的目的是保护程序员,防止他们在数据上进行无效的操作。由于设计哲学不同,C语言排斥强类型,它允许程序员需要时可以在不同类型的对象间赋值。类型系统的加入可以说是事后诸葛,从未在可用性方面进行过认真的评估和严格的测试。时至今日,许多C程序员仍然认为“强类型”只不过是增加了敲击键盘的无用功。
除了类型系统之外,C语言的许多其他特性是为了方便编译器设计者而建立的(为什么不呢?开始几年C语言的主要客户就是那些编译器设计者啊)。根据编译器设计者的思路而发展形成的语言特性有:
- 数组下标从0而不是1开始。绝大多数人习惯从1而不是0开始计数。编译器设计者则选择从0开始,因为偏移量的概念在他们心中已是根深蒂固。但这种设计让一般人感觉很别扭。尽管我们定义了一个数组a[100],你可千万别往a[100]里存储数据,因为这个数组的合法范围是从a[0]到a[99]。
- C语言的基本数据类型直接与底层硬件相对应。例如,不像Fortran,C语言中不存在内置的复数类型。某种语言要素如果底层硬件没有提供直接的支持,那么编译器设计者就不会在它上面浪费任何精力。C语言一开始并不支持浮点类型,直到硬件系统能够直接支持浮点数之后才增加了对它的支持。
- auto关键字显然是摆设。这个关键字只对创建符号表入口的编译器设计者有意义。它的意思是“在进入程序块时自动进行内存分配”(与全局静态分配或在堆上动态分配相反)。其他程序员不必操心auto这个关键字,它是缺省的变量内存分配模式。
- 表达式中的数组名可以看作是指针。把数组当作指针,简化了很多东西。我们不再需要一种复杂的机制区分它们,把它们传递到一个函数时不必忍受必须复制所有数组内容的低效率。不过,数组和指针并不是在任何情况下都是等效的,更详细的讨论参见第4章。
- float被自动扩展为double。尽管在ANSI C中情况不再如此,但最初浮点数常量的精度都是double型的,所有表达式中float变量总被自动转换成double。这样做的理由从未公诸于众,但它与PDP-11中浮点数的硬件表示方式有关。首先,在PDP-11或VAX中,从float转换到double代价非常小,只要在后面增加一个每个位均为0的字即可。如果要转换回来,去掉第二个字就可以了。其次,要知道在某些PDP-11的浮点数硬件表示形式中有一个运算模式位(mode bit),你可以只进行float的运算,也可以只进行double的运算,但如果想在这两种方式间进行切换,就必须修改这个位来改变运算模式。在早期的UNIX程序中,float用得不是太多,所以把运算模式固定为double 是比较方便的,省得编译器设计者去跟踪它的变化。
- 不允许嵌套函数(函数内部包含另一个函数的定义)。这简化了编译器,并稍微提高了C程序的运行时组织结构。具体的机理在第6章“运动的诗章:运行时数据结构”中详细描述。
- register关键字。这个关键字能给编译器设计者提供线索,就是程序中的哪些变量属于热门(经常被使用),这样就可以把它们存放到寄存器中。这个设计可以说是一个失误,如果让编译器在使用各个变量时自动处理寄存器的分配工作,显然比一经声明就把这类变量在生命期内始终保留在寄存器里要好。使用register关键字,简化了编译器,却把包袱丢给了程序员。
为了C编译器设计者的方便而建立的其他语言特性还有很多。这本身不是一件坏事,它大大简化了C语言本身,而且通过回避一些复杂的语言要素(如Ada中的泛型和任务,PL/I中的字符串处理,C 中的模板和多重继承),C语言更容易学习和实现,而且效率非常高。
和其他大多数语言不同,C语言有一个漫长的进化过程。在目前这个形式之前,它经历了许多中间状态。它历经多年,从一个实用工具进化为一种经过大量试验和测试的语言。第一个C编译器大约出现在1970年,距今20多年了[3]。时光荏苒,作为它的根基的UNIX系统得到了广泛使用,C语言也随之茁壮成长。它对直接由硬件支持的底层操作的强调,带来了极高的效率和移植性,反过来也帮助UNIX获得了巨大的成功。
K&R C
到了20世纪70年代中期,C语言已经很接近目前这种我们所知道和喜爱的形式了。更多的改进仍然存在,但大部分都只是一些细节的变化(比如允许函数返回结构值)和一些对基本类型进行扩展以适应新的硬件变化的改进。(比如增加关键字unsigned和long)。1978年,Steve Johnson编写了pcc这个可移植的C编译器。它的源代码对贝尔实验室之外开放,并被广泛移植,形成了整整一代C编译器的基础。C语言的演化之路如图1-2所示。
图1-2 后期的C
软件信条
一个非比寻常的Bug
C语言从Algol-68中继承了一个特性,就是复合赋值符。它允许对一个重复出现的操作数只写一次而不是两次,给代码生成器一个提示,即操作数寻址也可以类似地紧凑。这方面的一个例子是用b =3作为b=b 3的缩写。复合赋值符最初的写法是先写赋值符,再写操作符,就像:b= 3。在B语言的词法分析器里有一个技巧,使实现=op这种形式要比实现目前所使用的op=形式更简单一些。但这种形式会引起混淆,它很容易把
b=-3; /* 从b中减去3 */
和
b= -3; /* 把-3赋给b */
搞混淆。
因此,这个特性被修改为目前所使用的这种形式。作为修改的一部分,代码格式器程序indent也作了相应修改,用于确定复合赋值符的过时形式,并交换两者的位置,把它转换为对应的标准形式。这是个非常糟糕的决定,任何格式器都不应该修改程序中除空白之外的任何东西。令人不快的是,这种做法会引入一个Bug,就是几乎任何东西(只要不是变量),如果它出现在赋值符后面,就会与赋值符交换位置。
如果你运气好,这个Bug可能会引起语法错误,如:
epsilon=.0001;
会被交换成:
epsilon.=0001;
这条语句将无法通过编译器,你马上就能发现错误。但一条源语句也可能是这样的:
valve=!open; /*valve被设置为open的逻辑反*/
会悄无声息地交换成:
valve!=open; /*valve与open进行不相等比较*/
这条语句同样能够通过编译,但它的作用与源语句明显不同,它并不改变valve的值。
在后面这种情况下,这个Bug会潜伏下来,并不会被马上检测到。在赋值后面加个空格是很自然的事,所以随着复合赋值符的过时形式越来越罕见,人们也逐渐忘记了indent程序曾经被用于“改进”这种过时的形式。这个由indent程序引起的 Bug直到20世纪80年代中期才在各种C编译器中销声匿迹。这是一个应被坚决摒弃的东西!
1978年,C语言经典名著The C Programming Language出版了。这本书受到了广泛的赞誉,其作者Brian Kernighan和Dennis Ritchie也因此名声大噪,所以这个版本的C语言就被称为“K&R C”。出版商最初估计这本书将售出1000册左右。截止到1994年,这本书大约售出了150万册(参见图1-3)。C语言成为最近20年最成功的编程语言之一,可能就是最成功的。但随着C语言的广泛流行,许多人试图从C语言中产生其他变种。
图1-3 像猫王艾尔维斯一样,C语言无处不在
本段截选自《C专家编程》
UNIX系统
由于C语言因UNIX系统而生,也因此而流行,所以我们从UNIX系统开始(注意:我们提到的UNIX还包含其他系统,如FreeBSD,它是UNIX的一个分支,但是由于法律原因不使用该名称)。
1.在UNIX系统上编辑
UNIX C没有自己的编辑器,但是可以使用通用的UNIX编辑器,如emacs、jove、vi或X Window System文本编辑器。
作为程序员,要负责输入正确的程序和为储存该程序的文件起一个合适的文件名。如前所述,文件名应该以.c结尾。注意,UNIX区分大小写。因此,budget.c、BUDGET.c和Budget.c是3个不同但都有效的C源文件名。但是BUDGET.C是无效文件名,因为该名称的扩展名使用了大写C而不是小写c。
假设我们在vi编译器中编写了下面的程序,并将其储存在inform.c文件中:
#include <stdio.h>int main(void){ printf('A .c is used to end a C program filename.\n'); return 0;}
以上文本就是源代码,inform.c是源文件。注意,源文件是整个编译过程的开始,不是结束。
2.在UNIX系统上编译
虽然在我们看来,程序完美无缺,但是对计算机而言,这是一堆乱码。计算机不明白#include和printf是什么(也许你现在也不明白,但是学到后面就会明白,而计算机却不会)。如前所述,我们需要编译器将我们编写的代码(源代码)翻译成计算机能看懂的代码(机器代码)。最后生成的可执行文件中包含计算机要完成任务所需的所有机器代码。
以前,UNIX C编译器要调用语言定义的cc命令。但是,它没有跟上标准发展的脚步,已经退出了历史舞台。但是,UNIX系统提供的C编译器通常来自一些其他源,然后以cc命令作为编译器的别名。因此,虽然在不同的系统中会调用不同的编译器,但用户仍可以继续使用相同的命令。
编译inform.c,要输入以下命令:
cc inform.c
几秒钟后,会返回UNIX的提示,告诉用户任务已完成。如果程序编写错误,你可能会看到警告或错误消息,但我们先假设编写的程序完全正确(如果编译器报告void的错误,说明你的系统未更新成ANSI C编译器,只需删除void即可)。如果使用ls命令列出文件,会发现有一个a.out文件(见图1.5)。该文件是包含已翻译(或已编译)程序的可执行文件。要运行该文件,只需输入:
a.out
输出内容如下:
A .c is used to end a C program filename.
图1.5 用UNIX准备C程序
如果要储存可执行文件(a.out),应该把它重命名。否则,该文件会被下一次编译程序时生成的新a.out文件替换。
如何处理目标代码?C编译器会创建一个与源代码基本名相同的目标代码文件,但是其扩展名是.o。在该例中,目标代码文件是inform.o。然而,却找不到这个文件,因为一旦链接器生成了完整的可执行程序,就会将其删除。如果原始程序有多个源代码文件,则保留目标代码文件。学到后面多文件程序时,你会明白到这样做的好处。
Linux系统
Linux是一个开源、流行、类似于UNIX的操作系统,可在不同平台(包括PC和Mac)上运行。在Linux中准备C程序与在UNIX系统中几乎一样,不同的是要使用GNU提供的GCC公共域C编译器。编译命令类似于:
gcc inform.c
注意,在安装Linux时,可选择是否安装GCC。如果之前没有安装GCC,则必须安装。通常,安装过程会将cc作为gcc的别名,因此可以在命令行中使用cc来代替gcc。
本段截选自《C Primer Plus(第6版)中文版》