第六章 函数

106 阅读10分钟

函数是一个命名的代码块,通过调用函数可以执行相应的代码。函数可以有 0 个或多个参数,通常会产生一个结果。函数可以重载,即同一个名字可以对应多个不同函数。

函数基础

函数定义包括以下几个部分:返回类型、函数名、形参列表、函数体。

returnType functionName(type0 arg0, type1 arg1, ..., typen argn) {
  // some codes
  return returnValue;
}

执行函数可以使用调用运算符 ()(call operator)。调用运算符作用于表达式,该表达式是函数或指向函数的指针。() 里面是实参列表,用于初始化形参。调用表达式的类型就是函数的返回类型。

returnType res = functionName(arg0, arg1, ..., argn);

函数调用完成两项工作:

  1. 用实参初始化函数的形参;
  2. 将控制权转移给被调函数。

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

执行函数首先要隐式定义并初始化形参。遇到 return 语句时,函数结束执行过程。和函数调用一样,return 语句也要完成两项工作:

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

函数返回值用于初始化调用表达式的结果。

函数调用时会接收参数,实参应该和对应的形参类型匹配——能转为该形参类型,实参与形参的数量也应该匹配。也就是说,形参一定会被初始化。实参的求值顺序没有明确规定。

函数的形参列表可以为空,为与 C 语言兼容,也可以使用 void 关键字表示没有形参:

void f1() { /* ... */ }
void f2(void) { /* ... */ }
  • 每个形参的类型声明都不能省略。
  • 任何两个形参不能同名。
  • 函数里最外层作用域中的局部变量也不能和形参同名。
  • 形参名可选,但一般都会命名,是否命名不影响调用时的类型检查。

大多数类型都能作为函数的返回类型。void 是一种特殊的返回类型,表示不返回任何值。函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。

局部对象

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

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

函数体是一个语句块,块构成了一个作用域,可以在其中定义变量。形参和函数体内部定义的变量称为局部变量。它们仅在函数作用域内可见,同时,局部变量还会隐藏外层作用域中所有同名的声明。

所有函数体外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时创建,程序结束时销毁。局部变量的生命周期依赖于定义方式。

自动对象

对于普通局部变量,执行变量定义时创建该变量,到达定义所在块末尾时销毁该变量。只存在于执行期间的对象称为自动对象(automatic object)。块执行结束后,块中创建的自动对象的值变成未定义。

形参是一种自动对象,它通过实参初始化。对于局部变量对应的自动对象,如果没有初始值,就执行默认初始化,这意味着内置类型的未初始化局部变量的值是不确定的。

局部静态变量

局部变量定义成 static 类型,就是局部静态对象(local static object)。它在程序第一次为该对象执行定义语句时初始化,直到程序终止才被销毁,期间即使对象所在函数结束执行也不会有影响。如果局部静态变量没有显式初始化,将执行值初始化,内置类型的局部静态变量初始化为 0。

函数声明

函数的名字必须在使用之前声明。函数可以声明多次,但只能定义一次。函数声明以分号结尾,没有函数体。如果一个函数永远不会被用到,可以只声明无定义。函数声明无需形参的名字,实际上,形参的名字经常省略。形参名可以帮助使用者更好地理解函数功能。

void print(vector<int>::const_iterator beg, vector<int>::const_iterator end);

函数三要素——返回类型、函数名、形参类型——描述了函数的接口。函数声明也称作函数原型(function prototype)。与变量类似,函数也应该在头文件中声明,在源文件中定义。将函数声明直接放在源文件中会比较烦琐而且容易出错。把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。改变函数接口时,只需改变一条声明即可。定义函数的源文件应该把函数声明的头文件也包含进来,编译器负责验证函数的定义和声明是否匹配。

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

分离式编译

C++ 语言支持分离式编译,将程序分割到几个文件中,每个文件独立编译。要生成可执行文件,必须将用到的代码的位置告诉编译器。比如:

$ CC factMain.cpp fact.cpp # 生成 factMain.exe 或者 a.out
$ CC factMain.cpp fact.cpp -o main # 生成 main 或者 main.exe

CC 是编译器名字,$ 是系统提示符,# 后面是命令行下的注释语句。

如果其中一个源文件修改了,则只需重新编译变动的文件。大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生后缀名为 .obj.o 的文件,后缀名的含义是该文件包含目标代码(object code)。接下来编译器将目标文件链接(link)起来形成可执行文件。

