此篇文章摘取于即将登载于《Dr.Dobb's 软件研发》第三期(2003年10月)的《The New C:一切源于FORTRAN》,文章主要是介绍了C99的新特性受限指针,在得到作者Randy Meyers以及《Dr.Dobb's 软件研发》杂志负责人刘江先生的应允下,把全文的前面的一部分作为文档发表,希望能对大家有所帮助。
新的C语言:一切都源于FORTRAN
译注:本文是作者Randy Meyers在 CUJ杂志开的一个专题系列The New C的第二篇文章,主要是叙说C99中的新关键字restrict以及受限指针(restricted pointers)的历史渊源和使用方式。受限指针作为一种编译器优化代码的方式,是由编译器厂商提供特定的实现,因此这篇文章所谈论的并非在一切实现中都能得到支持,至于如何使用restrict关键字,这篇文章做了很好的说明,期望本文能给关心C语言和使用C语言的用户带来帮助。在翻译上,所有译者在翻译过程中有疑惑的术语或者其他一切都以括号形式把原文直接给出,诚心不想给读者半点误导,但是否如愿还需读者的评判,关于本文的一切可以用amstrongest@hotmail.com与译者联系和讨论。
有时候改进一种语言的最好方式就是让它和三十年前的古老样子更相似
一切都源于FORTRAN(It all began with FORTRAN)。
谈起上面的话,我并不是想说FORTRAN是第一个程序设计语言,但是在上个世纪六十年代(1960s)的一场关于如何在FORTRAN中实现参数传递的争论,却意外的使FORTRAN在七十年代(1970s)的超级计算机上面的性能有了巨大的提升,并且导致了九十年代(1990s)一个关于C语言的新特征被C99所接受,这就是受限指针(restricted pointers)。而理解受限指针的原始动机的最好方式就是回顾历史,重温发生在FORTRAN中的那个由争论所导致的意外。
和C不一样的是,在FORTRAN中如果一个函数被分配了一个新值作为参数,传递给函数的实参值将会改变,并且在函数返回时,调用者将会得到新的参数值。考虑下面Example 1所例举的代码,如果你以Y作为参数调用F,在F返回时,Y值将会是6。[译注:下面的程序没有出现变量Y,文中意思是Y是实参数,而下面程序出现的X是形参数,只是属于函数F的内部变量,但是当把Y复制给X后,并且改变X同时将改变外面调用的Y的值]
Example 1:
SUBROUTINE F(X)
INTEGER X
X = 6
END
这样的参数传递方式就使争论随之而来。不同的FORTRAN编译器可以选择两种实现方式中的一种来获得FORTRAN中的参数传递语义。第一种方式是引用参数传递(by reference),也就是典型的C程序员所使用的:写一个函数,并且在它的调用者中修改变量。(write a function that modifies a variable in its caller)。传递给函数的是一个参数的地址,并且在需要的时候任何地方都可以间接访问这个参数。如果FORTRAN编译器产生C代码的话,就会和下面Example 2.的C代码类似。
Example 2:
void f(int *x)
{
*x = 6;
}
然而,对于一些类型的计算机来说,间接访问局部变量比直接引用访问所带来的运行时开销要大的多。这也就导致了FORTRAN中参数传递的第二种实现方式。实参的地址依然会被传递给函数,但是函数一旦被调用,就将生成一个实参数的局部拷贝[译注:传递的是地址,但是函数内部拷贝的却是参数值],在函数生存期中将一直使用这个拷贝的局部变量,当函数返回时,将把拷贝变量赋值给调用函数的参数变量。这样的FORTRAN编译器如果产生C代码将会和下面的Example 3相类似。进/出拷贝(copy in/copy out)参数传递方式增加了函数进入和返回时的负担,但是如果一个参数被多次引用,而间接引用(在一些机器上代价十分昂贵)却不再使用的话,导致的结果就是性能的提升(在一些机器上面而言)。[译注:就是说第一种方式的主要的调用开销是间接引用,第二种方式的主要调用开销是拷贝变量,其中哪种更好,需要根据真实代码的情况衡量决定]。
Example 3:
void f(int *x)
{
int xcopy = *x;
xcopy = 6;
*x = xcopy;
}
大多数时候,编译器如何实现语言特征通常都被认为不过只是“实现细节”。它们不会影响程序员编写程序的方式,而语言的标准委员会允许语言的实现者自由选择和改变实现方式。然而,根据使用的参数传递机制,FORTRAN程序会产生不同的结果。考虑下面Example 4中的FORTRAN代码,以及以两种方式转换成的C代码:
Example 4:
SUBROUTINE G(X, Y)
INTEGER X, Y
X = 1 + X
Y = 5 + Y
END
// Translation using "by reference"
void g(int *x, int *y)
{
*x = 1 + *x;
*y = 5 + *y;
}
// Translation using
// "copy in/copy out"
void g(int *x, int *y)
{
int xcopy = *x;
int ycopy = *y;
xcopy = 1 + xcopy;
ycopy = 5 + ycopy;
*x = xcopy;
*y = ycopy;
}
G函数给它的参数加上了不同的常量,如果你把参数A和B传递给函数G,并且在调用前A的值是1,B的值是10。不用怀疑,无论使用FORTRAN中的那种函数参数传递机制,当函数返回时A的值将变成2而B的值将变成15。但是请考虑,如果你传递参数都是的A(并且被初始化为1),将会是什么情况?如果是使用引用调用(by reference)的参数传递机制,在函数返回时A将的值将变成7。A的值在赋值给*x的过程中时候被更新,因为x和y都指向A,所以在随后的对*y赋值的过程中A的值将再次被改写。相反,如果是使用进/出拷贝(copy in/copy out)的参数传递机制,在函数返回时,A的值将是6。调用发生后,在函数G中将不同的拷贝变量,并且每一个都将在函数返回时赋值给A,但最后的一个拷贝变量的返回值才会成为A的终值。
这两种不同的参数传递机制是任何程序设计语言定义者所必须面对的不一致性的代表。语言需要特殊的实现实现技术吗?也许这将会以付出效率为代价?语言的特性是否应该为了避免争议而改变?FORTRAN的定义者因为效率而允许同时存在两种参数传递机制。而一旦这样的决定做了出来,某种类型的程序就变的不一致了,并将导致无法定义的结果(outlawed)。
FORTRAN 66 标准包含了一系列可能会误导程序员的规则。在函数参数列表中,对于任何变量你都只能传递一次。如果你传递了一个变量作为函数参数,那么这个函数就不能再在全局上引用这个变量(FORTRAN COMMON)。如果你传递给一个变量给函数,你就不能再传递任何东西,并且这个函数也不能再引用任何东西,that overlaps it in storage (FORTRAN EQUIVALENCE)。在这样的规则下,没有什么程序可以确定应该采用何种参数传递机制。
大约十年以后[译注:意指1970s],为了实现超级计算机Cray 1的高性能,超级计算机需要高优化的编译器来使传统的程序能够使用机器的向量寄存器(vector registers)。考虑Example 5中的程序。其中对于函数来说最有效率的代码就是先后把数组指针x,y载入到向量寄存器中然后执行向量加指令来把两个向量寄存器中的变量加在一起。如果编译器以产生向量指令的方式来取代传统的使用循环来访问数组中的每一个元素的方式,那么代码的运行效率将得到巨大的提升。
Example 5:
void
vector_add(float *x, float *y,
float *result)
{
int i;
for (i = 0; i < 64; ++i)
result[i] = x[i] + y[i];
}
编译器中的优化器肯定会把循环转化成一系列的向量指令,但是问题在于那些向量指令是否真的whether the sequence of vector instructions is really equivalent to the original loop。你能在处理result数组的存储工作之前就把x,y数组载入到向量寄存器中,只因为你清楚result数组和x,y数组都是不同的个体。考虑如果result指向x[1],将会发生什么?在这种情况下result[0]其实就是x[1],同样result[I]其实就是x[I+1],每一次循环迭代过程中都会存储下一次的迭代中会被引用的变量。如果在做result的存储工作之前就把x载入到向量寄存器中去,变量值将会改变calculated change。正是在这一点上,FORTRAN的定义就带来了冲突。为了避免在传递机制中需要引入一个特殊的参数,FORTRAN标准定义了一系列精确的规则用来允许向量化编译器(vectorizing compiler)假设x,y和result都是互不相关的,non-overlapping arrays。就这样偶然的,FORTRAN在向量机上就有了巨大的性能优势。