Menu

C 语言函数原型:声明与定义的区别

为什么 C 语言要在调用函数之前看到它的形状:编写函数原型,修复隐式声明错误,把原型放进头文件,以及 f(void) 和 f() 的真正区别。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

编译器从上往下读

C 编译器一次性处理源文件,从第一行读到最后一行。遇到函数调用时,它必须已经知道三件事:函数返回什么,接收几个参数,参数是什么类型。不知道这些,它就无法生成正确的代码,也无法检查这次调用。

所以下面的代码有问题:

#include <stdio.h>

int main(void) {
    printf("%d\n", add(2, 3));   /* add has not been seen yet */
    return 0;
}

int add(int a, int b) {
    return a + b;
}

解决办法是函数原型:在调用之前写出函数的签名,在本该是函数体的位置写一个分号。

现在 main 可以放在最前面了,大多数 C 文件就是这样组织的:顶部的原型说明这个文件提供了什么,main 读起来就像大纲,细节放在下面。

声明与定义

这两个词很容易混淆,值得分清楚:

  • 声明说明一个函数存在,并给出它的类型。它以分号结尾,没有函数体。你想写多少次都可以。
  • 定义给出函数体。在整个程序中它必须恰好出现一次:同一个函数有两个定义会导致链接错误(“multiple definition of”)。
int add(int a, int b);                        /* declaration (prototype) */
int add(int a, int b) { return a + b; }       /* definition - also a declaration */

在原型中,参数名是可选的;对编译器来说只有类型才重要:

int add(int, int);              /* legal, and equivalent */
int add(int a, int b);          /* better: the names document the order */

还是写上参数名吧。void drawRect(int, int, int, int); 对读者毫无帮助,而 void drawRect(int x, int y, int width, int height); 则一目了然。

函数原型带来什么

不只是能调整定义的顺序,它还让编译器能检查你的调用。

有了作用域内的原型,3 和 2 会在调用之前被转换为 double。没有原型的话,它们会作为 int 传入,而 scale 会把它们当作 double 来读,得到的是垃圾值,在旧的 C 方言中还不会有任何诊断信息。

原型还能在编译时发现参数个数错误和类型不兼容:

scale(3.0);              /* error: too few arguments */
scale(3.0, "two");       /* error: passing char * where double is expected */

这正是你想要的错误:声音响亮,出现在编译时,直接指向出错的那一行。

隐式声明错误

调用一个编译器从未见过的函数,你就会得到 C 语言中最常见的消息之一:

warning: implicit declaration of function 'add' [-Wimplicit-function-declaration]

在 C89 中,编译器会去猜:假设函数返回 int,并接受你传入的任何参数。这种猜测往往是错的,当真正的函数返回 double 或指针时,结果就毫无意义。C99 从语言中删除了隐式声明,所以在 C99 及以后的标准中这是一个错误,较新版本的 GCC 和 clang 默认都会拒绝它。

两种原因,两种解决办法:

你自己的函数还没有声明。 在调用上方加上原型,或者把定义移到前面。

库函数忘了包含头文件。 库函数的原型位于它的头文件中,所以你需要 #include:

printf, scanf, fopen        ->  #include <stdio.h>
malloc, free, exit, atoi    ->  #include <stdlib.h>
strlen, strcpy, strcmp      ->  #include <string.h>
sqrt, pow, sin, fabs        ->  #include <math.h>
isdigit, toupper            ->  #include <ctype.h>
bool, true, false           ->  #include <stdbool.h>

一个相关的消息 conflicting types for 'add' 表示原型和定义不一致:某个参数类型不同,或者返回类型不同。改正错的那一个,两者必须完全一致。

相互递归需要原型

有时调整顺序也解决不了问题:两个互相调用的函数不可能都排在前面。

一个原型就打破了这个循环。普通的 递归 函数依赖的也是同一个机制:函数自己的名字在其函数体内是可见的,所以直接递归不需要原型,但相互递归需要。

头文件中的原型

一旦程序分布在多个 .c 文件中,原型就会移到一个头文件里,这样每个文件都从同一处看到相同的声明。

mathutils.h,即接口:

#ifndef MATHUTILS_H
#define MATHUTILS_H

int add(int a, int b);
int multiply(int a, int b);
double average(const int values[], int count);

#endif

mathutils.c,即实现:

