# 函数基础

典型的函数(function)定义包括以下部分:

  • 返回类型(return type)
  • 函数名字
  • 由 0 个或多个形参(parameter)组成的列表,其中,形参以逗号隔开,形参的列表位于一对圆括号之内
  • 函数体

例如:定义一个函数用于求数 n 的阶乘,其中,返回类型为 int 型,函数名字为 fact,形参为 int n,函数体为一对花括号 {} 内的语句

int fact(int n) {
    int ret = 1;
    while (n > 1)
        ret *= n--;
    return ret;
}

# 调用函数

通过 调用运算符(call operator)来执行函数

  • 调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针;圆括号之内是一个用逗号隔开的实参(argument)列表,我们用实参初始化函数的形参
  • 调用表达式的类型就是函数的返回类型

函数的调用完成两项工作:

  • (隐式地)定义并用实参初始化函数对应的形参
  • 将控制权转移给被调用函数

此时,主调函数(calling function)的执行被暂时中断,被调函数(called function)开始执行

当遇到一条 return 语句时,函数结束执行过程

return 语句也完成两项工作:

  • 返回 return 语句中的值(如果有的话)
  • 将控制权从被调函数转移回主调函数

# 形参和实参

实参是形参的初始值。第一个实参初始化第一个形参,第二个实参初始化第二个形参,以此类推

尽管实参与形参存在对应关系,但是并没有规定实参的求值顺序。编译器能以任意可行的顺序对实参求值

实参数量应与形参数量一致,并且,实参的类型必须与对应的形参类型匹配

# 函数的形参列表

函数的形参列表可以为空,但是不能省略

  • 要想定义一个不带形参的函数,最常用的办法是书写一个空的形参列表
  • 为了与 C 语言兼容,也可以使用关键字 void 表示函数没有形参
void f1() { /* ... */ }     // 隐式地定义空形参列表
void f2(void) { /* ... */ } // 显式地定义空形参列表

形参列表中的形参通常用逗号隔开,其中,每个形参都是含有一个声明符的声明(即使两个形参的类型一样,也必须把两个类型都写出来)

int f3(int v1, v2) { /* ... */ }     // 错误
int f4(int v1, int v2) { /* ... */ } // 正确

任意两个形参都不能同名,而且,函数最外层作用域中的局部变量也与函数形参同名

形参名是可选的。如果函数不会使用到个别形参,通常不命名该形参,以表示其不会在函数体内被使用

注意:即使某个形参不被函数使用,也必须为它提供一个实参

# 函数返回类型

大多数类型都能用作函数的返回类型

一种特殊的返回类型是 void ,它表示函数不返回任何值

函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针

# 局部对象

在 C++ 语言中,名字有作用域,对象有生命周期(lifetime)

  • 名字的作用域:程序文本的一部分,名字在其中可见
  • 对象的生命周期:程序执行过程中该对象存在的一段时间

形参和函数体内部定义的变量统称为局部变量(local variable)。它们对函数而言是 “局部” 的,仅在函数的作用域内可见

Parameters and variables defined inside a function body are referred to as local variables. They are “local” to that function and hide declarations of the same name made in an outer scope.

在所有函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁

局部变量的生命周期依赖于定义的方式。对于普通局部变量对应的对象,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它

# 自动对象

只存在于块执行期间的对象被称为 自动对象(automatic object)

当块的执行结束后,块中创建的自动对象的值就变成未定义的了

形参就是一种自动对象:函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也就被销毁

自动对象的初始化:

  • 形参对应的自动对象:用传递给函数的实参初始化形参对应的自动对象
  • 局部变量对应的自动对象:
    • 如果变量定义本身含有初始值,就用这个初始值进行初始化
    • 否则,如果变量定义本身不含初始值,执行默认初始化

内置类型的未初始化局部变量将产生未定义的值

# 局部静态对象

某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。此时,可以将局部变量定义成 static 类型

局部静态对象(local static object)在程序第一次经过对象定义语句时进行初始化,并且直到程序终止才被销毁。在此期间,即使对象所在的函数结束执行,也不会对它有影响

如果局部静态变量没有显式的初始值,它将执行值初始化,内置类型的局部静态变量初始化为 0

# 函数声明

类似于变量,函数只能定义一次,但可以声明多次

唯一的例外是,如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义

函数的声明和函数的定义非常类似,唯一的区别是,函数声明无须函数体,用一个分号替代即可

// 函数声明
void print(vector<int>::const_iterator beg,
           vector<int>::const_iterator end);

函数的声明不包含函数体,所以也就无须形参的名字(事实上,在函数的声明中经常省略形参的名字)

函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息

函数声明也称作函数原型(function prototype)

# 在头文件中进行函数声明

类似于变量,函数也应该在头文件中声明而在源文件中定义

允许把函数的声明直接放在使用该函数的源文件中,但是这么做可能会很烦琐而且容易出错。相反,如果把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。而且,一旦我们想改变函数的接口,只需改变一条声明即可

含有函数声明的头文件应该被包含到定义函数的源文件中

# 分离式编译

为了允许编写程序时按照逻辑关系将其划分开来,C++ 语言支持所谓的分离式编译(separate compilation)

分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译

如果我们修改了其中一个源文件,那么只需重新编译那个改动了的文件

大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是 .obj(Windows)或 .o(UNIX)的文件(该文件包含对象代码(object code))

# 参数传递

每次调用函数时都会重新创建它的形参,并用传入的实参对形参进行初始化

  • 当形参是引用类型时,它将绑定到对应的实参上。此时,我们说它对应的实参被 引用传递(passed by reference)或者函数被 传引用调用(called by reference)

  • 当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被 值传递(passed by value)或者函数被 传值调用(called by value)

# 传值参数

函数对形参做的所有操作都不会影响实参

int fact(int val) {
    int ret = 1;
    while (val) {
        ret *= val--;
    }
    return ret;
}

# 指针形参

可以将指针作为函数的形参

  • 调用函数时,会将指针实参拷贝给形参(形参和实参是两个相互独立的对象)
  • 由于形参和实参具有相同的值,可以通过形参间接地访问实参所指的对象,并且可以可以修改它所指对象的值
  • 改变形参并不会影响实参
// 该函数接受一个指针,然后将指针所指的值置 0
void reset(int *ip) { // 指针形参
    *ip = 0;  // 改变指针 ip 所指对象的值
    ip = 0;   // 只改变了 ip 的局部拷贝,实参并未被改变
}

熟悉 C 的程序员常常使用指针类型的形参访问函数外部的对象。在 C++ 语言中,建议使用引用类型的形参替代指针

# 传引用参数

引用形参绑定到对应的实参上

// 该函数接受一个 int 对象的引用,然后将对象的值置为 0
void reset(int &i) { // i 是传给 reset 函数的对象的另一个名字
    i = 0; // 改变了 i 所引对象的值
}

# 使用引用避免拷贝

拷贝大的类类型对象或者容器对象是比较低效的,此时可以通过引用传入参数

// 比较两个 string 对象的长度
bool isShorter(const string &s1, const string &s2) {
    return s1.size() < s2.size();
}

甚至,有的类类型(包括 IO 类型在内)根本就不支持拷贝操作。此时,函数只能通过引用形参访问该类型的对象

如果函数无须改变引用形参的值,最好将其声明为常量引用

# 使用引用形参返回额外信息

一个函数只能返回一个值,然而有时函数需要同时返回多个值,因此可以考虑通过引用形参获取多个结果

// 返回 s 中 c 第一次出现的位置索引
// 引用形参 occurs 负责统计 c 出现的总次数
string::size_type find_char(const string &s, char c,
                           string::size_type &occurs) {
    auto ret = s.size();   // 第一次出现的位置(如果有的话)
    occurs = 0;            // 出现次数置 0
    for (decltype(ret) i = 0; i != s.size(); ++i) {
        if (s[i] == c) {
            if (ret == s.size())
                ret = i;   // 记录 c 第一次出现的位置
            ++occurs;      // 更新出现次数
         }
    }
    return ret;            // c出现次数通过 occurs 隐式地返回
}

