C语言函数原型声明: 从K&R C到C99

DONG Yuxuan @ Jun 03, 2016 CST

C语言函数声明考古报告

K&R C

在 K&R C 时代,函数的声明和定义是长成这样的:

double square();	/* 声明 */
...
...
...
double square(x)	/* 定义 */
double x;
{
	return x * x;
}

明显这里有一个问题,那就是声明并不包含函数的参数表,这就是说编译器将允许调用者任意地传递参数,像这样:

#include <stdio.h>

double square();

main()
{
	square(1, 2, 3, 4, 5);
	return 0;
}

double square(x)
double x;
{
	return x * x;
}

这样得到的结果自然是不正确的。

另一方面我们注意到我们的main函数甚至连返回值都没有写,这在 K&R C 中也是可以的,其默认返回值为int, 所以我们最后能够return 0

C89/C99

到了 C89 时代,我们就可以比较正常地声明函数原型了:

double square(double);

为了兼容性考虑,C89 也支持老式的声明(包括缺省返回值),而在C99中,函数原型声明虽然依然可以省略参数表,但是返回值类型却必须明确指出。

我们继续考虑这种 K&R C 风格的函数原型,必须指出它是有一定方便之处的,比如我们想调用库函数qsort对一个整数数组按升序排序,那么我们需要定义一个比较函数:

int icmp(int *a,int *b) { return *a - *b; }

然而qsort要求比较函数的两个参数是const void *类型,在 C99 中,函数指针的隐式转换已经被禁止,所以像下面的代码是无法通过 C99 编译的:

#include <stdlib.h>
#include <stdio.h>

int icmp(int *a, int *b) { return *a - *b; }

int main(void) {
	int i ,buff[] = {3 ,7 ,4 ,1 ,0 ,9 ,6};
	qsort(buff ,sizeof buff / sizeof *buff, sizeof *buff, icmp);
	for (i = 0; i < sizeof buff / sizeof *buff; ++i) printf("%d\n", buff[i]);
	return 0;
}

这里就有两种方法,一种是我们把icmp做一个类型转换:

qsort(
	buff,
	sizeof buff / sizeof *buff,
	sizeof *buff,
	(int (*)(const void *, const void *))icmp
);

这写起来就非常繁琐,而另一种方法自然是按照qsort的要求来定义icmp(这当然是工程上最佳的做法):

int icmp(const void *a, const void *b) { return *(int *)a - *(int *)b; }

但这样需要在icmp的实现里做并没有什么意义的类型转换,比较难看,要使得代码简洁一点,我们可以利用 K&R C 风格的函数声明来强制转换:

qsort(buff, sizeof buff / sizeof *buff, sizeof *buff, (int (*)())icmp);

或者直接使用 K&R C 风格来声明我们的函数,像这样:

#include <stdlib.h>
#include <stdio.h>

int icmp();

int main(void)
{
    int i, buff[] = {3, 7, 4, 1, 0, 9, 6};
    qsort(buff, sizeof buff / sizeof *buff, sizeof *buff, icmp);
    for (i = 0; i < sizeof buff / sizeof *buff; ++i) printf("%d\n", buff[i]);
    return 0;
}

int icmp(int *a, int *b) { return *a - *b; }

默认参数提升

使用 K&R C 风格函数原型的时候务必要小心,因为 C 有一个叫做默认参数提升的性质,十分容易让程序员犯错误。

让我们先回到 K&R C 时代,还是考察我们的square函数:

#include <stdio.h>

double square();

main() 
{
	printf("%f\n", square(1));
	return 0;
}

double square(x)
double x;
{
	return x * x;
}

输出并不是我们预想中的1。

这是因为main函数对square函数的参数类型一无所知,所以当我们传递1作为实参时,它并不会被自动转换为double类型,而就是把一个int类型变量压栈了,而在控制权移交给square函数后,它却尝试从栈中弹出一个double变量,这样自然不能取得我们预想的结果。

所以正确的调用方法应该是:

printf("%f\n", square(1.0));

现在我们测试另一种写法:

printf("%f\n", square(1.0f));

按照我们刚才的说法,这种调用应该不能得到预期的结果,因为压入一个float却弹出一个double,不过事实上这代码却能够成功输出我们想要的值。这个事实说明,对于 K&R C 风格声明的函数,在一定的情况下参数类型会发生自动的转换,这种性质被称为默认参数提升,具体来说,它是这样的一些规则:

  1. 如果传入的参数是[unsigned] char[unsigned] short类型,则它会被转化为[unsigned] int类型入栈
  2. 如果传入的参数是float类型,则它会被转化为double类型入栈

为了更完整地解释这个性质,我们考虑单精度浮点数版本的square函数,并用 K&R C 风格进行声明:

float square();	/* 声明 */
...
...
...
float square(float x)	/* 定义 */
{
	return x * x;
}

由于默认参数提升的存在,(在只考虑传入浮点数参数的情况下)这个函数事实上等价于:

float square(double x)
{
	float y = (float)x;
	return y * y;
}

也就是说,如果我们传入一个float类型变量,那么它首先被转换为double类型压入栈中,而在函数内部,从栈中弹出这个double变量后会将它重新转换回float类型。

因此,如下的对单精度版本square函数的调用都是正确的:

square(1.0f);
square(1.0);

另一方面,默认参数提升只指针对没有声明类型的参数发生作用,如果我们使用C89或更新标准的语法来声明函数:

float square(float);	/* 声明 */
...
...
...
float square(float x)	/* 定义 */
{
	return x * x;
}

那么传给square的参数并不会被提升为double类型。

然而,并不是只有在 K&R C 风格的函数原型中才会出现类型不定的参数,事实上在 ANSI C (C89/C99/more) 风格的函数原型中也会出现无法确定参数信息的函数原型,这就是变参函数:

int printf(char *fmt, ...);

变参函数的不定参数依然会发生默认参数提升,这就是为什么在变参宏va_arg(va_list ap, type)中,第二个参数type不能指定为short, char, float,因为这是不可能的,如果调用者传递的参数是这些类型,他们都会被提升,这也就解释了为什么输出doublefloat都可以用printf("%f", ...)

参考文献

  1. Brian W. Kernighan, Dennis M. Ritchie. C程序设计语言.
  2. Stephen Prata. C Primer Plus.
  3. Andrew Koenig. C陷阱与缺陷.
  4. The C89 Draft.
  5. ISO/IEC 9899:1999.