| 目录 |

4 函数

“先生,在这样一个环境里可不行。所以我另外还被编入了三十多种次级功能,用来——”

——C3PO,在被无礼打断之前,报出一个如今已不值一提的附加功能数量,《星球大战》剧本

和你熟悉的其他语言很像,C 也有函数这个概念。

函数可以接受各种实参,并返回一个值。不过有一点很重要:实参和返回值的类型都是预先声明好的——因为 C 就喜欢这样!

我们来看一个函数。这个函数接受一个 int 作为实参,并返回一个 int。

#include <stdio.h>

int plus_one(int n)  // "定义"
{
    return n + 1;
}
 

plus_one 前面的 int 表示返回类型。

int n 表示这个函数接受一个 int 实参,它存放在形参 n 中。形参是一种特殊的局部变量,实参会被复制进去。

这里我要把“实参是复制进形参的”这一点讲透。C 里有很多东西,一旦你知道形参是实参的副本、而不是实参本身,就会好懂得多。稍后还会细说。

把程序往下看到 main(),就能看到对函数的调用,我们把返回值赋给了局部变量 j:

int main(void)
{
    int i = 10, j;
    
    j = plus_one(i);  // "调用"

    printf("i + 1 is %d\n", j);
}

趁我还没忘,请注意我是在使用函数之前先定义了它。要是没这么做,编译器在编译 main() 时就还不知道它,会报未知函数调用的错误。上面这段代码还有更规范的做法,即使用函数原型,不过那部分我们以后再讲。

还要注意,main() 也是一个函数!

它返回一个 int。

可这个 void 又是什么东西?它是一个关键字,用来表示该函数不接受任何实参。

你也可以返回 void,表示不返回任何值:

#include <stdio.h>

// 这个函数不接受实参,也不返回任何值:

void hello(void)
{
    printf("Hello, world!\n");
}

int main(void)
{
    hello();  // 打印 "Hello, world!"
}

4.1 按值传递

我前面提到过,当你向函数传递实参时,会复制该实参,并把副本存入对应的形参。

如果实参是一个变量,就会复制该变量的值,存入形参。

更一般地说,整个实参表达式会被求值,并确定其值。这个值会被复制到形参中。

无论如何,形参里的值都是独立的一份。它与你调用函数时用作实参的那些值或变量无关。

那我们来看一个例子。先研究一下,看看能否在运行之前判断出输出:

#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 圈子里管它叫按引用传递,这个故事我们改日再讲。

但再花哨的名头也掩盖不了一个事实:你传给函数的一切,无一例外,都会被复制到对应的形参中,函数操作的是那份局部副本,无论如何都是如此。记住这一点,哪怕我们谈的是所谓的按引用传递。

4.2 函数原型

如果你还记得前面几个小节那段“冰河时代”的往事,我提到过:必须先定义函数,然后才能使用它,否则编译器事先不知道它的存在,会直接报错罢工。

这话并不完全正确。你可以提前告知编译器:你将会使用一个具有某种类型、带有某个形参列表的函数。这样,函数就可以定义在任何地方(甚至可以在另一个文件里),只要在你调用该函数之前已经声明了函数原型。

幸运的是,函数原型其实很简单。它不过是函数定义第一行的副本,末尾再加一个分号,图个稳妥。例如,下面这段代码调用了一个稍后才定义的函数,因为前面已经声明了原型:

#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 里,记得吗?所以我们还是合法的,警官!

4.3 空形参列表

在 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 之前的编译器都退出使用,我们就可以把它丢掉了。


| 目录 |