本文翻译自Dr. Dobb's Journal官网上的一篇由Brian W. Kernighan和Dennis M. Ritchie共同撰写的名为"The State of C"的文章。这里谨将此篇译文献给不久前刚刚离我们而去的C语言之父 – Dennis M. Ritchie,愿一代计算机科学巨匠一路走好。

不再只是为了系统级编程(system programming)

C是一门通用的计算机编程语言,它最初由贝尔实验室(Bell Labs)的Dennis Rithie于1972年左右设计并实现。C的早期发展与其被用于Unix系统的实现密不可分,终端以及Unix系统上运行的大多数程序都是由C开发的。近些年,C在更为广泛的环境中逐渐流行起来,并且它不再依赖任何操作系统或机器了。

C最初被设计成一门用于"系统级编程"的语言,也就是说它被用于编写诸如编译器,操作系统以及文本编辑器之类的程序。不过它也已被证明非常适合其他类型程序的开发,包括数据库系统,电话交换系统,数值分析,工程程序以及大规模的字处理软件。今天,C已经成为世界上使用最为广泛的编程语言之一,并且几乎在每台计算机上我们都能看到C语言编译器的身影。

C的起源

C源于BCPL语言,后者由Martin Richards于1967年左右设计实现。BCPL是一门"无类型"的编程语言:它仅能操作一种数据类型,即机器字(machine word)。也正因为如此,BCPL特别适合面向机器字的硬件。1970年,Ken Thompson为运行在PDP-7上的首个Unix系统设计了一个精简版的BCPL,这个语言被称为B。它也是无类型的。

随着PDP-11的出现,下一版Unix也在PDP-11上实现,一个无类型的语言愈发显得不再适合这一硬件。PDP-11提供了多种不同规格大小的基本对象 – 一字节长的字符,两字节长的整型数以及四字节长的浮点数 – B语言无法处理这些不同规格大小的对象,也没有提供单独的操作符去操作它们。

C语言最初尝试通过向B语言中增加数据类型的想法来处理那些不同类型的数据。和大多数语言一样,在C中,每个对象都有一个类型以及一个值;类型决定了可用于值的操作的含义,以及对象占用的存储空间大小。例如,像int i, j;double d;以及float x这些声明决定了适于变量的操作集以及变量的存储空间需求。在语句d = x + i * j中,编译器使用类型信息确定这个整数乘法适合表达式i * j,但是在其结果与x相加之前,这个结果值必须先转换成一个浮点类型,然后最终的结果在被赋值给d时也必须先转换为双精度浮点类型。

虽然C最初是在一台PDP-II上实现的,但其早在1975年就开始在其他机器上使用了。Steve Johnson实现了一套"可移植编译器",这套编译器修改起来相对容易,并且可以为不同的机器生成代码。从那时起,C就已经在大多数计算机上被实现,从最小的微型计算机到与CRAY-2(译注:一种庞大的超级计算机)规模大小相近的大型机器。C语言很规范,即使没有一份正式的标准,你也可以写出C程序,这些程序无须修改就可以运行在任何支持C语言和最小运行时环境的机器上。

C最初在小型机器上实现,并且继承了一系列小语种编程语言的特点;与功能相比,C的设计者更倾向于简单和优雅。此外,从一开始,C语言就是为系统级编程而设计,程序的运行效率至关重要。因此,C语言与真实机器能力的良好匹配也就不足为奇了。例如,C语言为典型硬件所直接支持的对象:字符,整数(也许有多种大小),以及浮点数字(同样可能有多种大小)提供了相应的基本数据类型。

你可以创建一些更为复杂的对象,诸如数组,结构体等,但是C语言几乎没有提供将这些对象作为一个整体进行操作的操作符;你必须自己编写函数来实现诸如字符串比较,将一个数组赋值给另一个数组等功能。

还有一些不同寻常的是,C语言本身并没有提供输入输出操作。当然,这并不是说C程序无法进行I/O操作,只不过C语言中的IO操作是通过用户定义函数或库中的函数来完成的,而不是通过语言内置的语句。这与作为语言一部分的FORTRAN的READ和WRITE,BASIC的INPUT和PRINT恰恰相反。

