Chapter 6 Functions
6.1 函数基础
6.1.1 局部对象
C++ 中,名字具有作用域,对象具有生命周期。
- 名字的作用域是程序文本的一部分,名字在其中可见。
- 对象的生命周期是程序执行过程中该对象存在的一段时间。
形参和函数体内部定义的变量统称为局部变量(local variable)。它们对函数而言是 “局部” 的,仅在函数的作用域内可见,同时局部变量还会隐藏(hide)在外层作用域中同名的其他所有声明中。
在所有函数体之外定义的对象存在于程序的整个执行过程中。此类对象在程序启动时被创建,直到程序结束才会销毁。局部变量的生命周期依赖于定义的方式。
自动对象
我们把只存在于块执行期间的对象称为自动对象(automatic object)。当块的执行过程结束后,块中创建的自动对象的值就变成未定义的了。
形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也被销毁。
局部静态对象
某些时候,有必要令局部变量的生命周期贯穿函数调用之后及以后的时间。可以将局部变量定义成 static 类型从而获得这样的对象。局部静态对象(local static object)在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,再次期间即使对象所在的函数结束后执行也不会对它有什么影响。
6.1.2 函数声明
函数的声明和函数的定义非常类似,唯一的区别时函数声明无须函数体,用一个分号代替即可。
在头文件中进行函数声明
建议在头文件中声明,在源文件中定义。与之类似,函数也应该在头文件中声明而非在源文件中定义。
6.1.3 分离式编译
编译和链接多个源文件
举个例子,假设 fact 函数的定义位于一个名为 fact.cc 的文件中,它的声明位于名为 Chapter6.h 的头文件中。fact.cc 应该包含 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
6.2 参数传递
6.2.1 传值函数
当初始化一个非引用类型的变量时,初始值被拷贝给变量。此时,对变量的改动不会影响初始值。传值参数的机理完全一样,函数对形参做的所哟操作都不会影响实参。
指针形参
当执行指针拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。在 C++ 中,建议使用引用类型的形参来代替指针。
6.2.2 传引用参数
对引用的操作实际上是作用在引用所引的对象上,引用形参的行为与之类似。通过使用引用形参,允许函数改变一个或多个实参的值。
使用引用避免拷贝
拷贝大的类类型对象或者容器对象比较低微,甚至有的类类型(包括 IO 类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型的对象。
如果函数无须改变引用参数的值,最好将其声明为常量引用。
使用引用形参返回额外信息
一个函数只能返回一个值,然而有时函数需要同时返回多个值,引用形参为我们一次返回多个结果提供了有效途径。例如,我们定义一个名为 find_char 的函数,它返回在 string 对象中某个指定字符第一次出现的位置。同时,我们也希望能返回该字符出现的字符数。
该如何定义函数使得它能够既返回出现次数呢?一种方法时定义一个新的数据类型,让它包含位置和数量两个成员。另一种方法,我们可以给函数传入一个额外的引用实参,令其保存字符出现的次数:
// 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
}
6.2.3 const 形参和实参
当形参是 const 时,必须要注意 2.4.3 节(p57)关于顶层 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
*p = 0; // ok: changes through p are allowed; i is now 0
和其他初始化过程一样,当使用实参初始化形参时会忽略掉顶层 const。换句话说,形参的顶层 const 被忽略掉了。当形参有顶层 const 时,传给它常量对象或者非常量对象都是可以的。调用 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)
指针或引用形参与 const
形参的初始化方式和变量的初始化方式是一样的,我们可以用非常量初始化一个底层 const 对象,但是反过来不行;同时理解一盒普通的引用必须用同类型的对象初始化。
int i = 42;
const int *cp = &i; // ok: but cp can't change i (§ 2.4.2 (p. 62))
const int &r = i; // ok: but r can't change i (§ 2.4.1 (p. 61))
const int &r2 = 42; // ok: (§ 2.4.1 (p. 61))
int *p = cp; // error: types of p and cp don't match (§ 2.4.2 (p. 62))
int &r3 = r; // error: types of r3 and r don't match (§ 2.4.1 (p. 61))
int &r4 = 42; // error: can't initialize a plain reference from a literal (§ 2.3.1 (p.50))
将同样的初始化规则应用到参数传递上可得如下形式:
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(6.2.1 p188)只能使用 int*。
尽量使用常量引用
把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做给函数的调用者一种错误,即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大地限制函数所能接受的实参类型。就像刚刚看到的,我们不能把 const 对象、字面值或者需要类型转换的对象传递给普通的引用形参。
6.2.4 数组形参
数组的两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组以及使用数组时通常会将其转换成指针。因为不能拷贝数组,所以我们无法以按值传递的方式使用数组参数。因为数组会被转换成指针,所以当我进门为函数传递一个数组时,实际上传递的时指向数组首元素的指针。
尽管不能以按值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:
// 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)
如果我们传给函数的是一个指针,则实参自动地i转换成指向首元素的指针,数组的大小对函数的调用没有影响。
管理指针形参有三种常见的技术。
使用标记指定数组长度
这种方法要求数组本身包含一个结束标记,使用这种方法的典型事例是 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
}
为了调用这个函数,我们需要传入两个指针:一个指向输出的首元素,另一个指向尾元素的下一位置:
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, see § 3.5.3 (p.118)
显示传递一个表示数组大小的形参
第三种方法时专门定义一个表示数组大小的形参,在过去的程序中常常使用这种方法。使用该方法,重写 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;
}
}
数组形参和 const
当函数不需要对数组执行写操作的时候,数组形参应该是指向 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;
}
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
传递多维数组
和所有数组一样,当将多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略:
// matrix points to the first element in an array whose elements are arrays of ten ints
void print(int (*matrix)[10], int rowSize) { /* . . . */ }
// equivalent definition
void print(int matrix[][10], int rowSize) { /* . . . */ }
也可以使用数组的语法定义函数,此时编译器会一如既往地忽略第一个维度,所以最好不要把它包括在形参列表了。matrix 的声明看似是一个二维数组,实际上形参是指向含有 10 个整数的指针。
6.2.5 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"; // or argv[0] might point to an empty string
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;
当使用 argv 中的实例时,一定要记得可选的实参从 argv[1] 开始;argv[0] 保存程序的名字,而非用户输入。
6.2.6 含有可变形参的函数
为了编写能处理不同数量的函数,C++11 新标准提供了两种主要的方法:如果所有的实参类型相同,可以传递一个名为 initializer_list 的标准库类型;如果实参的类型不同,我们可以编写一种特殊的函数,也就是可变参数模板。C++ 还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。
initializer_list 形参
如果函数的实参数量位置但是所有的实参类型相同,我们可以使用 initializer_list 类型的实参。 initializer_list 是一种标准库类型,用于表示某种 特定类型的值的数组。 initializer_list 类型定义在同名的头文件中,它提供的操作如表 6.1 所示:
和 vector 一样,initializer_list 也是一种模板类型,定义 initializer_list 对象时,必须说明列表中所含元素的类型;和 vector 不一样的是,initializer_list 对象中的元素永远是常量值,我们无法改变 initializer_list 对象中元素的值。
省略符形参
省略符形参是为了便于 C++ 程序访问某些特殊的 C 代码而设置的,这些代码使用了名为 varags 的 C 标准库功能。省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:
void foo(parm_list, ...);
void foo(...);
6.3 返回类型和 return 语句
return 语句终止当前正在执行的函数并将控制权返回到调用该函数的地方。return 语句有两种形式:
return;
return expression;
6.3.1 无返回值函数
没有返回值的 return 语句只能在返回类型是 void 的函数中。返回 void 的函数不要求非得有 return 语句,因为在这类函数的最后一句后面会隐式地执行 return。
通常情况下,void 函数如果想在它的中间位置提前退出,可以使用 return 语句。一个返回类型是 void 的函数也能使用 return 语句的第二种形式,不过此时 return 语句的 expression 必须是另一个返回 void 的函数。强行令 void 函数返回其他类型的表达式将产生编译错误。
6.3.2 有返回值函数
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, see § 4.7 (p. 151)
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
}
在含有 return 语句的循环后面应该也有一条 return 语句,如果没有的话该程序就是错误的。
值是如何被返回的
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
不要返回局部对象的引用或指针
函数完成后,它所占用的存储空间也随之被释放掉,因此,函数终止意味着局部变量的引用将指向不再有效的内存区域:
// 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 语句都将返回未定义的值。对第一条 return 来说,显然它返回的是局部对象的引用。在第二条 return 语句中,字符串字面值转换成一个局部临时 string 对象,对于 manip 来说,该对象和 ret 一样都是局部的。当函数结束时临时对象占用的空间也就随之释放掉了,所以两条 return 语句都指向了不再可用的内存空间。
如前所述,返回局部对象的引是错误的;同样,返回局部对象的指针也是错误的。一旦函数完成,局部对象被释放,指针将会指向一个不存在的对象。
引用返回左值
函数的返回类型绝地给函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型得到右值。可以像使用其他左值那样来使用返回引用的函数的调用,特别是,我们能为返回类型是非常量引用的函数的结果赋值:
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;
}
主函数 main 的返回值
如果函数的返回类型不是 void,那么它必须返回一个值。但是我们允许 main 函数没有 return 语句直接结束。如果控制达到了 main 函数的结尾处而没有 return 语句,编译器将隐式地插入一条返回 0 的 return 语句。
main 函数的返回值可以看作是状态指示器。返回 0 表示执行成功,返回其他值表示执行失败,其中非 0 值的具体含义依机器而定。为了使返回值与机器无关,cstdlib 头文件定义了两个预处理变量,我们可以使用两个变量表示成功和失败:
int main()
{
if (some_failure)
return EXIT_FAILURE; // defined in cstdlib
else
return EXIT_SUCCESS; // defined in cstdlib
}
因为它们是预处理变量,所以既不能在前面加上 std::,也不能在 using 声明中出现。
6.3.3 返回数组和指针
因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化成这一任务,其中最直接的方法是适用类型别名:
typedef int arrT[10]; // arrT is a synonym for the type array of ten ints
using arrT = int[10]; // equivalent declaration of arrT; see § 2.5.1 (p. 68)
arrT* func(int i); // func returns a pointer to an array of five ints
其中 arrT 是含有 10 个整数的数组的别名。因为我们无法返回数组,所以将返回类型定义成数组的指针。因此,func 函数接受一个 int 实参,返回一个指向 10 个整数的数组的指针。
声明一个返回数组指针的函数
要想在声明 func 时不使用类型别名,我们必须牢记被定义的名字后面数组的维度:
int arr[10]; // arr is an array of ten ints
int *p1[10]; // p1 is an array of ten pointers
int (*p2)[10] = &arr; // p2 points to an array of ten ints
和这些生命一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。然而,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下所示:
Type (*function(parameter_list))[dimension];
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 新标准中可以使用尾置返回类型(trailing return type)。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用。尾置返回类型跟在形参列表后面并以一个 -> 符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们本应该出现返回类型的地方放置一个 auto:
// fcn takes an int argument and returns a pointer to an array of ten ints
auto func(int i) -> int(*)[10];
使用 decltype
如果我们知道函数返回的指针将指向哪个数组,就可以使用 decltype 关键字声明返回类型。例如,下面的函数返回一个指针,该指针根据参数 i 的不同指向两个已知数组中的某一个:
int odd[] = {1,3,5,7,9};
int even[] = {0,2,4,6,8};
// returns a pointer to an array of five int elements
decltype(odd) *arrPtr(int i)
{
return (i % 2) ? &odd : &even; // returns a pointer to the array
}
arrPtr 使用关键字 decltype 表示它的返回类型是个指针并且该指针所指的对象与 odd 一致。因为 odd 是数组,所以 arrPtr 返回一个指向返回含有 5 个整数的数组的指针。有一个地方需要注意:decltype 并不负责把数组类型转换成对应的指针,所以 decltype 的结果是个数组,要想表示 arrPtr 返回指针还必须在函数声明时加一个 * 符号。
6.4 函数重载
重载和 const 形参
顶层 const 不影响传入函数的对象。一个拥有顶层 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
最好只重载那些确实非常相似的操作。
6.5 特殊用途语言特性
6.5.1 默认实参
在函数的很多次调用中,某种形参都被赋予一个相同的值,此时,我们把这个反复出现的值称为默认实参(default argument)。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。
当设计含有默认实参的函数时,其中一项任务就是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。
6.5.2 内联函数和 constexpr 函数
内联函数可避免函数调用的开销
在函数的返回类型前面加上关键字 inline,这样就可以将它声明成内联函数了。一般来说,内联机制用于优化规模较小、流程直接、频繁调用的函数。很多编译器不支持内联递归函数,而且一个 75 行的函数也不大可能在调用点内联地展开。
constexpr 函数
constexpr 函数是值能够用于常量表达式的函数。定义 constexpr 函数的方法与其他函数的方法类似,不过要遵循几项规定:函数的返回类型及所有形参的类型都得是字面值类型,而且函数体中必须且只有一条 return 语句:
constexpr int new_sz() { return 42; }
constexpr int foo = new_sz(); // ok: foo is a constant expression
执行该初始化任务时,编译器把对 constexpr 函数的调用替换成其结果值。为了能在编译过程中随时展开,constexpr 函数被隐式地指定为内联函数。
constexpr 函数体内也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如,constexpr 函数中可以有空语句、类型别名及 using 声明。
我们允许 constexpr 函数的返回值并非一个常量。
// scale(arg) is a constant expression if arg is a constant expression
constexpr size_t scale(size_t cnt) { return new_sz() * cnt; }
当 scale 的实参是常量是常量表达式时,它的返回值也时常量表达式;反之则不然:
nt arr[scale(2)]; // ok: scale(2) is a constant expression
int i = 2; // i is not a constant expression
int a2[scale(i)]; // error: scale(i) is not a constant expression
把内联函数和 constexpr 放在头文件内
和其他函数不一样,内联函数和 constexpr 函数可以在程序中多次定义。毕竟,编译器要想展开函数仅有函数声明式不够的,还需要函数的定义。不过,对于某个给定的内联函数或者 constexpr 函数来说,它的多个定义必须完全一致。基于这个原因,内联函数和 constexpr 函数通常定义在头文件中。
6.5.3 调试帮助
头文件保护技术:程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽调试代码。这种方式用到两项预处理功能:assert 和 NDEBUG。
assert 预处理宏
assert 是一种预处理宏。所谓预处理宏其实是一个预处理变量,它的行为类似于内联函数,assert 宏使用一个表达式作为它的条件:
assert(expr);
首先对 expr 求值,如果表达式为假,assert 输出信息并终止程序的执行。如果表达式为真,则什么也不做。
assert 宏定义在 cassert 头文件中。如我们所知,预处理名字由处理器而非编译器管理,因此我们无须提供 using 声明。也就是说,我们应该使用 assert 而不是 std::assert,也不需要为 assert 提供 using 声明。
和预处理变量一样,宏名字在程序内必须唯一。含有 cassert 头文件的程序不能再定义名为 assert 的变量、函数或者其他实体。在实际编程过程中,即使我们没有包含 cassert 头文件,也最好不要为了其他目的使用 assert。很多头文件都包含了 assert,即头文件 cassert 可能通过其他途径被包含在程序中。
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
// ...
6.6 函数匹配
确定候选函数和可行函数
函数匹配的第一步时选定本次调用函数对应的重载函数集,集合中的函数称为候选函数(candidate function)。候选函数具有两个特征:一是与被调用的函数同名,二时其声明在调用点可见。
第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数(variable function)。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。
在使用实参数量初步判别了候选函数后,接下来考察实参的类型是否与形参匹配。和一般的函数调用类似,实参类型和形参类型满足转换规则。
如果没找到可行函数,编译器将报告无匹配函数的错误。
调用重载函数应尽量避免强制类型转换。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
6.6.1 实参类型转换
为了确定最佳匹配,编译器将实参类型的转换分成几个等级,具体排序如下所示:
- 精确匹配,包括以下情况:
- 实参类型和形参类型相同。
- 实参从数组类型或函数类型转换成对应的指针类型。
- 向实参添加顶层 const 或者从实参中删除顶层 const。
- 通过 const 转换实现的匹配。
- 通过类型提升的匹配。
- 通过算术类型转换实现的匹配。
- 通过类类型转换实现的匹配。
需要类型提升和算术类型转换的匹配
分析函数调用前,应该知道小整形一般都会提升到 int 类型或更大的整数类型。假设有两个函数,一个接受 int、另一个接受 short,则只有当调用提供的是 short 类型的值时才会选择 short 版本的函数。有时候,即便实参是一个很小的整数值,也会直接把它提升成 int 类型;此时使用 short 版本反而会导致类型转换:
void ff(int);
void ff(short);
ff('a'); // char promotes to int; calls f(int)
所有算术转换的类型都一样。例如,从 int 向 unsigned int 的转换并不比从 int 向 double 的转换级别高。举个具体点的例子。考虑
void manip(long);
void manip(float);
manip(3.14); // error: ambiguous call
字面值 3.14 的类型时 double,它既能转换成 long 也能转换成 float。因为两种存在可能性的算术类型转换,所以该调用具有二义性。
6.7 函数指针
函数指向的是指针而非对象。和其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关:
// compares lengths of two strings
bool lengthCompare(const string &, const string &);
要想声明一个可以指向该函数的指针,只需要指针替换函数名即可:
// pf points to a function returning bool that takes two const string references
bool (*pf)(const string &, const string &); // uninitialized
使用函数指针
把函数名作为一个值使用时,该函数自动地转换成指针:
pf = lengthCompare; // pf now points to the function named lengthCompare
pf = &lengthCompare; // equivalent assignment: address-of operator is optional
此外,还能直接使用指向函数的指针调用该函数,无须提前解引用指针:
bool b1 = pf("hello", "goodbye"); // calls lengthCompare
bool b2 = (*pf)("hello", "goodbye"); // equivalent call
bool b3 = lengthCompare("hello", "goodbye"); // equivalent call
在指向不同函数类型的指针不存在转换规则。但是和往常一样,我们可以为函数指针赋一个 nullptr 或者值为 0 的整形常量表达式,表示该指针没有指向任何一个函数:
string::size_type sumLength(const string&, const string&);
bool cstringCompare(const char*, const char*);
pf = 0; // ok: pf points to no function
pf = sumLength; // error: return type differs
pf = cstringCompare; // error: parameter types differ
pf = lengthCompare; // ok: function and pointer types match exactly
当使用重载函数时,上下文必须清晰地界定到底应该选用哪个函数。
函数指针形参
和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。此时,形参看起来时函数类型,实际上却时被当成指针使用:
// third parameter is a function type and is automatically treated as a pointer to function
void useBigger(const string &s1, const string &s2, bool pf(const string &, const string &));
// equivalent declaration: explicitly define the parameter as a pointer to function
void useBigger(const string &s1, const string &s2, bool (*pf)(const string &, const string &));
直接把函数作为实参使用,此时它会自动转换成指针:
// automatically converts the function lengthCompare to a pointer to function
useBigger(s1, s2, lengthCompare);
正如 useBigger 的声明语句所示,直接使用函数指针类型显得冗长而烦琐。类型别名和 decltype 能让我们简化使用了函数指针的代码:
// Func and Func2 have function type
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2; // equivalent type
// FuncP and FuncP2 have pointer to function type
typedef bool(*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2; // equivalent type
Func 和 Func2 是函数类型,而 FuncP 和 FuncP2 是指针类型。需要注意的是,decltype 返回函数类型,此时不会讲函数类型自动转换成指针类型。因为 decltype 的结果是函数类型,所以只有在结果前面加上 * 才能得到指针。可以使用如下的形式重新声明 useBigger:
// equivalent declarations of useBigger using type aliases
void useBigger(const string&, const string&, Func);
void useBigger(const string&, const string&, FuncP2);
返回指向函数的指针
和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。与往常一样,要想声明一个返回函数指针的函数,最简单的方法是使用类型别名:
using F = int(int*, int); // F is a function type, not a pointer
using PF = int(*)(int*, int); // PF is a pointer type
其中我们使用类型别名将 F 定义成函数类型,将 PF 定义成指向函数类型的指针。必须注意的是,和函数类型不一样,返回类型不会自动地转换成指针。我们必须显示地将返回类型定义成指针:
PF f1(int); // ok: PF is a pointer to function; f1 returns a pointer to function
F f1(int); // error: F is a function type; f1 can't return a function
F *f1(int); // ok: explicitly specify that the return type is a pointer to function