第六章 函数
函数是一个命名了的代码块,我们通过调用函数执行相应的代码,函数可以有0个或多个参数,而且(通常会产生一个结果)。可以重载函数,也就是说,同一个名字可以对应几个不同的函数。
6.1 函数基础
一个典型的函数(function)定义包括一下部分:返回类型(return type)、函数名字、由0个或多个形参(parameter)组成的列表以及函数体。
函数的形参列表
函数的形参列表可以为空,但不能省略。要想定义一个不带形参的函数,最常用的办法是写一个空的形参列表。不过为了与C语言兼容,也可以使用关键字void表示函数没有形参。
函数返回类型
大多数类型都是能用作函数的返回类型。一种特殊的返回类型是void,它表示函数 不返回任何值。函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。
6.1.1 局部对象
形参和函数体内部定义的变量统称为局部变量(local variable)。它们对函数而言是“局部”的,仅在函数的作用域内可见,同时局部变量还会隐藏(hide)在外层作用域中同名的其他所有声明中。
自动对象
对于普通局部变量对应的对象来说,当函数的控制路径经过变量定义的语句时创建该对象,当到达定义所在的块末尾时销毁它。我们把只存在于块执行期间的对象称为自动对象(automatic object)。当块的执行结束后,块中创建的自动对象的值就变成了未定义的了。
局部静态对象
某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static类型从而获得这样的对象。局部静态对象在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有什么影响。
size_t count_calls(){
static size_t ctr = 0; // 调用结束后,这个值仍然有效
return ++ ctr;
}
int main(){
for(size_t i = 0; i != 10; ++ i){
cout << count_calls() << endl;
}
return 0;
}
// 输出: 1 2 3 4 5 6 7 8 9 10
控制流第一次经过ctr的定义之前,ctr被创建并初始化为0。每次调用将ctr加1并返回新值。
size_t count_calls(size_t idx){
if(idx != 0){
cout << "count_calls " << ctr << endl; // 错误ctr未声明
}
static size_t ctr = 0; // 调用结束后,这个值仍然有效
return ++ ctr;
}
int main(){
for(size_t i = 0; i != 10; ++ i){
cout << count_calls(i) << endl;
}
return 0;
}
6.1.2 函数声明
和其它名字一样,函数的名字也必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。如果一个函数永远也不会被我们用到,那么它可以只有声明没有定义。
函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作**函数原型(**function prototype)。
6.1.3 分离式编译
C++语言支持所谓的分离式编译(separate compilation)。分离式编译允许我们把程序分割到几个文件中去,每个文件独立编译。
编译和链接多个文件
假设fact函数的定义位于一个名为fact.cc的文件中,它的声明位于名为Chapter6.h头文件中。显然与其它所有用到fact函数的文件一样,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
如果我们修改了其中一个源文件,那么只需重新编译那个改动了的文件。大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是.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 参数传递
每次调用函数时都会重新创建它的形参,并用传入的实参对形参进行初始化。形参初始化的机理与变量初始化一样。
当形参是引用类型时,我们说他对应的实参被引用传递(passed by reference)或者函数被**传引用调用(**called by reference)。和其他引用一样,引用形参也是它绑定的对象的别名:也就是说,引用形参是它对应的实参的别名。
当实参的值拷贝给形参时,形参和实参是两个互相独立的对象。我们说这样的实参被值传递(passed by value)或者函数被传值调用(called by value)。
6.2.1 传值参数
当初始化一个非引用类型的变量时,初始值被拷贝给对象。此时,对变量的改动不会影响初始值。
指针形参
指针的行为和其它非引用类型一样,当执行拷贝操作时,拷贝的是指针的值。拷贝之后,两个指针是不同的指针。因为指针使我们可以间接地访问它所指的对象,所以通过指针可以修改它所指对象的值。
熟悉C的程序员常常使用指针类型的形参访问函数外部的对象。在C++语言中,建议使用引用类型的形参代替指针
6.2.2 传引用参数
通过使用引用形参,允许函数改变一个或多个实参的值。
void reset(int &i){
i = 0; // 改变了i所引对象的值
}
使用引用避免拷贝
拷贝大的类类型对象或者容器对象比较低效,甚至有的类类型(包括IO类型在内)根本就不支持拷贝操作。当某种类型不支持拷贝操作时,函数只能通过引用形参访问该类型对象。
// 比较两个string对象的长度
bool isShorter(const string &s1, const string &s2){
return s1.size() < s2.size();
}
如果函数无需改变引用形参的值,最好将其声明为常量引用。
使用引用形参返回额外信息
一个函数只能返回一个值,然而有时函数需要同时访问多个值,引用形参为我们一次返回多个值提供了有效的途径。
6.2.3 const和形参实参
顶层const作用于对象本身:
const int ci = 42; // 不能改变ci,const是顶层的
int i = ci; // 正确:当拷贝ci时,忽略了它的顶层const
int * const p = &i; // const是顶层的,不能给p赋值
*p = 0; // 正确:通过p改变对象的内容是允许的,现在i变成了0
和其它初始化过程一样,当用实参初始化形参时会忽略掉顶层const。换句话说,形参的顶层const被忽略掉了。当形参有顶层const时,传给它常量对象或者非常量对象都是可以的:
void fcn(const int i); // fcn能读取i,但是不能向i写值
调用fcn函数时,既可以传入const int,也可以传入int。忽略掉形参的顶层const可能产生意想不到的结果:
void fcn(const int i); // fcn能读取i,但是不能向i写值
void fcn(int i); // 错误:重复定义了fcn(int)
指针或引用形参与const
我们可以使用非常量初始哈一个底层const对象,但反过来不行,同时一个普通的引用必须用同类型的对象初始化。
int i = 42;
const int *cp = &i; // 正确:但是cp不能改变i
const int &r = i; // 正确:但是r不能改变
const int &r2 = 42; // 正确
int *p = cp; // 错误:p的类型和cp类型不匹配
int &r3 = r; // 错误:r3的类型和r的类型不匹配
int &r4 = 42; // 错误:不能用字面值初始化一个非常量引用
尽量使用常量引用
把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外,使用引用而非常量引用也会极大地限制所能接受的实参类型。
// 不良设计:第一个形参的类型应该是const string&
string::size_type find_char(string &s, char c, string::size_type &occurs);
只能将find_char作用于string对象。类似下面这样的调用
find_char("Hello World", 'c', ctr);
将在编译时发生错误。
还有一种更难察觉的问题,假如其它函数(正确地)将它们形参定义成常量引用,那么第二个版本的find_char无法在此类函数中正常使用。
bool is_sentence(const string &s){
// 如果在s的末尾有且只有一个句号,则s是一个句子
string::size_type ctr = 0;
return find_char(s, '.', ctr) == s.size() - 1 && ctr == 1;
}
如果find_char的第一个形参类型是string&,那么上面这条调用find_char的语句将在编译时发生错误。原因在于s是常量引用,但find_char被(不正确的定义成只能接受普通引用)。
6.2.4 数组形参
数组的;两个特殊性质对我们定义和使用作用在数组上的函数有影响,这两个性质分别是:不允许拷贝数组以及使用数组时同农场会将其转换成指针。
// 尽管形式不同,但这三个print函数是等价的
// 每个函数都有一个const int*类型的形参
void print(const int *);
void print(const int[]);
void print(const int[10]); // 这里的维度表示我们期望数组有多少元素,实际不一定
使用标记指定数组长度
管理数组实参的第一种方法是要求数组本身包含一个结束标记(C风格字符串, ‘\0’)。
void print(const char *cp){
if(cp){
while(*cp){
cout << *cp++;
}
}
}
使用标准库规范
管理数组实参的第二种技术是传递指向数组首元素和尾后元素的指针。
void print(const int *beg, const int *end){
// 输出beg到end(不含end)的所有元素
while(beg != end){
cout << *beg++ << endl;
}
}
显式传递一个表示数组大小的形参
第三种管理数组实参的方法是专门定义一个表示数组大小的形参。
void print(const int ia[], size_t size){
for(size_t i = 0; i != size; ++ i){
cout << ia[i] << endl;
}
}
数组形参和const
当函数不需要对数组元素执行写操作的时候,数组形参应该是指向const的指针。只有当函数确实需要改变元素值的时候,才把形参定义成指向 非常量的指针。
数组引用形参
C++语言允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上。
void print(int (&arr)[10]){ // 不是int &arr[10]
for(auto elem : arr){
cout << elem << endl;
}
}
传递多维数组
C++语言中实际上没有真正的多维数组,所谓的多维数组其实就是数组的数组。
void print(int (*matrix)[10], int rowSize);
void print(int matrix[][10], int rowSize);
int *matrix[10]; // 10个指针构成的数组
int (*matrix)[10]; // 指向含有10个整数的数组的指针
6.5.2 main: 处理命令行选项
main函数是演示C++程序如何向函数传递数组的好例子。
int main(int argc, char *argv[]);
第二个形参argv是一个数组,它的元素是指向C风格字符串的指针;第一个形参argc表示数组中字符串的数量。
当实参传给main函数之后,argv的第一个元素指向程序的名字或者第一个空字符串,接下来的元素依次传递命令行提供的实参,最后一个指针之后的元素值保证为0.
$ prog -d -o ofile data0
argv[0] = "prog"; // 或者argv[0]也可以指向一个空字符串
argv[1] = "-d";
argv[2] = "-o";
argv[3] = "ofile";
argv[4] = "data0";
argv[5] = 0;
6.2.6 含有可变形参的函数
为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:如果所有的实参类型相同,可以传递一个initializer_list的标准库类型;如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板。
C++还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参,需要注意的是,这种功能一般只用于与C函数交互的接口程序。
initializer_list形参
initializer_list提供的操作
initializer_list<T> lst; // 默认初始化;T类型元素的空列表
initializer_list<T> lst{a, b, c ..}; // lst的元素数量和初始值一样多;lst的元素是对应初始值的副本;列表中的元素是const
lst2(lst); // 拷贝或赋值一个initializer_list对象不会拷贝列表中的元素;
lst2 = lst; // 拷贝后,原始列表和副本共享元素
lst.size(); // 列表中的元素数量
lst.begin(); // 返回指向lst中首尾元素的指针
lst.end(); // 返回指向lst中尾元素下一位置的指针
和vector一样,initializer_list也是一种模板类型。和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形参中传递一个值的序列,则必须把序列放在一堆花括号内:
// expected和actual是string对象
if(expected != actual){
error_msg({"functionX", expected, actual});
}else{
error_msg({"functionX", "okay"});
}
含有initializer_list形参的函数也可以同时拥有其它形参。
void error_msg(ErrCode e, initializer_list<string> il){
cout << e.msg() << ": ";
for(const auto &elem : il){
cout << elem << " ";
}
cout << endl;
}
省略符形参
省略符形参是为了便于C++程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。通常,省略符形参不应用于其它目的。你的C编译器文档会描述如何使用varargs。
省略符形参应该仅仅用于C和C++同用的类型,特别应该注意的是,大多数类型的对象在传递给省略符形参时都无法正确拷贝。
省略符形参只能出现在形参列表的最后一个位置,它的形式无外乎以下两种:
void foo(parm_list, ...);
void foo(...);
第一种形式指定了foo函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查。省略符形参对应的实参无需类型检查。在第一种形式中,形参声明后面的逗号是可选的。
6.3 返回类型和return语句
6.3.1 无返回值函数
没有返回值的return语句只能用在返回类型是void的函数中。返回void的函数不要求非得有return语句,因为这类函数的最后一句后面会隐式地执行return。
6.3.2 有返回值的函数
return语句返回值的类型必须与函数的返回类型相同,或者能隐式地转换成函数的返回类型。
尽管C++无法确保结果的正确性,但是可以保证每个return语句结果类型正确。也许无法顾及所有情况,但是编译器仍然尽量确保具有返回值的函数只能通过一条有效的return语句退出。
在含有return语句的循环后面应该也有一条return语句,如果没有的话该程序就是错误的。很多编译器都无法发现此类错误。
值是如何被返回的
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数调用的结果。
不要返回局部对象的引用或指针
函数完成后,它所占用的存储空间也随之被释放掉。因此,函数终止意味着局部变量的引用将只想不再有效的内存区域:
// 严重错误:这个函数试图返回局部对象的引用
const string &manip(){
string ret;
if(!ret.empty()){
return ret; // 错误
}else return "Empty"; // 错误
}
返回类类型的函数和调用运算符
如果函数返回指针、引用或类的对象,我们就能使用函数调用的结果访问结果对象的成员
auto sz = shorterString(s1, s2).size();
引用返回左值
我们能为返回类型是非常量引用的函数的结果赋值。
char &get_val(string &str, string::size_type ix){
return str[ix];
}
int main(){
string s("a value");
cout << s << endl;
get(s, 0) = 'A';
cout << s << endl;
return 0;
}
列表初始化返回值
C++11新标准规定,函数可以返回花括号包围的值的列表。类似于其它返回结果,此处的列表也用来对表示函数返回的值的临时量进行初始化。
vector<string> process(){
if(expected.empty()){
return {};
}else if(expected == actual){
retunr {"functionX", "okay"};
}else{
return {"functionX", expected, actual};
}
}
主函数main的返回值
我们允许main函数没有return语句直接结束,如果控制到达了main函数的结尾处没有return语句,编译器将隐式地插入一条返回0的return语句。
main函数的返回值可以看做是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量,我们可以使用这两个变量表示成功与失败:
int main(){
if(some_failure){
return EXIT_FAILURE;
}else{
return EXIT_SUCCESS;
}
}
6.3.3 返回数组指针
声明一个返回数组指针的函数
int arr[10]; // 数组
int *p1[10]; // 指针数组
int (*p2)[10]; // 数组指针
函数的形参列表跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下所示:
Type (*function( parameter_list )) [dimension]
Type表示元素的类型,dimension表示数组的大小, (*function( parameter_list ))两端的括号必须存在,如果没有括号则表示是返回指针的数组。
可以按照以下的顺序来逐层理解该声明的含义
- func(int i)表示调用func需要一个int实参
- (*func(int i)) 表示对函数返回结果解引用
- (*func(int i))[10] 表示解引用结果是一个大小为10 的数组
- int (*func(int i))[10] 表示数组的元素是int类型
使用位置返回类型
在C++11新标准中还有一种可以简化上述func声明的方法,就是使用尾置返回类型(trailing return type)。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用。尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置了一个auto:
// func 接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组
auto func(int i)-> int (*)[10];
因为我们把函数的返回类型放在了形参之后,所以可以很清楚的看到func函数返回是一个指针,并且该指针指向了含有十个整数的数组。
使用decltype
如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型:
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;
}
decltype并不负责把数组类型转换成对应的指针,所以decltype的结果是个数组,要想表示arrPtr返回指针还必须在函数声明时加一个*符号。
6.4 函数重载
如果同一作用域内的几个函数名字相同但形参列表不同。我们称之为重载(overload)函数。
main函数不能重载 !
定义重载函数
有一种典型的数据库应用,需要创建几个不同的函数分别根据名字、电话、账户号码等信息查找记录。函数重载使得我们可以定义一组函数,它们的名字都是lookup,但是查找的依据不同,我们能通过以下形式中的任意一种调用looup函数:
Record lookup(const Account&); // 根据Account查找记录
Record lookup(const Phone&); // 根据Phone查找记录
Record lookup(const Name&); // 根据Name查找记录
Account acct;
Phone phone;
Record r1 = lookup(acct);
Record r2 = lookup(phone);
不允许两个函数处理返回类型外其他所有的要素都相同。假设有两个函数,它们的形参列表一样是返回类型不同,则第二个函数声明是错误的:
Record lookup(const Account&);
bool lookup(const Account&); // 错误
判断两个形参的类型是否相异
有时候两个形参列表看起来不一样,但实际上是相同的:
// 每对声明的是同一个函数
Record lookup(const Account& account);
Record lookup(const Account&); // 省略了形参的名字
typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); // Telno和Phone的类型相同
第二对函数中,类型别名为已存在的类型提供了另外一个名字,它并不是创建新类型。因此,第二对中两个形参的区别仅在于一个使用类型原来的名字,另一个使用了它的别名,从本质上来说它们没什么不同。
重载和const形参
一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来:
Record lookup(Phone);
Record lookup(const Phone); // 重复声明
Record lookup(Phone*);
Record lookup(Phone* const); // 重复声明
另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量 对象还是非常量对象可以实现函数重载,此时的const还是底层的:
Record lookup(Account &);
Record lookup(const Account&); // 新函数,作用于常量引用
Record lookup(Account*);
Record lookup(const Account*); // 新函数,作用于指向常量的指针
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);
}
调用重载函数
定义了一组重载函数后,我们需要以合理的实参调用它们。函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用中的某一个关联起来,函数匹配也叫做重载确定(overload resolution)。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个函数。
当调用重载函数时有三种可能的结果:
- 编译器找到一个与实参最佳匹配(best match)的函数,并生成调用该函数的代码。
- 找不到任何一个函数与调用的实参匹配,此时编译器发出无匹配(no match)的错误信息。
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。对此也将发生错误,称为**二义性调用(**ambiguous call)。
6.4.1 重载与作用域
重载对作用域的一般性质并没有什么改变:弱国我们在内层作用域中声明名字,它将隐藏外层作用域中声明的同名实体。在不同作用域中无法重载函数名:
string read();
void print(const string &);
void print(double);
void fooBar(int ival){
bool read = false;
string s = read(); // 错误,隐藏了外层的read
void print(int);
print("Value: "); // 错误:void print(const string &)被隐藏
print(ival); // 正确
print(3.14); // 正确,调用print(int),print(double)被隐藏
}
在C++语言中,名字查找发生在类型检查之前
6.5 特殊用途的语言特性
6.5.1 默认实参
某些函数有这样一种形参,在函数的很多次调用中它们都被赋予了一个相同的值,此时,我们把这个反复出现的值称为该函数的默认实参(default argument)。调用含有默认实参的函数时,可以包含该实参,也可以省略该实参。
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, '#');
函数调用实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。
window = screen(, , '?'); // 错误
window = screen('?'); // 调用screen('?', 80, ' ');
当设计含有默认实参的函数时,其中一项任务是合理设置形参的顺序,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。
默认实参声明
对于函数的声明来说。通常的习惯是将其放在头文件中,并且一个函数只声明一次,但是多次声明同一个函数也是合法的。不过有一点需要注意,在给定作用域中的一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧测定所有形参必须都有默认值。
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 = 80;
sz ht();
string screen(sz = ht(), sz = wd, char = def);
string window = screen(); // 调用screen(ht(), 80, ' ');
用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:
void f2(){
def = '*'; // 改变了默认实参的值
sz wd = 100; // 隐藏了外层定义的wd,但是没有改变默认值
window = screen(); // 调用screen(ht(), 80, '*');
}
6.5.2 内联函数和constexpr函数
内联函数可以避免函数调用的开销
将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。假设我们把shorterString函数定义成内联函数,则如下调用
cout << shorterString(s1, s2) << endl;
将在编译过程中展开成类似于下面的形式:
cout << (s1.size() < s2.size() ? s1 : s2) << endl;
从而消除了shorterString函数的运行时开销。
在shorterString函数的返回类型前面加上关键字inline,这样就可以将它声明成内联函数了:
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函数返回的是常量表达式,所以可以用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不是常量表达式
int a2[scale(i)]; // 错误:scale[i]不是常量表达式
constexpr函数不一定返回常量表达式
把内联函数和constexpr函数放在头文件内
对于某个给定的内联函数或者constexpr函数来说,它们多个定义必须完全一致,基于这个原因,内联函数和constexpr函数通常定义在头文件中。
6.5.3 调试帮助
C++程序员有时会用到一种类似于头文件保护的技术,以便有选择地调试代码。基本思想是,程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到了两项预处理功能:assert 和 NDEBUG。
assert预处理宏
assert是一种预处理宏(preprocessor macro)。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件:
assert(expr)
首先对expr求值,如果表达式为假(即0),assert输出信息并终止程序执行。如果表达式为真(即非0),assert什么也不做。
assert宏定义在cassert头文件中。预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无需提供using声明。也就是说,我们应该使用assert而不是std::assert,也不需要为assert提供using声明。
assert宏通常用于检查“不能发生”的条件。
assert(word.size() > threshold);
NDEBUG预处理变量
assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做,默认状态下没有定义NDEBUG,此时assert将执行运行时检查。
我们可以使用一个#define语句定义NDEBUG,从而关闭调试状态。同时,很多编译器都提供了一个命令行选项使我们可以定义预处理变量:
$ CC -D NDEBUG main.C # use /D with the Microsoft complier
这条命令的作用等价于在main.c文件的一开始写#define NDEBUG。
定义NDEBUG能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert应该仅用于验证那些确实不可能发生的事情。我们可以把assert当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查。
除了用assert外,也可以使用NDEBUG编写自己的条件调试代码。如果NDEBUG未定义,将执行#ifndef和#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略掉:
void print(const int ia[], size_t size){
#ifndef NDEBUG
// __func__是编译器定义的一个局部静态变量,用于存放函数的名字
cerr << __func__ << ": array size is " << size << endl;
#endif
// ...
}
除了C++编译器定义的__func__
之外,预处理器还定义了另外4个对于程序调试很有用的名字:
__FILE__ 存放文件名的字符串字面值
__LINE__ 存放当前行号的整型字面值
__TIME__ 存放文件编译时间的字符串字面值
__DATE__ 存放文件编译日期的字符串字面值
if(word.size() < thresbold){
cerr << "Error: " << __FILE__
<< " : in function " << __func__
<< " at line " << __LINE__ << endl
<< " Compiled on " << __DATE__
<< " at " << __TIME__ << endl
<< " Word read was \"" << word
<< "\": length too short" << endl;
}
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
6.6 函数匹配
void f();
void f(int);
void f(int, int);
void f(double, double = 3.14);
f(5.6);
确定候选函数和可行函数
函数匹配的第一步是选定本次调用对应的重载函数集,集合中的函数称为候选函数(candidate function)。候选函数具备两个特征:一是与被调用的函数同名,二是其声明在调用点可见。这里的f调用有4个候选函数。
第二步考察本次调用提供的实参,然后从候选函数中选出能被这组实参调用的函数,这些新选出的函数称为可行函数(viable function)。可行函数也有两个特征:一是其形参数量与本次调用提供的实参数量相等,二是每个实参的类型与对应的形参类型相同,或者能转换成形参的类型。这里的f调用有2个可行函数。
其中最后那个函数本应该接受两个double值,但因为它含有一个默认实参,所以只调用一个实参也能调用它。
如果函数含有默认实参,则我们在调用该函数时传入的实参数量可能少于它实际使用的实参数量。
在使用实参数量初步判别了候选函数后,接下来考察的类型是否与形参匹配。
寻找最佳匹配
函数匹配的第三步是从可行函数中选择与本次调用最匹配的函数。
在例子中,调用只提供了一个(显式的)实参,它的类型是double。如果调用f(int),实参将不得不从double转换成int。另一个可行函数f(double, double)则与实参精确匹配。精确匹配比需要转换的匹配更好,因此,编译器把f(5.6)解析成含有两个double形参的函数调用,并使用默认值填补第二个实参。
含有多个形参的函数匹配
f(42, 2.56);
选择可行函数的方法和只有一个实参时一样,编译器选择那些形参数量满足要求且实参类型和形参类型能够匹配的函数。此例中,可行函数包括f(int, int)和d(double, double)。接下来,编译器一次检查每个实参以确定哪个函数是最佳匹配。如果有且只有一个函数满足下列条件,则匹配成功。
- 该函数每个实参的匹配都不劣于其它可行函数需要的匹配。
- 至少有一个实参的匹配优于其它可行函数提供的匹配
如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用时错误的。编译器将报告二义性调用的信息。
因此f(42,256)
会报告二义性错误。
调用重载函数时应尽量避免强制类型转换。如果在实际应用中确实需要强制类型转换,则说明我们设计的形参集合不合理。
6.6.1 实参类型转换
为了确定最佳匹配,编译器将实参到形参类型的转换划分成几个等级,具体排序如下所示:
1、精确匹配,包括以下情况
- 实参类型和形参类型相同
- 实参从数组类型或函数类型转换成对应的指针类型
- 向实参添加顶层const或者从实参中删除顶层const
2、通过const转换实现的匹配
3、通过类型提升实现的匹配
4、通过算术类型转换或指针转换实现的匹配
5、通过类类型转换实现的匹配
需要类型提升和算术类型转换的匹配
假设有两个函数,一个接受int,一个接受short,则只有当调用提供的是short类型的值才会选择short版本的函数
void ff(int);
void ff(short);
ff('a'); // ff(int)
所有算术类型转换的级别都一样。
void manip(long);
void manip(float);
manip(3.14); // 错误,二义性调用
函数匹配和const实参
如果重载函数的区别在于它们的引用类型的形参是否引用了const,或者指针类型的形参是否指向const,当调用发生时编译器通过实参是否是常量来决定选择哪个函数。
Record lookup(Account&);
Record lookup(const Account&);
const Account a;
Account b;
lookup(a); // 调用lookup(const Account&)
lookup(b); // 调用lookup(Account&)
6.7 函数指针
函数指针指向的是函数而非对象。和其他指针一样,函数指针指向某特定类型。函数的类型由他的返回类型和形参类型共同决定,与函数名无关。
bool lengthCompare(const string &, const string &);
bool (*pf)(const string &, const string &);
使用函数指针
当我们把函数名作为一个值使用时,该函数自动地转换成指针。
pf = lengthCompare; // pf指向名为lengthCompare的函数
pf = &lengthCompare; // 等价赋值语句,&可选
bool b1 = pf("hello", "goodbye");
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 = sumLength; // 错误,返回值不匹配
pf = cstringCompare; // 错误:形参类型不匹配
pf = lengthCompare;
重载函数的指针
当我们使用重载函数时,上下文必须清晰地界定到底选用哪个函数。如果定义了指向重载函数的指针
void ff(int*);
void ff(unsigned int);
void (*pf)(unsigned int) = ff; // 指向ff(unsigned int)
void (*pf2)(int) = ff; // 错误
double (*pf3)(int*) = ff; // 错误
函数指针形参
和数组类似,虽然不能定义函数类型的形参,但形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用:
void useBigger(const string &s1, const string &s2, bool pf(const string s&, const string &));
void useBigger(const string &s1, const string &s2, bool (*pf)(const string s&, const string &)); // 等价声明
类型别名和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;// 等价类型
void useBigger(const string&, const string & , Func);
void useBigger(const string&, const string & , FuncP2);
返回指向函数的指针
和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。与往常一样,要想声明一个返回函数指针的函数,最简单的方法是使用类型别名:
using F = int(int*, int); // F是函数类型,不是指针
using PF = int(*)(int*, int); // PF是指针类型
和函数类型的形参不一样,返回类型不会自动地转换成指针。我们必须显式地将返回类型直到为指针:
FF f1(int);
F f1(int); // 错误
F *f1(int);
// 直接声明
int (*f1(int))(int *, int);
按照由内向外的顺序阅读这条声明语句:f1有形参列表,所以f1是个函数;f1前面有*,所以说明f1返回一个指针;指针类型包含形参列表,因此指针指向函数,返回类型为int。
// 尾置返回类型
auto f1(int) -> int (*)(int *, int);
将auto和decltype用于函数指针类型
如果我们明确知道返回的函数是哪一个,就能使用decltype简化书写函数指针返回类型的过程。例如假定有两个函数,它们的返回类型都是string::size_type,并且各有两个const string类型的形参,此时我们可以编写第三个函数,它接受一个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&);