“先生,在这样一个环境里可不行。所以我另外还被编入了三十多种次级功能,用来——”
——C3PO,在被无礼打断之前,报出一个如今已不值一提的附加功能数量,《星球大战》剧本
和你熟悉的其他语言很像,C 也有函数这个概念。
函数可以接受各种实参,并返回一个值。不过有一点很重要:实参和返回值的类型都是预先声明好的——因为 C 就喜欢这样!
我们来看一个函数。这个函数接受一个 int 作为实参,并返回一个 int。
plus_one 前面的 int 表示返回类型。
int n 表示这个函数接受一个 int 实参,它存放在形参 n 中。形参是一种特殊的局部变量,实参会被复制进去。
这里我要把“实参是复制进形参的”这一点讲透。C 里有很多东西,一旦你知道形参是实参的副本、而不是实参本身,就会好懂得多。稍后还会细说。
把程序往下看到 main(),就能看到对函数的调用,我们把返回值赋给了局部变量 j:
趁我还没忘,请注意我是在使用函数之前先定义了它。要是没这么做,编译器在编译
main()时就还不知道它,会报未知函数调用的错误。上面这段代码还有更规范的做法,即使用函数原型,不过那部分我们以后再讲。
还要注意,main() 也是一个函数!
它返回一个 int。
可这个 void 又是什么东西?它是一个关键字,用来表示该函数不接受任何实参。
你也可以返回 void,表示不返回任何值:
#include <stdio.h>
// 这个函数不接受实参,也不返回任何值:
void hello(void)
{
printf("Hello, world!\n");
}
int main(void)
{
hello(); // 打印 "Hello, world!"
}我前面提到过,当你向函数传递实参时,会复制该实参,并把副本存入对应的形参。
如果实参是一个变量,就会复制该变量的值,存入形参。
更一般地说,整个实参表达式会被求值,并确定其值。这个值会被复制到形参中。
无论如何,形参里的值都是独立的一份。它与你调用函数时用作实参的那些值或变量无关。
那我们来看一个例子。先研究一下,看看能否在运行之前判断出输出:
#include <stdio.h>
void increment(int a)
{
a++;
}
int main(void)
{
int i = 10;
increment(i);
printf("i == %d\n", i); // 这会打印出什么?
}乍一看,i 的值是 10,我们把它传给函数 increment()。值在那里被递增,所以我们打印它时,它一定是 11,对吧?
“习惯失望吧。”
——恐怖海盗罗伯茨,《公主新娘》
但它并不是 11——它打印的是 10!怎么会这样?
原因就在于,你传给函数的表达式会被复制到对应的形参上。形参是一份副本,而不是原件。
所以 main() 里的 i 是 10。我们把它传给 increment()。该函数中对应的形参名为 a。
复制就这样发生了,就像赋值一样。粗略地说,就是 a = i。于是在那一刻,a 是 10。而在 main() 里,i 也是 10。
然后我们把 a 递增到 11。但我们根本没碰 i!它仍然是 10。
最后,函数执行完毕。它的所有局部变量都被丢弃(再见,a!),我们回到 main(),那里的 i 仍然是 10。
然后我们打印它,得到 10,就这样。
这就是为什么在前一个使用 plus_one() 函数的例子里,我们 return 了局部修改后的值,好让我们能在 main() 中再次看到它。
这看起来有点受限,是吧?你心里想的可能是:一个函数只能带回一份数据。不过,还有另一种把数据带回来的方法;C 圈子里管它叫按引用传递,这个故事我们改日再讲。
但再花哨的名头也掩盖不了一个事实:你传给函数的一切,无一例外,都会被复制到对应的形参中,函数操作的是那份局部副本,无论如何都是如此。记住这一点,哪怕我们谈的是所谓的按引用传递。
如果你还记得前面几个小节那段“冰河时代”的往事,我提到过:必须先定义函数,然后才能使用它,否则编译器事先不知道它的存在,会直接报错罢工。
这话并不完全正确。你可以提前告知编译器:你将会使用一个具有某种类型、带有某个形参列表的函数。这样,函数就可以定义在任何地方(甚至可以在另一个文件里),只要在你调用该函数之前已经声明了函数原型。
幸运的是,函数原型其实很简单。它不过是函数定义第一行的副本,末尾再加一个分号,图个稳妥。例如,下面这段代码调用了一个稍后才定义的函数,因为前面已经声明了原型:
#include <stdio.h>
int foo(void); // 这就是原型!
int main(void)
{
int i;
// 我们可以在 foo() 的定义之前就在这里调用它,因为
// 原型已经在上面声明过了!
i = foo();
printf("%d\n", i); // 3490
}
int foo(void) // 这就是定义,和原型一模一样!
{
return 3490;
}如果你在使用函数之前没有声明它(无论是提供原型还是提供定义),你做的就是所谓的隐式声明。第一版 C 标准(C89)允许这种做法,该标准对此有相应规则,但如今已不再允许。而且,在新代码中也没有任何正当理由去依赖它。
你可能已经注意到我们一直在用的示例代码有点不对劲……那就是,我们一直在用老牌的 printf() 函数,却既没有定义它,也没有声明原型!我们怎么能这样无法无天?其实我们并没有。原型是存在的;它就在我们用 #include 包含的那个头文件 stdio.h 里,记得吗?所以我们还是合法的,警官!
在 C23 中,函数定义或原型里的空形参列表,与你在那里写上 void 的含义完全相同。
void foo();
void foo(void); // 在 C23 中也是如此话虽如此,如今的 C 代码似乎仍然偏爱在形参列表里显式写出 void,这主要是因为 C23 之前,空形参列表的行为明显不同。而且不是往好的方向不同。
我们现在就来深入探讨,不过简单来说:如果代码有可能用 C23 之前的编译器构建,那么在所有函数声明中都要始终用 void 表示空形参列表。
我们需要考虑两种情形。
先来看一个可能的函数定义:
void foo() // 那里其实应该写上 `void`
{
printf("Hello, world!\n");
}虽然规范说这表示函数 foo() 不接受任何形参,但如果你错误地传入了实参,编译器完全可以不警告你。而一旦这样做,就是未定义行为。要避免这些,直接在那里写上 void 即可。
就函数原型而言,用不用 void 有显著差别:
void foo();
void foo(void); // 这可不一样!在原型里省掉 void,等于告诉编译器:关于该函数的形参没有任何额外信息。这实际上关闭了所有类型检查,使得 void foo(); 根本算不上“原型”。它只是一个前向声明。
所以,写原型时若形参列表为空,务必使用 void。
而且,你知道,无论如何这都是好习惯。也许有一天,等所有 C23 之前的编译器都退出使用,我们就可以把它丢掉了。