C语言本身未提供的功能还包括:它没有存储管理,比如像Pascal语言的new函数那样;它没有提供并行处理的基础设施,比如像Ada的rendezvous机制那样。你可以很容易地用C实现这些功能,不过它们已经通过函数库提供了,并且同样并非语言本身的一部分。在符号记法方面,函数调用要比直接使用操作符显得更加笨拙,例如,BASIC中的字符串比较语句如下:

IF A$ = B$ THEN

使用C语言,你可能会像这样实现这一功能:

if (equal(a, b))…

与内嵌代码相比,函数调用还会带来更多的额外性能开销。

不管怎样,C中省略的特性的程度也是它的显著特点之一。

语言元素

控制流:C语言中的控制流相当传统,但比FORTRAN或BASIC语言提供的更为丰富。C提供了两种决策语句:if … else和switch。在下面语句中

if (expr) statl else stat2

expr被求值;如果求值结果为真(非零),语句stat1会被执行;否则,语句stat2会被执行。整个语句中的else部分是可选的。在下面语句中

switch (expr) {
   case const1: stat1
   case const2: stat2
   …
   default: stat
}

expr被求值,求值结果再与各个case中的常量相比较。如果找到一个匹配的case,那么对应的stat就会被执行。如果没有找到可以匹配的case,default部分对应的stat将会被执行。default部分是可选的。C语言中的switch语句有些类似Pascal中的case语句,只是后者没有default部分。

C同样也提供了三种循环:while,for和do。在下面语句中

while (expr) stat

expr被求值;如果求值结果为真,stat将会被执行,并且expr会被再次求值。当expr求值结果为假时,这个循环结束。语句:

for (stat1; expr; stat3) stat2

等价于下面的while循环:

stat1
while (expr) {
  stat2
  stat3
}

除了结束条件测试的含义不同之外,do语句与Pascal中的repeat…until语句很相似,在下面的语句中:

do stat while(expr)

stat被执行,并且expr被求值和测试。如果求值结果为真,这个循环将重复执行。

break语句执行的结果是从一个封闭的循环或switch语句立即跳出;而continue语句执行结果则是使得一个循环的下一次迭代立即开始。C还提供了goto语句,但这个语句很少使用。

在上述所有例子中,stat可以是一个单一的语句,比如x = 3或是包含在括号内的一组语句,这里提到的括号类似于其他语言中的begin…end。语句以分号结束。

数据类型:C语言提供的基本类型包括char(一个字节);int,short和long(不同长度的整型);以及float和double(两种不同长度的浮点数)。字符和不同的整型数可以是有符号的或者无符号的。

使用数组,结构体,联合体以及指针,你可以将这些对象组合成一个"派生"数据类型的无限集合(原则上),我们常见的数组:

char mesg[100];

定义了一个100个字节的数组mesg,通过mesg[0]到mesg[99]我们可以访问到数组中的每个元素。C没有提供字符串数据类型;而是用结尾字节为0的字符数组代替字符串。这就是编译器生成诸如字符串常量"hello world\n"的方法。在一个字符串中,某些"转义序列",诸如\n,用于表示特定的字符,比如换行符。"hello world\n"这个字符串包含了12个字符以及一个结尾0字节。

结构体是一些不必具有相同数据类型的相关变量的集合(类似Pascal中的record)。例如,

struct object {
   int x, y;   /* position */
   float v;    /* velocity */
   char id[10]; /* identification */
};
struct object obj;

声明了一个名为object的结构体并且定义了一个该结构体类型的变量obj。引用结构体内的个体成员可以通过类似obj.v这样的语句进行。注意,object结构体包含了一个数组类型成员id,我们可以通过obj.id[0]到obj.id[9]来访问该数组成员中的各个元素。你也可以定义结构体数组。

C语言提供了指针,或叫作机器地址作为这门语言本身不可或缺的一部分。指针的形式没有Pascal和Ada中约束地那么严格。下面的语句