$ CC -c factMain.cpp # 生成 factMain.o
$ CC -c fact.cpp # 生成 fact.o
$ CC factMain.o fact.o # 生成 factMain.exe 或者 a.out
$ CC factMain.o fact.o -o main # 生成 main 或者 main.exe

参数传递

形参初始化的机制与变量初始化一样。引用类型的形参将绑定到对应的实参上;其它类型的形参接收的是实参值的拷贝。

  1. 形参是引用类型时,称实参被引用传递(passed by reference)或者函数被传引用调用(called by reference)。
  2. 实参值拷贝到形参时,形参和实参相互独立,称实参被值传递(passed by value)或者函数被传值调用(called by value)。

传值参数

对于传值参数,函数对形参所做的所有操作都不会影响实参。指针形参与其它非引用类型一样,但可以通过指针读写所指对象。

void reset(int *ip)
{
  *ip = 0;
  ip = 0;
}

C 语言常使用指针类型的形参访问函数外部变量,C++ 语言建议使用引用类型的形参代替指针。

传引用参数

通过使用引用形参,函数可以改变一个或多个实参的值。

void reset(int &ri)
{
  ri = 0;
}

拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括 IO 类型在内)根本就不支持拷贝操作,此时只能通过引用形参访问该类型的对象。

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

函数需要同时返回多个值时,可以让返回值数据类型包含多个成员,也可以使用引用形参。

const 形参和实参

和一般的变量初始化一样,使用实参初始化形参时忽略掉顶层 const。当形参有顶层 const 时,传给它常量对象或非常量对象都可以。

// 以下两种声明将引起冲突
void fcn(int i) { /* some codes */ }
void fcn(const int i) { /* some codes */ }

C++ 允许定义多个同名函数,前提是不同函数的形参列表应该有明显差别。

把函数不会改变的形参定义成普通引用,会给调用者产生误导,函数可以修改它的实参值;此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型:不能把 const 对象、字面量、需要类型转换的对象传递给普通的引用形参。

假如其它函数将它们的形参定义成常量引用,则无法直接传给非常量引用的形参。

void f1(int &i);

void f2(const int &i)
{
   f1(i); // 报错
}

数组形参

由于不允许拷贝数组,而且使用数组时会转换为指针。因此无法以值传递的方式使用数组参数。形参可以写为类似数组的形式:

// 下面三种函数声明等价
void print(const int*);
void print(const int[]);
void print(const int[10]);

int i = 0, j[2] = {1, 2};
print(&i);
print(j);

以上三种形式等价,都是只有一个类型为 const int* 参数的函数。数组大小对函数调用没有影响。

和其它使用数组的代码一样,以数组为形参的函数也必须确保使用数组时不会越界。

调用者需要提供额外信息将数组的确切尺寸告诉函数。管理指针形参有三种常用技术:

  1. 使用标记指定数组长度

    要求数组本身包含一个结束标记,比如:C 风格字符串。适用于那些有明显结束标记且该标记不会与普通数据混淆的情况。

  2. 使用标准库规范

    将指向数组首元素和尾后元素的指针传递给函数。

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

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

函数形参也可以是数组的引用,但由于数组大小是类型的一部分,因此只要不超过数组大小,函数体内就可以放心使用数组。而数组实参传递给函数时,也会检查实参数组大小。

void print(int (&ria)[10]);

多维数组是数组的数组,多维数组形参有两种定义方式:

  1. 首元素指针

    数组第二维(及后面的维度)的大小是数组首元素指针类型的一部分。

  2. 使用数组语法

    编译器会忽略掉第一个维度,所以最好不要放到形参列表内。

void print(int (*matrix)[10], int rowSize);
void print(int matrix[][10], int rowSize);

main:处理命令行选项

有时需要给 main 传递实参,常见的情况是让用户设置一组选项来确定函数要执行的操作。这些命令行选项通过两个可选的形参传递给 main 函数:

int main(int argc, char *argv[]) { /* ... */ }
// 等价于
int main(int argc, char **argv) { /* ... */ }
  1. 形参 argc 表示数组中字符串的数量,即数组 argv 中有效元素的个数;
  2. 形参 argv 是一个数组,它的元素是指向 C 风格字符串的指针。

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