# const 形参和实参

在用实参初始化形参时,会忽略掉形参的顶层 const

换而言之,当形参具有顶层 const 时,传给它常量对象或者非常量对象都是可以的(可参考 const 限定符

void fcn (const int i) {
    /* fcn 可以读取 i ,但不能向 i 写值 */
}

调用 fcn 函数时,既可以传入 const int ,也可以传入 int

在 C++ 语言中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显的区别,例如:

void helper (string &s, int start) { /* 第一个 helper 函数 */ };
void helper (int n) { /* 第二个 helper 函数 */ };

然而,由于形参的顶层 const 会被忽略,以下两个同名函数不能同时定义

void fcn (const int i) { /* */ };
void fcn (int i) { /* */ }; // 错误:重复定义了 fcn(int)

因为顶层 const 被忽略掉了,所以在上面的代码中传入两个 fcn 函数的参数可以完全一样,因此第二个 fcn 是错误的

# const 与 指针或引用形参

形参的初始化方式和变量的初始化方式是一样的:

  • 可以使用非常量来初始化一个底层 const 对象,但是反过来不行
  • 一个引用必须用同类型的对象初始化

变量初始化:

int i = 42;
const int *cp = &i; // 正确:但是不能通过 cp 改变 i
const int &r = i;   // 正确:但是不能通过 r 改变 i
const int &r2 = 42; // 正确
int *p = cp;        // 错误:p 的类型和 cp 的类型不匹配(存在通过 p 改变 cp 所指对象的风险)
int &r3 = r;        // 错误:r3 的类型和 r 的类型不匹配(存在通过 r3 改变 r 所引对象的风险)
int &r4 = 42;       // 错误:不能用字面值初始化一个非常量引用

参数传递:

int i = 0;
const int ci = i;
string::size_type ctr = 0;
reset(&i);   // 调用形参类型是 int * 的 reset 函数
reset(&ci);  // 错误:不能用指向 const int 对象的指针初始化 int * 形参
reset(i);    // 调用形参类型是 int & 的 reset 函数
reset(ci);   // 错误:不能将普通引用绑定到 const 对象 ci 上
reset(42);   // 错误:不能将葡萄引用绑定到字面值上
reset(ctr);  // 错误:类型不匹配,ctr 是无符号类型
find_char("Hello World!", 'o', ctr); // 正确:find_char 的第一个形参是对常量的引用

要想调用引用版本的 reset ,只能使用 int 类型的对象,而不能使用字面值、求值结果为 int 的表达式、需要转换的对象或者 const int 类型的对象。类似的,要想调用指针版本的 reset,只能使用 int* 类型的对象

我们能传递一个字符串字面值作为 find_char 的第一个实参,这是因为该函数的引用形参是常量引用,而 C++ 允许我们用字面值初始化常量引用

# 尽量使用常量引用

将函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做会给函数的调用者造成误导,即,函数可以修改它的实参的值

此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型。例如,我们不能把 const 对象、字面值或者需要类型转换的对象传递给普通的引用形参

# 数组形参

数组 无法直接通过数组名进行拷贝,因此,无法以值传递的方式使用数组参数

由于 数组(通常)会被转换成指针,当我们为一个函数传递一个数组时,实际上传递的是指向数组首元素的指针

尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:

// 尽管表现形式不同,但这三个 print 函数是等价的
// 每个函数都有一个 const int* 类型的形参
void print (const int*)
void print (const int []) // 函数的意图是作用于一个数组
void print (const int [10]) // 这里的维度表示我们期望的数组维度,实际不一定是这个维度

当编译器处理对 print 函数的调用时,只检查传入的参数是否是 const int* 类型

int i = 0, j[2] = {0, 1};
print(&i); // 正确:&i 的类型是 int*
print(j);  // 正确:j 转换成 int* 并指向 j[0]

如果我们传给 print 函数的是一个数组,则实参自动地转换成指向数组首元素的指针。其中,数组的大小对函数的调用没有影响

以数组作为形参的函数必须确保使用数组时不会越界

# 数组指针形参

因为数组是以指针的形式传递给函数的,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息

管理指针形参有三种常用的技术:

  • 使用标记指定数组长度
  • 使用标准库规范
  • 显式传递一个表示数组大小的形参

# 使用标记指定数组长度

  • 要求数组本身包含一个结束标记
  • 这种方法适用于那些有明显结束标记且该标记不会与普通数据混淆的情况
  • 典型示例:C 风格字符串,C 风格字符串存储在字符数组中,并且在最后一个字符后面跟着一个空字符
// 遇到空字符停止
void print(const char *cp) {
    if (cp)          // 若 cp 不是一个空指针
        while (*cp)  // 只要指针所指的字符不是空字符
            cout << *cp++; // 输出当前字符并将指针向前移动一个位置
}

# 使用标准库规范

传递指向数组首元素和尾后元素的指针

void print(const int *beg, const int *end) { // 传入两个指针:一个指向首元素,另一个指向尾元素的下一位置
    // 输出 beg 到 end 之间(不含 end )的所有元素
    while (beg != end)
        cout << *beg++ << endl; // 输出当前元素并将指针向前移动一个位置
}

int j[2] = {0, 1};
print(begin(j), end(j)); // begin 和 end 函数,分别得到指向数组 j 的首元素和尾后元素的指针

# 显式传递一个表示数组大小的形参

即,专门定义一个形参表示数组大小

// const int ia[] 等价于 const int* ia
// size 表示数组的大小,将它显式地传给函数,用于控制对 ia 的访问
void print(const int ia[], size_t size) {
    for (size_t i = 0; i != size; ++i) {
        cout << ia[i] << endl;
    }
}

int j[] = {0, 1, 2}; // 大小为 3 的整型数组
print(j, end(j) - begin(j));

正如之前所说,当函数不需要对数组元素执行写操作的时候,数组形参应该是指向 const 的指针(即,常量指针)。只有当函数确实要改变元素值的时候,才把形参定义成指向非常量的指针

# 数组引用形参

C++ 允许将变量定义成数组的引用(详见 数组 ),同样地,也允许将形参定义成数组的引用,此时,引用形参绑定到对应的数组上

// 正确:形参是数组的引用,维度是类型的一部分
void print(int (&arr)[10]) {
    for (auto elem : arr)
        cout << elem << endl;
}

注意, &arr 两端的括号必不可少:

f(int &arr [10])   // 错误:将 arr 声明成了引用的数组
f(int (&arr) [10]) // 正确:arr 是具有 10 个整数的整型数组的引用

数组大小是构成数组类型的一部分,只要不超过维度,就可以在函数体内放心地使用数组

然而,这一用法也在无形中限制了 print 函数的可用性,即,只能将函数作用于大小为 10 的数组:

int i = 0, j[2] = {0, 1};
int k[10] = {0,1,2,3,4,5,6,7,8,9};
print(&i);   // 错误:实参不是含有 10 个整数的数组
print(j);    // 错误:实参不是含有 10 个整数的数组
print(k);    // 正确:实参是含有 10 个整数的数组

# 传递多维数组

将多维数组传递给函数时,真正传递的是指向数组首元素的指针

因为我们处理的是数组的数组,首元素本身就是一个数组,数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略

// matrix 指向数组的首元素,该数组的元素是由 10 个整数构成的数组
void print(int (*matrix) [10], int rowSize) { /* ... */ } // matrix 是一个指向含有 10 个整数的数组的指针

注意, *matrix 两端的括号必不可少

int *matrix[10];   // 10 个指针构成的数组
int (*matrix)[10]; // 指向含有 10 个整数的数组的指针

我们也可以使用数组的语法定义函数(此时编译器会一如既往地忽略掉第一个维度,所以最好不要把它包括在形参列表内)

// matrix 看似是一个二维数组,实则是指向含有 10 个整数的数组的指针
void print(int matrix[][10], int rowSize) { /* ... */ }

# main: 处理命令行选项

到目前为止,我们定义的 main 函数都只有空形参列表:

int main() { ...}

然而,有时我们确实需要给 main 传递实参。一种常见的情况是,用户通过设置一组选项来确定函数所要执行的操作。例如,假定 main 函数位于可执行文件 prog 之内,我们可以向程序传递下面的选项:

prog -d -o ofile data0

这些命令行选项通过两个(可选的)形参传递给 main 函数:

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

第二个形参 argv 是一个数组,它的元素是指向 C 风格字符串的指针;第一个形参 argc 表示数组中字符串的数量

因为第二个形参是数组,所以 main 函数也可以定义成:

int main(int argc, char **argv) { ... } // argv 执指向 char*

当实参传给 main 函数之后,argv 的第一个元素指向程序的名字或者一个空字符串,接下来的元素依次传递命令行提供的实参。最后一个指针之后的元素值保证为 0

以上面提供的命令行 prog -d -o ofile data0 为例,argc 等于 5,argv 包含如下 C 风格字符串

argv[0] = "prog";   // 或者 argv[0] 也可以指向一个空字符串
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;

当使用 argv 中的实参时,一定要记得可选的实参从 argv[1] 开始; argv[0] 保存程序的名字,而非用户输入

# 含有可变形参的函数

有时我们无法提前预知应该向函数传递几个实参。例如,我们想要编写代码输出程序产生的错误信息,此时最好用同一个函数实现该项功能,以便对所有错误的处理能够整齐划一。然而,错误信息的种类不同,所以调用错误输出函数时传递的实参也各不相同

为了编写能处理不同数量实参的函数,C++ 11 新标准提供了两种主要的方法:

  • 如果所有的实参类型相同,可以传递一个名为 initializer_list 的标准库类型
  • 如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板

C++ 还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参(这种功能一般只用于与 C 函数交互的接口程序

# initializer_list 形参

如果函数的实参数量未知、但是全部实参的类型都相同,我们可以使用 initializer_list 类型的形参

initializer_list 是一种标准库类型,用于表示某种特定类型的值的数组

initializer_list 类型定义在同名的头文件中

和 vector 一样,initializer_list 也是一种模板类型。定义 initializer_list 对象时,必须说明列表中所含元素的类型

initializer_list<string> ls; // initializer_list 的元素类型是 string
initializer_list<int> li;    // initializer_list 的元素类型是 int

和 vector 不一样的是,initializer_list 对象中的元素永远是常量值,我们无法改变 initializer_list 对象中元素的值

我们使用如下的形式编写输出错误信息的函数,使其可以作用于可变数量的实参:

void error_msg(initializer_list<string> il) {
    for (auto beg = il.begin(); beg != il.end(); ++beg)
        cout << *beg << " " ;
    cout << endl;
}

其中,作用于 initializer_list 对象的 begin 和 end 操作类似于 vector 对应的成员

  • begin () 成员提供一个指向列表首元素的指针
  • end () 成员提供一个指向列表尾后元素的指针

initializer_list 包含 begin 和 end 成员,因此可以使用 范围 for 循环处理其中的元素

如果想向 initializer_list 形参中传递一个值的序列,则必须把序列放在一对花括号内

// expected 和 actual 是 string 对象
if (expected != actual)
    error_msg({"functionX", expected, actual});
else
    error_msg({"functionX", "okay"});

含有 initializer_list 形参的函数也可以同时拥有其他形参。例如,调试系统可能有个名为 ErrCode 的类,用来表示不同类型的错误,因此我们可以改写之前的程序,使其包含一个 initializer_list 形参和一个 ErrCode 形参

void error_msg(ErrCode e, initializer_list<string> il) {
    cout << e.msg() << ": ";
    for (const auto &elem : il)
        cout << elem << " " ;
    cout << endl;
}

为了调用这个版本的 error_msg 函数,需要额外传递一个 ErrCode 实参

if (expected != actual)
    error_msg(ErrCode(42), {"functionX", expected, actual});
else
    error_msg(ErrCode(0), {"functionX", "okay"});

# 省略符形参

省略符形参是为了便于 C++ 程序访问某些(使用了名为 varargs 的 C 标准库功能的)C 代码而设置的

通常,省略符形参不应用于其他目的

省略符形参应该仅仅用于 C 和 C++ 通用的类型
特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝

省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:

void foo(parm_list, ...);
void foo(...);

第一种形式指定了 foo 函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查

省略符形参所对应的实参无须类型检查

在第一种形式中,形参声明后面的逗号是可选的

# 返回类型和 return 语句

return 语句:终止当前正在执行的函数,并将控制权返回到调用该函数的地方

return 语句的两种形式:

return; // 无返回值
return expression; // 有返回值

# 无返回值函数

没有返回值的 return 语句只能用在返回类型是 void 的函数中

返回 void 的函数并不一定要有 return 语句,因为这类函数最后会隐式地执行 return

一个返回类型是 void 的函数也能使用 return 语句的第二种形式,不过此时 return 语句的 expression 必须是另一个返回 void 的函数

强行令 void 函数返回其他类型的表达式将产生编译错误

# 有返回值函数

只要函数的返回类型不是 void ,该函数内的每条 return 语句就必须返回一个值

return 语句返回值的类型必须与函数的返回类型相同,或者能隐式地转换成函数的返回类型

# 值是如何被返回的

返回一个值的方式:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果

如果函数返回的类型是局部变量,则会返回该变量的副本

例如,书写一个返回类型是 string 的函数 make_plural,由于该函数的返回类型是 string ,返回值将被拷贝到调用点。即,该函数将返回 word 的副本或者一个未命名的临时 string 对象

string make_plural(size_t ctr, const string &word, const string ending) {
    return (ctr > 1) ? word + ending : word;
}

如果函数返回引用类型,则不需要拷贝其所引对象

例如:书写一个函数,其中形参和返回类型都是 const string 的引用,不管是调用函数还是返回结果都不会真正拷贝 string 对象

// 挑出两个 string 对象中较短的那个,返回其引用
const string &shorterString(const string &s1, const string &s2) {
    return s1.size() <= s2.size() ? s1 : s2;
}

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

函数完成后,它所占用的存储空间也随之被释放掉,因此,不要返回局部对象的引用或指针

const string &manip() { // 严重错误:函数试图返回局部对象的引用
    string ret;
   if (!ret.empty())
       return ret;      // 错误:返回局部对象的引用!
   else
       return "Empty";  // 错误:"Empty" 是一个局部临时量
}

# 返回类类型的函数和调用运算符

和其他运算符一样,调用运算符也有优先级和结合律

调用运算符的优先级 与 点运算符和箭头运算符 相同,并且也符合左结合律

如果函数返回指针、引用或类的对象,我们就能使用函数调用的结果访问结果对象的成员

例如,我们可以通过如下形式得到较短 string 对象的长度:

// 调用 string 对象的 size 成员,该 string 对象是由 shorterString 函数返回的
auto sz = shorterString(s1, s2).size();

# 引用返回左值

函数的返回类型决定函数调用是否是左值:调用一个返回引用的函数将会得到左值,其他返回类型将得到右值

因此,可以像使用其他左值那样来使用返回引用的函数,特别是,可以给返回类型为非常量引用的函数的结果赋值

例如:

char &get_val(string &str, string::size_type ix) {
    return str[ix]; // get_val 假定索引值是有效的
}

int main() {
    string s("a value");
    cout << s << endl;   // 输出 a value
    get_val(s, 0) = 'A'; // 将 s[0] 的值改为 'A'
    cout << s << endl;   // 输出 A value
    return 0;
}

如果返回类型是常量引用,则不能给调用的结果赋值

shorterString("hi", "bye") = "X"; // 错误:返回值是个常量

# 列表初始化返回值

C++ 11 标准规定,函数可以返回花括号包围的值的列表,该列表用来初始化函数调用点处的临时量

  • 如果列表为空,临时量执行值初始化
  • 否则,返回的值由函数的返回类型决定

例如:定义一个返回 vector 对象的函数,其中,vector 对象用来存放表示错误信息的 string 对象

vector<string> process() {
    // . . .
    // expected 和 actual 是 string 对象
    if (expected.empty())
        return {};  // 返回一个空 vector 对象
    else if (expected == actual)
        return {"functionX", "okay"}; // 返回列表初始化的 vector 对象
    else
        return {"functionX", expected, actual};
}

如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间

如果函数返回的是类类型,由类本身来定义如何使用初始值

# 主函数 main 的返回值

之前介绍过,如果函数的返回类型不是 void ,那么它必须返回一个值。但是这条规则有个例外:允许 main 函数没有 return 语句而直接结束

这是因为,如果控制到达了 main 函数的结尾处而且没有 return 语句,编译器将隐式地插入一条返回 0 的 return 语句

# 递归

如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为递归函数(recursive function)

// 计算 val 的阶乘
int factorial(int val) {
    if (val > 1)
        return factorial(val-1) * val;
    return 1;
}

main 函数不能调用它自己

# 返回数组指针

因此数组不能被拷贝,所以函数不能返回数组。但是,函数可以返回数组的指针或者引用

从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化这一任务,其中最直接的方法是使用 类型别名

例如:func 函数返回一个指向包含 10 个整数的数组的指针,其中,arrT 是含有 10 个整数的数组的别名

typedef int arrT[10]; // arrT 是一个类型别名,它表示的类型是含有 10 个整数的数组
using arrT = int[10]; // arrT 的等价声明
arrT* func(int i);    // func 返回一个指向含有 10 个整数的数组的指针

# 声明一个返回数组指针的函数

如果我们想定义一个返回数组指针的函数,并且不希望使用类型别名,则必须满足:

  • 数组的维度跟在函数名字之后
  • 函数的形参列表也跟在函数名字后面,并且形参列表应该先于数组的维度

因此,返回数组指针的函数形式如下所示:

Type (*function(parameter_list))[dimension]

其中,Type 表示元素的类型,dimension 表示数组的大小,(*function (parameter_list)) 两端的括号必须存在(如果没有这对括号,函数的返回类型将是指针的数组)

例如:

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

可以按照以下的顺序来逐层理解该声明的含义:・

  • func (int i) 表示调用 func 函数时需要一个 int 类型的实参
  • (*func (int i)) 意味着我们可以对函数调用的结果执行解引用操作
  • (*func (int i))[10] 表示解引用 func 的调用将得到一个大小为 10 的数组
  • int (*func (int i))[10] 表示数组中的元素是 int 类型

# 使用尾置返回类型

在 C++ 11 标准中还有一种可以简化上述 func 声明的方法,就是使用尾置返回类型(trailing return type)

任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用

尾置返回类型跟在形参列表后面,并以一个 -> 符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个 auto

例如:

// func 接受一个 int 类型的实参,返回一个指针,该指针指向含有 10 个整数的数组
auto func(int i) -> int(*)[10];

因为我们把函数的返回类型放在了形参列表之后,所以可以清楚地看到 func 函数返回的是一个指针,并且该指针指向了含有 10 个整数的数组

# 使用 decltype

如果我们知道函数返回的指针将指向哪个数组,可以使用 decltype 关键字声明返回类型

例如,下面的函数返回一个指针,该指针根据参数 i 的不同指向两个已知数组中的某一个

int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
// 返回一个指针,该指针指向含有 5 个整数的数组
decltype(odd) *arrPtr(int i) {
    return (i % 2) ? &odd : &even; // 返回一个指向数组的指针
}

arrPtr 使用关键字 decltype 表示它的返回类型是个指针,并且该指针所指对象的类型与 odd 一致。因为 odd 是数组,所以 arrPtr 返回一个指向含有 5 个整数的数组的指针

注意: decltype 并不负责把数组类型转换成对应的指针,所以 decltype 的结果是个数组,要想表示 arrPtr 返回指针还必须在函数声明时加一个 * 符号

# 函数重载

如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为 重载(overloaded)函数

例如:

void print(const char *cp);
void print(const int *beg, const int *end);
void print(const int ia[], size_t size);

这些函数接受的形参类型不一样,但是执行的操作非常类似。当调用这些函数时,编译器会根据传递的实参类型推断想要的是哪个函数

int j[2] = {0,1};
print("Hello World");        // 调用 print(const char*)
print(j, end(j) - begin(j)); // 调用 print(const int*, size_t)
print(begin(j), end(j));     // 调用 print(const int*, const int*)

main 函数不能重载

# 重载函数的定义与调用

# 定义重载函数

重载的函数应该在形参数量或形参类型上有所不同

// 定义一组函数,分别根据名字、电话、账号等信息查找记录
Record lookup(const Account&); // 根据 Account 查找记录
Record lookup(const Phone&);   // 根据 Phone 查找记录
Record lookup(const Name&);    // 根据 Name 查找记录

Account acct;
Phone phone;
Record r1 = lookup(acct);  // 调用接受 Account 的版本
Record r2 = lookup(phone); // 调用接受 Phone 的版本

不允许两个函数除了返回类型外其他所有要素都相同:假设有两个函数,它们的形参列表一样但是返回类型不同,则第二个函数的声明是错误的

Record lookup(const Account&);
bool lookup(const Account&); // 错误:与上一个函数相比只有返回类型不同

# 判断两个形参的类型是否相异

有时候两个形参列表看起来不一样,但实际上是相同的

// 每一对声明都是同一个函数
Record lookup(const Account &acct);
Record lookup(const Account&); // 省略了形参的名字

typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); // Telno 和 Phone 的类型相同(Telno 是 Phone 的别名)

由于顶层 const 不影响传入函数的对象(详见 参数传递 ),一个拥有顶层 const 的形参无法和另一个没有顶层 const 的形参区分开来

Record lookup(Phone);
Record lookup(const Phone);   // 重复声明了 Record lookup(Phone)

Record lookup(Phone*);
Record lookup(Phone* const);  // 重复声明了 Record lookup(Phone*)

如果形参是某种类型的指针或引用,则可区分其指向的是常量对象还是非常量对象(此时考虑的是底层 const ),以实现函数重载

// 对于接受引用或指针的函数而言,常量对象与非常量对象对应的形参不同
// 定义了 4 个独立的重载函数
Record lookup(Account&);       // 函数作用于 Account 的引用
Record lookup(const Account&); // 新函数,作用于 Account 的常量引用

Record lookup(Account*);       // 新函数,作用于 Account 的指针
Record lookup(const Account*); // 新函数,作用于指向 Account 常量的指针

在上面的例子中,编译器可以通过实参是否是常量来推断应该调用哪个函数

  • 因为 const 不能转换成其他类型,所以我们只能把 const 对象(或指向 const 的指针)传递给 const 形参
  • 相反地,因为非常量可以转换成 const ,所以上面的 4 个函数都能作用于非常量对象或者指向非常量对象的指针
  • 不过,当我们要传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数

# 建议:何时不应该重载函数

尽管函数重载能在一定程度上减轻我们为函数起名字、记名字的负担,但是最好只重载那些确实非常相似的操作

有些情况下,给函数起不同的名字能使得程序更易理解

例如,以下是几个负责移动屏幕光标的函数

Screen& moveHome();
Screen& moveAbs(int, int);
Screen& moveRel(int, int, string direction);

乍看上去,似乎可以把这组函数统一命名为 move ,从而实现函数的重载。然而,重载之后这些函数失去了名字中本来拥有的信息(因为这些函数移动光标的具体方式各不相同)

一般来说,是否重载函数要看哪个更容易理解

# const_cast 和重载

const_cast 常常用于有函数重载的上下文中

考虑下面的 shorterString 函数,其参数和返回类型都是 const string 的引用,我们可以对两个非常量的 string 实参调用这个函数,但返回的仍然是 const string 的引用

// 无论实参是常量还是非常量,返回类型都是 const string &
const string &shorterString(const string &s1, const string &s2) { // 形参是 const string & 类型
    return s1.size() <= s2.size() ? s1 : s2;
}

为了实现 “当实参不是常量时,得到的结果是一个普通的引用,即 string &”,可以使用 const_cast 定义一种新的 shorterString 函数:

string &shorterString(string &s1, string &s2) { // 形参是 string & 类型
    auto &r = shorterString(const_cast<const string&>(s1),
                            const_cast<const string&>(s2)); // 调用形参为 const string & 类型的 shorterString 函数
    return const_cast<string&>(r);
}

在这个版本的函数中,首先将它的实参强制转换成对 const 的引用,然后调用了 shorterString 函数的 const 版本。const 版本返回对 const string 的引用,这个引用事实上绑定在了某个初始的非常量实参上。因此,我们可以再将其转换回一个普通的 string &

# 调用重载的函数

函数匹配(function matching):把函数调用与一组重载函数中的某一个关联起来。具体而言,编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数

函数匹配也叫做重载确定(overload resolution)

调用重载函数时,有三种可能的结果:

  • 编译器找到一个与实参最佳匹配(best match)的函数,并生成调用该函数的代码
  • 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配(no match)的错误信息
  • 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性调用(ambiguous call)

# 重载与作用域

一般来说,将函数声明置于局部作用域内不是一个明智的选择。但是为了说明作用域和重载的相互关系,我们将暂时违反这一原则而使用局部函数声明

如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体

因此,在不同的作用域中,无法重载函数名

string read();
void print(const string &);
void print(double);   // 重载 print 函数
void fooBar(int ival) {
    bool read = false; // 新作用域,隐藏了外层作用域中声明的 read
    string s = read(); // 错误:read 是一个布尔对象,而不是函数(因为外层的 read 函数被隐藏了)
    // 通常来说,在局部作用域中声明函数不是一个好的选择
    void print(int);  // 新作用域,隐藏了外层作用域中的 print
    print("Value: "); // 错误:print(const string &) 被隐藏掉了
    print(ival);      // 正确:当前 print(int) 可见
    print(3.14);      // 正确:调用 print(int) (外层的 print(double) 被隐藏了)
}

当我们调用 print 函数时,编译器首先寻找对该函数名的声明,找到的是接受 int 值的那个局部声明。一旦在当前作用域中找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体。剩下的工作就是检查函数调用是否有效了

在 C++ 语言中,名字查找发生在类型检查之前

第一个调用 print ("Value:") 传入一个字符串字面值,但是当前作用域内 print 函数唯一的声明要求参数是 int 类型,由于字符串字面值无法转换成 int 类型,这个调用是错误的(虽然外层作用域中的 print (const string&) 函数与本次调用匹配,但是它已经被隐藏掉了,根本不会被考虑)

假设我们把 print (int) 和其他 print 函数声明放在同一个作用域中,则它将成为另一种重载形式:

void print(const string &);
void print(double); // print 函数的重载形式
void print(int);    // print 函数的另一种重载形式
void fooBar2(int ival) {
    print("Value: "); // 调用 print(const string &)
    print(ival);      // 调用 print(int)
    print(3.14);      // 调用 print(double)
}

# 特殊用途语言特性

本节我们介绍函数相关的三种语言特性,这些特性对大多数程序都有用,它们分别是:默认实参、内联函数和 constexpr 函数,以及在程序调试过程中常用的一些功能

# 默认实参

有些函数的某个参数在大多数(但不是所有)调用中都被赋予了特定的值。此时,我们可以把这个特定值称为函数的默认实参(default argument)

默认实参作为形参的初始值出现在形参列表中

例如,我们使用 string 对象表示窗口的内容。一般情况下,我们希望该窗口的高、宽和背景字符都使用默认值,但是同时我们也应该允许用户为这几个参数自由指定与默认值不同的数值。因此,我们为每一个形参都提供了默认实参

typedef string::size_type sz;
string screen(sz ht = 24, sz wid = 80, char backgrnd = ' ');

我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值

调用含有默认实参的函数时,可以包含该实参,也可以省略该实参

# 使用默认实参调用函数

如果我们想使用默认实参,只要在调用函数的时候省略该实参就可以了

函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)

