《C++ Primer 5th》笔记(6 / 19):函数 迷南。 2023-01-18 09:22 83阅读 0赞 ### 文章目录 ### * * 函数基础 * * 局部对象 * 函数声明 * 分离式编译 * 参数传递 * * 传递参数 * 传引用参数 * * 使用引用避免拷贝 * 使用引用形参返回额外信息 * const形参和实参 * * 指针或引用形参与const * 尽量使用常量引用 * 数组形参 * * 使用标记指定数组长度 * 使用标准库规范 * 显示传递一个表示数组大小的形参 * 数组形参和const * 数组引用形参 * 传递多维数组 * main:处理命令行选项 * 含有可变形参的函数 * * initializer\_list形参 * 省略符形参 * 返回类型和return语句 * * 无返回值函数 * 有返回值函数 * * 值是如何被返回的 * 不要返回局部对象的引用或指针 * 返回类类型的函数和调用运算符 * 引用返回左值 * 列表初始化返回值 * 主函数main的返回值 * 递归 * 返回数组指针 * * 声明一个返回数组指针的函数 * 使用尾置返回类型 * 使用decltype * 函数重载 * * 定义重载函数 * * 判断两个形参的类型是否相异 * 重载和const形参 * 建议:何时不应该重载函数 * const\_cast和重载 * 调用重载的函数 * 重载与作用域 * 特殊用途语言特性 * * 默认实参 * * 使用默认实参调用函数 * 默认实参声明 * 默认实参初始值 * 内联函数和constexpr函数 * * 内联函数可避免函数调用的开销 * constexpr函数 * 把内联函数和constexpr函数放在头文件内 * 调试帮助 * * assert预处理宏 * NDEBUG预处理变量 * 函数匹配 * * 例子:调用应该选用哪个重载函数 * * 确定候选函数和可行函数 * 寻找最佳匹配(如果有的话) * 含有多个形参的函数匹配 * 实参类型转换 * * 需要类型提升和算术类型转换的匹配 * 函数匹配和const实参 * 函数指针 * * 使用函数指针 * * 重载函数的指针 * 函数指针形参 * 返回指向函数的指针 * 将auto和decltype用于函数指针类型 函数是一个命名了的代码块,我们通过调用函数执行相应的代码。函数可以有0个或多个参数,而且(通常)会产生一个结果。可以重载函数,也就是说,同一个名字可以对应几个不同的函数。 ## 函数基础 ## 一个典型的函数(function)定义包括以下部分: * 返回类型(return type) * 函数名字 * 由0个或多个形参(parameter)组成的列表,形参以逗号隔开,形参的列表位于一对圆括号之内 * 函数体。函数执行的操作在语句块中说明,该语句块称为函数体( function body) 我们通过**调用运算符**(call operator)来执行函数。调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针; 圆括号之内是一个用逗号隔开的实参(argument)列表,我们用实参初始化函数的形参。 调用表达式的类型就是函数的返回类型。 **编写函数** 举个例子,我们准备编写一个求数的阶乘的程序。n 的阶乘是从1到n所有数字的乘积,例如5的阶乘是120。 1 * 2 * 3 * 4 * 5 = 120 程序如下所示: // factorial of val is val * (val - 1) * (val - 2) . . . * ((val - (val - 1)) * 1) int fact(int val) { int ret = 1; // local variable to hold the result as we calculate it while (val > 1) ret *= val--; // assign ret * val to ret and decrement val return ret; // return the result } **调用函数** 要调用fact函数,必须提供一个整数值,调用得到的结果也是一个整数: int main() { int j = fact(5); // j equals 120, i.e., the result of fact(5) cout << "5! is " << j << endl; return 0; } 函数的调用完成两项工作: 1. 用实参初始化函数对应的形参, 2. 将控制权转移给被调用函数。此时,主调函数(calling function)的执行被暂时中断,被调函数(called function)开始执行。 执行函数的第一步是(隐式地)定义并初始化它的形参。因此,当调用fact函数时,首先创建一个名为val的int变量,然后将它初始化为调用时所用的实参5。 当遇到一条return语句时函数结束执行过程。和函数调用一样,return语句也完成两项工作: 1. 返回return 语句中的值(如果有的话), 2. 将控制权从被调函数转移回主调函数。 函数的返回值用于初始化调用表达式的结果,之后继续完成调用所在的表达式的剩余部分。因此,我们对fact函数的调用等价于如下形式: int val = 5; // initialize val from the literal 5 int ret = 1; // code from the body of fact while (val > 1) ret *= val--; int j = ret; // initialize j as a copy of ret **形参和实参** 实参是形参的初始值。第一个实参初始化第一个形参,第二个实参初始化第二个形参,以此类推。尽管实参与形参存在对应关系,但是并没有规定实参的求值顺序(参见4.1.3节,第123页)。编译器能以任意可行的顺序对实参求值。 实参的类型必须与对应的形参类型匹配,这一点与之前的规则是一致的,我们知道在初始化过程中初始值的类型也必须与初始化对象的类型匹配。函数有几个形参,我们就必须提供相同数量的实参。因为函数的调用规定实参数量应与形参数量一致,所以形参一定会被初始化。 在上面的例子中,fact函数只有一个int类型的形参,所以每次我们调用它的时候,都必须提供一个能转换成int的实参: fact( "hello" ); //错误:实参类型不正确 fact(); //错误:实参数量不足 fact(42, 10, 0); //错误:实参数量过多 fact(3.14); //正确:该实参能转换成int类型 * 因为不能将const char\*转换成int,所以第一个调用失败。 * 第二个和第三个调用也会失败,不过错误的原因与第一个不同,它们是因为传入的实参数量不对。要想调用fact函数只能使用一个实参,只要实参数量不是一个,调用都将失败。 * 最后一个调用是合法的,因为 double可以转换成int。执行调用时,实参隐式地转换成int类型(截去小数部分),调用等价于 fact (3); **函数的形参列表** 函数的形参列表可以为空,但是不能省略。要想定义一个不带形参的函数,最常用的办法是书写一个空的形参列表。不过为了与C语言兼容,也可以使用关键字void表示函数没有形参: void f1(){ /* ...*/} //隐式地定义空形参列表 void f2(void){ /* ...*/} //显式地定义空形参列表* 形参列表中的形参通常用逗号隔开,其中每个形参都是含有一个声明符的声明。即使两个形参的类型一样,也必须把两个类型都写出来: int f3(int v1, v2){ /* ...*/ }//错误 int f4(int v1, int v2){ /* ...*/}//正确 任意两个形参都不能同名,而且函数最外层作用域中的局部变量也不能使用与函数形参一样的名字。 形参名是可选的,但是由于我们无法使用未命名的形参,所以形参一般都应该有个名字。 偶尔,函数确实有个别形参不会被用到,则此类形参通常不命名以表示在函数体内不会使用它。不管怎样,是否设置未命名的形参并不影响调用时提供的实参数量。即使某个形参不被函数使用,也必须为它提供一个实参。 **函数返回类型** 大多数类型都能用作函数的返回类型。一种特殊的返回类型是void,它表示函数不返回任何值。函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针,在本章,会陆续介绍。 ### 局部对象 ### 在C++语言中,名字有作用域,对象有**生命周期**(lifetime)。理解这两个概念非常重要。 * 名字的作用域是程序文本的一部分,名字在其中可见。 * 对象的生命周期是程序执行过程中该对象存在的一段时间。 如我们所知,函数体是一个语句块。块构成一个新的作用域,我们可以在其中定义变量。形参和函数体内部定义的变量统称为**局部变量**(local variable)。它们对函数而言是“局部”的,仅在函数的作用域内可见,同时局部变量还会**隐藏**(hide)在外层作用域中同名的其他所有声明中。 在所有函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁。局部变量的生命周期依赖于定义的方式。 **自动对象** 对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义语句时创建该对象,当到达定义所在的块末尾时销毁它。我们把只存在于块执行期间的对象称为**自动对象**(automatic object)。当块的执行结束后,块中创建的自动对象的值就变成未定义的了。(呼之即来,挥之即去) 形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也就被销毁。 我们用传递给函数的实参初始化形参对应的自动对象。对于局部变量对应的自动对象来说,则分为两种情况: * 如果变量定义本身含有初始值,就用这个初始值进行初始化; * 否则,如果变量定义本身不含初始值,执行默认初始化。这意味着内置类型的未初始化局部变量将产生未定义的值。 **局部静态对象** 某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static类型从而获得这样的对象。**局部静态对象**(local static object)在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。 (MyNote:局部静态对象具有全局变量长命期与局部变量的私有性。) 举个例子,下面的函数统计它自己被调用了多少次,这样的函数也许没什么实际意义,但是足够说明问题: size_t count_calls() { static size_t ctr = 0; // value will persist across calls return ++ctr; } int main() { for (size_t i = 0; i != 10; ++i) cout << count_calls() << endl; return 0; } 这段程序将输出从1到10(包括10在内)的数字。 如果局部静态变量没有显式的初始值,它将执行值初始化,内置类型的局部静态变量初始化为0。 ### 函数声明 ### 和其他名字一样,函数的名字也必须在使用之前声明。类似于变量,**函数只能定义一次,但可以声明多次**。唯一的例外是如第15章将要介绍的,如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。 函数的声明和函数的定义非常类似,唯一的区别是函数声明无须函数体,用一个分号替代即可。 因为函数的声明不包含函数体,所以也就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用处的,它可以帮助使用者更好地理解函数的功能: //我们选择beg和end作为形参的名字以表示这两个迭代器划定了输出值的范围 void print (vector<int>::const_iterator beg, vector<int>:: const_iterator end) ; 函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作**函数原型**(function prototype)。 **在头文件中进行函数声明** 回忆之前所学的知识,我们建议变量在头文件中声明,在源文件中定义。与之类似,**函数也应该在头文件中声明而在源文件中定义**。 看起来把函数的声明直接放在使用该函数的源文件中是合法的,也比较容易被人接受;但是这么做可能会很烦琐而且容易出错。相反,如果把函数声明放在头文件中,就能确保同一函数的所有声明保持一致。而且一旦我们想改变函数的接口,只需改变一条声明即可。 定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。 **Best Practices:含有函数声明的头文件应该被包含到定义函数的源文件中。** (MyNote:函数声明在头文件,定义在源文件。) ### 分离式编译 ### 随着程序越来越复杂,我们希望把程序的各个部分分别存储在不同文件中。例如,函数存在一个文件里,把使用这些函数的代码存在其他源文件中。为了允许编写程序时按照逻辑关系将其划分开来,C++语言支持所谓的**分离式编译**(separate compilation)。分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。 **编译和链接多个源文件** 举个例子,假设fact**函数的定义**位于一个名为fact.cc的文件中,它的声明位于名为Chapter6.h的头文件中。显然与其他所有用到fact函数的文件一样,fact.cc应该包含chapter6.h头文件(\#include “Chapter6.h”)。 另外,我们在名为factMain.cc 的文件中创建main函数,main函数将调用fact函数。要生成可执行文件(executable file),必须告诉编译器我们用到的代码在哪里。 对于上述几个文件来说,编译的过程如下所示: $ cc factMain.cc fact.cc # generates factMain.exe or a.out $ cc factMain.cc fact.cc -o main # generates main or main.exe 其中,cc是编译器的名字,$是系统提示符,\#后面是命令行下的注释语句。接下来如果运行可执行文件,就会执行我们定义的main函数。 如果我们修改了其中一个源文件,那么只需重新编译那个改动了的文件。大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是.obj(Windows)或.o(UNIX)的文件,后缀名的含义是该文件包含对象代码(object code)。 接下来编译器负责把对象文件链接在一起形成可执行文件。在我们的系统中,编译的过程如下所示: $ cc -c factMain.cc # generates factMain.o $ cc -c fact.cc # generates fact.o $ cc factMain.o fact.o # generates factMain.exe or a.out $ cc factMain.o fact.o -o main # generates main or main.exe 你可以仔细阅读编译器的用户手册,弄清楚由多个文件组成的程序是如何编译并执行的。 (MyNote:这就是头文件与源文件如何联系在一起。) ## 参数传递 ## 如前所述,每次调用函数时都会**重新创建**它的形参,并用传入的实参对形参进行初始化。 **Note:形参初始化的机理与变量初始化一样。** 和其他变量一样,形参的类型决定了形参和实参交互的方式。**如果形参是引用类型,它将绑定到对应的实参上;否则,将实参的值拷贝后赋给形参**。 * 当形参是引用类型时,我们说它对应的实参被**引用传递**(passed by reference)或者函数被**传引用调用**(called by reference)。和其他引用一样,引用形参也是它绑定的对象的别名;也就是说,引用形参是它对应的实参的别名。 * 当实参的值被拷贝给形参时,形参和实参是两个相互独立的对象。我们说这样的实参被**值传递**(passed by value)或者函数被**传值调用**(called by value)。 ### 传递参数 ### 当初始化一个非引用类型的变量时,初始值被拷贝给变量。此时,对变量的改动不会影响初始值: int n = 0; //int类型的初始变量 int i = n; // i是n的值的副本 i = 42; // i的值改变;n的值不变 传值参数的机理完全一样,函数对形参做的所有操作都不会影响实参。 例如,在fact函数 int fact(int val) { int ret = 1; // local variable to hold the result as we calculate it while (val > 1) ret *= val--; // assign ret * val to ret and decrement val return ret; // return the result } 内对变量val执行递减操作: ret *= val--;//将val的值减1 尽管fact函数改变了val的值,但是这个改动不会影响传入fact的实参。调用fact(i)不会改变i的值。 **指针形参** 指针的行为和其他非引用类型一样。当执行指针拷贝操作时,拷贝的是指针的值(MyNote:指针,即地址值)。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接地访问它所指的对象,所以通过指针可以修改它所指对象的值: int n = 0, i = 42; int *p = &n, *q = &i; // p points to n; q points to i *p = 42; // value in n is changed; p is unchanged p = q; // p now points to i; values in i and n are unchanged 指针形参的行为与之类似: // function that takes a pointer and sets the pointed-to value to zero void reset(int *ip) { *ip = 0; // changes the value of the object to which ip points ip = 0; // changes only the local copy of ip; the argument is unchanged } 调用reset函数之后,实参所指的对象被置为o,但是实参本身并没有改变: int i = 42; reset(&i); // changes i but not the address of i cout << "i = " << i << endl; // prints i = 0 **Best Practices:熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参替代指针。** (MyNote:引用,我个人理解为特殊的指针。) ### 传引用参数 ### 回忆过去所学的知识,我们知道对于引用的操作实际上是作用在引用所引的对象上。 int n = 0, i = 42; int &r = n; // r is bound to n (i.e., r is another name for n) r = 42; // n is now 42 r = i; // n now has the same value as i i = r; // i has the same value as n 引用形参的行为与之类似。通过使用引用形参,允许函数改变一个或多个实参的值。 举个例子,我们可以改写上一小节的reset程序,使其接受的参数是引用类型而非指针: // function that takes a reference to an int and sets the given object to zero void reset(int &i) // i is just another name for the object passed to reset { i = 0; // changes the value of the object to which i refers } 和其他引用一样,引用形参**绑定初始化**它的对象。当调用这一版本的 reset 函数时,i绑定我们传给函数的int对象,此时改变i也就是改变i所引对象的值。此例中,被改变的对象是传入reset的实参。 调用这一版本的reset函数时,我们直接传入对象而无须传递对象的地址: int j = 42; reset(j); // j is passed by reference; the value in j is changed cout << "j = " << j << endl; // prints j = 0 在上述调用过程中,形参i仅仅是j的又一个名字。在reset内部对i的使用即是对j的使用。 -------------------- MyNote:传引用与传指针相比,传引用使用时,无需像传指针那样要用一个解引用,这样简洁些。 // function that takes a pointer and sets the pointed-to value to zero void reset(int *ip) { *ip = 0; // changes the value of the object to which ip points ip = 0; // changes only the local copy of ip; the argument is unchanged } #### 使用引用避免拷贝 #### 拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括IO类型在内)根本就不支持拷贝操作。**当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象**。 举个例子,我们准备编写一个函数比较两个string 对象的长度。因为string对象可能会非常长,所以应该尽量**避免直接拷贝它们**,这时使用引用形参是比较明智的选择。又因为比较长度无须改变string 对象的内容,所以把形参定义成对常量的引用: // compare the length of two strings bool isShorter(const string &s1, const string &s2) { return s1.size() < s2.size(); } 如将要介绍的,当函数无须修改引用形参的值时最好使用常量引用。(只读属性) **Best Practices:如果函数无须改变引用形参的值,最好将其声明为常量引用。** #### 使用引用形参返回额外信息 #### 一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们**一次返回多个结果提供了有效的途径**。 举个例子,我们定义一个名为find\_char的函数,它返回在string对象中某个指定字符第一次出现的位置。同时,我们也希望函数能返回该字符出现的总次数。 该如何定义函数使得它能够既返回位置也返回出现次数呢? 1. 一种方法是定义一个新的数据类型,让它包含位置和数量两个成员。 2. 还有另一种更简单的方法,我们可以给函数传入一个额外的引用实参,令其保存字符出现的次数: // returns the index of the first occurrence of c in s // the reference parameter occurs counts how often c occurs string::size_type find_char(const string &s, char c, string::size_type &occurs) { auto ret = s.size(); // position of the first occurrence, if any occurs = 0; // set the occurrence count parameter for (decltype(ret) i = 0; i != s.size(); ++i) { if (s[i] == c) { if (ret == s.size()) ret = i; // remember the first occurrence of c ++occurs; // increment the occurrence count } } return ret; // count is returned implicitly in occurs } 当我们调用find\_char函数时,必须传入三个实参:作为查找范围的一个string对象、要找的字符以及一个用于保存字符出现次数的size\_type对象。假设s是一个string对象,ctr是一个size\_type对象,则我们通过如下形式调用find\_char函数: auto index = find_char(s, 'o', ctr); 调用完成后,如果string对象中确实存在o,那么ctr的值就是。出现的次数,index指向o第一次出现的位置;否则如果string对象中没有o, index等于 s.size()而ctr等于0。 ### const形参和实参 ### 当形参是const时,必须要注意第2章关于顶层const的内容。如前所述,顶层const作用于对象本身: const int ci = 42; // we cannot change ci; const is top-level int i = ci; // ok: when we copy ci, its top-level const is ignored int * const p = &i; // const is top-level; we can't assign to p,注意,初始化与赋值在C++中是两码事 *p = 0; // ok: changes through p are allowed; i is now 0 和其他初始化过程一样,当用实参初始化形参时会忽略掉顶层const。换句话说,形参的顶层const被忽略掉了。当形参有顶层const时,传给它常量对象或者非常量对象都是可以的: void fcn(const int i) { /* fcn can read but not write to i */ } 调用fcn函数时,既可以传入const int也可以传入int。忽略掉形参的顶层const可能产生意想不到的结果: void fcn(const int i) { /* fcn can read but not write to i */ } void fcn(int i) { /* . . . */ } // error: redefines fcn(int) 在C++语言中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显的区别。因为顶层const被忽略掉了,所以在上面的代码中传入两个fcn函数的参数可以完全一样。因此第二 fcn是错误的,尽管形式上有差异,但实际上它的形参和第一个fcn的形参没什么不同。 #### 指针或引用形参与const #### 形参的初始化方式和变量的初始化方式是一样的,所以回顾通用的初始化规则有助于理解本节知识。 我们可以使用非常量初始化一个底层const对象,但是反过来不行;同时一个普通的引用必须用同类型的对象初始化。 int i = 42; const int *cp = &i; // ok: but cp can't change i const int &r = i; // ok: but r can't change i const int &r2 = 42; // ok: //我们可以使用非常量初始化一个底层const对象,但是反过来不行; int *p = cp; // error: types of p and cp don't match int &r3 = r; // error: types of r3 and r don't match int &r4 = 42; // error: can't initialize a plain reference from a literal //普通引用变量绑定一个变量 将同样的初始化规则应用到参数传递上可得如下形式: // function that takes a reference to an int and sets the given object to zero void reset(int &i) // i is just another name for the object passed to reset { i = 0; // changes the value of the object to which i refers } int i = 0; const int ci = i; string::size_type ctr = 0; reset(&i); // calls the version of reset that has an int* parameter reset(&ci); // error: can't initialize an int* from a pointer to a const int object reset(i); // calls the version of reset that has an int& parameter reset(ci); // error: can't bind a plain reference to the const object ci reset(42); // error: can't bind a plain reference to a literal reset(ctr); // error: types don't match; ctr has an unsigned type // ok: find_char's first parameter is a reference to const find_char("Hello World!", 'o', ctr); 要想调用引用版本的reset,只能使用int类型的对象,而不能使用字面值、求值结果为int的表达式、需要转换的对象或者const int类型的对象。类似的,要想调用指针版本的reset只能使用int\*。 另一方面,我们能传递一个字符串字面值作为find\_char的第一个实参,这是因为该函数的引用形参是常量引用,而C++允许我们用字面值初始化常量引用。 #### 尽量使用常量引用 #### 把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外,**使用引用而非常量引用也会极大地限制函数所能接受的实参类型**。就像刚刚看到的,我们不能把const对象、字面值或者需要类型转换的对象传递给普通的引用形参。 这种错误绝不像看起来那么简单,它可能造成出人意料的后果。以上文的find\_char函数为例,那个函数(正确地)将它的string类型的形参定义成常量引用。假如我们把它定义成普通的string&: // bad design: the first parameter should be a const string& string::size_type find_char(string &s, char c, string::size_type &occurs); 则只能将find\_char函数作用于string对象。类似下面这样的调用 find_char("Hello World", 'o', ctr);//string &s = "Hello World";不行,const string &s = "Hello World";行 将在编译时发生错误。 还有一个更难察觉的问题,假如其他函数(正确地)将它们的形参定义成常量引用,那么第二个版本的find\_char无法在此类函数中正常使用。举个例子,我们希望在一个判断string对象是否是句子的函数中使用find\_char: bool is_sentence(const string &s) { // if there's a single period at the end of s, then s is a sentence string::size_type ctr = 0; return find_char(s, '.', ctr) == s.size() - 1 && ctr == 1; } 如果find\_char的第一个形参类型是string&,那么上面这条调用find\_char的语句将在编译时发生错误。原因在于s是常量引用,但find\_char被(不正确地)定义成只能接受普通引用。 解决该问题的一种思路是修改is\_sentence的形参类型,但是这么做只不过转移了错误而已,结果是is\_sentence函数的调用者只能接受非常量string对象了。 **正确的修改思路**是改正find\_char函数的形参。如果实在不能修改find\_char,就在is \_sentence内部定义一个string类型的变量,令其为s的副本,然后把这个string对象传递给find\_char。 ### 数组形参 ### 数组(第3章内容)的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是: 1. 不允许拷贝数组 2. 使用数组时(通常)会将其转换成指针。 因为不能拷贝数组,所以我们无法以值传递的方式使用数组参数。因为数组会被转换成指针,所以当我们为函数传递一个数组时,实际上传递的是指向数组首元素的指针。 尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式: // despite appearances, these three declarations of print are equivalent // each function has a single parameter of type const int* void print(const int*); void print(const int[]); // shows the intent that the function takes an array void print(const int[10]); // dimension for documentation purposes (at best) 尽管表现形式不同,但上面的三个函数是等价的:每个函数的唯一形参都是const int \*类型的。当编译器处理对print函数的调用时,只检查传入的参数是否是const int \*类型: int i = 0, j[2] = { 0, 1}; print(&i); // ok: &i is int* print(j); // ok: j is converted to an int* that points to j[0] 如果我们传给 print 函数的是一个数组,则实参自动地转换成指向数组首元素的指针,数组的大小对函数的调用没有影响。 **WARNING:和其他使用数组的代码一样,以数组作为形参的函数也必须确保使用数组时不会越界。** 因为数组是以指针的形式传递给函数的,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息。管理指针形参有三种常用的技术。 #### 使用标记指定数组长度 #### 管理数组实参的第一种方法是要求数组本身包含一个结束标记,使用这种方法的典型示例是C风格字符串。C风格字符串存储在字符数组中,并且在最后一个字符后面跟着一个空字符。函数在处理C风格字符串时遇到空字符停止: void print(const char *cp) { if (cp) // if cp is not a null pointer while (*cp) // so long as the character it points to is not a null character cout << *cp++; // print the character and advance the pointer } **这种方法适用于那些有明显结束标记且该标记不会与普通数据混淆的情况**,但是对于像int这样所有取值都是合法值的数据就不太有效了。 #### 使用标准库规范 #### 管理数组实参的第二种技术是传递指向数组首元素和尾后元素的指针,这种方法受到了标准库技术的启发,使用该方法,我们可以按照如下形式输出元素内容: void print(const int *beg, const int *end) { // print every element starting at beg up to but not including end while (beg != end) cout << *beg++ << endl; // print the current element // and advance the pointer } while循环使用解引用运算符和后置递减运算符输出当前元素并在数组内将beg向前移动一个元素,当beg和end相等时结束循环。 为了调用这个函数,我们需要传入两个指针:一个指向要输出的首元素,另一个指向尾元素的下一位置: int j[2] = { 0, 1}; // j is converted to a pointer to the first element in j // the second argument is a pointer to one past the end of j print(begin(j), end(j)); // begin and end functions 只要调用者能正确地计算指针所指的位置,那么上述代码就是安全的。在这里,我们使用标准库begin和end函数提供所需的指针。 #### 显示传递一个表示数组大小的形参 #### 第三种管理数组实参的方法是专门定义一个表示数组大小的形参,在C程序和过去的C++程序中常常使用这种方法。使用该方法,可以将print函数重写成如下形式: // const int ia[] is equivalent to const int* ia // size is passed explicitly and used to control access to elements of ia void print(const int ia[], size_t size) { for (size_t i = 0; i != size; ++i) { cout << ia[i] << endl; } } 这个版本的程序通过形参size的值确定要输出多少个元素,调用print函数时必须传入这个表示数组大小的值: int j[] = { 0, 1 }; // int array of size 2 print(j, end(j) - begin(j)); 只要传递给函数的size值不超过数组实际的大小,函数就是安全的。 #### 数组形参和const #### 我们的三个print函数都把数组形参定义成了指向const的指针,本章关于引用的讨论同样适用于指针。 * 当函数不需要对数组元素执行写操作的时候,数组形参应该是指向const的指针。 * 只有当函数确实要改变元素值的时候,才把形参定义成指向非常量的指针。 (MyNote:只读时用const。) #### 数组引用形参 #### C++语言允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上: // ok: parameter is a reference to an array; the dimension is part of the type void print(int (&arr)[10]) { for (auto elem : arr) cout << elem << endl; } Note:&arr两端的括号必不可少: f(int &arr[10]) // error: declares arr as an array of references f(int (&arr)[10]) // ok: arr is a reference to an array of ten ints 因为数组的大小是构成数组类型的一部分,所以只要不超过维度,在函数体内就可以放心地使用数组。但是,这一用法也无形中限制了print函数的可用性,我们只能将函数作用于大小为10的数组: int i = 0, j[2] = { 0, 1}; int k[10] = { 0,1,2,3,4,5,6,7,8,9}; print(&i); // error: argument is not an array of ten ints print(j); // error: argument is not an array of ten ints print(k); // ok: argument is an array of ten ints 第16章将要介绍我们应该如何编写这个函数,使其可以给引用类型的形参传递任意大小的数组。 #### 传递多维数组 #### 我们曾经介绍过,在C++语言中实际上没有真正的多维数组,所谓多维数组其实是数组的数组。 和所有数组一样,当将多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度〉的大小都是数组类型的一部分,不能省略: // matrix points to the first element in an array whose elements are arrays of ten ints void print(int (*matrix)[10], int rowSize) { /* . . . */ } 上述语句将matrix声明成指向含有10个整数的数组的指针。 Note:再一次强调,\*matrix两端的括号必不可少: int *matrix[10]; // array of ten pointers int (*matrix)[10]; // pointer to an array of ten ints 我们也可以使用数组的语法定义函数,此时编译器会一如既往地忽略掉第一个维度,所以最好不要把它包括在形参列表内: // equivalent definition void print(int matrix[][10], int rowSize) { /* . . . */ } matrix的声明看起来是一个二维数组,实际上形参是指向含有10个整数的数组的指针。 ### main:处理命令行选项 ### main函数是演示C++程序如何向函数传递数组的好例子。到目前为止,我们定义的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。 以上面提供的命令行为例,argc应该等于5,argv应该包含如下的C风格字符串: argv[0] = "prog" ; //或者argv[0]也可以指向一个空字符串 argv[1] = "-d"; argv[2] = "-o"; argv[3] = "ofile"; argv[4] = "datao"; argv[5] = 0; WARNING:当使用argv中的实参时,**一定要记得可选的实参从argv\[1\]开始**,argv\[0\]保存程序的名字,而非用户输入。 ### 含有可变形参的函数 ### 有时我们无法提前预知应该向函数传递几个实参。例如,我们想要编写代码输出程序产生的错误信息,此时最好用同一个函数实现该项功能,以便对所有错误的处理能够整齐划一。然而,错误信息的种类不同,所以调用错误输出函数时传递的实参也各不相同。 为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法: 1. 如果所有的实参类型相同,可以传递一个名为initializer\_list的标准库类型; 2. 如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板,关于它的细节将在第16章介绍。 C++还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。本节将简要介绍省略符形参,不过需要注意的是,这种功能一般只用于与C函数交互的接口程序。 #### initializer\_list形参 #### 如果函数的实参数量未知但是全部实参的类型都相同,我们可以使用**initializer\_list**类型的形参。initializer\_list是一种标准库类型,用于表示某种特定类型的值的数组。initializer\_list类型定义在同名的头文件中,它提供的操作如下表所示。 <table> <thead> <tr> <th>.</th> <th>.</th> </tr> </thead> <tbody> <tr> <td>initializer_list lst;</td> <td>默认初始化;T类型元素的空列表</td> </tr> <tr> <td>initializer_list lst{a, b, c…};</td> <td>lst的元素数量和初始值一样多;lst的元素是对应初始值的副本;列表中的元素是const</td> </tr> <tr> <td>lst2(lst)</td> <td>拷贝或赋值一个initializer_list对象不会拷贝列表中的元素;拷贝后,原始列表和副本共享元素</td> </tr> <tr> <td>lst2 = lst</td> <td>同上条</td> </tr> <tr> <td>lst.size()</td> <td>列表中的元素数量</td> </tr> <tr> <td>lst.begin()</td> <td>返回指向lst中首元素的指针</td> </tr> <tr> <td>lst.end()</td> <td>返回指向lst中尾元素下一位置的指针</td> </tr> </tbody> </table> 和vector一样,initializer\_list也是一种模板类型。定义initializer\_list对象时,必须说明列表中所含元素的类型: initializer_list<string> ls; // initializer_list of strings initializer_list<int> li; // initializer_list of ints 和 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()成员提供一个指向列表尾后元素的指针。我们的函数首先初始化 beg令其表示首元素,然后依次遍历列表中的每个元素。在循环体中,解引用beg 以访问当前元素并输出它的值。 如果想向initializer\_list形参中传递一个值的序列,则必须把序列放在一对花括号内: // expected, actual are strings if (expected != actual) error_msg({ "functionX", expected, actual}); else error_msg({ "functionX", "okay"}); 在上面的代码中我们调用了同一个函数error\_msg,但是两次调用传递的参数数量不同:第一次调用传入了三个值,第二次调用只传入了两个。 含有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; } 因为initializer\_list包含begin和end成员,所以我们可以使用范围for循环处理其中的元素。和之前的版本类似,这段程序遍历传给il形参的列表值,每次迭代时访问一个元素。 为了调用这个版本的error\_msg函数,需要额外传递一个ErrCode实参: if (expected != actual) error_msg(ErrCode(42), { "functionX", expected, actual}); else error_msg(ErrCode(0), { "functionX", "okay"}); #### 省略符形参 #### 省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。通常,省略符形参不应用于其他目的。你的C编译器文档会描述如何使用varargs。 **WARNING**:省略符形参应该仅仅用于C和C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。 省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种: void foo(parm_list, ...); void foo(...); 第一种形式指定了foo 函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查。省略符形参所对应的实参无须类型检查。在第一种形式中,形参声明后面的逗号是可选的。 ## 返回类型和return语句 ## return语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。return语句有两种形式: return; return expression; ### 无返回值函数 ### 没有返回值的return 语句只能用在返回类型是void 的函数中。返回void的函数不要求非得有return语句,因为在这类函数的最后一句后面会隐式地执行return。 通常情况下,void函数如果想在它的中间位置提前退出,可以使用return语句。return的这种用法有点类似于我们用break语句退出循环。例如,可以编写一个swap函数,使其在参与交换的值相等时什么也不做直接退出: void swap(int &v1, int &v2) { // if the values are already the same, no need to swap, just return if (v1 == v2) return; // if we're here, there's work to do int tmp = v2; v2 = v1; v1 = tmp; // no explicit return necessary } 这个函数首先检查值是否相等,如果相等直接退出函数,如果不相等才交换它们的值。在最后一条赋值语句后面隐式地执行return。 一个返回类型是void的函数也能使用return语句的第二种形式,不过此时return语句的expression必须是另一个返回void的函数。强行令void函数返回其他类型的表达式将产生编译错误。 ### 有返回值函数 ### return语句的第二种形式提供了函数的结果。只要函数的返回类型不是 void,则该函数内的每条return语句必须返回一个值。return语句返回值的类型必须与函数的返回类型相同,或者能隐式地转换成函数的返回类型。 尽管C++无法确保结果的正确性,但是可以保证每个return语句的结果类型正确。也许无法顾及所有情况,但是编译器仍然尽量确保具有返回值的函数只能通过一条有效的return语句退出。例如: // incorrect return values, this code will not compile bool str_subrange(const string &str1, const string &str2) { // same sizes: return normal equality test if (str1.size() == str2.size()) return str1 == str2; // ok: == returns bool // find the size of the smaller string; conditional operator auto size = (str1.size() < str2.size()) ? str1.size() : str2.size(); // look at each element up to the size of the smaller string for (decltype(size) i = 0; i != size; ++i) { if (str1[i] != str2[i]) return; // error #1: no return value; compiler should detect this error } // error #2: control might flow off the end of the function without a return // the compiler might not detect this error } 1. 第一个错误是for循环内的return语句是错误的,因为它没有返回值,编译器能检测到这个错误。 2. 第二个错误是函数在for循环之后没有提供 return语句。 在上面的程序中,如果一个string对象是另一个的子集,则函数在执行完for循环后还将继续其执行过程,显然应该有一条return 语句专门处理这种情况。编译器也许能检测到这个错误,也许不能。如果编译器没有发现这个错误,则运行时的行为将是未定义的。 **WARNING**:在含有return语句的循环后面应该也有一条return语句,如果没有的话该程序就是错误的。很多编译器都无法发现此类错误。 #### 值是如何被返回的 #### **返回一个值的方式和初始化一个变量或形参的方式完全一样**:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。 必须注意当函数返回局部变量时的初始化规则。例如我们书写一个函数,给定计数值、单词和结束符之后,判断计数值是否大于1。如果是,返回单词的复数形式。如果不是,返回单词原形: //如果ctr的值大于1,返回word的复数形式 string make_plural(size_t ctr, const string &word, const string &ending) { return (ctr > 1) ? word + ending : word; } 该函数的返回类型是string,意味着返回值将被拷贝到调用点。因此,该函数将返回word的副本或者一个未命名的临时string对象,该对象的内容是word和ending的和。 同其他引用类型一样,如果函数返回引用,则该引用仅是它所引对象的一个别名。举个例子来说明,假定某函数挑出两个string形参中较短的那个并返回其引用: //挑出两个string对象中较短的那个,返回其引用 const string &shorterstring(const string &sl,const string &s2){ return s1.size() <= s2.size() ? s1 : s2 ; } 其中形参和返回类型都是const string 的引用,不管是调用函数还是返回结果都不会真正拷贝string对象。 #### 不要返回局部对象的引用或指针 #### 函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将指向不再有效的内存区域: // disaster: this function returns a reference to a local object const string &manip() { string ret; // transform ret in some way if (!ret.empty()) return ret; // WRONG: returning a reference to a local object! else return "Empty"; // WRONG: "Empty" is a local temporary string } 上面的两条 return语句都将返回未定义的值,也就是说,试图使用manip函数的返回值将引发未定义的行为。 * 对于第一条return语句来说,显然它返回的是局部对象的引用。 * 在第二条return语句中,字符串字面值转换成一个局部临时string对象,对于manip来说,该对象和 ret一样都是局部的。 当函数结束时临时对象占用的空间也就随之释放掉了,所以两条return语句都指向了不再可用的内存空间。 **Tip**:要想确保返回值安全,我们不妨提问:引用所引的是在函数之前已经存在的哪个对象? 如前所述,返回局部对象的引用是错误的;同样,返回局部对象的指针也是错误的。一旦函数完成,局部对象被释放,指针将指向一个不存在的对象。 #### 返回类类型的函数和调用运算符 #### 和其他运算符一样,调用运算符也有优先级和结合律。调用运算符的优先级与点运算符和箭头运算符相同,并且也符合左结合律。因此,如果函数返回指针、引用或类的对象,**我们就能使用函数调用的结果访问结果对象的成员**。 例如,我们可以通过如下形式得到较短string对象的长度: //调用string对象的size成员,该string对象是由shorterstring函数返回的 auto sz = shorterString(s1,s2).size(); 因为上面提到的运算符都满足左结合律,所以 shorterString 的结果是点运算符的左侧运算对象,点运算符可以得到该string对象的size成员,size又是第二个调用运算符的左侧运算对象。 #### 引用返回左值 #### 函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值: char &get_val(string &str, string::size_type ix) { return str[ix]; // get_val assumes the given index is valid } int main() { string s("a value"); cout << s << endl; // prints a value get_val(s, 0) = 'A'; // changes s[0] to A这里函数调用是左值,虽然有点怪,但是语法正确的 cout << s << endl;// prints A value return 0; } 把函数调用放在赋值语句的左侧可能看起来有点奇怪,但其实这没什么特别的。返回值是引用,因此调用是个左值,和其他左值一样它也能出现在赋值运算符的左侧。 如果返回类型是常量引用,我们不能给调用的结果赋值,这一点和我们熟悉的情况是一样的: shorterString ( "hi" , "bye" ) = "X";//错误:返回值是个常量 #### 列表初始化返回值 #### C++11新标准规定,函数可以返回花括号包围的值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化,否则,返回的值由函数的返回类型决定。 举个例子,回忆前文的error\_msg函数,该函数的输入是一组可变数量的string 实参,输出由这些string对象组成的错误信息。在下面的函数中,我们返回一个vector对象,用它存放表示错误信息的string对象: vector<string> process() { // . . . // expected and actual are strings if (expected.empty()) return { }; // return an empty vector else if (expected == actual) return { "functionX", "okay"}; // return list-initialized vector else return { "functionX", expected, actual}; } 第一条return语句返回一个空列表,此时,process 函数返回的vector对象是空的。如果expected不为空,根据expected和actual是否相等,函数返回的vector对象分别用两个或三个元素初始化。 如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间。如果函数返回的是类类型,由类本身定义初始值如何使用。 #### 主函数main的返回值 #### 之前介绍过,如果函数的返回类型不是void,那么它必须返回一个值。 但是这条规则有个**例外**:我们允许main函数没有return语句直接结束。如果控制到达了main函数的结尾处而且没有return语句,编译器将隐式地插入一条返回0的return语句。 第1章介绍的,main函数的返回值可以看做是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量,我们可以使用这两个变量分别表示成功与失败: int main() { if (some_failure) return EXIT_FAILURE; // defined in cstdlib else return EXIT_SUCCESS; // defined in cstdlib } 因为它们是预处理变量,所以既不能在前面加上std::,也不能在using声明中出现。 #### 递归 #### 如果一个函数调用了它自身,不管这种调用是直接的还是间接的,都称该函数为**递归函数**(recursive function)。举个例子,我们可以使用递归函数重新实现求阶乘的功能: //计算val的阶乘,即1 * 2* 3 ...* val int factorial (int val) { if (val > 1) return factorial (val-1)* val; return 1; } 在上面的代码中,我们递归地调用factorial 函数以求得从val中减去1后新数字的阶乘。当val递减到1时,递归终止,返回1。 在递归函数中,一定有某条路径是不包含递归调用的,否则,函数将“永远”递归下去,换句话说,函数将不断地调用它自身直到程序栈空间耗尽为止。我们有时候会说这种函数含有**递归循环**(recursion loop)。在factorial函数中,递归终止的条件是val等于1。 下面的表格显示了当给factorial函数传入参数5时,函数的执行轨迹。 <table> <thead> <tr> <th>调用</th> <th>返回</th> <th>值</th> </tr> </thead> <tbody> <tr> <td>factorial(5)</td> <td>factorial(4) * 5</td> <td>120</td> </tr> <tr> <td>factorial(4)</td> <td>factorial(3) * 4</td> <td>24</td> </tr> <tr> <td>factorial(3)</td> <td>factorial(2) * 3</td> <td>6</td> </tr> <tr> <td>factorial(2)</td> <td>factorial(1) * 2</td> <td>2</td> </tr> <tr> <td>factorial(1)</td> <td>1</td> <td>1</td> </tr> </tbody> </table> **Note**:main函数不能调用它自己。 ### 返回数组指针 ### 因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化这一任务,其中最直接的方法是使用类型别名: typedef int arrT[10];// arrT是一个类型别名,它表示的类型是含有10个整数的数组 using arrT = int [10];// arrT的等价声明 arrT* func(int i) ;// func返回一个指向含有10个整数的数组的指针 其中 arrT是含有10个整数的数组的别名。因为我们无法返回数组,所以将返回类型定义成数组的指针。因此,func函数接受一个int实参,返回一个指向包含10个整数的数组的指针。 #### 声明一个返回数组指针的函数 #### 要想在声明func时不使用类型别名,我们必须牢记被定义的名字后面数组的维度: int arr[10] ;// arr是一个含有10个整数的数组 int *p1[10] ;//p1是一个含有10个指针的数组 int (*p2)[10] = &arr;// p2是一个指针,它指向含有10个整数的数组 和这些声明一样,**如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后**。然而,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下所示: Type (*function(parameter_list))[dimension] 类似于其他数组的声明,Type表示元素的类型,dimension表示数组的大小。(\*function(parameter\_list))两端的括号必须存在,就像我们定义p2时两端必须有括号一样。如果没有这对括号,函数的返回类型将是指针的数组。 举个具体点的例子,下面这个func函数的声明没有使用类型别名: 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)函数。例如,上文中我们定义了几个名为print的函数: 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"); // calls print(const char*) print(j, end(j) - begin(j)); // calls print(const int*, size_t) print(begin(j), end(j)); // calls print(const int*, const int*) 函数的名字仅仅是让编译器知道它调用的是哪个函数,而函数重载可以在一定程度上减轻程序员起名字、记名字的负担。 Note:main函数不能重载。 ### 定义重载函数 ### 有一种典型的数据库应用,需要创建几个不同的函数分别根据名字、电话、账户号码等信息查找记录。函数重载使得我们可以定义一组函数,它们的名字都是lookup,但是查找的依据不同。我们能通过以下形式中的任意一种调用lookup函数: Record lookup(const Account&); // find by Account Record lookup(const Phone&); // find by Phone Record lookup(const Name&); // find by Name Account acct; Phone phone; Record r1 = lookup(acct); // call version that takes an Account Record r2 = lookup(phone); // call version that takes a Phone 其中,虽然我们定义的三个函数各不相同,但它们都有同一个名字。编译器根据实参的类型确定应该调用哪一个函数。 对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。在上面的代码中,虽然每个函数都只接受一个参数,但是参数的类型不同。 **不允许两个函数除了返回类型外其他所有的要素都相同**。假设有两个函数,它们的形参列表一样但是返回类型不同,则第二个函数的声明是错误的: Record lookup(const Account&); bool lookup(const Account&); // error: only the return type is different #### 判断两个形参的类型是否相异 #### 有时候两个形参列表看起来不一样,但实际上是相同的:(似非而是) // each pair declares the same function Record lookup(const Account &acct); Record lookup(const Account&); // parameter names are ignored typedef Phone Telno; Record lookup(const Phone&); Record lookup(const Telno&); // Telno and Phone are the same type 1. 第一对声明中,第一个函数给它的形参起了名字,第二个函数没有。形参的名字仅仅起到帮助记忆的作用,有没有它并不影响形参列表的内容。 2. 第二对声明看起来类型不同,但事实上Telno不是一种新类型,它只是 Phone的别名而已。类型别名为已存在的类型提供另外一个名字,它并不是创建新类型。因此,第二对中两个形参的区别仅在于一个使用类型原来的名字,另一个使用它的别名,从本质上来说它们没什么不同。 #### 重载和const形参 #### 前文介绍,顶层const(第2章)不影响传入函数的对象。一个拥有顶层const的形参**无法**和另一个没有顶层const的形参区分开来: Record lookup(Phone); Record lookup(const Phone); // redeclares Record lookup(Phone) Record lookup(Phone*); Record lookup(Phone* const); // redeclares Record lookup(Phone*) 在这两组函数声明中,每一组的第二个声明和第-一个声明是等价的。 另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的const是**底层的**: // functions taking const and nonconst references or pointers have different parameters // declarations for four independent, overloaded functions Record lookup(Account&); // function that takes a reference to Account Record lookup(const Account&); // new function that takes a const reference Record lookup(Account*); // new function, takes a pointer to Account Record lookup(const Account*); // new function, takes a pointer to const 在上面的例子中,编译器可以通过实参是否是常量来推断应该调用哪个函数。 因为const不能转换成其他类型(第4章“其他隐式类型转换”节内容),所以我们只能把const对象(或指向const的指针)传递给const形参。 相反的,因为非常量可以转换成const,所以上面的4个函数都能作用于**非常量对象**或者指向**非常量对象的指针**。不过,接下来将要介绍的,当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用**非常量版本**的函数。 #### 建议:何时不应该重载函数 #### 尽管函数重载能在一定程度上减轻我们为函数起名字、记名字的负担,但是最好只重载那些确实非常相似的操作。有些情况下,给函数起不同的名字能使得程序更易理解。举个例子,下面是几个负责移动屏幕光标的函数: Screen& moveHome(); screen& moveAbs(int, int); Screen& moveRel(int, int, string direction); 乍看上去,似平可以把这组函数统一命名为move,从而实现函数的重载: Screen& move(); Screen& move(int, int); Screen& move(int, int, string direction); 其实不然,重载之后这些函数失去了名字中本来拥有的信息。尽管这些函数确实都是在移动光标,但是具体移动的方式却各不相同。以moveHome为例,它表示的是移动光标的一种特殊实例。 一般来说,是否重载函数要看哪个更容易理解: //哪种形式更容易理解呢? myscreen.moveHome();//我们认为应该是这一个! myscreen.move(); #### const\_cast和重载 #### const\_cast为第4章内容。 const\_cast只能改变运算对象的底层const。 const char *pc; char *p = const_cast<char*>(pc);// 正确:但是通过p 写值是未定义的行为 对于将常量对象转换成非常量对象的行为,我们一般称其为“**去掉const性质**(cast away the const)”。一旦我们去掉了某个对象的const性质,编译器就**不再阻止**我们对该对象进行写操作了。 回忆上文的shorterString函数: // return a reference to the shorter of two strings const string &shorterString(const string &s1, const string &s2) { return s1.size() <= s2.size() ? s1 : s2; } 这个函数的参数和返回类型都是 const string 的引用。我们可以对两个非常量的string实参调用这个函数,但返回的结果仍然是const string 的引用。 因此我们需要一种新的 shorterString函数,当它的实参不是常量时,得到的结果是一个普通的引用,使用const\_cast可以做到这一点: string &shorterString(string &s1, string &s2) { auto &r = shorterString(const_cast<const string&>(s1), const_cast<const string&>(s2)); return const_cast<string&>(r); } 在这个版本的函数中,首先将它的实参强制转换成对const 的引用,然后调用了shorterString函数的const版本。const版本返回对const string的引用,这个引用事实上绑定在了某个初始的非常量实参上。因此,我们可以再将其转换回一个普通的string&,这显然是安全的。 (MyNote:因为const不能转换成其他类型(第4章“其他隐式类型转换”节内容),如果想修改函数返回的const字符串对象,显然是不行的。) #### 调用重载的函数 #### 定义了一组重载函数后,我们需要以合理的实参调用它们。**函数匹配**( function matching)是指一个过程,在这个过程中我们把函数调用与一组重载函数中的某一个关联起来,函数匹配也叫做**重载确定**(overload resolution)。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。 在很多(可能是大多数)情况下,程序员很容易判断某次调用是否合法,以及当调用合法时应该调用哪个函数。通常,重载集中的函数区别明显,它们要不然是参数的数量不同,要不就是参数类型毫无关系。 此时,确定调用哪个函数比较容易。但是在**另外一些情况下要想选择函数就比较困难了**,比如当两个重载函数参数数量相同且参数类型可以相互转换时(第4章“类型转换”)。我们将在本章“函数匹配”节介绍当函数调用存在类型转换时编译器处理的方法。 现在我们需要掌握的是,当调用重载函数时有三种可能的结果: * 编译器找到一个与实参**最佳匹配**(best match)的函数,并生成调用该函数的代码。 * 找不到任何一个函数与调用的实参匹配,此时编译器发出**无匹配**(no match)的错误信息。 * 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为**二义性调用**(ambiguous call)。 ### 重载与作用域 ### **WARNING**:一般来说,将函数声明置于局部作用域内不是一个明智的选择。但是为了说明作用域和重载的相互关系,我们将暂时违反这一原则而使用局部函数声明。 对于刚接触C++的程序员来说,不太容易理清作用域和重载的关系。其实,重载对作用域的一般性质并没有什么改变:**如果我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同的作用域中无法重载函数名**: string read(); void print(const string &); void print(double); // overloads the print function void fooBar(int ival) { bool read = false; // new scope: hides the outer declaration of read string s = read(); // error: read is a bool variable, not a function // bad practice: usually it's a bad idea to declare functions at local scope void print(int); // new scope: hides previous instances of print print("Value: "); // error: print(const string &) is hidden print(ival); // ok: print(int) is visible print(3.14); // ok: calls print(int); print(double) is hidden } 大多数读者都能理解调用read函数会引发错误。因为当编译器处理调用read的请求时,找到的是定义在局部作用域中的read。这个名字是个布尔变量,而我们显然无法调用一个布尔值,因此该语句非法。 调用print函数的过程非常相似。在fooBar内声明的print(int)隐藏了之前两个print函数,因此只有一个print函数是可用的:该函数以int值作为参数。 当我们调用print函数时,编译器首先寻找对该函数名的声明,找到的是接受 int值的那个局部声明。一旦在当前作用域中找到了所需的名字,编译器就会忽略掉外层作用域中的同名实体。剩下的工作就是检查函数调用是否有效了。 **Note**:在C++语言中,名字查找发生在类型检查之前。 第一个调用传入一个字符串字面值,但是当前作用域内print 函数唯一的声明要求参数是int类型。字符串字面值无法转换成int类型,所以这个调用是错误的。在外层作用域中的print (const string&)函数虽然与本次调用匹配,**但是它已经被隐藏掉了,根本不会被考虑**。 当我们为print函数传入一个double类型的值时,重复上述过程。编译器在当前作用域内发现了print(int)函数,double类型的实参转换成int类型,因此调用是合法的。 假设我们把print(int)和其他print函数声明放在同一个作用域中,则它将成为另一种重载形式。此时,因为编译器能看到所有三个函数,上述调用的处理结果将完全不同: void print(const string &); void print(double); // overloads the print function void print(int); // another overloaded instance void fooBar2(int ival) { print("Value: "); // calls print(const string &) print(ival); // calls print(int) print(3.14); // calls print(double) } ## 特殊用途语言特性 ## ### 默认实参 ### 某些函数有这样一种形参,在函数的很多次调用中它们都被赋予一个相同的值,此时,我们把这个反复出现的值称为函数的**默认实参**(default argument)。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。 例如,我们使用string对象表示窗口的内容。一般情况下,我们希望该窗口的高、宽和背景字符都使用默认值。但是同时我们也应该允许用户为这几个参数自由指定与默认值不同的数值。为了使得窗口函数既能接纳默认值,也能接受用户指定的值,我们把它定义成如下的形式: typedef string::size_type sz; string screen(sz ht = 24, sz wid = 80, char backgrnd = ''); 其中我们为每一个形参都提供了默认实参,默认实参作为形参的初始值出现在形参列表中。我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。 #### 使用默认实参调用函数 #### 如果我们想使用默认实参,只要在调用函数的时候省略该实参就可以了。例如,screen 函数为它的所有形参都提供了默认实参,所以我们可以使用0、1、2或3个实参调用该函数: string window; window = screen(); // equivalent to screen(24,80,' ') window = screen(66);// equivalent to screen(66,80,' ') window = screen(66, 256); // screen(66,256,' ') window = screen(66, 256, '#'); // screen(66,256,'#') 函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。例如,要想覆盖backgrnd的默认值,必须为ht和wid提供实参: window = screen(, , '?'); // error: can omit only trailing arguments window = screen('?'); // calls screen('?',80,' ') 需要注意,第二个调用传递一个字符值,是合法的调用。然而尽管如此,它的实际效果却与书写的意图不符。 该调用之所以合法是因为’?‘是个char,而函数最左侧形参的类型string::size\_type是一种无符号整数类型,所以char类型可以转换成函数最左侧形参的类型。当该调用发生时,**char类型的实参隐式地转换成string::size\_type**,然后作为height的值传递给函数。在我们的机器上,’?'对应的十六进制数是0x3F,也就是十进制数的63,所以该调用把值63传给了形参height。 当设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,**尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面**。 #### 默认实参声明 #### 对于函数的声明来说,通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。假如给定 //表示高度和宽度的形参没有默认值 string screen(sz, sz, char = ' '); 我们不能修改一个已经存在的默认值: string screen(sz, sz, char = '*"); //错误:重复声明 但是可以按照如下形式添加默认实参: string screen(sz = 24, sz = 80, char); //正确:添加默认实参 **Best Practices:通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。** #### 默认实参初始值 #### 局部变量不能作为默认实参。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参: // the declarations of wd, def, and ht must appear outside a function sz wd = 80; char def = ' '; sz ht(); string screen(sz = ht(), sz = wd, char = def); string window = screen(); // calls screen(ht(), 80, ' ') 用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时: void f2() { def = '*'; // changes the value of a default argument sz wd = 100; // hides the outer definition of wd but does not change the default//这里wd是局部变量,上面的wd是全部变量。 window = screen(); // calls screen(ht(), 80, '*') } 我们在函数f2内部改变了def 的值,所以对screen的调用将会传递这个更新过的值。另一方面,虽然我们的函数还声明了一个局部变量用于隐藏外层的 wd,但是该局部变量与传递给screen的默认实参没有任何关系。 ### 内联函数和constexpr函数 ### 上文我们编写了一个小函数shorterString,它的功能是比较两个string 形参的长度并返回长度较小的string的引用。把这种规模较小的操作定义成函数有很多好处,主要包括: * 阅读和理解shorterString函数的调用要比读懂等价的条件表达式容易得多。 * 使用函数可以确保行为的统一,每次相关操作都能保证按照同样的方式进行。 * 如果我们需要修改计算过程,显然修改函数要比先找到等价表达式所有出现的地方再逐一修改更容易。 * 函数可以被其他应用重复利用,省去了程序员重新编写的代价。 然而,使用shorterstring 函数也存在一个潜在的缺点:调用函数一般比求等价表达式的值要慢一些。 在大多数机器上,一次函数调用其实包含着一系列工作: * 调用前要先保存寄存器,并在返回时恢复; * 可能需要拷贝实参; * 程序转向一个新的位置继续执行。 #### 内联函数可避免函数调用的开销 #### 将函数指定为**内联函数**(inline),通常就是将它在每个调用点上“内联地”展开。 假设我们把shorterString函数定义成内联函数,则如下调用 cout<< shorterstring (s1, s2) <<endl; 将在编译过程中展开成类似于下面的形式 cout << (s1.size() < s2.size() ? s1 : s2) << endl; 从而消除了shorterString函数的运行时开销。 在shorterString函数的返回类型前面加上关键字inline,这样就可以将它声明成内联函数了: //内联版本:寻找两个string对象中较短的那个 inline const string &shorterstring(const string &s1,const string &s2){ return s1.size() <= s2.size() ? s1 : s2; } **Note**:内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。 一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器都不支持内联递归函数,而且一个75行的函数也不大可能在调用点内联地展开。 #### constexpr函数 #### **constexpr函数**(constexpr function)是指能用于常量表达式(第2章内容)的函数。定义 constexpr函数的方法与其他函数类似,不过**要遵循几项约定**:函数的返回类型及所有形参的类型都得是字面值类型(第2章内容),而且函数体中必须有且只有一条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函数的返回值并非一个常量: //如果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不是常量表达式 ,const int i = 2;才是 int a2[scale(i)] ;//错误:scale (i)不是常量表达式 如上例所示,当我们给scale函数传入一个形如字面值2的常量表达式时,它的返回类型也是常量表达式。此时,编译器用相应的结果值替换对scale函数的调用。 如果我们用一个非常量表达式调用scale函数,比如int类型的对象i,则返回值是一个非常量表达式。当把 scale函数用在需要常量表达式的上下文中时,由编译器负责检查函数的结果是否符合要求。如果结果恰好不是常量表达式,编译器将发出错误信息。 **Note**:constexpr函数不一定返回常量表达式。 #### 把内联函数和constexpr函数放在头文件内 #### 和其他函数不一样,内联函数和 constexpr函数可以在程序中多次定义。毕竟,编译器要想展开函数仅有函数声明是不够的,还需要函数的定义。不过,对于某个给定的内联函数或者constexpr函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和constexpr函数通常定义在头文件中。 ### 调试帮助 ### C++程序员有时会用到一种类似于头文件保护(第2章有相关论述)的技术,以便有选择地执行调试代码。基本思想是,程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能:assert和 NDEBUG。 #### assert预处理宏 #### assert是一种预处理宏(preprocessor marco)。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件: assert (expr) ; **首先对expr求值,如果表达式为假(即 0),assert输出信息并终止程序的执行。如果表达式为真(即非0),assert什么也不做。** assert宏定义在cassert头文件中。如我们所知,预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无须提供using声明。也就是说,我们应该使用assert而不是std::assert,也不需要为assert提供using声明。 和预处理变量一样,宏名字在程序内必须唯一。含有cassert头文件的程序不能再定义名为assert 的变量、函数或者其他实体。在实际编程过程中,即使我们没有包含cassert头文件,也最好不要为了其他目的使用assert。很多头文件都包含了cassert,这就意味着即使你没有直接包含 cassert,它也很有可能通过其他途径包含在你的程序中。 assert 宏常用于检查“不能发生”的条件。例如,一个对输入文本进行操作的程序可能要求所有给定单词的长度都大于某个阈值。此时,程序可以包含一条如下所示的语句: assert(word.size() > threshold); #### NDEBUG预处理变量 #### assert的行为依赖于一个名为NDEBUG 的预处理变量的状态。如果定义了NDEBUG,则assert 什么也不做。默认状态下没有定义NDEBUG,此时 assert将执行运行时检查。 我们可以使用一个\#define语句定义NDEBUG,从而**关闭调试状态**。 同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量: $ cc -D NDEBUG main.c # use /D with the Microsoft compiler 这条命令的作用等价于在main.c文件的一开始写\#define NDEBUG。 定义NDEBUG能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert应该仅用于验证那些确实不可能发生的事情。**我们可以把assert当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查**。 除了用于assert外,也可以使用NDEBUG编写自己的条件调试代码。如果NDEBUG未定义,将执行\#ifndef和\#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略掉: void print(const int ia[], size_t size) { #ifndef NDEBUG // _ _func_ _ is a local static defined by the compiler that holds the function's name cerr << __func__ << ": array size is " << size << endl; #endif // ... 在这段代码中,我们使用变量\_\_func\_\_输出当前调试的函数的名字。编译器为每个函数都定义了\_\_func\_\_,它是const char的一个静态数组,用于存放函数的名字。 除了C++编译器定义的\_\_func\_\_之外,预处理器还定义了另外4个对于程序调试很有用的名字: * \_\_FILE\_\_存放文件名的字符串字面值。 * \_\_LINE\_\_存放当前行号的整型字面值。 * \_\_TIMEB\_\_存放文件编译时间的字符串字面值。 * \_\_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 ## 函数匹配 ## ### 例子:调用应该选用哪个重载函数 ### 在大多数情况下,我们容易确定某次**调用应该选用哪个重载函数**。然而,当几个重载函数的形参数量相等以及某些形参的类型可以由其他类型转换得来时,这项工作就不那么容易了。以下面这组函数及其调用为例: void f(); void f (int) ; void f (int, int) ; void f (double, double = 3.14); f (5.6);//调用void f (double, double) #### 确定候选函数和可行函数 #### 函数匹配的**第一步**是选定本次调用对应的重载函数集,集合中的函数称为**候选函数**(candidate function)。候选函数具备两个特征: 1. 与被调用的函数同名, 2. 其声明在调用点可见。 在这个例子中,有4个名为f的候选函数。 **第二步**考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为**可行函数**(viable function)。可行函数也有两个特征: 1. 其形参数量与本次调用提供的实参数量相等, 2. 每个实参的类型与对应的**形参类型相同**,或者能转换成形参的类型。 我们能根据实参的数量从候选函数中排除掉两个。不使用形参的函数和使用两个int形参的函数显然都不适合本次调用,这是因为我们的调用只提供了一个实参,而它们分别有0个和两个形参。 使用一个 int形参的函数和使用两个double形参的函数是可行的,它们都能用一个实参调用。其中最后那个函数本应该接受两个double值,但是因为它含有一个默认实参,所以只用一个实参也能调用它。 **Note**:如果函数含有默认实参,则我们在调用该函数时传入的实参数量可能少于它实际使用的实参数量。 在使用实参数量初步判别了候选函数后,接下来考察实参的类型是否与形参匹配。和一般的函数调用类似,实参与形参匹配的含义可能是它们具有相同的类型,也可能是实参类型和形参类型满足转换规则。在上面的例子中,剩下的两个函数都是可行的: * f(int)是可行的,因为实参类型double 能转换成形参类型int。 * f(double,double)是可行的,因为它的第二个形参提供了默认值,而第一个形参的类型正好是 double,与函数使用的实参类型完全一致。 **Note**:如果没找到可行函数,编译器将报告无匹配函数的错误。 #### 寻找最佳匹配(如果有的话) #### 函数匹配的**第三步**是从可行函数中选择与本次调用最匹配的函数。在这一过程中,逐一检查函数调用提供的实参,寻找形参类型与实参类型最匹配的那个可行函数。下一节将介绍“最匹配”的细节,它的基本思想是,**实参类型与形参类型越接近,它们匹配得越好**。 在我们的例子中,调用只提供了一个(显式的)实参,它的类型是double。如果调用f(int) ,实参将不得不从double转换成int。另一个可行函数f(double,double)则与实参精确匹配。**精确匹配比需要类型转换的匹配更好**,因此,编译器把f(5.6)解析成对含有两个double形参的函数的调用,并使用默认值填补我们未提供的第二个实参。 #### 含有多个形参的函数匹配 #### 当实参的数量有两个或更多时,函数匹配就比较复杂了。对于前面那些名为f的函数,我们来分析如下的调用会发生什么情况: (42,2.56); 选择可行函数的方法和只有一个实参时一样,编译器选择那些形参数量满足要求且实参类型和形参类型能够匹配的函数。此例中,可行函数包括 f(int,int)和 f(double,double)。 接下来,编译器依次检查每个实参以确定哪个函数是最佳匹配。如果有且只有一个函数满足下列条件,则匹配成功: * 该函数每个实参的匹配都不劣于其他可行函数需要的匹配。The match for each argument is no worse than the match required by any other viable function * 至少有一个实参的匹配优于其他可行函数提供的匹配。There is at least one argument for which the match is better than the match provided by any other viable function 如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用的信息。 在上面的调用中,只考虑第一个实参时我们发现函数f(int,int)能精确匹配;要想匹配第二个函数,int类型的实参必须转换成double类型。显然需要内置类型转换的匹配劣于精确匹配,因此仅就第一个实参来说,f(int, int)比 f(double,double)更好。 接着考虑第二个实参2.56,此时f(double,double)是精确匹配;要想调用f(int,int)必须将2.56从double类型转换成int类型。因此仅就第二个实参来说,f(double,double)更好。 (MyNote:公说公有理婆说婆有理。) **编译器最终将因为这个调用具有二义性而拒绝其请求**:因为每个可行函数各自在一个实参上实现了更好的匹配,从整体上无法判断孰优孰劣。看起来我们似乎可以通过强制类型转换其中的一个实参来实现函数的匹配,但是在设计良好的系统中,不应该对实参进行强制类型转换。 **Best Practices**:调用重载函数时应**尽量避免强制类型转换**。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。 ### 实参类型转换 ### 为了确定最佳匹配,编译器将实参类型到形参类型的转换划分成几个等级,具体排序如下所示: 1. 精确匹配,包括以下情况: * 实参类型和形参类型相同。 * 实参从数组类型或函数类型转换成对应的指针类型。 * 向实参添加顶层const或者从实参中删除顶层const。 2. 通过const转换实现的匹配。 3. 通过类型提升实现的匹配。 4. 通过算术类型转换或指针转换实现的匹配。 5. 通过类类型转换实现的匹配(第14章内容)。 (2~4项为第4章“类型转换”内容) #### 需要类型提升和算术类型转换的匹配 #### **WARNING**:内置类型的提升和转换可能在函数匹配时产生意想不到的结果,但幸运的是,在设计良好的系统中函数很少会含有与下面例子类似的形参。 分析函数调用前,我们应该知道小整型一般都会提升到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);//错误:二义性调用 字面值3.14的类型是double,它既能转换成long也能转换成float。因为存在两种可能的算数类型转换,所以该调用具有二义性。 #### 函数匹配和const实参 #### 如果重载函数的区别在于它们的引用类型的形参是否引用了const,或者指针类型的形参是否指向const,则当调用发生时编译器通过实参是否是常量来决定选择哪个函数: Record lookup (Account&); //函数的参数是Account的引用 Record lookup(const Account& ); //函数的参数是一个常量引用 const Account a; Account b; lookup(a); //调用lookup (const Account&) lookup(b); //调用lookup(Account&) 在第一个调用中,我们传入的是const对象a。因为不能把普通引用绑定到const对象上,所以此例中唯一可行的函数是以常量引用作为形参的那个函数,并且调用该函数与实参a精确匹配。 在第二个调用中,我们传入的是非常量对象b。对于这个调用来说,两个函数都是可行的,因为我们既可以使用b初始化常量引用也可以用它初始化非常量引用。然而,用非常量对象初始化常量引用需要类型转换,接受非常量形参的版本则与b精确匹配。因此,应该选用非常量版本的函数。 指针类型的形参也类似。如果两个函数的唯一区别是它的指针形参指向常量或非常量,则编译器能通过实参是否是常量决定选用哪个函数: * 如果实参是指向常量的指针,调用形参是const\*的函数; * 如果实参是指向非常量的指针,调用形参是普通指针的函数。 ## 函数指针 ## 函数指针指向的是函数而非对象。和其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关。例如: //比较两个string对象的长度 bool lengthCompare (const string &, const string &); 该函数的类型是bool (const string&,const string&)。要想声明一个可以指向该函数的指针,只需要用指针替换函数名即可: //pf指向一个函数,该函数的参数是两个const string的引用,返回值是bool类型 bool (*pf)(const string &, const string &);//未初始化 * 从我们声明的名字开始观察,pf前面有个\*,因此pf是指针; * 右侧是形参列表,表示pf指向的是函数; * 再观察左侧,发现函数的返回类型是布尔值。 因此,pf 就是一个指向函数的指针,其中该函数的参数是两个const string 的引用,返回值是bool类型。 **Note**:\*pf**两端的括号必不可少**。如果不写这对括号,则pf是一个返回值为bool指针的函数: //声明一个名为 pf的函数,该函数返回bool* bool *pf (const string &, const string &); ### 使用函数指针 ### 当我们把函数名作为一个值使用时,该函数自动地转换成指针。例如,按照如下形式我们可以将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的整型常量表达式,表示该指针没有指向任何一个函数: 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(第2章内容)能让我们**简化**使用了函数指针的代码: // 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;//等价的类型 我们使用typedef定义自己的类型。Func和Func2是函数类型,而FuncP和 FuncP2是指针类型。需要注意的是,decltype返回函数类型,此时不会将函数类型自动转换成指针类型。因为decltype的结果是函数类型,所以只有在结果前面加上\*才能得到指针。可以使用如下的形式重新声明useBigger: // useBigger的等价声明,其中使用了类型别名 void useBigger(const string&, const string&, Func); void useBigger(const string&, const string&, FuncP2); 这两个声明语句声明的是同一个函数,在第一条语句中,编译器自动地将Func表示的函数类型转换成指针。 #### 返回指向函数的指针 #### 和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。与往常一样,要想声明一个返回函数指针的函数,最简单的办法是使用类型别名: using F = int(int*, int); //F是函数类型,不是指针 using PF = int(*)(int*, int); //PF是指针类型 其中我们使用类型别名将F定义成函数类型,将PF定义成指向函数类型的指针。必须时刻注意的是,和函数类型的形参不一样,返回类型**不会自动**地转换成指针。我们必须显式地将返回类型指定为指针: PF f1(int); //正确:PF是指向函数的指针,f1返回指向函数的指针 F f1(int); //错误:F是函数类型,f1不能返回一个函数 F *f1(int); //正确:显式地指定返回类型是指向函数的指针 当然,我们也能用下面的形式直接声明f1: int (*f1(int))(int*, int) ; 按照**由内向外**的顺序阅读这条声明语句: 1. 我们看到f1有形参列表,所以f1是个函数; 2. f1前面有\*,所以f1返回一个指针; 3. 进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是int。 出于完整性的考虑,有必要提醒读者我们还可以使用**尾置返回类型**的方式声明一个返回函数指针的函数: auto f1(int) -> int(*) (int* , int) ; #### 将auto和decltype用于函数指针类型 #### 如果我们明确知道返回的函数是哪一个,就能使用decltype简化书写函数指针返回类型的过程。 例如假定有两个函数,它们的返回类型都是string::size\_type,并且各有两个const strings类型的形参,此时我们可以编写第三个函数,它接受一个string类型的参数,返回一个指针,该指针指向前两个函数中的一个: string::size_type sumLength(const string&, const string&); string::size_type largerLength(const string&, const string&); //根据其形参的取值,getFcn函数返回指向sumLength或者largerLength的指针 decltype(sumLength) *getFcn (const string &); 声明getFcn唯一需要注意的地方是,牢记当我们将decltype作用于某个函数时,它返回函数类型而非指针类型。因此,我们显式地加上\*以表明我们需要返回指针,而非函数本身。
相关 《C++ Primer 5th》笔记(9 / 19):顺序容器 文章目录 顺序容器概述 确定使用哪种顺序容器 容器库概览 迭代器 迭代器范围 淩亂°似流年/ 2023年01月21日 02:21/ 0 赞/ 27 阅读
相关 《C++ Primer 5th》笔记(8 / 19):IO库 文章目录 IO类 IO类型间的关系 IO对象无拷贝或赋值 条件状态 素颜马尾好姑娘i/ 2023年01月19日 08:19/ 0 赞/ 31 阅读
相关 《C++ Primer 5th》笔记(7 / 19):类 文章目录 定义抽象数据类型 设计Sales\_data类 关键概念:不同的编程角色 缺乏、安全感/ 2023年01月19日 04:47/ 0 赞/ 28 阅读
相关 《C++ Primer 5th》笔记(5 / 19):语句 文章目录 简单语句 空语句 别漏写分号,也别多写分号 复合语句(块) 语句作用域 墨蓝/ 2023年01月18日 04:28/ 0 赞/ 140 阅读
相关 《C++ Primer 5th》笔记(4 / 19):表达式 文章目录 基础 基本概念 组合运算符和运算对象 运算对象转换 缺乏、安全感/ 2023年01月17日 15:00/ 0 赞/ 169 阅读
相关 《C++ Primer 5th》笔记(1 / 19):C++基础 文章目录 编写一个简单的C++程序 编译、运行程序 初识输入输出 注释简介 控制流 爱被打了一巴掌/ 2023年01月14日 15:56/ 0 赞/ 179 阅读
相关 《C++ Primer 5th》笔记(12 / 19):动态内存 文章目录 动态内存与智能指针 shared\_ptr类 make\_shared函数 痛定思痛。/ 2022年10月16日 07:33/ 0 赞/ 230 阅读
相关 《C++ Primer 5th》笔记(11 / 19):关联容器 文章目录 使用关联容器 使用map 使用set 关联容器概述 定义关联容器 一时失言乱红尘/ 2022年10月14日 05:41/ 0 赞/ 385 阅读
相关 c++ primer 6th 函数 一、函数基础 1. 即使两个形参的类型一样,也必须把两个类型都写出来。 2. 局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在 电玩女神/ 2021年11月01日 13:34/ 0 赞/ 300 阅读
还没有评论,来说两句吧...