函数

函数是一个命名的代码块,通过调用执行。
函数可以:

  • 有 0 个或多个参数
  • 通常返回一个结果
  • 支持重载:同名、不同参数列表

函数基础

一个典型函数由以下部分组成:

  • 返回类型
  • 函数名
  • 形参列表
  • 函数体

例如:

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

函数调用

通过调用运算符 () 调用函数:

1
int sum = add(1, 2);

调用函数时会发生两件事:

  1. 用实参初始化形参
  2. 控制权转移到被调函数

函数执行结束后,再返回主调函数继续执行。

形参和实参

  • 形参:定义在函数中的参数
  • 实参:调用函数时提供的值
1
2
int add(int a, int b); // a、b 是形参
add(3, 4); // 3、4 是实参

说明:

  • 实参数量必须与形参数量匹配
  • 实参类型要与形参类型匹配,或能转换
  • 函数实参的求值顺序一般未规定

空形参列表

没有参数的函数也必须写圆括号:

1
2
void f1() { }
void f2(void) { }

两种写法都表示:无参数

形参声明规则

每个形参都必须写出类型:

1
2
int f(int a, int b);   // 正确
// int f(int a, b); // 错误

其他规则:

  • 同一函数中形参名不能重复
  • 函数最外层作用域中,局部变量不能与形参同名

返回类型

函数返回类型表示调用表达式的结果类型。

例如:

1
2
int f();      // 返回 int
void g(); // 不返回值

注意:

  • 函数不能返回数组类型
  • 函数不能返回函数类型
  • 但可以返回:
  • 数组的指针
  • 数组的引用
  • 函数指针

局部对象

函数体内部定义的对象,以及形参,都属于局部对象

局部对象有两个重要概念:

  • 作用域:名字在哪一段代码中可见
  • 生命周期:对象在程序运行中存在多久

自动对象

普通局部变量属于自动对象

1
2
3
void f() {
int x = 0;
}

特点:

  • 进入块时创建
  • 离开块时销毁

形参也属于自动对象。

局部静态对象

static 定义的局部变量称为局部静态对象

1
2
3
4
5
void count_calls() {
static int cnt = 0;
++cnt;
cout << cnt << endl;
}

特点:

  • 第一次执行到定义语句时初始化
  • 程序结束时才销毁
  • 多次调用函数时会保留上一次的值

若未显式初始化:

  • 内置类型会初始化为 0

函数声明

函数在使用前必须先声明。

函数可以:

  • 声明多次
  • 定义一次

函数声明也叫函数原型

1
int add(int, int);   // 声明

函数声明只需写:

  • 返回类型
  • 函数名
  • 形参类型

不必写形参名。

声明与定义分离

通常:

  • 头文件中声明函数
  • 源文件中定义函数

这样便于多个文件共享接口。

参数传递

函数调用时,形参与实参如何交互,取决于形参类型。

分两种主要方式:

  • 值传递
  • 引用传递

传值参数

如果形参不是引用,则实参的值会被拷贝给形参。

1
2
3
4
5
6
void f(int x) {
x = 10;
}

int n = 0;
f(n); // 不会改变 n

特点:

  • 改变形参,不影响实参
  • 会发生一次拷贝

指针形参

指针也是值传递:拷贝的是指针的值

1
2
3
void reset(int *p) {
*p = 0; // 修改 p 所指对象
}

说明:

  • 改变 p 本身,不影响实参指针
  • 通过 *p 可以修改指向的对象

例如:

1
2
int i = 42;
reset(&i); // i 被改为 0

现代 C++ 中,若只是想操作某个对象,通常更推荐用引用形参。

传引用参数

若形参是引用,则它绑定到实参上,形参就是实参的别名。

1
2
3
void reset(int &i) {
i = 0;
}

调用:

1
2
int j = 42;
reset(j); // j 被改为 0

特点:

  • 不发生拷贝
  • 可以直接修改实参
  • 常用于大对象,避免拷贝开销

用引用返回额外结果

函数只能直接 return 一个值,但可以通过引用形参返回更多信息。

1
2
3
4
void find(int x, int &pos, bool &ok) {
pos = x;
ok = true;
}

const 形参

如果函数不需要修改参数,应优先使用 const 引用:

1
2
3
void print(const string &s) {
cout << s;
}

优点:

  • 避免拷贝
  • 保证不会修改实参
  • 能接收常量对象和临时对象

const 与实参

顶层 const 会被忽略,但底层 const 不能随意丢掉。

例如:

1
void f(const int &x);

可以传入:

  • 非常量 int
  • 常量 int

数组形参

数组有两个重要特点:

  1. 不能拷贝
  2. 使用时通常会退化为指针

因此,数组作为函数参数时,实际上传递的是首元素指针。

1
2
void print(const int arr[], int size);
void print(const int *arr, int size); // 等价

数组参数要防止越界

因为传进来的只是指针,函数本身通常不知道数组长度,所以要额外传长度,或传首尾指针。