prog -d -o ofile data0

main 函数位于可执行文件 prog 内。

argv[0] = "prog";
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;

使用 argv 中的实参时,必须记得可选的实参从 argv[1] 开始,argv[0] 是程序的名字。

含有可变形参的函数

为了让函数处理不同数量的实参,有以下两种方法:

  1. 如果实参类型相同,可以使用标准库类型 initializer_list
  2. 如果实参类型不同,可以使用可变参数模板。

省略符是一种特殊的形参类型,可用于传递可变数量的实参,一般只用于与 C 函数交互的接口程序。

initializer_list 类型定义在同名头文件中。initializer_list 对象中的元素永远是常量值。

initializer_list.png

void error_msg(ErrCode e, initializer_list<string> sl);

string s1, s2;
error_msg(ErrCode(42), {s1, s2});

省略符形参便于 C++ 程序访问某些特殊的 C 代码,这些代码使用了名为 varargs 的 C 标准库功能。通常,省略符形参不应用于其他目的。

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

省略符形参只能出现在形参列表的最后一个位置:

void foo(param_list, ...);
void foo(...);

param_list 指定的形参类型会执行正常的类型检查,省略符形参对应的实参无须检查,其中逗号可选。

返回类型和 return 语句

return 语句有两种形式:

return;
return expression;

无返回值函数

return; 只能用于返回类型为 void 的函数。返回类型为 void 的函数中可以:

  1. 使用 return;
  2. 没有 return,此时会在函数末尾隐式执行 return;
  3. 使用 return expression;,此时 expression 必须是另一个返回 void 的函数,否则将产生编译错误。

有返回值函数

return 语句返回值类型必须与函数的返回类型相同,或者能隐式转换成函数的返回类型。C++ 可以保证每个 return 语句的结果类型正确,也许无法顾及所有情况,但是编译器仍然尽量确保具有返回值的函数只能通过一条有效 return 语句退出。

在含有 return 语句的循环后面应该也有一条 return 语句,否则程序就是错误的,很多编译器都无法发现此类错误,如果这样,运行时的行为将是不确定的。

bool str_subrange( /* ... */ )
{
   // ...
   for ( /* ... */ ) {
      if ( /* ... */ ) {
         /* ... */
         return true;
      }
   }
   // 返回值类型错误
}

返回一个值和初始化一个变量或形参的方式完全一样:返回值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。和其它引用类型一样,如果函数返回引用,则该引用只是所引对象的别名。

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

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"; // 局部字面量导致引用的值不确定
   }
}

调用运算符 () 的优先级和点运算符 .、箭头运算符 -> 一样,而且也符合左结合律。

auto sz = shorterString(s1, s2).size();

函数的返回类型决定了函数调用表达式是否为左值。调用一个返回引用的函数得到左值,其它返回类型得到右值。如果返回类型是非常量引用,可以为其赋值。

char &get_val(string &s1, string::size_type idx);

string s("hello world");
get_val(s, 0) = 'H';

函数也可以返回 {} 包起来的值的列表。该列表用于初始化函数返回的临时量,如果列表为空,临时量执行值初始化,否则由函数返回类型决定。如果函数返回的是内置类型,则 {} 内最多只有一个值,而且该值所占空间不应大于目标类型空间。如果返回的是类类型,由类本身定义初始值如何使用。

vector<string> process()
{
   // ...
   return {};
   // ...
   return {"result", "okay"};
}

除了 main 函数之外,返回类型不是 void 的函数必须返回一个值。对于 main 函数,允许没有 return 语句直接结束。如果控制到达了 main 函数结尾还没有 return 语句,则编译器将隐式 return 0main 函数的返回值可看做状态指示器。0 表示执行成功,其它值表示执行失败,非 0 值的含义由机器决定。为使返回值与机器无关,cstdlib 头文件定义了两个预处理变量表示成功与失败:

int main()
{
   // ...
   if (some_failure) {
      return EXIT_FAILURE;
   } else {
      return EXIT_SUCCESS;
   }
}

如果函数调用了自身,不管是直接调用还是间接调用,都称该函数为递归函数。递归函数中,一定有某条路径不包含递归调用;否则将永远递归下去,直到程序栈空间耗尽为止。有时也称这种函数含有递归循环(recursion loop)。main 函数不能调用自身

返回数组指针