#include "mathutils.h"

int add(int a, int b) {
    return a + b;
}

int multiply(int a, int b) {
    return a * b;
}

double average(const int values[], int count) {
    if (count == 0) return 0.0;
    int total = 0;
    for (int i = 0; i < count; i++) total += values[i];
    return (double) total / count;
}

main.c,即使用者:

#include <stdio.h>
#include "mathutils.h"

int main(void) {
    printf("%d\n", add(2, 3));
    return 0;
}

然后:

gcc main.c mathutils.c -o program

这里有两个值得养成的习惯。#ifndef / #define / #endif 这层包装叫做头文件保护(include guard):如果头文件通过两条路径被包含,它能防止声明被处理两次。另外,mathutils.c 包含了它自己的头文件,这看起来多余,其实不然:它让编译器把每个定义和其他文件将要使用的声明进行对照检查。如果两者哪天不一致了,你会立刻发现,而不是等到链接时。更多内容见头文件一页。

尖括号(<stdio.h>)在系统包含路径中查找;引号("mathutils.h")先在你的项目目录中查找。自己的头文件用引号。

f(void) 与 f()

这一点确实出人意料,也是这些文档中每个例子都写 int main(void) 的原因。

void ping(void);      /* takes NO arguments - calls with arguments are rejected */
void pong();          /* says NOTHING about the parameters */

void ping(void); 是一个原型:它声明这个函数不接收参数,所以 ping(1, 2, 3) 是编译错误。

void pong(); 是从标准化之前的 C 继承下来的旧式声明。它声明了返回类型,但对参数只字未提,所以编译器无法检查调用:pong(1, 2, 3) 会悄无声息地通过编译,并产生未定义的行为。

int main(void) {      /* right: main takes no arguments */
int main() {          /* legal, but argument checking is off */

没有参数的函数,一律写 (void)。C23 把 () 改成和 (void) 含义相同,终于消除了这个陷阱,但还有大量代码和编译器没有跟上,而 (void) 在每一个 C 标准中都是正确的。

同样的区别也适用于定义。void ping(void) { } 是原型式的定义;void ping() { } 则不是,它不会为出现在它之前的调用启用检查。

常见错误

  • 在定义后面加了分号。 int add(int a, int b); { return a + b; } 声明了 add,然后留下一个游离的代码块。错误信息令人困惑,原因只是一个字符。
  • 原型后面漏了分号。 编译器会继续读入后面的内容,然后在几行之下报告一个莫名其妙的错误。
  • 原型和定义不一致。 conflicting types for ...。让它们完全相同,或者更好的做法是在实现文件中包含头文件,让检查自动进行。
  • 在另一个函数内部声明函数。 这是合法的(在 main 内部写 int add(int, int);),但这个声明的作用域就只限于那个函数。把原型放在文件作用域中。
  • 在头文件中定义函数。 从两个 .c 文件包含它,链接器就会报告重复定义。头文件放声明,定义放在 .c 文件中。
  • 指望 () 做检查。 它不做检查。写 (void)。

常见问题

C 语言中什么是函数原型?

函数原型是对函数签名(返回类型、函数名和参数类型)的声明,以分号结尾,而不是函数体:int add(int a, int b);。它告诉编译器这个函数怎么调用,以便检查调用是否正确,但不说明函数做什么。

C 语言中声明和定义有什么区别?

声明引入名字和类型(int add(int, int);),可以出现多次。定义提供函数体(int add(int a, int b) { return a + b; }),在整个程序中必须恰好出现一次。每个定义同时也是一个声明。

C 语言中怎么解决“implicit declaration of function”?

在调用之前加上函数原型。对于你自己的函数,把 returnType name(paramTypes); 放在文件顶部附近或头文件中;对于库函数,包含正确的头文件:printf 用 #include <stdio.h>,malloc 用 <stdlib.h>,strlen 用 <string.h>,sqrt 用 <math.h>。

C 语言中 f(void) 和 f() 有什么区别?

void f(void); 声明一个不接收任何参数的函数,编译器会拒绝任何传了参数的调用。void f(); 是旧式声明,对参数只字未提,所以参数检查被关闭了。始终写 (void);C23 终于让两者含义相同,但旧代码和旧编译器中两者仍有区别。

Coddy 编程语言插图

和 Coddy 一起学编程

开始