例如:

1
2
3
4
void print(const int *beg, const int *end) {
while (beg != end)
cout << *beg++ << " ";
}

数组引用形参

如果想让函数接收“固定大小的数组本身”,可以用数组引用:

1
void f(int (&arr)[10]);

含义:

  • arr 是一个引用
  • 绑定到“含 10 个 int 的数组”

注意区分:

1
2
// void f(int &arr[10]); // 错误:引用数组
void f(int (&arr)[10]); // 正确:数组引用

main 的参数

main 常见写法:

1
int main(int argc, char *argv[]) { }

也可写成:

1
int main(int argc, char **argv) { }

其中:

  • argc:命令行参数个数
  • argv:参数数组,每个元素都是 C 风格字符串指针

说明:

  • argv[^0] 通常是程序名
  • 用户输入参数从 argv[^1] 开始

可变形参

当函数参数个数不固定时,可以使用:

  1. initializer_list
  2. 可变参数模板
  3. 省略符 ...

常用、推荐的是 initializer_list

initializer_list

适用于:参数个数不固定,但类型相同

1
2
3
4
5
6
7
8
9
#include <initializer_list>
using std::initializer_list;

int sum(initializer_list<int> lst) {
int total = 0;
for (int v : lst)
total += v;
return total;
}

调用:

1
sum({1, 2, 3, 4});

常用操作

写法 含义
initializer_list<T> lst; 空列表
initializer_list<T> lst{a, b, c}; 用元素初始化
lst.size() 元素个数
lst.begin() 首元素指针
lst.end() 尾后位置

说明:

  • 元素是 const
  • 拷贝 initializer_list 不会拷贝元素本身

省略符形参

形式:

1
2
void foo(...);
void foo(int x, ...);

它主要用于与 C 兼容的场景,不够类型安全,一般不推荐使用

return 语句

return 用于:

  • 结束当前函数
  • 将控制权交回调用处
  • 如果有返回值,则把结果返回

无返回值函数

返回类型为 void 的函数可以写:

1
return;

或不写 return,执行到函数末尾自动结束。

有返回值函数

返回类型不是 void 时,每条可能执行到的路径都应该返回一个值。

1
2
3
4
5
int f(int x) {
if (x > 0)
return x;
return 0;
}

要求:

  • 返回值类型要与函数返回类型一致
  • 或能隐式转换

不要返回局部对象的引用或指针

错误示例:

1
2
3
4
const string& bad() {
string s = "hello";
return s; // 错误
}

因为局部对象在函数结束时被销毁,返回其引用或指针会悬空。

返回值类别

  • 返回引用的函数,调用结果是左值
  • 返回非引用类型,调用结果通常是右值

例如:

1
int &get(int &x) { return x; }

可以写:

1
get(i) = 10;

列表初始化返回值

C++11 允许返回花括号列表:

1
2
3
vector<int> f() {
return {1, 2, 3};
}

main 的返回值

main 返回值表示程序执行状态:

  • 0:成功
  • 0:失败

也可以使用 <cstdlib> 中的宏:

1
2
3
4
5
6
7
8
#include <cstdlib>

int main() {
if (true)
return EXIT_SUCCESS;
else
return EXIT_FAILURE;
}

递归

如果一个函数直接或间接调用自己,就称为递归函数

例如:

1
2
3
4
5
int fact(int n) {
if (n <= 1)
return 1;
return n * fact(n - 1);
}

递归必须有:

  • 递归调用
  • 终止条件

否则会无限递归,最终导致栈溢出。

返回数组指针

函数不能返回数组本身,但可以返回:

  • 数组指针
  • 数组引用

普通写法

1
int (*func(int i))[10];

含义:

  • func 是函数
  • 接收一个 int
  • 返回一个指针
  • 该指针指向“含 10 个 int 的数组”

尾置返回类型

C++11 可以写得更清楚:

1
auto func(int i) -> int(*)[10];

使用 decltype

如果已有数组对象,可借助 decltype

1
2
3
4
5
6
int odd[] = {1, 3, 5, 7, 9};
int even[] = {0, 2, 4, 6, 8};

decltype(odd) *arrPtr(int i) {
return (i % 2) ? &odd : &even;
}

注意:

  • decltype(odd) 的结果是数组类型
  • 若想返回“数组指针”,还要再加 *

函数重载

同一作用域中,函数名相同但形参列表不同,称为重载

例如:

1
2
3
void print(int);
void print(double);
void print(const string&);

重载要求

重载函数必须在以下方面不同:

  • 参数个数不同
  • 参数类型不同
  • 参数顺序不同(类型不同的前提下)

不能只靠返回类型区分重载

1
2
int  f(int);
double f(int); // 错误

顶层 const 不影响重载

下面两个函数其实相同:

1
2
int func(int);
int func(const int); // 错误,重复声明

因为传值参数的顶层 const 会被忽略。

const 引用/指针与重载