char *pc;
struct object *pobj;

声明了一个指向字符的指针pc,以及一个指向object结构体的指针pobj。通过声明语句中使用的形式*pc或*pobj,我们可以访问到指针指向的数据的值;这个"解引用"操作符*等价于Pascal中的脱字符号(^)。结构体中的单独成员可以通过,例如pobj->v的形式访问。

如果p是一个指向T类型对象的指针,并且当前指向一个T类型数组中的一个元素,那么p+1则是指向该数组下一个元素的指针。同样,如果p和q是指向同一数组中元素的两个指针,并且p小于q,那么q-p则为p到q之间元素的个数。总之,指针的算术操作会按照指针所指向的对象的大小进行缩放;对象的实际大小通常在你编写程序时是不相关的。当与对象的实际大小相关时,C提供了sizeof操作符用于计算对象的大小,这样程序本身就无须为特定机器显式指定对象的大小了。C所整合的完整的指针和地址计算是这门语言的一个优势。

操作符与表达式:与多数传统编程语言相比,C语言拥有一套丰富的操作符。除了普通算术操作符+,-,*,/和%(取余)之外,其他几组操作符也值得给予特殊的关注。

首先,C提供了用于操作一个字内部的比特位的操作符(见表1)。

&       bitwise AND(按位与)
|       bitwise OR(按位或)
^       bitwise exclusive-OR(按位异或)
~       one's complement(按位反)
<<      left shift(左移)
>>      right shift(右移)
表1: 操作字内部比特位的C操作符; 对于许多系统级编程的程序来说,这些操作符十分必要。

例如,列表1中的函数计算其参数中值为1的比特位的个数,它通过重复测试参数最右侧比特位的值,并每次把参数右移一位,直到参数值为0为止。声明中的unsigned意为函数将n视为逻辑数量,而不是一个算术变量。

bitcount(n)        /* count 1 bits in n */
   unsigned int n;
{
   int b;
   for (b = 0; n != 0; n >>= 1)
   if (n & 1)
      ++b;
   return b;
}
列表1:bitcount函数计算其参数中值为1的比特位的个数,它通过重复测试参数最右侧比特位的值,并每次把参数右移一位,直到参数值为0为止。

函数bitcount阐释了第二组操作符。任何类似>>这样的接受两个操作数的操作符都有一个对应的"赋值操作符",比如>>=,因此下面的语句

v = v >> expr

可以被简化为:

v >>= expr

这个符号更易读,特别是当v是一个复杂的表达式而不是一个单字母的变量时。

第三组操作符用于处理逻辑条件。操作符&&和||自左向右求值,表达式的值一经得到,求值过程立即停止。在类似下面的结构中

if (i 0)…

如果i大于或等于N(假定N是数组x的大小),那么包含x[i]的值测试将不会执行。逻辑操作符的这种行为被称为”短路求值“。

函数:一个C程序的整体结构是一组变量和函数的声明和定义。如果程序规模较大,这些定义常常被放到独立的文件中;你可以单独编译它们,并且使用链接器将它们链接在一起。

在一个函数内部,变量通常是"自动的"- 也就是说,它们在程序执行进入函数时出现,在离开函数后消失,就像在bitcount函数中那样。不过,如果你将一个变量声明为static,那么这个变量的值将从一次函数调用保留到下一次函数调用。在任何函数外面声明的变量是全局的,在程序的任何位置都可以访问到它们。

函数是支持递归调用的;标准(并且有些老套)的例子是阶乘函数(见列表2)。

fact (n)      /* returns n! (n >= 0) */
   int n;
{
   if (n <= 0)
      return 1;
   else
   return n * fact(n-1);
}
列表2: 递归函数的经典例子 – 用C实现的阶乘函数。

C语言使用传值的方式将参数传递给函数,即函数收到的是一份参数的拷贝,而不是原来的数据对象。(注意,函数bitcount修改了它的参数变量,不过这是安全的,因为它实际上是一个参数的拷贝。)通过传递指向数据对象的指针,你也可以获得与传引用一样的效果。函数的参数和返回值可以是任何基本数据类型 – 指针,结构体,或者联合体。如果要传递数组给函数,你需要传递一个指向这个数组第一个元素的指针。