string window;
window = screen();              // 等价于 screen(24, 80, ' ')
window = screen(66);            // 等价于 screen(66, 80, ' ')
window = screen(66, 256);       // 等价于 screen(66, 256, ' ')
window = screen(66, 256, '#');  // 等价于 screen(66, 256, '#')

要想覆盖 backgrnd 的默认值,必须为 ht 和 wid 提供实参

window = screen(, , '?'); // 错误:只能省略尾部的实参
window = screen('?');     // 调用 screen('?', 80, ' ')
                          // char 型的 '?' 可以转换成 string::size_type 类型的值 63

当设计含有默认实参的函数时,需要合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,让那些经常使用默认值的形参出现在后面

# 默认实参声明

对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的

不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值

假如给定

// 表示高度和宽度的形参没有默认值
string screen(sz, sz, char = ' ');

我们就不能再修改一个已经存在的默认值

string screen(sz, sz, char = '*'); // 错误:重复赋予默认实参

但是可以按照如下形式添加默认实参

string screen(sz = 24, sz = 80, char); // 正确:添加默认实参

通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中

# 默认实参初始值

除了局部变量不能作为默认实参外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参

// wd、def 和 ht 的声明必须出现在函数之外
sz wd = 80;
char def = ' ';
sz ht();
string screen(sz = ht(), sz = wd, char = def);

