C语言的美与丑

C语言的设计非常如同公理体系般简洁,而所能够演绎出来的内容又足够完备。这是对C的美的一种简单概括。

我要说的“公理体系”中的最核心的组件,便是指针。有智者说到,计算机科学中的一切问题,都可以通过增加一层间接(indirection)来解决1 。指针可以用来解决什么问题呢?

1. 引用参数

C语言按值传递参数,因此函数内部对参数变量的修改对外部没有影响。有的时候,希望函数能够对外部有影响,最典型的例子是scanf函数。这时候,指针赋予了函数修改外部变量的能力。

C++另外引入了一种称为引用的类型,是C++中传递引用参数的推荐做法。但是,引用除了更加受限(不能为NULL;初始化之后无法改变指向)之外,和指针是完全平行的。我想这恐怕不是C++的设计者喜欢这么一点点语法糖,而是诸如运算符重载这样的语法必须依靠引用才能合理地实现。

虽然是不得已而为之,但是两套功能几乎重复的系统,在我看来是丑的。例如在拷贝构造函数这样的结构中:

Foo::Foo(Foo &that)
{
	this->foo = that.foo;
	this->bar = that.bar;
}

一边是指针,另一边是引用,显得非常不对称。

2. 动态内存管理

一般的自动变量,生存期仅限于所在函数活动的期间。一旦函数返回,对象也就随之销毁。使用指针可以突破这个限制,也就是实现了对象生存期和代码层次结构的解耦合。

动态内存管理的另一个好处是,可以在运行时动态确定需要分配的内存大小。C++的STL用std::vector之类的容器实现了动态大小的数组,封装了动态内存管理的过程。但它的内部仍然是使用指针,结合newdelete运算符实现的。

3. 自引用结构

数据结构的一个思想是,它可以递归定义。例如,一个二叉树的两个子树又各是一个二叉树。指针能够很自然地表现这种自引用结构:

struct binary_tree {
	struct binary_tree *left, *right;
};

4. 函数指针

我第一次见到函数指针的用法(用于qsort)时,简直惊呆了。此后,我对C++的虚函数以及多态机制就没有了多少神秘感。老实说,C++的虚函数在形式上比较漂亮,用起来也的确方便,但也不再是什么“黑魔法”。并不是C++新的语法结构带来了多态的功能,这个功能实际上是函数指针提供的,C++的语法只不过将它封装了起来而已(“虚函数表”作为一个实现细节,使用者无需特别关注)。


另一方面,C语言被设计出来时,主要的目的是用它编写可移植的UNIX操作系统。因此,在很多方面,实用性占了主导地位。因此,有些语法结构的限制很大,缺少概念上的完整性。

1. 多维数组

C语言对多维数组的要求非常严苛,除了第1维以外其余所有维的长度在编译阶段必须是常数。只有满足这个条件,才可以使用诸如arr[i][j]的语法来访问多维数组。例如

/* N和M是编译时已知的常数 */
int a[N][M]; /* int *a = 大小为 sizeof (int) * N * M 的一段栈空间 */
a[i][j] = 0; /* a[i * M + j] = 0; */

从注释可以理解,为什么第2维的长度必须给出。因为编译器在计算目标地址的时候要用到它。

C99提出了可变长度数组(VLA)的概念,成功地解决了这个问题。但是受到C++委员会的抵制,VLA没有进入C++标准。编译器对VLA的支持程度也是参差不齐。

对于编写操作系统而言,恐怕很少遇到多维数组的情况。但是科学计算却常要用到。最典型的例子是矩阵运算:

void mat_mul(int m, int n, int p, double prod[m][p], double a[m][n], double b[n][p])
{
	/* ... */
}

