定义和声明

在 C 语言中,定义和声明是两个极其容易混淆的概念,定义是明确告诉编译器完整的信息,而声明只是给编译器打一个“白条”,具有如下特点:

  • 声明对应的定义可以暂时并不提供,定义可能在源文件的后面,也可能在其它源文件或编译单元中。
  • 前置声明可以让我们在正式定义之前就使用它,这对于解决循环依赖是必要的。
  • 同样的声明可以重复多次提供,但是定义至多只有一次,否则会报错符号重定义。
  • 如果编译器在链接所有编译单元时,如果需要使用某个具体实现,但是没有找到声明对应的定义——对应的“白条”没有被兑现,就会报错符号未定义。
  • 如果只是提供了一个声明,但是没有实际使用它,那么编译器会直接将其忽略掉,此时即使没有提供对应的定义也是允许的,因为压根不需要。

我们主要考虑变量的声明/定义和函数的声明/定义:

  • 变量:
    • 变量声明:变量声明只是告诉编译器变量的名称和类型,但不分配内存。
    • 变量定义:变量定义是在编译过程中分配内存和值并为变量指定名称的操作。
  • 函数:
    • 函数声明:函数声明(也称为函数原型)包含函数的返回类型、参数列表和函数名,但没有函数体。它只是告诉编译器有这个函数存在,但是并不提供实现。
    • 函数定义:函数定义包括函数的完整实现,包括函数的返回类型、参数列表和函数体。

对于涉及到自定义数据结构(例如结构体)的内容在本文中不做讨论,因为语法比较复杂,并且C和C++的处理并不相同。

定义和声明语句通常是难以区分的,因为C语言在这方面的语法比较灵活自由,有很多默认行为。

变量定义/声明

变量定义/声明的标准示例如下,必须加上extern关键词才可能被识别为变量声明,否则会被编译器尝试改为变量定义(见下文)

1
2
3
extern int x_dec; // 变量声明

int x_def = 10; // 变量定义

如果我们在声明的语句中同时提供了值,那么它就会被重新视作定义,extern将会被忽略。(C语言标准并不推荐这种用法,但是编译器通常是这么实现的)

1
extern int x_def = 10; // 变量声明

变量的声明可以重复出现,但是定义不行

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

extern int s; // 变量声明
extern int s; // 变量声明

int s; // 变量定义

int main() {
s = 10;
return 0;
}

下面的代码则会报错符号重定义

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int s; // 变量定义

int s; // 变量定义

int main() {
s = 10;
return 0;
}

在多文件编程中,下面的示例会导致符号s重定义

1
2
3
4
5
6
// func.c
int s = 10; // 变量定义

// main.c
int s = 100; // 变量定义
int main(){;}

一个合理的做法是将其中一个改为变量声明,例如

1
2
3
4
5
6
// func.c
int s = 10; // 变量定义

// main.c
extern int s; // 变量声明
int main(){;}

或者在公共头文件中提供变量声明,在某一个源文件中提供变量定义,例如

1
2
3
4
5
6
7
8
9
10
// head.h
extern int s; // 变量声明

// func.c
#include "head.h"
int s = 10; // 变量定义

// main.c
#include "head.h"
int main(){;}

对下面的语句是变量定义还是变量声明的判定比较复杂,这被称作试探性定义:如果在附近没有找到定义,那么语句就会被视作定义。

1
int x; // 变量定义

如果在当前作用域的前面或后面找到了定义,那么int x;语句会退化为声明

1
2
3
4
5
6
7
int x;  // 变量声明

int x = 10; // 变量定义

int y = 20; // 变量定义

int y; // 变量声明

如果我们在变量定义中没有提供默认值,对全局变量会执行默认初始化,对于局部变量则不会。

函数定义/声明

与变量定义/声明不同,函数定义/声明的区分非常简单:以;结尾不含函数体的,就是函数声明,反之就是函数定义。 标准示例如下

1
2
3
extern void fn_dec(); // 函数声明

void fn_def(){} // 函数定义

与变量声明不同的是,即使忽略extern关键词,函数声明也不会被编译器视作函数定义,因为没有提供函数体。

1
void fn_dec(); // 函数声明

通常的做法是使用前置的函数声明,例如

1
2
3
4
5
6
7
8
void hello(); // 函数声明

int main(){
hello();
return 0;
}

void hello(){} // 函数定义

这里我们可以在提供函数定义之前就使用hello()函数。

在多文件编程中,可以在其中一个源文件中定义函数,在另一个源文件中声明函数并使用,例如

1
2
3
4
5
6
7
8
9
10
// func.c
void hello(){} // 函数定义

// main.c
void hello(); // 函数声明

int main(){
hello();
return 0;
}

更推荐的做法是将函数声明放置在公共头文件中,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
// head.h
void hello(); // 函数声明

// func.c
#include "head.h"
void hello(){} // 函数定义

// main.c
#include "head.h"
int main(){
hello();
return 0;
}

对于含有参数的函数,在函数声明时,编译器只关心几个信息:函数名称+参数个数和对应类型+返回值类型,并不关注具体的参数名称。 例如我们在函数声明中可以省略所有的参数名称(为了可读性,不建议这么做)

1
2
3
4
5
int add(int, int, int);

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

如果没有实际使用某个参数,那么即使在函数定义时也可能省略参数名称(不能省略参数类型),例如下面的函数并没有使用第三个参数

1
2
3
int add2(int a, int b, int){
return a+b+c;
}

小结

通常情况下,我们主要在如下的情景中使用函数声明和变量声明:

  • 单文件编程中,我们习惯在源文件的头部集中提供前置声明,这使我们在正式定义之前就可以使用它们;
  • 多文件编程中,通常在公共头文件中提供声明,以供不同的编译单元使用;
  • 对于库,需要在接口头文件中提供正确使用所必要的声明。