string window = screen(); // 调用 screen(ht(), 80, ' ')

用作默认实参的名字(例如 ht () )会在函数声明所在的作用域内解析,但是会在函数调用时才求值

void f2() {
    def = '*';          // 改变了默认实参的值
    sz wd = 100;        // 隐藏了外层定义的 wd ,但是没有改变默认值
    window = screen();  // 调用 screen(ht(), 80, '*')
}

我们在函数 f2 内部改变了 def 的值,所以对 screen 的调用将会传递这个更新过的值。另一方面,虽然我们的函数还声明了一个局部变量用于隐藏外层的 wd ,但是该局部变量与传递给 screen 的默认实参没有任何关系

# 内联函数和 constexpr 函数

此前我们编写了一个函数 shroterString ,用于比较两个 string 形参的长度并返回长度较小的 string 的引用。将这种规模较小的操作定义成函数,具有以下优点:

  • 便于阅读
  • 可以确保行为的统一
  • 如果我们需要修改计算过程,修改函数要比修改所有等价表达式更容易
  • 可以被其他应用重复利用

然而,使用 shorterString 函数也存在一个潜在的缺点:调用函数一般比求等价表达式的值要慢一些

在大多数机器上,一次函数调用其实包含着一系列工作:

  • 调用前要先保存寄存器,并在返回时恢复
  • 可能需要拷贝实参
  • 程序转向一个新的位置继续执行