ANSI标准

很多年来,C语言的定义就是《C程序设计语言》第一版中的参考手册。1983年,ANSI(美国国家标准协会)成立了一个委员会以提供一版最新的,全面的C语言定义。其结果是,C语言的ANSI标准,或叫作ANSI C,预计将在1988年年底得到批准。最新的编译器已经提供了对这一标准中绝大部分特性的支持。

自从1978年以来,这门语言变化甚少;这版标准的目标之一就是确保目前大多数现存的程序依旧是有效的,或者失败,但编译器可以产生有关新行为的警告。

基本上,有关C语言的最重要的改变就是一种声明和定义函数的新语法。现在一个函数的声明可以包含有关函数参数的描述了;定义语法也为了匹配参数而作出了改变。额外的信息让编译器更加容易地检测到因为参数不匹配而导致的错误;根据我们的经验,这是一个非常有用的补充。

为了说明这一点,考虑下面这个典型的C代码片断:

int n;
double x, sqrt();
x = sqrt(n);

函数sqrt期望一个double类型的参数,但是n却是一个整型。这个错误无法被检测出来,并且这个执行的结果也肯定没有任何意义。而采用ANSI C的新函数原型语法,你可以用如下方式重新编写这段代码:

int n;
double x, sqrt(double);
x = sqrt(n);

这里编译器已经被告知函数sqrt期望的参数类型,所以编译器生成代码将整型数n转换为浮点数。如果你不小心编写了一个无法被转换为double类型的表达式,比如 x = sqrt(&n),编译器将捕捉到这个错误。

函数定义的语法为了匹配参数而作出了改变;形式参数列在函数名字后面的括号内。因此,函数bitcount的定义就变成了:

bitcount(unsigned int n)
{
 …
}

还有一些其他小规模的语言变化。结构体赋值,枚举,以及void数据类型,所有那些被广泛使用的特性,现在都正式成为了语言的一部分。你可以进行自动结构体和数组的初始化,你也可以作单精度浮点运算;这些在小机器上会获得更好的计算性能。

标准更加详细地阐述了算术转换的属性。并且支持十六进制常量,转义序列以及八进制常量。用于做文本宏替换的C预编译器也变得愈加精致了;它为宏生成过程提供了更多的控制。这里的大多数改变只是对你的编程有轻微的影响。

这个标准的第二个显著的贡献就是C标准库的定义。它详细说明了访问操作系统的函数(比如,读写文件),格式化输入输出(scanf和printf),内存分配(malloc),字符串操作(比如,strcmp),数学计算(比如sin和lag),等等诸如此类的函数。

一些被包含在用户编写的程序中的标准头文件为函数和数据类型声明提供了统一的访问。使用这个标准库与主机系统交互的程序可以确保其行为是兼容的。库中的大多数函数都是仔细地仿照了Unix系统的"标准I/O库",并且在其他系统上也具备相似的程序。同样,你不会看到太大的变化。

由于大多数计算机都直接支持C提供的数据类型以及控制结构,因此实现一个自包含程序所需要的运行时库是极其微小的。标准库函数只是被显式调用,所以如果你不需要,你可以避开它们。大多数标准库函数都是用C实现的,并且除了隐藏的操作系统细节外,库函数自身是可移植的。

C的评价

C是一门紧凑,高效且极富表现力的语言。事实上,C的确足够优秀,以至于它在很多系统上几乎完全取代了汇编语言编程。一个简洁,可读性强的高级语言的使用具有压倒性的优势;它仅仅使阅读程序变成可能,这在使用其他一些语言时是极其困难的。

C是一门相对"低级"的语言。这种定性是没有贬义的;只是说C语言与大多计算机一样处理着相同类型的对象,即字符,数字,以及地址。这些类型的数据可以由真实计算机实现的算术和逻辑操作符结合和移动。

