2.4 初识函数
这一节中,我们将开始讲述 C++ 中的一种重要的程序结构:函数。函数涉及的内容非常多,我们将分多个部分来讲解。
现实问题
考虑这样一个问题:
- 已知一个
int
类型的数x
- 将这个数
x
赋值给一个数a
- 判断
a * a
是否大于x
,如果是,则进行第4步,否则进行第5步 - 求
(a + x / a) / 2
的值,并赋值给a
,然后回到第3步 - 如果不是,将
a
的值赋给sqrt_x
写成 C++ 代码,这个问题的解决方案可能是这样的:
int x = 42; // 已知 x 的值
int a = x; // 将 x 赋值给 a
while (a * a > x) {
a = (a + x / a) / 2;
}
int sqrt_x = a; // 将 a 的值赋给 sqrt_x
现在,我们需要计算十个这样的 x
对应的 sqrt_x
。我们当然可以这样实现:
int x1 = 42;
int a1 = x1;
while (a1 * a1 > x1) {
a1 = (a1 + x1 / a1) / 2;
}
int sqrt_x1 = a1;
int x2 = 43;
int a2 = x2;
//...
//... 依此类推
这样直接复制粘贴这段代码十次,这会导致代码会非常冗长,难以阅读。此外,如果我们要更改计算方法,那么我们需要修改十处代码,这样的代码是非常难以维护的。
所以,我们需要一种方法来避免这种情况。这就是函数最初的作用。
函数的定义
为了解决上面的问题,我们可以定义一个函数来计算 sqrt_x
。函数定义的形式如下:
return_type function_name ( parameter_list ) statement
其中,return_type 表示函数的返回类型,是一个表示类型的描述符;function_name 表示函数的名称,是一个描述符;parameter_list 是函数的参数列表;statement 表示函数的函数体,是一个复合语句。
返回值是函数计算的结果,返回类型表示返回值的类型(返回值在后面返回语句中介绍)。
函数名是一个用来代指函数的标识符,函数名可以用来调用函数。注意,函数并不是一个对象,不能使用赋值表达式来给函数赋值。
参数列表可以为空,或者是一个用逗号分隔的参数声明列表,形式是:
- ①
参数声明
- ②
参数声明列表 , 参数声明
这里面,每个参数声明的形式是 类型 标识符
或者 类型
(后者省略标识符)。
函数体用一个复合语句来表示,当函数被调用时,复合语句中的语句会被依次执行。
这样,我们可以定义一个函数来计算 sqrt_x
:
int sqrt(int x) {
int a = x;
while (a * a > x) {
a = (a + x / a) / 2;
}
return a;
}
上面的代码里,int
是返回类型,sqrt
是函数名,int x
是参数列表。
返回语句
在上面这段函数的最后,出现了一个 return a;
,这是一种跳转语句,称为返回语句。
返回语句的形式是 return 表达式;
,在执行 return
语句时,会计算 表达式
的值,并将这个值作为函数的返回值,然后结束函数的执行。
这里,return a;
的作用是将 a
的值作为函数的返回值,然后结束函数的执行。
函数的调用
函数的调用是一种,其形式是:
function_name ( argument_list )
这里,function_name 是函数的名称,argument_list 是参数列表。
其中,参数列表是用逗号分隔的表达式序列,形式是:
- ①
assign_expression
- ②
argument_list , assign_expression
这里,assign_expression
是。
参数列表与逗号表达式
参数列表看起来和很像,但是它们是不同的。
逗号表达式可以单独作为一个表达式语句,并且逗号表达式的值是最后一个表达式的值。例如:
a = 1, b = 2; // 两个赋值表达式组成的逗号表达式,a 的值是 1
a = (1, 2); // a 的值是 2
而参数列表只能作为函数调用表达式的一部分,参数列表各个表达式的值会被依次用来初始化函数的参数。
看到连续由逗号分隔的表达式时,可以通过外侧的括号 ()
左侧是否有函数名来区分这两种结构。
例如,我们可以这样调用 sqrt
函数:
int x = 42;
int sqrt_x = sqrt(x);
在这个例子中,sqrt(x)
是一个函数调用表达式,sqrt
是函数名,x
是参数列表。
当然,我们也可以用更复杂的表达式作为参数:
int x = 42;
int sqrt_x = sqrt(x + 1);
甚至我们可以用函数调用表达式作为参数:
int x = 42;
int sqrt_x = sqrt(sqrt(x + 1) + 1);
函数调用表达式的计算过程是:
- 计算函数调用表达式中的参数列表中,每个参数的值,初始化函数的参数
- 依次执行函数体中的语句,直到函数执行结束
- 将结束函数调用所使用的
return
语句种表达式的值作为函数调用表达式的值
在上面的例子中,这一段代码
int x = 42;
int sqrt_x = sqrt(x + 1);
的执行过程是:
- 初始化
x
的值为42
- 计算
x + 1
的值,得到43
- 用
43
初始化sqrt
函数的参数x
- 执行
sqrt
函数体中的语句,直到函数执行结束,此时返回语句return a;
的表达式是a
,即a
的值,得到6
- 将
6
作为sqrt(x + 1)
的值,初始化sqrt_x
这样,我们只需要调用 sqrt
函数十次,就可以得到十个 sqrt_x
的值,而不需要把这个计算过程抄十次。如下:
int sqrt(int x) {
int a = x;
while (a * a > x) {
a = (a + x / a) / 2;
}
return a;
}
int x1 = 42;
int sqrt_x1 = sqrt(x1);
int x2 = 43;
int sqrt_x2 = sqrt(x2);
//...
//... 依此类推
形式参数与实际参数
在描述函数调用的时候,例如 int sqrt_x2 = sqrt(x2);
,人并不喜欢称呼 sqrt(x2)
中的 x2
叫什么“函数调用表达式的直接子表达式”。它被用来初始化函数的参数,所以干脆直接就叫做“参数”。
称 x2
为“参数”当然是没有错的,但是,这样就会带来一个混淆——int sqrt(int x)
中的 x
也叫“参数”,并且显然不是一个东西,在这个函数调用中,x
是从 x2
初始化而来的另一个对象。为了避免这种混淆,我们通常称呼 int sqrt(int x)
中的 x
为“形式参数”,称呼 sqrt(x2)
中的 x2
为“实际参数”。或者更简略的称呼为“形参”和“实参”。当然,在没有混淆的语境中,称呼 x2
为“参数”更符合人的直觉。
省略标识符的参数
在参数列表中,如果省略了标识符,那么这个参数是一个匿名参数,这样的参数在函数体中是无法访问的。例如:
int f(int) {
// 无法访问匿名参数
return 0;
}
但是,这样的参数在函数调用时也需要传递值,例如:
int a = f(42);
无返回值函数
有的函数不需要返回值,这样的函数称为无返回值函数。无返回值函数的返回类型是 void
。例如,我们可以定义一个无返回值函数 add_a
:
int a = 1;
void add_a(int x) {
a += x;
}
这里,a += x
是一个赋值表达式,其值是 a + x
的值,副作用是将 a + x
的值赋值给 a
。
函数内部语句的副作用称为函数的副作用,这里,add_a
函数的副作用是是将 a
对象增加参数 x
的值。
无返回值函数的返回类型是 void
,这是一个特殊的类型,表示没有返回值。因此,无返回值函数的 return
语句是没有表达式的,例如:
int a = 1;
void add_a(int x) {
if(x > 0) return; // 无返回值
a += x;
}
此外,调用无返回值函数时,这个函数调用表达式也没有值。例如:
int a = 1;
void add_a(int x) {
a += x;
}
add_a(2); // 正确:add_a 是无返回值函数
int b = add_a(2);// 错误:add_a 是无返回值函数,没有返回值,这个初始化语句是错误的
void 类型
void
类型没有值,不能进行任何运算操作,也不能声明 void
类型的对象。此外,void
类型有其他的性质,我们会在后面的章节中介绍。
纯函数
纯函数是指一类只通过参数计算返回值的函数。纯函数里面的语句没有任何副作用会影响到函数之外的程序部分。 例如,sqrt
函数就是一个纯函数,它只通过参数 x
计算返回值,没有任何副作用。而 add_a
函数就不是纯函数,它会改变对象 a
的值。
在程序编写中,纯函数有着非常良好的性质,因为它们不会对程序的其他部分产生影响。这样的函数更容易理解和维护,尤其是在涉及多线程处理的时候。
但是,我们也不可避免的需要设计一些有副作用的函数,例如 add_a
函数。这在程序设计中也是必不可少的。
参数和返回值的初始化
在函数调用时,参数的初始化是通过参数列表中的表达式来完成的。
参数和返回值的初始化如同普通变量的初始化一般,遵循相同的转换规则。例如:
int foo(bool a) {
return a;
}
int r = foo(42); // r 的值是 1,用 42 初始化参数 a 被转换为 true,true 被转换为 1
前向声明
类似于一个对象的名字必须要在声明之后才能使用,函数的名字也必须在定义之后才能调用。例如:
int sqrt(int x) {
add_sqrt_counter();// 错误:add_sqrt_counter 函数还没有定义
int a = x;
while (a * a > x) {
a = (a + x / a) / 2;
}
return a;
}
int sqrt_counter = 0;
void add_sqrt_counter() {
if (sqrt_counter < 10)
sqrt_counter += 1;
else
sqrt_counter = sqrt(sqrt_counter + 1);
}
在上面的程序里,add_sqrt_counter
函数在 sqrt
函数中被调用,但是 add_sqrt_counter
函数的定义在 sqrt
函数的定义之后。这样的程序是错误的。然而,我们又不能把 add_sqrt_counter
函数的定义放在 sqrt
函数的定义之前,因为 add_sqrt_counter
函数中又调用了 sqrt
函数。
这时,我们可以使用前向声明。函数的前向声明的形式是:
return_type function_name ( expression_list ) ;
其中,return_type 是函数的返回类型,function_name 是函数的名称,expression_list 是参数列表。
读者可以将函数的前向声明的形式理解为把函数体改成一个分号 ;
。
使用前向声明,我们可以这样写:
void add_sqrt_counter(); // 前向声明
int sqrt(int x) {
add_sqrt_counter();// 正确:add_sqrt_counter 函数已经声明
int a = x;
while (a * a > x) {
a = (a + x / a) / 2;
}
return a;
}
int sqrt_counter = sqrt(42);
void add_sqrt_counter() { // add_sqrt_counter 函数的定义
sqrt_counter += 1;
}
这样,我们就解决了函数之间的循环依赖无法表达的问题。
前向声明可以声明多次,但是只能定义一次。前向声明的返回类型和参数列表必须保持一致,但是参数名没有限制。例如:
void foo(int x, bool y); // 符合语法的前向声明
void foo(int x, bool); // 符合语法得前向声明
void foo(int, int);// 错误:参数列表类型不一致
void foo(bool, int);// 错误:参数列表类型不一致(顺序也必须一致)
int foo(int x, bool y);// 错误:返回值类型不一致
void foo(int, bool y) { // 函数定义
// ...
}