数组不能被拷贝,所以函数不能返回数组,但是可以返回数组的指针或引用。使用类型别名可以简化定义。

typedef int arrT[10];
using arrT = int[10];

arrT *func(int i);
arrT &func1(int i);

返回数组指针的函数形式如下:

Type (*function(parameter_list))[dimension];

Type 是元素类型,dimension 是数组大小。尾置返回类型(trailing return type)可以简化上述函数声明。任何函数定义都能使用尾置返回,但是对于返回类型比较复杂的函数最有效。尾置返回类型跟在形参列表后面并以 -> 符号开头,并在本应出现返回类型的位置放一个 auto

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

如果知道函数返回的指针所指的数组,可以使用 decltype 声明返回类型。

函数重载

如果同一作用域中几个函数名字相同但形参列表不同,称之为重载函数(overloaded function)。函数名字只让编译器知道调用的是哪个函数,而函数重载可以在一定程度上减轻程序员起名字、记名字的负担。

main 函数不能重载。

重载函数的定义类似如下形式:

Record lookup(const Account&);
Record lookup(const Phone&);
Record lookup(const Name&);

对于重载函数,它们应该在形参数量或形参类型上有所不同。不允许两个函数除了返回类型外其他所有要素都相同。由于顶层 const 不影响传入函数的对象,因此顶层 const 无法区分函数声明。但是,指针或引用的底层 const 可以区分指向的是常量对象还是非常量对象,从而实现函数重载。

Record lookup(const Account &acct);
Record lookup(const Account&); // 重复声明

typedef Phone Telno;
Record lookup(const Account&);
Record lookup(const Telno&); // 重复声明

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

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

Record lookup(Account*);
Record lookup(const Account*); // 重载

Record lookup(Account&);
Record lookup(const Account&); // 重载

虽然,非常量对象(/指向非常量对象的指针)可以传给常量引用(/指向常量对象的指针),但是编译器会优先选择非常量版本的函数。

函数重载能在一定程度上减轻起名字、记名字的负担,但是最好只重载那些确实非常相似的操作。有时给函数起不同名字能使程序更易理解。

const_cast 在重载函数上下文中最有用。

const string &shorterString(const string &s1, const string &s2);

string &shorterString(string &s1, string &s2)
{
   auto &r = shorterString(const_cast<const string&>(s1), const_cast<const string&>(s2));
   return const_cast<string&>(r);
}

将函数调用与一组重载函数中的某一个关联起来的过程称为函数匹配(function matching),也称为重载解析(overload resolution)。编译器会将调用的实参与重载集合中每个函数的形参做比较,调用重载函数有三种可能的结果:

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

重载与作用域

一般来说,不应在局部作用域中声明函数。

内层作用域中声明的名字,将隐藏外层作用域中声明的同名实体。这导致不同作用域中无法重载函数名:

void print(const string&);
void print(double);

void fooBar(int ival)
{
   void print(int);

   print("Value: "); // 错误:void print(const string&) 被隐藏
   print(ival);
   print(3.14); // 正确:调用 void print(int)
}

调用函数时,编译器首先根据作用域规则寻找函数名的声明,然后检查函数调用是否有效。

C++ 中名字查找在类型检查之前。

特殊用途语言特性

默认实参

如果一个函数在多次调用中,某个参数都被赋予一个相同的值,可以使用默认实参(default argument)。调用有默认实参的函数时,可以省略该实参。

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

screen();
screen(66);
screen(66, 256);
screen(66, 256, '#');

默认实参作为形参的初始值出现在形参列表中。可以为一个或多个形参定义默认值,但是,一旦某个形参被赋予了默认值,后面的所有形参都必须有默认值。函数调用时,实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参。

设计含有默认实参的函数时,其中一项任务是合理设置形参顺序,尽量让不怎么使用默认值的形参出现在前面,让那些经常使用默认值的形参出现在后面。

函数声明通常放在头文件中且只声明一次,但是多次声明同一函数也是合法的。在给定作用域中一个形参只能赋予一次默认实参,后续声明只能为之前没有默认值的形参添加默认实参,而且该形参右侧的所有形参都必须有默认值。

string screen(sz, sz, char = ' ');
string screen(sz = 10, sz = 100, char);

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

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

sz wd = 80;
char def = '&';
sz ht();

string screen(sz = ht(), sz = wd, char = def);