由于C语言相对小巧,因此我们可以用较小的篇幅描述这门语言,并且快速的学习它。你可以合理地期待去认识,理解,并且适当地使用整门语言。

C语言的另外一个优点是其可移植性。虽然C语言与很多计算机的能力相匹配,但其实现是与任何特定的机器体系结构无关的。稍微谨慎一些,你就可以很容易地编写出无须修改即可运行在多种机器上的可移植的程序。C标准显式地明确了可移植性问题,并且规定了一组常量,用于描述运行程序的机器的特性。

C的另外一个优势在于其缺乏约束。编程语言的一个流行的趋势是"强类型",其大致含义是由语言进行细致的检查,并保证程序只包含合法的数据类型组合。强类型有些时候可以尽早地捕捉到bug,不过它也意味着一些程序无法被编写出来,因为它们本质上违反了类型组合的规则。

一个存储分配器就是一个很好的例子:你无法用Pascal编写出Pascal的new函数 – 返回指向一块存储的指针,因为在Pascal中没有办法定义一个返回任意类型的函数。不过使用C语言你就可以轻松且安全地实现这个函数,因为C语言允许你阐明对类型规则的特定违背是有意为之的。

C不是一门强类型语言,不过随着它的演化,它的类型检查已经得到了加强。最初的C定义不赞成再使用,但仍然被允许。指针和整数的互换,这早已被淘汰,而现在的标准需要适当的声明以及显式的转换,这也是一些优秀编译器已经强制要求的了。new函数的声明则是在这一方向上迈出的另外一步。编译器会给出大多数类型错误的警告,并且不会对不兼容的数据类型进行自动转换。尽管如此,C保留了一条其基本的设计哲学,即程序员知道他们在做些什么;它只是需要你显式地阐述你的意图即可。

C已经被证明是一种优秀语言,甚至其他语言也可以编译为C语言。一个最好的例子就是Yacc编译器,它可以将一门语言的语法规范转换为一个用于解释这门语言语句的C程序。自然而然,C语言本身也可以用这种方法实现。

C语言出现什么问题了?在低级别上,存在一些关于操作符优先级的低劣的选择。一些用户感觉switch语句应该做出一些变化,不要让控制流像现在那样从一个case走到下一个case。简洁的语法有时会让新手畏缩;复杂的声明时常难于阅读。《C程序设计语言》第二版中的一个新例子就是这样的一对程序:它们在C声明与自然语言单词之间进行相互转换。

如果你依赖未定义属性或实现定义的属性时,可移植性问题有时就会发生。例如,函数参数的求值次序是未指定的,因此你编写出来的依赖求值次序的代码在不同机器上很可能得到不同的执行结果。这不是一个严重的问题,因为很容易检测到依赖关系。但人们有时仍然忽视它,导致产生不幸的影响。

接下来是什么?

在过去的十年中,C语言不断演化,虽然变化的速率很缓慢。ANSI标准正式地接受了这些改变,并且也加入了一些其自身的特性。由编译器进行的错误检查的数量一直在稳步上升:虽然语言中对你的所作所为的约束依旧不多,但现在当你做出一些奇怪的事情前,你需要更多显式地确认你的操作。

在接下来的若干年,C语言可能走向何方?最有可能的演化就是继续目前这种缓慢但稳定的改进,谨慎地添加一些新的特性。谨慎是必要的,因为与目前已经存在的庞大数量的C代码保持兼容是极其重要的。我们不能无缘无故地做出改变。

实事求是地说,C语言本身不可能进行较大程度的改变了;反而一些新语言将源自于C语言。C++就是一个例子,它提供了数据抽象以及面向对象编程的设施,而且几乎完整地保留了与C的兼容性(参见"更好的C?")。与此同时,随着你使用C语言经验的增加,C本身依然经久耐用。伴随着15年的C语言使用经验,我们仍然有这样的感觉。

© 2011, bigwhite. 版权所有.

Related posts:

  1. 你提供默认选项了吗
  2. 也谈'万能'栈
  3. Observer模式的C实现
  4. 理解’位域’
  5. 也谈指针运算