# 内联函数可以避免函数调用的开销

将函数指定为内联函数(inline),通常就是将它在每个调用点上 “内联地” 展开

假设我们把 shorterString 函数定义成内联函数,则如下调用

cout << shorterString(s1, s2) << endl;

将在编译过程中展开成类似于下面的形式

cout << (s1.size() < s2.size() ? s1 : s2) << endl;

从而消除了 shorterString 函数的运行时开销

在函数返回类型的前面加上加上关键字 inline ,即可将其声明为内联函数

// 内联版本:寻找两个 string 对象中较短的那个
inline const string & shorterString(const string &s1, const string &s2) {
        return s1.size() <= s2.size() ? s1 : s2;
}

内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求

一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数

很多编译器都不支持内联递归函数,而且,一个太长的函数也不太可能在调用点 “内联地” 展开

# constexpr 函数

constexpr 函数(constexpr function)是指能用于常量表达式的函数

constexpr 函数的定义:

  • 函数的返回类型及所有形参的类型都必须是字面值类型
  • 函数体中必须有且只有一条 return 语句
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz();  // 正确:foo 是一个常量表达式

在上面的代码中,我们把 new_sz 定义成一个无参数的 constexpr 函数,于是,new_sz 函数返回的是常量表达式,因此可以用 new_sz 函数来初始化 constexpr 类型的变量 foo