void main()
{
   def = '#'; // 影响 screen();
   sz wd = 42; // 不影响 screen();
   screen();
}

用作默认实参的名字在声明时解析,调用时求值。

内联函数和 constexpr 函数

把规模较小的操作定义成函数有如下好处:易读、一致、易修改、可复用;而缺点则是:调用函数一般比计算等价表达式要慢。在大多数机器上,一次函数调用需要一系列工作:

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

内联函数(inline)通过关键字 inline 声明,通常将它在调用点 “内联地” 展开,从而避免函数调用的开销。

inline const string &shorterString(const string &s1, const string &s2)
{
  return s1.size() < s2.size() ? s1 : s2;
}

cout << shorterString(s1, s2) << endl;
// 编译过程中展开成类似下面的形式
cout << (s1.size() < s2.size() ? s1 : s2) << endl;

内联说明只是向编译器发出请求,编译器可以忽略这个请求。一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器不支持内联递归函数,而且一个几十行的函数也不大可能在调用点内联地展开。

constexpr 函数是指能用于常量表达式的函数。它的定义遵循如下约定:

  1. 返回值类型及所有形参类型都得是字面值类型;
  2. 函数体中必须有且只有一条 return 语句。
constexpr int new_sz()
{
   return 42;
}

返回值定义成 constexpr 的函数,编译器会在程序编译时验证该函数返回值是否为常量表达式。如果返回值确是常量表达式,执行初始化任务时,编译器把对 constexpr 函数的调用替换成结果值。为了能在编译过程中展开,constexpr 函数被隐式指定为内联函数。constexpr 函数体内可以包含其他运行时不执行任何操作的语句,比如:空语句、类型别名、using 声明等。

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

// 入参是常量表达式时,返回值也是常量表达式
constexpr size_t scale(size_t cnt)
{
   return new_sz() * cnt;
}

内联函数和 constexpr 函数可以在程序中多次定义。编译器要想展开函数不能只有函数声明,还要有函数定义。对于某个给定的内联函数或者 constexpr 函数,多个定义必须完全一致。基于此,内联函数和 constexpr 函数通常定义在头文件中

调试帮助

C++ 程序员有时会用到一种类似于头文件保护的技术,以便有选择地执行调试代码。基本思想是,程序可以包含一些用于调试的代码,但是这些代码只在开发时使用,发布时要屏蔽掉调试代码。这种方法用到两项预处理功能:assertNDEBUG

assert 是一种预处理宏(preprocessor macro)。预处理宏就是一个预处理变量,行为类似于内联函数。assert 宏常用于检查 “不应该发生” 的条件,它接收一个表达式:

assert(expr);

如果 expr 值为假,assert 输出信息并终止程序执行,否则不执行任何操作。

assert 宏定义在 cassert 头文件中。预处理名字由预处理器管理,而非编译器,因此可以直接使用预处理名字,而不用管命名空间。和预处理变量一样,宏名字在程序内必须唯一。含有 cassert 头文件的程序不能再定义名为 assert 的变量、函数、其它实体。实际编程中,还可能会间接包含 cassert 头文件。

assert 的行为依赖于名为 NDEBUG 的预处理变量的状态。如果定义了 NDEBUGassert 什么也不做。默认状态下没有定义 NDEBUG,此时将执行运行时检查。NDEBUG 可以通过 #define 来定义,很多编译器也提供了命令行选项来定义:

$ CC -D NDEBUG main.cpp # 等价于在 main.cpp 文件开头写 #define NDEBUG

assert 是调试程序的一种辅助手段。也可以使用 NDEBUG 编写自己的条件调试代码。

void print()
{
#ifndef NDEBUG
   cout << __func__ << "..." << endl;
#endif
}

编译器为每个函数名定义了 __func__,它是一个静态数组,以 C 风格字符串的形式存放了当前调试的函数名。除了 C++ 编译器定义的 __func__ 之外,预处理器还定义了另外 4 个对于程序调试很有用的名字:

  • __FILE__,存放文件名的字符串字面值。
  • __LINE__,存放当前行号的整型字面值。
  • __TIME__,存放文件编译时间的字符串字面值。
  • __DATE__,存放文件编译日期的字符串字面值。

函数匹配