我们希望这段程序能够做任何\(\mathbf{A}^{m\times n}\)和\(\mathbf{B}^{n\times p}\)的矩阵乘法。上述代码只有在支持VLA的编译器中能够通过。如果不使用VLA,那么就只能放弃既有的多维数组的语法,而求助于指针:把诸如double a[m][n]的定义改成double *a,然后将代码中诸如a[i][j]的访问改成a[i * n + j]。其实也不是特别痛苦,举这个例子只不过想说明,C语言中多维数组的应用场合非常受限,因此比较丑。

上面这个矩阵乘法的例子在C++中可以通过函数模板来实现:

template <size_t n, size_t p>
void mat_mul(int m, double prod[][p], double a[][n], double b[n][p])
{
	/* ... */
}

当然,这个写法一方面很丑(凭什么np是模板参数,m就不是?),另一方面,对于每一组不同的(n, p),编译器都会产生一套特化的代码,实际上却并没有必要。如果程序经常处理尺寸不同的矩阵,那么目标代码就会极度膨胀。此外,即便使用了模板,被传递进来的矩阵的尺寸也必须在编译期就是常数,否则编译器无法进行模板推导。这就限制了一些使用场景,例如:由用户输入2个尺寸任意的矩阵,然后计算它们的乘积。

事实上,在C++中,处理多维数组也是一件比较头疼的事情。

2. 复合字面量

在传统的C语言中,数组、结构体之类的复合类型都是二等公民:它们不能被赋值、作为参数传递、作为返回值返回。如果要间接地实现上述功能,还是需要借助指针。在C89中,结构体的“公民权利”被稍微提升了一些:它可以被赋值、作为参数传递,作为返回值返回。但是,仍然存在一些问题。

结构体的赋值,仅限于从一个变量赋值给另一个变量,而无法从各个元素的角度进行整体赋值。另一方面,这个所谓的“整体赋值”操作在初始化阶段却是允许的:

struct Foo foo = {1, 2, 3}; /* OK */
foo = {4, 5, 6}; /* WRONG! */

在C99中,上述问题进一步被引入的新概念——复合字面量解决。可以使用

foo = (struct Foo) {4, 5, 6};

对结构体进行整体赋值。但是这个语法估计又受到了C++的抵制,因为C++已经设计了构造函数来解决这个问题:

struct Foo {
	int x, y, z;
	Foo(int x, int y, int z) : x(x), y(y), z(z) {}
};

Foo foo(1, 2, 3);
foo = Foo(4, 5, 6);

C++的做法也有一些丑的地方,这里暂且不说。

在C99之前,C语言存在唯一的一种复合字面量:字符串字面量。这是一个突兀存在的特例,因为它实在是太常用了。

printf("hello, world\n");

相当于

{
	static char temp_str[] = {'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd', '\n', '\0'};
	printf(temp_str);
}

可想而知,如果不引入这个特例,C语言基本上不能用。

C99的复合字面量也支持数组类型,例如上面的例子在C99中也可以写成

printf((char []) {'h', 'e', 'l', 'l', 'o', ',', ' ', 'w', 'o', 'r', 'l', 'd', '\n', '\0'});

并不是特别地有用。即便在C99中,数组仍然是二等公民,而且具有一些非常奇怪的特性。

  1. 数组仍然不可以进行整体赋值,除了初始化的过程以外。
int array[] = {1, 2, 3}; /\* OK \*/
array = (int []) {4, 5, 6}; /\* WRONG! \*/
  1. 数组可以被作为参数传递。但是实际上传递的并非数组本身,而是指向其第1个元素的指针。换言之,
    int foo(int bar[N]);
    声明的函数原型和
    int foo(int *bar);
    是完全等价的。这是C语言为了传递数组参数而又不改变其二等公民身份而引入的一个奇怪政策。
  2. 没有任何函数能够返回一个数组。如果可以的话,这个函数的原型将变得非常奇怪,类似
    int foo()[N];
    恐怕没有人会喜欢。事实上,因为数组不能被赋值,返回一个数组的函数也基本上没有任何意义。

分享