执行该初始化任务时,编译器把对 constexpr 函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr 函数被隐式地指定为内联函数

constexpr 函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如,constexpr 函数中可以有空语句、类型别名以及 using 声明

constexpr 函数的返回值可以不是一个常量

例如:定义一个 constexpr 函数 scale 。当我们给 scale 函数传入一个形如字面值 2 的常量表达式时,它的返回类型也是常量表达式,此时,编译器用相应的结果值替换对 scale 函数的调用。相反,如果我们用一个非常量表达式调用 scale 函数,比如 int 类型的对象 i ,则返回值是一个非常量表达式

// 如果 arg 是常量表达式,则 scale(arg) 也是常量表达式
constexpr size_t scale(size_t cnt) { return new_sz() * cnt; }

// 当 scale 的实参是常量表达式时,它的返回值也是常量表达式;反之则不是
int arr[scale(2)]; // 正确:scale(2) 是常量表达式
int i = 2;         // i 不是常量表达式
int a2[scale(i)];  // 错误:scale(i) 不是常量表达式

当把 scale 函数用在需要常量表达式的上下文中时,编译器会负责检查函数的结果是否符合要求。如果结果恰好不是常量表达式,编译器将发出错误信息

constexpr 函数不一定返回常量表达式

# 把内联函数和 constexpr 函数放在头文件内

和其他函数不一样,内联函数和 constexpr 函数可以在程序中多次定义

不过,对于某个给定的内联函数或者 constexpr 函数来说,它的多个定义必须完全一致

因此,内联函数和 constexpr 函数通常定义在头文件中

# 调试帮助

C++ 程序员有时会用到一种类似于 头文件保护 的技术,以便有选择地执行调试代码

基本思想:程序可以包含一些用于调试的代码,这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码

这种方法用到两项预处理功能: assertNDEBUG

# assert 预处理宏

assert 是一种预处理宏(preprocessor marco)

预处理宏是一个行为类似于内联函数的预处理变量

assert 宏使用一个表达式作为它的条件:

assert(expr);

其首先对 expr 求值,如果表达式为假(即,值为 0),assert 输出信息并终止程序的执行。如果表达式为真(即,值不为 0),assert 什么也不做

assert 宏定义在 cassert 头文件中,即:

#include<cassert>

预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字,即,直接使用 assert 而不是 std::assert,也不需要为 assert 提供 using 声明

和预处理变量一样,宏名字在程序内必须唯一。因此,含有 cassert 头文件的程序不能再定义名为 assert 的变量、函数或者其他实体

assert 宏常用于检查 “不能发生” 的条件

例如,一个对输入文本进行操作的程序,可能要求所有给定单词的长度都大于某个阈值

assert(word.size() > threshold);

# NDEBUG 预处理变量

NDEBUG 是一个预处理变量,表示当前不处于调试状态(not debug),可用于控制 assert 的行为:

  • 如果没有定义 NDEBUG ,assert 将执行运行时检查
  • 如果定义了 NDEBUG ,则 assert 什么也不做

默认状态下,没有定义 NDEBUG

我们可以使用一个 #define 语句定义 NDEBUG ,从而关闭调试状态

#define NDEBUG // 关闭调试状态(必须定义在 cassert 头文件之前)
#include <cassert>

int main(void) {
    int x = 0;
    assert(x);
}

定义 NDEBUG 能避免检查各种条件所需的运行时开销(其实根本就不会执行运行时检查),因此,assert 应该仅用于验证那些确实不可能发生的事情

我们可以把 assert 当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查