函数按以下步骤进行匹配:

  1. 选定本次调用对应的重载函数集,集合中的函数称为候选函数(candidate function)。候选函数具备两个特征:

    1. 与被调函数同名;
    2. 其声明在调用点可见。
  2. 根据实参从候选函数中选出能被调用的函数,这些函数称为可行函数(viable function)。可行函数有两个特征:

    1. 其形参数量与实参数量相等;
    2. 实参类型与对应的形参类型相同,或能转换为形参类型。

    如果函数有默认实参,则调用时传入的实参数量可能少于实际使用的实参数量。

    如果没有找到可行函数,编译器将报告无匹配函数的错误。

  3. 从可行函数中选择与调用最匹配的函数。这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。“最匹配” 的基本思想是,实参类型与形参类型越接近,匹配得越好。

    实参数量有两个或多个时,编译器将依次检查每个实参以确定最佳匹配函数。如果有且仅有一个函数满足如下条件,则匹配成功:

    • 该函数每个实参的匹配都不劣于其它可行函数需要的匹配。
    • 至少有一个实参的匹配优于其它可行函数提供的匹配。

    如果检查完所有实参之后没有找到匹配函数,则编译器报二义性调用的信息。

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

实参类型转换

为确定最佳匹配,编译器将实参类型到形参类型的转换分为几个等级,排序如下:

  1. 精确匹配,包括:
    • 实参类型与形参类型相同;
    • 实参从数组类型或函数类型转换成对应的指针类型;
    • 向实参添加顶层 const 或从实参中删除顶层 const

    由此可见,如果两条声明语句区别仅在于顶层 const,任何实参都会产生二义性,即顶层 const 无法区分函数声明。

  2. 通过 const 转换实现的匹配,非底层 const 可转为底层 const
  3. 通过类型提升实现的匹配。
  4. 通过算术类型转换或指针转换实现的匹配。
  5. 通过类类型转换实现的匹配。

内置类型的提升和转换可能在函数匹配时产生意想不到的结果。

void ff(int);
void ff(short);
ff('a'); // 调用 void ff(int)

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

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

如果重载函数的区别在于它们的引用类型的形参是否引用了 const,或者指针类型的形参是否指向 const,那么调用函数时,编译器根据实参底层是否为常量来决定选择哪个函数:

Record lookup(Account&);
Record lookup(const Account&);

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

函数指针

函数指针指向函数而非对象。函数指针也指向某种特定的函数类型。函数类型由返回类型和形参类型共同决定,与函数名无关。

bool lengthCompare(const string&, const string&); // 所声明的函数类型为 bool (const string&, const string&)
bool (*pf)(const string&, const string&); // 未初始化

只有在调用 ()、取地址 &decltype 中,函数名才作为函数使用;其它情况下,函数名作为值使用,将自动转为指针。函数指针可以直接调用,无须解引用。

pf = lengthCompare;
pf = &lengthCompare;

bool b1 = pf("hello", "world");
bool b2 = (*pf)("hello", "world");

指向不同函数类型的指针间不存在转换规则。可以将 nullptr 或值为 0 的整型常量表达式赋给函数指针,表示该指针没有指向任何一个函数。

定义重载函数的指针时,必须清晰界定应该选用哪个函数,编译器通过指针类型决定选用哪个函数,指针类型必须和重载函数中的某个精确匹配。

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

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

和数组类似,形参不能是函数类型,但可以是指向函数的指针。声明为函数类型的形参,也会被编译器转为指针类型。使用类型别名和 decltype 可以避免书写冗长而繁琐的函数指针类型。

// 以下两种声明等价
void useBigger(const string &s1, const string &s2, bool lengthCompare(const string&, const string&));
void useBigger(const string &s1, const string &s2, bool (*pf)(const string&, const string&));

useBigger(s1, s2, lengthCompare); // lengthCompare 将转为函数指针

typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2;

typedef bool (*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2;

// 上面两条函数声明可以简化为如下两条
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP);

和数组类似,虽然不能返回一个函数,但可以返回一个指向函数的指针。返回类型必须是指针类型,而不能是函数类型,编译器不会将返回的函数或数组类型自动转为对应的指针类型。

using F = int (int*, int);
using PF = int (*)(int*, int);

int func(int*, int);

// 以下 4 种声明等价
PF f1(int);
F *f1(int);
int (*f1(int))(int*, int);
auto f1(int) -> int (*)(int*, int);
decltype(func) *f1(int);