底层 const 可以构成不同重载:

1
2
void f(int &);
void f(const int &);

调用时:

  • 非常量对象优先匹配非常量版本
  • 常量对象只能匹配常量版本

调用重载函数

编译器在一组同名函数中选择最合适的那个过程叫:

  • 函数匹配
  • 重载确定

可能有 3 种结果:

  1. 找到最佳匹配,调用成功
  2. 没有可匹配函数,报错
  3. 有多个都能匹配,但没有最佳者,产生二义性

二义性示例

1
2
3
4
void f(long);
void f(double);

f(10); // 可能产生二义性

因为 10 转成 longdouble 都可行。

重载与作用域

函数重载只发生在同一作用域中。

不同作用域里的同名函数,不是重载关系,而可能发生名字隐藏

并且:

C++ 先做名字查找,再做类型检查

默认实参

默认实参就是给参数提供默认值,调用时可以省略。

1
string screen(int h, int w, char c = ' ');

调用:

1
2
screen(24, 80);      // c 使用默认值
screen(24, 80, '*'); // 自己提供 c

规则

  • 默认实参必须从右向左连续设置
  • 一旦某个参数有默认值,它右边的参数都必须有默认值

正确:

1
void f(int a, int b = 0, int c = 0);

错误:

1
// void f(int a = 0, int b, int c); // 错误

默认实参的补充声明

同一作用域中,一个参数只能被赋默认值一次。

后续声明只能给之前没有默认值的参数补默认值。

1
2
3
string screen(int, int, char = ' ');
string screen(int, int, char); // 正确
// string screen(int, int, char = '*'); // 错误,不能改默认值

默认实参的值

默认实参在函数调用时使用。

通常不能用局部变量作为默认实参。

内联函数

在函数前加 inline,可请求编译器把函数在调用处展开。

1
2
3
inline int add(int a, int b) {
return a + b;
}

适合:

  • 代码短小
  • 逻辑简单
  • 调用频繁

说明:

  • inline 只是建议,不保证一定展开
  • 内联函数通常定义在头文件中

constexpr 函数

constexpr 函数可以用于常量表达式。

1
2
3
constexpr int new_sz() {
return 42;
}

使用:

1
constexpr int foo = new_sz();

要求

传统规则下,constexpr 函数通常要求:

  • 返回类型是字面值类型
  • 形参类型是字面值类型
  • 返回的结果在需要时能构成常量表达式

注意:

  • constexpr 函数不一定每次都返回常量表达式
  • 只有在传入常量表达式实参时,结果才可能是常量表达式

inline

很多 constexpr 函数也会被隐式视作内联函数。

通常也定义在头文件中。

调试帮助

常见调试工具:

  • assert
  • NDEBUG

assert

assert 是预处理宏,定义在:

1
#include <cassert>

用法:

1
assert(expr);

含义:

  • expr 为真,什么都不做
  • expr 为假,输出错误信息并终止程序

例如:

1
assert(i > 0);

NDEBUG

assert 是否生效与 NDEBUG 有关。

  • 未定义 NDEBUGassert 生效
  • 定义了 NDEBUGassert 被关闭

也可用它控制自定义调试代码:

1
2
3
#ifndef NDEBUG
cerr << "debug info" << endl;
#endif

函数指针

函数指针是“指向函数的指针”。

例如有函数:

1
int func(int);

则对应的函数指针写法为:

1
int (*pf)(int);

含义:

  • pf 是指针
  • 指向一个参数为 int、返回 int 的函数

赋值与调用

函数名在需要时会自动转换成函数指针:

1
pf = func;

调用函数指针时:

1
2
int a = pf(1);
int b = (*pf)(2);

两种写法等价。

空函数指针

函数指针可赋值为:

1
pf = nullptr;

表示不指向任何函数。

返回函数指针

直接写比较复杂,通常建议用类型别名。

1
2
using F = int(int*, int);     // 函数类型
using PF = int(*)(int*, int); // 函数指针类型

返回函数指针的函数:

1
PF f1(int);

直接声明

也可以直接写:

1
int (*f1(int))(int*, int);

含义:

  • f1 是函数
  • 接收一个 int
  • 返回一个函数指针
  • 该函数指针指向的函数:参数为 (int*, int),返回 int

尾置返回类型

更清晰的写法:

1
auto f1(int) -> int(*)(int*, int);

易错点总结

实参求值顺序通常未规定

不要写依赖实参求值顺序的代码。

数组作参数时会退化为指针

1
void f(int arr[]); // 等价于 void f(int *arr);

不要返回局部变量的引用或指针

函数结束后局部对象已销毁。

重载不能只靠返回类型区分

1
2
int f(int);
double f(int); // 错误

默认实参必须从右往左连续给出

修改实参时优先考虑引用参数

不修改时优先考虑 const 引用。

initializer_list 元素是常量

不能修改其中元素。

main 的用户参数从 argv[^1] 开始

argv[^0] 通常是程序名。