除了用于 assert 外,也可以使用 NDEBUG 编写自己的条件调试代码(详见 预处理变量

  • 如果 NDEBUG 未定义,将执行 #ifndef 和 #endif 之间的代码
  • 如果定义了 NDEBUG ,则会忽略 #ifndef 和 #endif 之间的代码

例如:

void print(const int ia[], size_t size) {
#ifndef NDEBUG
// _ _func_ _ 是编译器定义的一个局部静态变量,用于存放函数的名字
cerr << _ _func_ _ << ": array size is " << size << endl;
#endif
// ...

在这段代码中,我们使用变量 _ _func_ _ 输出当前调试的函数的名字(即 "print" )

编译器为每个函数都定义了 _ _func_ _ ,它是 const char 的一个静态数组,用于存放函数的名字

除了 C++ 编译器定义的 _ _func_ _ 之外,预处理器还定义了另外 4 个用于程序调试的名字:

  • _ _FILE_ _ :存放文件名的字符串字面值
  • _ _LINE_ _ :存放当前行号的整型字面值
  • _ _LINE_ _ :存放当前行号的整型字面值
  • _ _DATE_ _ :存放文件编译日期的字符串字面值

可以使用这些常量在错误消息中提供更多信息,例如:

if (word.size() < threshold)
    cerr << "Error: " << _ _FILE_ _
         << " : in function " << _ _func_ _
         << " at line " << _ _LINE_ _ << endl
         << "       Compiled on " << _ _DATE_ _
         << " at " << _ _TIME_ _ << endl
         << "       Word read was \"" << word
         << "\":  Length too short" << endl;

如果我们给程序提供了一个长度小于 threshold 的 string 对象,将得到下面的错误消息:

Error:wdebug.cc : in function main at line 27
       Compiled on Jul 11 2012 at 20:50:03
       Word read was "foo": Length too short

# 函数匹配

当重载函数的形参数量相等、并且某些形参的类型可以由其他类型转换得到时,不太容易确定某次调用应该选用哪个重载函数

# 函数匹配

函数匹配:从一组重载函数中选取最佳函数的过程

# 函数匹配的步骤

选定本次调用对应的重载函数集

  • 集合中的函数称为 候选函数(candidate function)
  • 候选函数具备两个特征:
    • 与被调用的函数同名
    • 其声明在调用点可见

考察本次调用提供的实参,从候选函数中选出能被这组实参调用的函数

  • 这些新选出的函数称为 可行函数(viable function)
  • 可行函数具备两个特征:
    • 形参数量与本次调用提供的实参数量相等(如果函数含有默认实参,在调用该函数时,传入的实参数量可能少于它实际使用的实参数量)
    • 每个实参的类型与对应的形参类型相同,或者能转换成形参的类型
  • 如果没找到可行函数,编译器将报告无匹配函数的错误

逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数(即,寻找最佳匹配

  • 如果有且只有一个函数满足下列条件,则匹配成功:
    • 该函数每个实参的匹配都不劣于其他可行函数需要的匹配
    • 至少有一个实参的匹配优于其他可行函数提供的匹配
  • 下一节将介绍到:实参类型与形参类型越接近,它们匹配得越好
  • 如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。此时,编译器将报告二义性调用的信息

调用重载函数时应尽量避免强制类型转换,如果在实际应用中确实需要强制类型转换,则说明我们设计的形参列表不合理

# 实例分析

以下面这组函数及其调用为例:

void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);

f(5.6); // 调用 f(double, double)

第一步:可以确定上述 4 个名为 f 的函数均为候选函数

第二步:可以根据实参的数量排除 void f () 和 void f (int, int) ,选出 void f (int) 和 void f (double, double = 3.14) 两个可行函数

  • 排除的两个函数:我们的调用提供了一个实参,而 void f () 不使用形参,void f (int, int) 使用两个形参
  • 可行的两个函数:void f (int) 使用一个 int 形参,而我们调用传入的 double 实参可以转换成形参类型 int ;void f (double, double = 3.14) 本应接受两个 double 实参,但因为它含有一个默认实参,所以可以只用一个实参来调用它

第三步:逐一考察实参,寻找最佳匹配

  • 调用提供了一个(显式的)double 型实参,如果调用 f (int) ,实参将不得不从 double 转换成 int
  • 相反,可行函数 void (double, double) 则与实参精确匹配
  • 精确匹配比需要类型转换的匹配更好,因此,编译器把 f (5.6) 解析成对函数 void (double, double) 的调用,并使用默认值填补我们未提供的第二个实参

当实参的数量有两个或更多时,函数匹配就比较复杂了

例如,考察形如 f (42, 2.56) 的调用:

第一步:4 个名为 f 的函数均为候选函数

第二步:确定 void f (int, int) 和 void f (double, double) 为可行函数

第三步:逐一考察实参,寻找最佳匹配

  • 考虑第一个实参 42 :函数 f (int, int) 能精确匹配,而函数 f (double, double) 需要先将 int 类型实参转换成 double 类型。此时,函数 f (int, int) 优于 函数 f (double, double)
  • 接着考虑第二个实参 2.56 :函数 f (double, double) 能精确匹配,而函数 f (int, int) 须将 2.56 从 double 类型转换成 int 型。此时,函数 f (double, double) 优于 函数 f (int, int)
  • 由于每个可行函数各自在一个实参上实现了更好的匹配,无法从整体上判断孰优孰劣。编译器最终将因为这个调用具有二义性而拒绝其请求

看起来我们似乎可以通过强制类型转换其中的一个实参来实现函数的匹配,但是在设计良好的系统中,不应该对实参进行强制类型转换

# 实参类型转换

为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下所示(越靠前的,匹配程度越好):

  • 精确匹配,其包括以下情况:
    • 实参类型和形参类型相同
    • 实参从数组类型或函数类型转换成对应的指针类型
    • 向实参添加顶层 const 或者从实参中删除顶层 const
  • 通过 const 转换实现的匹配
  • 通过类型提升实现的匹配
  • 通过算术类型转换或指针转换实现的匹配
  • 通过类类型转换实现的匹配

# 需要类型提升和算术类型转换的匹配

小整型一般都会提升到 int 类型或更大的整数类型

例如,假设有两个函数,一个接受 int 、另一个接受 short ,则

  • 只有当调用提供的是 short 类型的值时才会选择 short 版本的函数
  • 有时候,即使实参是一个很小的整数值,也会直接将它提升成 int 类型,此时使用 short 版本反而会导致类型转换
void ff(int);
void ff(short);

ff('a'); // char 提升成 int ,调用 f(int)

所有算术类型转换的级别都一样

例如,从 int 向 unsigned int 的转换并不比从 int 向 double 的转换级别高

void manip(long);
void manip(float);

manip(3.14); // 错误:二义性调用

# 函数匹配和 const 实参

如果重载函数的区别仅在于函数的引用类型的形参是否为常量引用(或者,指针类型的形参是否为常量指针),编译器将通过实参是否为常量来决定选择哪个函数

When we call an overloaded function that differs on whether a reference or pointer parameter refers or points to const, the compiler uses the constness of the argument to decide which function to call.

例如:

Record lookup(Account&);       // 函数的参数是 Account 的引用
Record lookup(const Account&); // 函数的参数是 Account 的常量引用

const Account a;
Account b;
lookup(a);   // 调用 lookup(const Account&)
lookup(b);   // 调用 lookup(Account&)

第一个调用传入的是 const 对象 a :因为不能把普通引用绑定到 const 对象上,唯一可行的函数是 以常量引用作为形参的函数 lookup (const Account&) ,并且调用该函数与实参 a 精确匹配

第二个调用传入的是非常量对象 b :对于该调用而言,两个函数都是可行的,因为 b 既可以用来初始化常量引用也可以用来初始化非常量引用。然而,用非常量对象来初始化常量引用需要类型转换,因此,应该选用非常量版本的函数 lookup (Account&)

指针类型的形参也是类似的:如果两个函数的唯一区别是指针形参指向常量或非常量,编译器通过实参是否为常量来决定选用哪个函数

  • 如果实参是指向常量的指针,调用形参为 const* 的函数
  • 如果实参是指向非常量的指针,调用形参是普通指针的函数

# 函数指针

函数指针(function pointer)指向的是函数而非对象

类似于其他指针,函数指针也指向一个特定的类型(即,函数的类型)

函数的类型由它的返回类型和形参类型共同决定,与函数名无关

A function pointer is just that—a pointer that denotes a function rather than an object. Like any other pointer, a function pointer points to a particular type. A function’s type is determined by its return type and the types of its parameters. The function’s name is not part of its type.

例如,以下函数的类型是 bool (const string &, const string &)

// 比较两个 string 对象的长度
bool lengthCompare(const string &, const string &);

要想声明一个可以指向该函数的指针,只需要用指针替换函数名即可:

// pf 指向一个函数,该函数的参数是两个 const string 的引用,返回值是 bool 类型
bool (*pf)(const string &, const string &);

针对 bool (*pf)(const string &, const string &) 的分析逻辑:

  • pf 前面有个 * ,表示 pf 是指针
  • 右侧是形参列表,表示 pf 指向的是函数
  • 再观察左侧,发现函数的返回类型是布尔值
  • 因此,pf 就是一个指向函数的指针,其中该函数的参数是两个 const string 的引用,返回值是 bool 类型

*pf 两端的括号必不可少,如果不写这对括号(即: bool *pf(const string &, const string &); ),pf 将是一个返回类型为 bool * 的函数

# 使用函数指针

当我们把函数名作为一个值使用时,该函数自动地转换成指针

例如,按照如下形式我们可以将函数 lengthCompare 的地址赋给 pf

pf = lengthCompare;  // pf 指向名为 lengthCompare 的函数
pf = &lengthCompare; // 等价的赋值语句(取地址符是可选的)

我们可以直接使用指向函数的指针来调用该函数,而无须提前解引用指针

bool b1 = pf("hello", "goodbye");    // 调用 lengthCompare
bool b2 = (*pf)("hello", "goodbye"); // 等价的调用
bool b3 = lengthCompare("hello", "goodbye"); // 等价的调用

指向不同函数类型的指针是不可以转换的,但是,我们可以为函数指针赋一个 nullptr 或者值为 0 的整型常量表达式,表示该指针没有指向任何一个函数

There is no conversion between pointers to one function type and pointers to another function type. However, as usual, we can assign nullptr or a zero-valued integer constant expression to a function pointer to indicate that the pointer does not point to any function.

bool (*pf)(const string &, const string &);
string::size_type sumLength(const string&, const string&);
bool cstringCompare(const char*, const char*);

pf = 0;              // 正确:pf 不指向任何函数
pf = sumLength;      // 错误:返回类型不匹配

pf = cstringCompare; // 错误:形参类型不匹配
pf = lengthCompare;  // 正确:函数和指针的类型精确匹配

# 重载函数的指针

如果定义了指向重载函数的指针,编译器通过指针类型决定选用哪个函数,指针类型必须与重载函数中的某一个精确匹配

void ff(int*);
void ff(unsigned int);

void (*pf1)(unsigned int) = ff;  // pf1 指向 ff(unsigned)

void (*pf2)(int) = ff;    // 错误:没有任何一个 ff 与该形参列表匹配
double (*pf3)(int*) = ff; // 错误:ff 和 pf3 的返回类型不匹配

# 函数指针形参

类似于数组,我们不能定义函数类型的形参,但是,可以将指向函数的指针作为形参

我们可以定义一个类似于函数类型的形参,它会自动地转换成指向函数的指针(类似于 数组形参

// 第三个形参是函数类型,它会自动地转换成指向函数的指针
void useBigger(const string &s1, const string &s2,
               bool pf(const string &, const string &));

// 等价的声明:显式地将形参定义成指向函数的指针
void useBigger(const string &s1, const string &s2,
               bool (*pf)(const string &, const string &));

如果我们直接将函数作为实参使用,它会自动转换成指针

// 自动将函数 lengthCompare 转换成指向该函数的指针
useBigger(s1, s2, lengthCompare);

由于直接将函数指针类型作为形参(例如,useBigger 的声明语句中的第三个形参)会显得冗长,可以使用 类型别名 和 decltype 来简化函数指针的代码

例如:首先使用 typedef 和 decltype 来定义自己的函数类型和函数指针类型

// Func 和 Func2 是函数类型
typedef bool Func(const string&, const string&); // 函数类型
typedef decltype(lengthCompare) Func2; // 函数类型的等价声明

// FuncP 和 FuncP2 是指向函数的指针
typedef bool (*FuncP)(const string &, const string&); // 函数指针类型
typedef decltype(lengthCompare) *FuncP2; // 函数指针类型的等价声明

注意:decltype 返回的是函数类型,不会将函数类型自动转换成指针类型,所以需要再加上 * 才能得到指针

然后重新声明 useBigger 函数:

// useBigger 的等价声明,其中使用了类型别名
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP2);

这两个声明语句声明的是同一个函数,在第一条语句中,编译器自动地将 Func 表示的函数类型转换成指针

# 返回指向函数的指针

类似于数组,我们不能返回一个函数,但是,可以返回指向函数类型的指针

由于编译器不会自动将函数返回类型当成对应的指针类型处理,我们必须显式地将返回类型写成指针形式

声明一个返回函数指针的函数,最直接的办法是(类似于 返回数组指针 ):

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

按照由内向外的顺序阅读这条声明语句:

  • f1 有形参列表,所以 f1 是一个函数
  • f1 前面有 * ,所以 f1 返回的是一个指针
  • 进一步观察发现,指针所指向的类型为 int (int*, int) ,其包含形参列表,因此,该指针指向的是一个返回类型为 int 的函数

声明一个返回函数指针的函数,最简单的办法是使用类型别名:

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

必须时刻注意的是,和函数类型的形参不一样,返回类型不会自动地转换成指针,因此,我们必须显式地将返回类型指定为指针

PF f1(int); // 正确:PF 是指向函数的指针,f1 返回指向函数的指针
F f1(int);  // 错误:F 是函数类型,f1 不能返回一个函数
F *f1(int); // 正确:将函数返回类型显式地指定为指向函数的指针

此外,我们也可以使用尾置返回类型的方式来声明一个返回函数指针的函数(可参考 使用尾置返回类型 ):

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

# 将 auto 和 decltype 用于函数指针类型

如果我们明确知道返回的函数是哪一个,可以使用 decltype 关键字将返回类型声明为 函数指针类型

例如,假定有两个函数,它们的返回类型都是 string::size_type ,并且各有两个 const string& 类型的形参

string::size_type sumLength(const string&, const string&);
string::size_type largerLength(const string&, const string&);

此时,我们可以编写第三个函数,它接受一个 string 类型的参数,返回一个指针,该指针指向前两个函数中的某一个

// 根据其形参的取值,getFcn 函数返回指向 sumLength 或者 largerLength 的指针
decltype(sumLength) *getFcn(const string &);

注意: decltype 并不负责把函数类型转换成对应的指针,所以 decltype 的结果是个函数类型,因此,我们显式地加上 * 以表明我们需要返回指针而不是函数本身

# 术语表

二义性调用(ambiguous call) :是一种编译时发生的错误。造成二义性调用的原因:在函数匹配时,两个或多个函数提供的匹配一样好,编译器找不到唯一的最佳匹配

实参(argument) :函数调用时提供的值,用于初始化函数形参

Assert :是一个预处理宏,作用于一条表示条件的表达式

  • 当未定义预处理变量 NDEBUG 时,assert 对条件求值
  • 如果 assert 的条件为假,输出一条错误信息并终止当前程序的执行

自动对象(automatic object) :仅存在于函数执行过程中的对象

  • 当程序的控制流经过此类对象的定义语句时,创建该对象
  • 当到达了定义所在的块的末尾时,销毁该对象

最佳匹配(best match) :从一组重载函数中为调用选出的一个函数。最佳匹配(如果存在的话)至少在一个实参上比其他所有可行函数更优,同时,在其他实参的匹配上不会更差

传引用调用(call by reference) :对引用传递的函数的调用

传值调用(call by value) :对值传递的函数的调用

候选函数(candidate function) :解析某次函数调用时考虑的一组函数。候选函数的名字应该与函数调用使用的名字一致,并且在调用点候选函数的声明在作用域之内

constexpr :可以返回常量表达式的函数。一个 constexpr 函数被隐式地声明成内联函数

默认实参(default argument) :当调用缺少了某个实参时,为该实参指定的默认值

可执行文件(executable file) ;是操作系统能够执行的文件,包含着与程序有关的代码

函数(function) :可调用的计算单元

函数体(function body) :是一个块,用于定义函数所执行的操作

函数匹配(function matching) :编译器解析重载函数调用的过程,在此过程中,实参与每个重载函数的形参列表逐一比较

函数原型(function prototype) :函数的声明,包含函数名字、返回类型和形参类型。要想调用某函数,在调用点之前必须声明该函数的原型

隐藏名字(hidden name) :某个作用域内声明的名字会隐藏掉外层作用域中声明的同名实体

initializer_list :是一个标准类,表示的是一组花括号包围的类型相同的对象,对象之间以逗号隔开

内联函数(inline function) :请求编译器在可能的情况下在调用点展开函数。内联函数可以避免常见的函数调用开销

链接(link) :是一个编译过程,负责把若干对象文件链接起来形成可执行程序

局部静态对象(local static object) :它的值在函数调用结束后仍然存在。在第一次使用局部静态对象前创建并初始化它,当程序结束时局部静态对象才被销毁

局部变量(local variable) :定义在块中的变量

无匹配(no match) :是一种编译时发生的错误,原因是在函数匹配过程中所有函数的形参都不能与调用提供的实参匹配

对象代码(object code) :编译器将我们的源代码转换成对象代码格式

对象文件(object file) :编译器根据给定的源文件生成的保存对象代码的文件。一个或多个对象文件经过链接生成可执行文件

对象生命周期(object lifetime) :每个对象都有相应的生命周期

  • 块内定义的非静态对象的生命周期从它的定义开始,到定义所在的块末尾为止
  • 程序启动后创建全局对象
  • 程序控制流经过局部静态对象的定义时创建该局部静态对象
  • 当 main 函数结束时销毁全局对象和局部静态对象

重载确定(overload resolution) :参见函数匹配

重载函数(overloaded function) :函数名与其他函数相同的函数。多个重载函数必须在形参数量或形参类型上有所区别

形参(parameter) :在函数的形参列表中声明的局部变量。用实参初始化形参

引用传递(pass by reference) :描述如何将实参传递给引用类型的形参。引用形参和其他形式的引用工作机理类似,形参被绑定到相应的实参上

值传递(pass by value) :描述如何将实参传递给非引用类型的形参。非引用类型的形参实际上是相应实参值的一个副本

预处理宏(preprocessor macro) :类似于内联函数的一种预处理功能。除了 assert 之外,现代 C++ 程序很少再使用预处理宏了

递归循环(recursion loop) :描述某个递归函数没有终止条件,因而不断调用自身直至耗尽程序栈空间的过程

递归函数(recursive function) :直接或间接调用自身的函数

返回类型(return type) :是函数声明的一部分,用于指定函数返回值的类型

分离式编译(separate compilation) :把一个程序分割成多个独立源文件的能力

尾置返回类型(trailing return type) :在参数列表后面指定的返回类型

可行函数(viable function) :是候选函数的子集。可行函数能匹配本次调用,它的形参数量与调用提供的实参数量相等,并且每个实参类型都能转换成相应的形参类型

() 运算符( () operator) :调用运算符,用于执行某函数。括号前面是函数名或函数指针,括号内是以逗号隔开的实参列表(可能为空)

参考:C++ Primer 中文版(第 5 版)

阅读次数