少女祈祷中...

开场白

相比于第一周,第二周刚开始就举步维艰

课余的空闲时间减少、课程作业变多、阅读的信息量增大……

同样是三个章节的跨度,第二周需要新学的东西多了很多,笔记的篇幅也相应地增长,甚至已经没法做到“一天20面”的可能了。

预期是第二周完成第七章,看来也必须放在第三周了。

不过,

那又如何呢?

无非是完成得更晚一点罢了。

~o( =∩ω∩= )m

万一禅关砉然破,美人如玉剑如虹。

Week 1: 潜龙勿用

点击跳转第一期内容:潜龙勿用

Week 2: 见龙在田

表达式

表达式由一个或多个运算对象(operand)组成,对表达式求值将得到一个结果(result)。字面值和变量是最简单的表达式(expression),其结果就是字面值和变量的值。把一个运算符(operator)和一个或多个运算对象组合起来可以生成较复杂度表达式。

本章节在书中的内容与C语言基础知识高度重合,笔记也因此省略了很多。

基本概念

重载运算符

当运算符作用于类类型的运算对象时,用户可以自行定义其含义,成为重载运算符(overloaded operator)。IO库的 >><< 运算符以及string对象、vector对象和迭代器使用的运算符都是重载的运算符。

我们使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由该运算符定义的;但是运算对象的个数、运算符的优先级和结合律都是无法改变的。

左值和右值

左值(rvalue)和右值(lvalue)的概念是从C语言继承过来的。一般来说,在需要右值的地方可以用左值来代替,但是不能把右值当成左值使用。

使用decltype关键字时,左值和右值有所不同。如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到一个引用类型。例如:

1
2
3
int a1 = 42 , a2 = 21;
int *p = &a1;
decltype ( *p ) a3 = a2; //得到引用类型,a3是a2的引用

另一方面,因为取地址运算符生成右值,所以decltype(&p)的结果是int**,也就是说,结果是一个指向整形指针的指针。

算术运算符

算术运算符的运算对象和求值结果都是右值。

除法和取余

C++11新标准规定:

除法运算中,商一律向0取整(即直接切除小数部分)。

加入取余运算后,除了-m导致溢出的特殊情况,其他时候(-m)/nm/(-n)都等于-(m/n)m%(-n)等于m%n(-m)%n等于-(m%n)

1
2
3
4
21 % 6;  	/* 结果是3 */		21 / 6;  	/* 结果是3 */
21 % 7; /* 结果是0 */ 21 / 7; /* 结果是3 */
-21 % -8; /* 结果是-5 */ -21 / -8; /* 结果是2 */
21 % -5; /* 结果是1 */ 21 / -5; /* 结果是-4 */

如果m%n不等于0,则它的符号和m相同。

逻辑和关系运算符

逻辑与和逻辑或运算符

逻辑与(&&)和逻辑或(||)运算符都是先求左侧运算对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时才会计算右侧运算对象的值。这种策略称为短路求值(short-circuit evaluation)。

举一个使用逻辑或的例子:

假定有一个存储着若干string对象的vector对象,要求输出string对象的内容并且在遇到空字符串或者以句号结束的字符串时进行换行。使用基于范围的for循环处理string对象中的每个元素:

1
2
3
4
5
6
7
8
9
//s是对常量的引用;元素既没有被拷贝也没有被改变
for ( const auto &s : text ) { //对于text的每个元素
cout << s; //输出当前元素
//遇到空字符串或者以句号结束的字符串进行换行
if ( s.empty() || s[s.size() - 1] == '.')
cout << endl;
else
cout << " "; //否则用空格隔开
}

关系运算符

关系运算符比较运算对象的大小并返回布尔值。关系运算符都满足左结合律

赋值运算符

赋值运算的结果是它的左侧运算对象,并且是一个左值,类型即左侧运算对象的类型。如果左右运算对象类型不同,则右侧运算对象转换成左侧运算对象的类型。

1
2
int k = 0;
k = 3.14; //结果:类型是int,值是3

C++11新标准允许使用花括号括起来的初始值列表作为赋值运算语句的右侧运算对象:

1
2
3
k = {3.14};						//错误:窄化转换
vector<int> vi; //初始为空
vi = {0,1,2,3,4,5,6,7,8,9}; //vi现在含有10个元素了,值从0到9

如果左侧运算对象是内置类型,那么初始值列表最多只能包含一个值,而且该值即使转换的话也不应该大于目标类型的空间。

vector模板重载了赋值运算符并且可以接受初始值列表,当赋值发生时用右侧运算对象的元素替换左侧运算对象的元素。

赋值运算满足右结合律:

1
2
int ival , jval;
ival = jval = 0; //正确,都被赋值为0

赋值运算优先级较低

因为赋值运算优先级相对较低,所以将其放在条件中时,需要加上括号。

将赋值语句放在条件当中是很好的写法,以下是示例,其中循环的目的是反复调用一个函数直到返回期望的值为止:

1
2
3
4
5
6
7
8
9
10
11
12
//这是一种繁琐、容易出错的写法
int i = get_value(); //得到第一个值
while ( i != 42 ) {
//其他处理……
i = get_value(); //得到剩下的值
}

//更好的写法:条件部分表达得更加清晰
int i;
while ( ( i = get_value() ) != 42 ) {
//其他处理……
}

第二个版本的while条件更容易表达我们的真实意图:不断循环读取数据直到遇到42为止。

递增和递减运算符

Tip: 非必需情况下,不使用递增递减运算符的后置版本。由于需要保存被改变量的原始值,在不需要原始值的情况下使用后置版本的操作就是一种浪费。这种浪费对于复杂的迭代器类型可能开销巨大。

建议养成使用前置版本的习惯。

在一条语句中混用解引用和递增运算符

这里使用需要后置版本的例子:

1
2
3
4
auto pbeg = v.begin();
//输出元素直至遇到第一个负值为止
while ( pbeg != v.end() && *pbeg >= 0 )
cout << *pbeg++ << endl; //输出当前值并将pbeg向前移动一个元素

后置递增运算符的优先级高于解引用运算符。因此 *pbeg++ 等价于 *(pbeg++)

Tip: 形如 *pbeg++ 的表达式一开始可能不太容易理解,但其实这是一种被广泛使用的、有效的写法。当这种形式熟悉之后,书写

1
cout << *iter++ << endl;

要比书写下面的等价语句更简洁、也更少出错

1
2
cout << *iter << endl;
++iter;

条件运算符

条件运算符( ? : )允许我们把简单的if-else逻辑嵌入到单个表达式当中,按如下形式使用:

1
cond ? expr1 : expr2;

cond是判断条件的表达式,而expr1expr2是两个类型相同或可能转换为某个公共类型的表达式。条件运算符同样使用短路求值的策略。

嵌套条件运算符

允许在条件运算符的内部嵌套另一个条件运算符:

1
2
string finalgrade = ( grade > 90 ) ? "high pass"
: ( grade < 60 ) ? "fail" : "pass";

条件运算符满足右结合律,意味着运算对象(一般)按照从右向左的顺序组合。因此在上述代码中,靠右边的条件运算构成了靠左边的条件运算的 : 分支

在输出表达式中使用条件运算符

条件运算符的优先级非常低,通常需要在两端加上括号:

1
2
3
cout << ( ( grade < 60 ) ? "fail" : "pass" );	//输出pass或者fail
cout << ( grade < 60 ) ? "fail" : "pass"; //输出0或1
cout << grade < 60 ? "fail" : "pass"; //错误,试图比较cout和60

其中,第二条表达式等价于:

1
2
cout << ( grade < 60 );	  //输出0或1
cout ? "fail" : "pass"; //根据cout的值是true还是false产生对应的字面值

第三条表达式等价于:

1
2
cout << grade;		//小于运算符的优先级低于移位运算符,所以先输出grade
cout < 60 ? "fail" : "pass"; //然后比较cout和60

位运算符

位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。

一般来说,如果运算对象是“小整型”,则它的值会被自动提升成较大的整数类型。如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号的值,因此是一种未定义的行为。

移位运算符(又叫IO运算符)满足左结合律。

sizeof运算符

sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof满足右结合律,其所得到的值是一个size_t类型的常量表达式。

该运算符的运算对象有两种形式:

1
sizeof (type)		 		sizeof expr

在第二种形式中,sizeof返回的是表达式结果类型的大小,而且sizeof并不会实际计算其运算对象的值:

1
2
3
4
5
6
7
Sales_data data , *p;
sizeof( Sales_data ); //存储Sales_data类型的对象所占的空间大小
sizeof data; //data的类型的大小,即sizeof(Sales_data)
sizeof p; //指针所占空间的大小
sizeof *p; //p所指类型的空间大小,即sizeof(Sales_data)
sizeof data.revenue; //Sales_data的revenue成员对应类型的大小
sizeof Sales_data::revenue; //另一种获取revenue大小的方式(无须提供一个具体的对象)

注意 sizeof *p :首先,因为sizeof满足右结合律并且与 * 运算符的优先级一样,所以等价于 sizeof(*p) 。其次,因为sizeof不会实际求运算对象的值,所以即使p是一个无效(即未初始化)的指针也不会有什么影响。在sizeof的运算对象中解引用一个无效指针仍然是一种安全的行为。

sizeof运算符的结果部分地依赖于其作用的类型:

得到的结果
char类型 或 类型为char的表达式 1
引用类型 被引用对象所占空间大小
指针 指针本身所占空间大小
解引用指针 指针指向的对象所占空间大小,指针不需有效
数组 整个数组所占空间大小
string对象或vector对象 该类型固定部分的大小

对数组执行sizeof的结果等价于对数组中所有元素各执行一次sizeof运算并求和,注意,sizeof运算并不会把数组转换成指针来处理。

因为sizeof的返回值是一个常量表达式,所以我们可以用sizeof的结果声明数组的维度:

1
2
3
//sizeof(ia)/sizeof(*ia)返回ia的元素数量
constexpr size_t sz = sizeof(ia)/sizeof(*ia);
int arr2[sz]; //正确,sizeof返回一个常量表达式

逗号运算符

逗号运算符(comma operator)含有两个运算对象,按照从左往右的顺序依次求值。

对于逗号运算符来说,首先对左侧的表达式求值,然后将求值结果丢弃掉。逗号运算符真正的结果是右侧表达式的值。如果右侧运算对象是左值,那么最终求值结果也是左值。

逗号运算符常被用在for循环当中:

1
2
3
4
vector<int>::size_type cnt = ivec.size();
//将把从size到1的值赋给ivec的元素
for ( vector<int>::size_type ix = 0 ; ix != ivec.size() ; ++ix , --cnt )
ivec[ix] = cnt;

类型转换

如果两种类型之间可以相互转换(conversion),那么它们就是关联的。

算术转换

把一种算术类型转换成另一种算术类型。

整型提升

整型提升(integral promotion)负责把小整数类型转换成较大的整数类型。对于boolcharsigned charunsigned charshort、和unsigned short等类型来说,只要它们所有可能的值都能在int中,就会提升成int类型,否则提升成unsigned int类型。

布尔值false提升成0true提升成1

较大的char类型(wchar_tchar16_tchar32_t)提升成intunsigned intlongunsigned longlong longunsigned long long中最小的一种类型,前提是转换后的类型要能容纳原类型所有可能的值。

无符号类型运算对象

如果一个运算对象是无符号类型,另一个是带符号类型:

① 若无符号类型不小于带符号类型,那么有符号类型转变成无符号的。

例如unsigned intint运算,int转换为unsigned int。此时就可能导致运算错误。

② 若带符号类型大于无符号类型,此时转换结果依赖于机器。并再细分为两种情况:

(1) 无符号类型的所有值均能存在带符号类型中,则“无”转“带”。

例如longunsigned intlong占用空间比int大,则unsigned intlong

(2) 否则,“带”转“无”

例如longunsigned intintlong大小相同,则longunsigned int

其他隐式转换

数组转换成指针

在大多数用到数组的表达式中,数组自动转换成指向数组首元素的指针:

1
2
int ia[10];
int *ip = ia; //ia自动转换成指向首元素的指针

当数组被用作decltype关键字的参数、或者作为取地址符(&)、sizeoftypeid等运算符的运算对象时,上述转换不会发生。同样,如果用一个引用来初始化数组,上述转换也不会发生。

指针转换

C++还规定了几种其他的指针转换方式:

  • 常量整数值0或者字面值nullptr能转换成任意指针类型
  • 指向任意非常量的指针可以转换成void*
  • 指向任意对象的指针能转换成const void*
  • (有继承关系的类型间还有另外一种指针转换方式)
转换成布尔类型

如果指针或算术类型的值为0,转换结果为false;否则为true

转换成常量

允许将指向非常量类型的指针转换成指向相应类型的常量类型的指针。也就是说,如果T是一种类型,我们就能将指向 T 的指针或引用分别转换成指向 const T 的指针或引用。

1
2
3
4
int i;
const int &j = i; //非常量转换成const int的引用
const int *p = &i; //非常量的地址转换成const的地址
int &r = j , *q = p; //错误,不允许const转换成非常量

相反的转换并不存在,因为它试图删掉底层const

类类型定义的转换

类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。(如果同时提出多个转换请求,这些请求将被拒绝。)

已有的例子:

1
2
string s , t = "a value";	//字符串字面值转换成string类型
while( cin >> s ) //while的条件部分把cin转换成布尔值

IO库定义了从istream向布尔值的转换规则。

显式转换

有时我们希望显式地将对象强制转换成另外一种类型,这种方法称作强制类型转换(cast)。

Tip: 虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的,干扰了正常的类型检查,应尽量避免使用。

命名的强制类型转换

一个命名的强制类型转换具有如下形式:

1
cast-name <type> (expression);

其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果为左值。cast-namestatic_castdynamic_castconst_castreinterpret_cast中的一种,指定了执行的是那种转换。

(dynamic_cast支持运行时类型识别,此处先掠过)

static_cast

任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast。例如,通过将一个运算对象强制转换成double类型就能使表达式执行浮点数除法:

1
2
//进行强制类型转换以便执行浮点数除法
double slope = static_cast<double>(j) / i;

当需要把一个较大的算术类型赋值给较小的类型时,static_cast非常有用。此时相当于告诉编译器:我们知道且不在乎潜在的精度损失。

一般来说,如果编译器发现了一个较大的算术类型试图赋值给一个较小的类型,就会发出警告信息;但是当我们执行了显示的类型转换后,警告信息就被关闭了。

static_cast对于编译器无法自动执行的类型转换也非常有用。例如,我们可以使用static_cast找回存在于void*的指针:

1
2
3
void* p = &d;		//正确,任何非常量对象的地址都能存入void*
//正确,将void*转换回初始的指针类型
double *dp = static_cast<double*>(p);

我们必须保证转换后所得的类型就是指针所指的类型。类型一旦不符,将产生未定义的后果。

const_cast

const_cast只能改变运算对象的底层const

1
2
3
const char *pc;
char *p = const_cast<char*>(pc);
//正确,但是通过p写值是未定义的行为

对于常量对象转换为非常量对象的行为,一般称为“去掉const性质(cast away the const)”。一旦去掉某个对象的const性质,编译器就不再阻止我们对该对象进行写操作了。如果该对象本身不是个常量,使用强制类型转换是合法的行为;然而如果对象是个常量,在使用const_cast执行写操作就会产生未定义的后果。

只有const_cast能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。

同样地,const_cast不能用于改变表达式类型:

1
2
const char *cp;
const_cast<string>(cp); //错误,const_cast只能改变常量属性

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

reinterpret_cast

reinterpret_cast通常为运算对象的位模式提供较低层次上的重新解释。

假设有如下转换:

1
2
int *ip;
char *pc = reinterpret_cast<char*>(ip);

必须牢记pc所指的是int而非字符,如果把pc当成普通的字符指针使用就可能在运行时发生错误:

1
string str(pc);

使用reinterpret_cast非常危险。

reinterpret_cast本质上依赖于机器,要想安全使用reinterpret_cast必须对涉及的类型和编译器实现转换的过程都非常了解。

旧式的强制类型转换

在早期版本的C++语言中,显式地进行强制类型转换包含两种形式:

1
2
type (expr);			//函数形式的强制类型转换
(type) expr; //C语言风格的强制类型转换

根据所涉及的类型不同,旧式的强制类型转换分别与const_caststatic_castreinterpret_cast有相似的行为。当我们执行旧式的强制类型转换时,如果换成const_caststatic_cast也合法,则其行为与对应的命名转换一致;否则与reinterpret_cast类似。

语句

通常情况下,语句(statement)是顺序执行的。

C++语言提供了一组控制流(flow-of-control)语句以支持更复杂的执行路径。

为了避免重复,C语言有的概念和性质不在本文中阐述。

复合语句

复合语句(compound statement)是指用花括号括起来的(可能为空的)语句和声明的序列,也被称作(block)。一个块就是一个作用域,在块中引入的名字只能在块内部以及嵌套的在块中的子块里访问。

块不以分号作为结束

switch语句

switch语句首先对括号里的表达式求值,该表达式紧跟在关键字switch后面,可以是一个初始化的变量声明(如 switch ( int i = get_num ) )。表达式的值转换成整数类型,然后与每个case标签比较。

如果表达式和某个case标签的值匹配成功,程序从该标签之后的第一条语句开始执行,直到到达了switch的结尾或者遇到一条break语句为止。

如果switch语句的表达式和所有case都没有匹配上,将直接跳转到switch结构之后的第一条语句。

case关键字和它对应的值一起被称为 case标签 (case label)。case标签必须是整型常量表达式:

1
2
3
4
5
6
char ch = getVal();
int ival = 42;
switch(ch) {
case 3.14: //错误,case标签不是一个整数
case ival: //错误,case标签不是一个常量
}

任何两个case标签的值不能相同,否则就会引发错误。另外,default也是一种特殊的case标签。

如果没有任何一个case标签能匹配上switch表达式的值,程序将执行紧跟在default标签(default label)后面的语句。

Tip: default部分程序的最后,如果不加break语句,也会像其它case一样继续向下执行,而不是跳出当前的控制流。

for语句

这一部分for语句以及不在这里呈现的while语句、dowhile语句都继承了C语言的性质,只提少部分重点。

传统for语句

1
2
for ( init-statement ; condition ; expression )
statement

关键字for以及括号里的部分称作for语句头

Tip: 和其他的声明一样,init-statement也可以定义多个对象。但是init-statement只能有一条声明语句,因此,所有变量的基础类型必须相同。

范围for语句

1
2
for ( declaration : expression )
statement

expression表示的必须是一个序列,比如用花括号括起来的初始值列表、数组或者vectorstring等类型的对象。这些类型的共同特点是拥有能返回迭代器的beginend成员。

范围for语句的定义来源于与之等价的传统for语句:

1
2
3
4
for ( auto beg = v.begin(), end = v.end() ; beg!= end ; ++beg ) {
auto &r = *beg; //r必须是引用类型,这样才能对元素执行写操作
r *= 2; //将v中每个元素的值翻倍
}

这也是为什么不能通过范围for语句增加vector对象(或其他容器)的元素的原因。

在范围for语句中,预存了end()的值。一旦在序列中添加(删除)元素,end函数的值就可能变得无效了。

跳转语句

跳转语句中断当前的执行过程。C++ 语言提供了4种跳转语句:breakcontinuegotoreturn。(这里只介绍goto语句。)

goto语句

goto语句(goto statement)的作用是从goto语句无条件跳转到同一函数内的另一条语句。

不要在程序中使用goto语句,因为它使得程序既难理解又难修改。

goto的语法形式是:

1
goto label;

其中,label是用于标识一条语句的标示符。带标签语句(labeled statement)是一种特殊的语句,在它之前有一个标示符以及一个冒号:

1
end: return;		//带标签语句,可以作为goto的目标

标签标示符独立于变量或其他标示符的名字,因此,标签标示符可以和程序中其他实体的标示符使用同一个名字而不会相互干扰。goto语句和控制权转向的那条带标签的语句必须位于同一个函数内。

switch类似,goto语句也不能将程序的控制权从变量的作用域外转移到作用域之内:

1
2
3
4
5
6
	//...
goto end;
int ix = 10; //错误,goto语句绕过了一个带初始化的变量定义
end:
//错误,此代码需要使用ix,但是goto语句绕过了它的声明
ix = 42;

向前跳过一个已经执行的定义是合法的。跳回到变量定义之前意味这系统将销毁该变量,然后重新创建它:

1
2
3
4
5
6
//向后跳过一个带初始化的变量定义是合法的
begin:
int sz = get_size();
if ( sz <= 0 ) {
goto begin;
}

try语句块和异常处理

异常是指存在于运行时的反常行为,这些行为超出了函数正常功能的范围。典型的异常包括失去数据库连接以及遇到意外输入等。处理反常行为可能是设计所有系统最难的一部分。

如果程序中含有可能引发异常的代码,那么通常也会又专门的代码处理问题。例如,如果程序的问题是输入无效,则异常处理部分可能会要求用户重新输入正确的数据;如果丢失了数据库连接,会发出报警信息。

异常处理机制程序检测异常处理这两部分的协作提供支持。在 C++ 语言中,异常处理包括:

  • throw表达式(throw expression),异常检测部分使用throw表达式来表示它遇到了无法处理的问题。我们说throw引发(raise)了异常。
  • try语句块(try block),异常处理部分使用try语句块处理异常。try语句块以关键字try开始,并以一个或多个catch子句(catch clause)结束。try语句块中代码抛出的异常通常会被某个catch子句处理。因为catch子句“处理”异常,所以它们也被称作异常处理代码(exception handler)。
  • 一套异常类(exception class),用于在throw表达式和相关的catch子句之间传递异常的具体信息。

throw表达式

throw表达式包含关键字throw和紧随其后的一个表达式,其中表达式的类型就是抛出的异常类型。throw语句后面通常紧跟一个分号,从而构成一条表达式语句。

举个例子,把两个Sales_item对象相加的程序。这个程序检查它读入的记录是否是关于同一种书籍的,如果不是,输出一条信息然后退出。

1
2
3
4
5
6
7
8
9
10
Sales_item item1, item2;
cin >> item1 >> item2;
//首先检查item1和item2是否是同一种书籍
if ( item1.isbn() == item2.isbn() ) {
cout << item1 + item2 << endl;
return 0; //表示成功
} else {
cerr << "Data must refer to same ISBN" << endl;
return -1; //表示失败
}

在真实的程序中,应该把对象相加的代码和用户交互的代码分离开来。此例中,我们改写程序使得检查完成后不在直接输出一条信息,而是抛出一个异常:

1
2
3
4
5
//首先检查两条数据是否是关于同一种书籍的
if ( item1.isbn() != item2.isbn() )
throw runtime_error("Data must refer to same ISBN");
//如果程序执行到了这里,表示两个ISBN是相同的
cout << item1 + item2 << endl;

如果ISBN不一样,就抛出一个异常。该异常是类型runtime error的对象。抛出异常将终止当前的函数,并把控制权转移给能处理该异常的代码。

类型runtime_error是标准库异常类型的一种,定义在stdexpr头文件中。我们必须初始化runtime_error的对象,方式是给它提供一个string对象或者一个C风格字符串,这个字符串中有一些关于异常的辅助信息。

try语句块

try语句块的通用语法形式是:

1
2
3
4
5
6
7
try {
program-statements
} catch (exception-declaration) {
handler-statements
} catch (exception-declaration) {
handler-statements
} //...

try语句块的一开始是try关键字,随后紧跟着一个块。

跟在try块之后的是一个或多个catch子句。catch子句包括三部分:关键字catch、括号内一个(可能未命名的)对象的声明(称作异常声明,exception declaration)以及一个块。当选中了某个catch子句之后,执行与之对应的块。catch一旦完成,程序跳转到try语句块最后一个catch子句之后的那条语句继续执行。

try语句块中的program-statements组成程序的正常逻辑,像其他任何块一样,program-statements可以有包括声明在内的任意C++语句。一如往常,try语句块内声明的变量在块外部无法访问,特别是在catch子句内也无法访问。

编写处理代码

在之前的例子里,我们使用了一个throw表达式以避免把两个代表不同书籍的Sales_item相加。我们假设执行Sales_item对象加法的代码是与用户交互的代码分离开来的。其中与用户交互的代码负责处理的异常,它的形式可能如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while ( cin >> item1 >> item2 ) {
try {
//执行添加两个Sales_item对象的代码
//如果添加失败,代码抛出一个runtime_error异常
} catch ( runtime_error err ) {
//提醒用户两个ISBN必须一致,询问是否重新输入
cout << err.what()
<< "\nTry Again? Enter y or n" << endl;
char c;
cin >> c;
if ( !cin || c == 'n' )
break; //跳出while循环
}
}

程序本来要执行的任务出现在try语句块中,这是因为这段代码可能会抛出一个runtime_error类型的异常。

try语句块对应一个catch子句,该子句负责处理类型为runtime_error的异常。如果try语句块的代码抛出了runtime_error异常,接下来执行catch块内的语句。在我们书写的catch子句中,输出一段提示信息要求用户指定程序是否继续。如果用户输入’n’,执行break语句并退出while循环;否则,直接执行while循环的右侧花括号,意味着程序控制权跳回到while条件部分准备下一次迭代。

给用户的提示信息中输出了err.what()的返回值。我们知道err的类型是runtime_error,因此能推断whatruntime_error的一个成员函数。每个标准库异常类都定义了名为what的成员函数,这些函数没有参数,返回值是C风格字符串(即const char*)。其中,runtime_errorwhat成员返回的是初始化一个具体对象时所用的string对象的副本。

函数在寻找处理代码的过程中退出

在复杂系统中,程序遇到抛出异常的代码前,其执行路径可能已经经过了多个try语句块。例如,一个try语句块可能调用了包含另一个try语句块的函数,新的try语句块可能调用了包含又一个try语句块的新函数,以此类推。

寻找处理代码的过程与函数调用链刚好相反。当异常被抛出时,首先搜索抛出该异常的函数。如果没找到匹配的catch子句,终止该函数,并在调用该函数的函数中继续寻找。如果还是没有找到匹配的catch子句,这个新的函数也被终止,继续搜索调用它的函数。以此类推,沿着程序的执行路径逐层回退,直到找到适当类型的catch子句为止。

如果最终还是没有找到任何匹配的catch子句,程序转到名为terminate的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。

对于那些没有任何try语句块定义的异常,也按照类似的方式处理:毕竟,没有try语句块也就意味着没有匹配的catch子句。如果一段程序没有try语句块且发生了异常,系统会调用terminate函数并终止当前程序的执行。

Note: Luv本地实测含有异常的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdexcept>
using std :: runtime_error; //注意这里

int main(){
try {
//代码抛出一个runtime_error异常
throw runtime_error("first exception");
} catch ( runtime_error err ) {
//提醒用户两个ISBN必须一致,询问是否重新输入
cout << err.what()
<< "\nTry Again? Enter y or n" << endl;
char c;
cin >> c;
if ( !cin || c == 'n' )
cout << "Break out!";
}

//直接抛出一个异常,此时没有catch子句与之匹配
//程序转到名为terminate的标准库函数
throw runtime_error("second exception");

return 0;
}
1
2
3
4
5
6
first exception
Try Again? Enter y or n
n
Break out!
terminate called after throwing an instance of 'std::runtime_error'
what(): second exception

并且,此时主函数return的值为3。

标准异常

C++标准库定义了一组类,用于报告标准库函数遇到的问题。这些异常类也可以在用户编写的程序中使用,它们分别定义在4个头文件中:

  • exception头文件定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息。
  • stdexcept头文件定义了几种常用的异常类,在下标列出。
  • new头文件定义了bad_alloc异常类型,这种类型在后续有关动态内存章节介绍。
  • type_info头文件定义了bad_cast异常类型,这种类型在最后一章介绍。
定义的异常类
runtime_error 只有在运行时才能检测出的问题
range_error 运行时错误:生成的结果超出了有意义的值域范围
overflow_error 运行时错误:计算上溢
underflow_error 运行时错误:计算下溢
logic_error 程序逻辑错误
domain_error 逻辑错误:参数对应的结果值不存在
invalid_argument 逻辑错误:无效参数
length_error 逻辑错误:试图创建一个超出该类型最大长度的对象
out_of_range 逻辑错误:使用一个超出有效范围的值

标准异常类只定义了几种运算,包括创建或拷贝异常类型的对象,以及为异常类型的对象赋值。

我们只能以默认初始化的方式初始化exceptionbad_allocbad_cast对象,不允许为这些对象提供初始值。

其他异常类型的行为则恰好相反:应该使用string对象或者C风格字符串初始化这些类型的对象,且不允许使用默认初始化的方式。当创建此类对象时,必须提供初始值,该初始值含有错误相关的信息。

异常类型只定义了一个名为what的成员函数,该函数没有任何参数,返回值是一个指向C风格字符串的const char*。该字符串的目的是提供关于异常的一些文本信息。

Note: Luv进入头文件查看,发现what成员函数的定义只在runtime_error和logic_error中出现,推测其他异常类应该分别是这两种异常类的继承子类,经测试,它们同样可以调用what成员函数。

what函数返回的C风格字符串的内容与异常对象的类型有关。如果异常类型有一个字符串初始值,则what返回该字符串。对于其他无初始值的异常类型来说,what返回的内容由编译器决定。

函数

函数(function)是一个命名了的代码块,我们通过调用函数执行相应的代码。函数可以有0个或多个参数,而且(通常)会产生一个结果。可以重载函数,也就是说,同一个名字可以对应几个不同的函数。

与前面章节类似,我们对于已经在C语言中熟悉的内容将不在本文中展示。

函数基础

形参列表

函数的形参(parameter)列表可以为空,但是不能省略。为了与C语言兼容,也可以用关键字void表示函数没有形参:

1
2
void f1 () { /* ... */ }			//隐式地定义空形参列表
void f2 (void) { /* ... */ } //显式地定义空形参列表

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

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

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

形参名是可选的(可以不写)。但是由于我们无法使用未命名的形参,所以形参一般都应该有个名字。是否设置未命名的形参并不影响调用时提供的实参数量,即使某个形参不被函数使用,也必须为它提供一个形参。

函数返回类型

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

局部对象

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

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

局部对象的生命周期依赖于定义的方式。

自动对象

我们把只存在于块执行期间的对象称为自动对象(automatic object)。当块的执行结束后,块中创建的自动对象被销毁,值也变成未定义的。

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

局部静态对象

某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static类型从而获得这样的对象。局部静态对象(local static object)在程序的执行路径第一次经过定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。

函数声明

和其他名字一样,函数的名字也必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。

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

因为函数的声明不包含函数体,所以也就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用处的,它可以帮助使用者更好地理解函数的功能:

1
2
3
//我们选择beg和end作为形参的名字以表示这两个迭代器划定了输出值的范围
void print ( vector<int>::const_iterator beg ,
vector<int>::const_iterator end );

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

在头文件中进行函数声明

与变量类似,函数也应该在头文件中声明而在源文件中定义,方便确保同一函数的所用声明保持一致。

定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。

分离式编译

为了允许编写程序时按照逻辑关系将其划分开来,C++语言支持所谓的分离式编译(separate compilation)。分离式编译允许我们把程序分隔到几个文件中去,每个文件独立编译。

编译和链接多个源文件

举个例子,假设fact函数的定义位于一个名为fact.cc的文件中,它的声明位于名为Chapter6.h的头文件中。显然与其他所用用到fact函数的文件一样,fact.cc应该包含Chapter6.h头文件。另外,我们在名为factMain.cc的文件中创建main函数,main函数将调用fact函数。要生成可执行文件(executable file),必须告诉编译器我们用到的代码在哪里。

对于上述几个文件来说,编译的过程如下所示:

1
2
$ 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)。

接下来编译器负责把对象文件联结在一起形成可执行文件。在我们的系统中,编译的过程如下所示:

1
2
3
4
$ 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 # generates main or main.exe

参数传递

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

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

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

Tip: 可以使用引用形参返回多个结果。

使用引用避免拷贝

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

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

const形参和实参

当形参是const时,像其他初始化过程一样,我们会忽略掉被初始化的对象(即形参)的顶层const。当形参有顶层const时,传给它常量对象或者非常量对象都是可以的:

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

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

注意,忽略掉形参的顶层const可能产生意想不到的结果:

1
2
void fcn (const int i) {	/* fcn能够读取i,但是不能向i写值 */		}
void fcn (int i) { /* ... */ } //错误,重复定义了fcn(int)

在C++语言中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显的区别。因为顶层const被忽略掉了,所以在上面的代码中传入两个fcn函数的参数可以完全一样,因此第二个fcn是错误的。

指针或引用形参与const

形参的初始化方式和变量的初始化方式是一样的,我们可以用一个非常量初始化一个底层const对象,但是反过来不行:

1
2
3
4
5
6
7
int i = 42;				
const int *cp = &i; //正确,但是cp不能改变i
const int &r = i; //正确,但是r不能改变i
const int &r2 = 42; //正确,字面值初始化常量引用
int *p = cp; //错误,p和cp的类型不匹配
int &r3 = r; //错误,r3和r的类型不匹配
int &r4 = 42; //错误,不能用字面值初始化一个非常量引用

将同样的初始化规则应用到参数传递上可得如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void reset (int *p){
*ip = 0; //改变指针ip所指对象的值
ip = 0; //只改变了ip的局部拷贝,实参未被改变
}
void reset (int &i){
i = 0; //改变了i所引对象的值
}
string::size_type find_char(const string &s , char c ,
string::size_type &occurs);
int i = 0;
const int ci = i;
string::size_type ctr = 0;
reset(&i); //调用形参类型是int*的reset函数
reset(&ci); //错误,不能用指向const int对象的指针初始化int*
reset(i); //调用形参类型是int&的reset函数
reset(ci); //错误,不能用普通引用
reset(42); //错误,不能把普通引用绑定到字面值上
reset(ctr); //错误,类型不匹配,ctr是无符号类型
//正确,find_char的第一个形参是对常量的引用
find_char("Hello World!" , 'o' , ctr);

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

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

尽量使用常量引用

把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外使用引用而非常量引用也会极大地限制函数所能接受的实参类型。

Tip: 这个函数编写习惯很重要。

数组形参

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

1
2
3
4
5
//尽管形式不同,但这三个print函数是等价的
//每个函数都有一个const int*类型的形参
void print(const int*);
void print(const int[]); //可以看出来,函数的意图是作用于一个数组
void print(const int[10]); //这里的维度表示我们期望数组含有多少个元素,实际不一定

尽管表达形式不同,但上面的三个函数是等价的:每个函数的唯一形参都是const int* 类型的。当编译器处理对print函数的调用时,只检查传入的参数是否是const int* 类型:

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

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

因为数组是以指针的形式传递给函数的,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息。管理指针形参有三种常用的技术:

使用标记指定数组长度

要求数组本身包含了一个结束标记,典型示例是C风格字符串。函数在处理C风格字符串时遇到空字符停止:

1
2
3
4
5
6
void print(const char *cp)
{
if(cp) //若cp不是一个空指针
whlie(*cp) //只要指针所指的字符不是空字符
cout << *cp++; //输出当前字符并将指针向前移动一个位置
}

使用标准库规范

传递指向数组首元素和尾后元素的指针,这种方法收到了标准库技术的启发:

1
2
3
4
5
6
7
8
9
10
11
void print( const int *beg , const int *end )
{
//输出beg和end之间(不含end)的所有元素
while ( beg != end )
cout << *beg++ << endl; //输出当前元素并将指针向前移动一个位置
}

int j[2] = { 0 , 1 };
//j转换成指向它首元素的指针
//第二个实参是指向j的尾后元素的指针
print( begin(j) , end(j) ); //标准库begin和end函数

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

专门定义了一个表示数组大小的形参,在C程序和过去的C++程序中常常使用这种方法:

1
2
3
4
5
6
7
8
9
10
11
//const int ia[]等价于const int *ia
//size表示数组的大小,将它显式地传给函数用于控制对ia元素的访问
void print( const int ia[] , size_t size )
{
for ( size_t i = 0 ; i != size ; ++i ) {
cout << ia[i] << endl;
}
}

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

以上三个print函数都把数组形参定义成了指向const的指针。当函数不需要对数组元素执行写操作的时候,数组形参应该是指向const的指针。

数组引用形参

C++语言允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上:

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

&arr 两端的括号必不可少。

因为数组的大小是构成数组类型的一部分,所以只要不超过维度,在函数体内就可以放心地使用数组。但是,这一用法也无形中限制了print函数的可用性,我们只能将函数作用于大小为10的数组。

传递多维数组

和所有数组一样,当将多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略:

1
2
3
4
//matrix指向数组的首元素,该数组的元素是由10个整数构成的数组
void print(int (*matrix)[10], int rowSize) { /* ... */ }
//等价定义
void print(int matrix[][10], int rowSize) { /* ... */ }

第二种声明看起来是一个数组,实际上形参是指向含有10个整数的数组的指针。

main: 处理命令行选项

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

1
prog -d -o ofile data0

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

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

第二个形参argv是一个数组,它的元素是指向C风格字符串的指针;第一个形参argc表示数组中字符串的数量。因为第二个形参是数组,所以main函数也可以定义成:

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

其中argv指向char*

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

以上面提供的命令行为例,argc应该等于5,argv应该包含如下的C风格字符串:

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

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

含有可变形参的函数

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

  • 如果所有的实参类型相同,可以传递一个名为initializer_list的标准库类型;
  • 如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板,其细节我们在后面章节讲解。
initializer_list形参

如果函数的实参数量未知但是全部实参的类型相同,我们可以使用initializer_list类型的形参。initializer_list是一种标准库类型,用于表示某种特定类型的值的数组。其定义在同名的头文件中,提供的操作如下表所示:

提供的操作
initializer_list lst; 默认初始化;T类型元素的空列表
initializer_list lst{a,b,c…}; lst的元素数量和初始值一样多;lst的元素是对应初始值的副本;列表中的元素是const
lst2 (lst) , lst2 = lst 拷贝或赋值一个initializer_list对象;拷贝后,原是列表和副本共享元素
lst.size() 列表中的元素数量
lst.begin() 返回指向lst中首元素的指针
lst.end() 返回1指向lst中尾元素下一位置的指针

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
void error_msg (ErrCode e, initializer_list<string> il)
{
cout << e.msg() << ": ";
for (const auto &elem : il)
cout << elem << " ";
cout << endl;
}

因为initializer_list包含beginend成员,所以我们可以使用范围for循环处理其中的元素。为了调用该版本的error_msg函数,需要额外传递一个ErrCode实参:

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

省略符形参

C++ 还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。这种功能是为了便于 C++ 程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs的C标准库功能。通常,省略符形参不应用于其他目的。你的C编译器文档会描述如何使用varargs

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

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

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

第一种形式制定了foo函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查。省略符形参对应的实参无须类型检查。在第一种形式中,形参声明后面的逗号是可选的。

返回类型和return语句

值是如何被返回的

返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数的调用结果。

同其他引用类型一样,如果函数返回引用,则该引用仅是它所引对象的一个别名。举个例子,假定某函数挑出两个string形参中较短的那个并返回其引用:

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

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

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

1
2
3
4
5
6
7
8
9
10
//严重错误:这个函数试图返回局部对象引用
const string &manip()
{
string ret;
//以某种方式改变一下ret
if( !ret.empty() )
return ret; //错误,返回局部对象的引用
else
return "Empty"; //错误,"Empty"是一个局部临时量
}

return的两个对象都是局部的,最后都会指向不可再用的空间。

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

如果函数返回指针、引用或类的对象,我们就能使用函数调用的结果访问结果对象的成员。例如,我们可以通过如下形式得到较短的string对象的长度:

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

引用返回左值

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

可以像使用其他左值那样来使用返回引用的函数的调用,特别地,我们能为返回类型是非常量引用的函数的结果赋值:

1
2
3
4
5
6
7
8
9
10
11
char &get_val (string &str , string::size_type ix)
{
return str[ix]; //get_val假定索引值是有效的
}
int main(){
string s ("a value");
cout << s << endl; //输出a value
get_val( s , 0 ) = 'A'; //将s[0]的值改为A
cout << s << endl; //输出A value
return 0;
}

列表初始化返回值

C++11新标准规定,函数可以返回花括号包围的值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。

例如,在下面的函数中,我们返回一个vector对象,用它存放表示错误信息的string对象:

1
2
3
4
5
6
7
8
9
10
11
vector<string> process()
{
// ...
// expected 和 actual 是 string 对象
if (expected.empty())
return {}; //返回一个空vector对象
else if (expected == actual)
return {"functionX","okey"}; //返回列表初始化的vector对象
else
return {"functionX",expected,actual};
}

第一条return语句返回一个空列表,此时,process函数返回的vector对象是空的。如果expected不为空,根据expectedactual是否相等,函数返回的vector对象分别用两个或三个元素初始化。

如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间。如果函数返回的是类类型,由类本身定义初始值如何使用。

主函数main的返回值

main函数的返回值可以看做是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。

为了使返回值与机器无关,cstdlib头文件定义了两个预处理变量,我们可以使用这两个变量分别表示成功和失败:

1
2
3
4
5
6
int main(){
if (some_failure)
return EXIT_FAILURE; //定义在cstdlib头文件中
else
return EXIT_SUCCESS; //定义在cstdlib头文件中
}

因为它们是预处理变量,所以既不能在前面加上std::,也不能在using声明中出现。

返回数组指针

因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化这一任务,其中最直接的方法是使用类型别名:

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

其中arrT是含有10个整数的数组的别名。因为我们无法返回数组,所以将返回类型定义成数组的指针。因此,func函数接受一个int实参,返回一个指向包含10个整数的数组的指针。

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

要想在声明func时不使用类型别名,我们必须记住,数组的维度应跟随在要定义的数组名之后:

1
2
3
int arr[10];			//arr是一个含有10个整数的数组
int *p1[10]; //p1是一个含有10个指针的数组
int (*p2)[10] = &arr; //p2是一个指针,它指向含有10个整数的数组

和这些声明一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。然而,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下所示:

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

类似于数组的声明,Type表示元素的类型,dimension表示数组的大小。(*function(parameter_list))两端的括号必须存在,就像我们定义p2时两端必须有括号一样。如果没有这对括号,函数的返回类型将是指针的数组。

举个更具体的例子,线面这个func函数的声明没有类型别名:

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

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

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

使用尾置返回类型

在C++11新标准中还有一种可以简化上述func声明的方法,就是使用尾置返回类型(trailing return type)。任何函数的定义都能使用尾置返回,但是这种形式对于返回类型比较复杂的函数最有效,比如返回类型是数组的指针或者数组的引用。尾置返回类型跟在形参列表后面并以一个 -> 符号开头。为了表示函数真正的返回类型跟在形参列表之后,我们在本应该出现返回类型的地方放置一个auto

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

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

使用decltype

还有一种情况,如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype关键字声明返回类型。例如,下面的函数返回一个指针,该指针根据参数 i 的不同指向两个已知数组中的某一个:

1
2
3
4
5
6
7
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返回指针还必须在函数声明时加一个 * 符号。

Note: Luv个人觉得此处的decltype作用相当于前面的typedef取别名的方法。

函数重载

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

函数的名字仅仅是让编译器知道它调用的是哪个函数,而函数重载可以在一定程度上减轻程序员起名字、记名字的负担。

main函数不能重载。

定义重载函数

对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。

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

1
2
Record lookup(const Account&);
cool lookup(const Account&); //错误,与上一个函数相比只有返回类型不同
1
2
3
typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); //Telno和Phone类型相同

类型别名不是创建新类型,两个声明是一样的。

重载和const形参

顶层const不影响传入函数的对象。一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来:

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

两组声明分别是等价的。

另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的const是底层的:

1
2
3
4
5
6
//对于接受引用或指针的函数来说,对象是常量还是非常量对应的形参不同。
//以下定义了4个独立的重载函数
Record lookup(Account&); //函数作用于Account的引用
Record lookup(const Account&); //新函数,作用于常量引用
Record lookup(Account*); //新函数,作用于指向Account的指针
Record lookup(const Account*); //新函数,作用于指向常量的指针

以上,我们只能把const对象传给使用const形参的函数;而4个函数都能作用于非常量对象或者指向非常量对象的指针。

不过,当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。

const_cast和重载

const_cast在重载函数的情境中最有用。例如以下的shortString函数:

1
2
3
4
//比较两个string对象的长度,返回较短的那个引用
const string &shortString(const string &s1, const string &s2){
return s1.size <= s2.size() ? s1 : s2;
}

这个函数的参数和返回类型都是const string的引用。我们可以对两个非常量的string实参调用函数,但返回的结果仍是const string的引用。因此我们需要一种新的shortString函数,当它的实参不是常量时,得到的结果是一个普通的引用,使用const_cast可以做到这一点:

1
2
3
4
string &shortString(string &s1, string &s2){
auto &r = shortString(const_cast<const string&>(s1), const_cast<const string&>(s2));
return const_cast<string&>(r);
}

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

调用重载的函数

函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用和一组重载函数中的某一个关联起来,函数匹配也叫做重载确定(overload resolution)。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个参数。

调用重载函数时可能有三个结果:

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

Tip: 不要将函数声明在局部作用域内。尤其当重载函数时,内层的函数声明会隐藏外层的同名函数声明,使得作用域内不能准确匹配重载函数。

特殊用途语言特性

以下是一些对程序非常有用的语言特性,分别是:默认实参、内联函数和constexpr函数,以及在程序调试过程中常用的一些功能。

默认实参

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

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

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

其中我们为每一个形参都提供了默认实参,默认实参作为形参的初始值出现在形参列表中。我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。

使用默认实参

使用默认实参只要在调用函数时省略该实参就可以了:

1
2
3
4
5
string window;
window = screen(); //等价于screen(24,80,' ')
window = screen(66); //等价于screen(66,80,' ')
window = screen(66,256); //screen(66,256,' ')
window = screen(66,256,'#'); //screen(66,256,'#')

函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。例如想要覆盖backgrnd的默认值,必须为htwid提供实参:

1
2
window = screen( , , '?');		//错误,只能省略尾部的实参
window = screen('?'); //调用screen('?',80,' ')

需要注意,第二个调用传递一个字符值,是合法的调用。但是它的实际效果却与书写的意图不符。

当设计含有默认实参的函数时,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。

默认实参声明

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

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

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

1
string screen(sz, sz, char = '*');			//错误,重复声明

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

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

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

设置和改变默认实参初始值

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

1
2
3
4
5
6
//wd、def和ht的声明必须出现在函数之外
sz wd = 80;
char def = ' ';
sz ht();
string screen(sz = ht(), sz = wd, char = def);
string window = screen(); //调用screen(ht(), 80, ' ')

用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:

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

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

内联函数和constexpr函数

把先前像shortString这样的规模较小的操作定义为一个函数有很多好处,但也存在一个潜在的缺点:调用函数一般比求等价表达式的值要慢一些。

在大多数机器上,一次函数调用其实包含着一系列工作:调用前要先保存寄存器,并在返回时恢复;可能需要拷贝实参;程序转向一个新的位置继续执行。

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

将函数指定为内联函数(inline),通常就是将它在每个调用点上“内联地”展开。假设我们把shortString函数定义成内联函数。则如下调用:

1
cout << shortString(s1, s2) << endl;

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

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

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

shortString函数的返回类型前面加上关键字inline,就可以将它声明成内联函数了:

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

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

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

constexpr函数

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

定义constexpr函数的方法与其他函数类似,不过要遵守两项约定:

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

我们把new_sz定义成无参数的constexpr函数。

因为编译器能在程序编译时验证new_sz函数返回的是常量表达式,所以可以用new_sz函数初始化constexpr类型的变量foo

以上展示的是constexpr函数返回常量表达式的情况,但实际上,constexpr函数也能返回非常量表达式,这取决于调用该类型函数传递的实参是否有常量表达式:

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

scale的实参是常量表达式时,它的返回值也是常量表达式;反之则不然:

1
2
3
int arr[scale(2)];		//正确,scale(2)是常量表达式
int i = 2; //i不是常量表达式
int a2[scale(i)]; //错误,scale(i)不是常量表达式

如上例所示,当我们给scale函数传入一个形如字面值2的常量表达式时,它的返回类型也是常量表达式。此时,编译器用相应的结果值替换对scale函数的调用。

如果我们用一个非常量表达式调用scale函数,比如int类型的对象i,则返回值是一个非常量表达式。当把scale函数用在需要常量表达式的上下文中时,由编译器负责检查函数的结果是否符合要求。如果结果恰好不是常量表达式,编译器将发出错误消息。

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

Tip: 内联函数和constexpr函数通常定义在头文件中。

调试功能

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

assert预处理宏

assert是一种预处理宏(preprocessor marco)。所谓预处理宏其实是一个预处理变量,它的行为有点类似于内联函数。assert宏使用一个表达式作为它的条件:

1
assert(expr);

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

assert宏定义cassert头文件中。如我们所知,预处理名字由预处理器而非编译器管理,因此我们可以直接使用预处理名字而无须提供using声明。也就是说,我们应该使用assert而不是std::assert,也不需要为assert提供using声明。

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

即使没有包含cassert头文件,也最好不要为了其他目的使用assert,因为很多头文件都包含了cassert。

assert宏常用来检查“不允许发生”的条件。例如,一个对输入文本进行操作的程序可能要求所有给定单词的长度都大于某个阈值。此时,程序可以包含一条如下所示的语句:

1
assert(word.size() > threshold);

NDEBUG预处理变量

assert的行为依赖于一个名为NDEBUG的预处理变量的状态。如果定义了NDEBUG,则assert什么也不做。默认状态下没有定义NDEBUG,此时assert将执行运行时检查。

我们可以使用一个#define语句定义NDEBUG,从而关闭调试状态。同时,很多编译器提供了一个命令行选项使我们可以定义预处理变量:

1
$ CC -D NDEBUG main.C # use /D with the Microsoft compiler

这条命令的作用等价于在main.c文件的一开始写 #define NDEBUG

定义NDEBUG能避免检查各种条件所需的运行时开销,当然此时根本就不会执行运行时检查。因此,assert应该仅用于验证那些确实不可能发生的事情。我们可以把assert当成调试程序的一种辅助手段,但是不能用它替代真正的运行时逻辑检查,也不能替代程序本身应该包含的错误检查。

除了用于assert外,也可以使用NDEBUG编写自己的条件调试代码。如果NDEBUG未定义,将执行#ifndef和#endif之间的代码;如果定义了NDEBUG,这些代码将被忽略掉:

1
2
3
4
5
6
7
8
void print( const int ia[], size_t size)
{
#ifndef NDEBUG
// __func__ 是编译器定义的一个局部静态变量,用于存放函数的名字。
cerr << __func__ << ": array size is " << size << endl;
#endif
//...
}

Note: 以下所有的变量名中的空格只是为了书写展示,在实际代码中都是没有的。

在这段代码中,我们使用变量 _ _ func _ _ 输出当前调试的函数的名字。编译器为每个函数都定义了 _ _ func _ _ ,它是const char的一个静态数组,用于存放函数的名字。

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

变量名 用途
_ _ FILE _ _ 存放文件名的字符串字面值
_ _ LINE _ _ 存放当前行号的整型字面值
_ _ TIME _ _ 存放文件编译时间的字符串字面值
_ _ DATA _ _ 存放文件编译日期的字符串字面值

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

1
2
3
4
5
6
7
8
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;

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

1
2
3
Error: Wdebug.cpp : in function print at line 19
Compiled on Mar 10 2024 at 16:45:10
Word read was "foo": Length too short

函数匹配

  1. 先选定候选函数:与被调用函数同名、其声明在调用点可见。
  2. 再选出可行函数:实参数量足够(允许默认实参),每个实参的类型与对应的形参类型相同或者能转换成形参的类型。
  3. 在可行函数中找最佳匹配(如果有的话)。

最佳匹配

如果有且只有一个函数满足下列条件,则匹配成功:

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

如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用错误。

所有算数类型转换的级别是一样的。例如,从int向unsigned int的转换并不比从int向double的转换级别高:

1
2
3
void manip(long);
void manip(float);
manip(3.14); //错误,二义性调用

字面值3.14的类型是double,它既能转换成float。因为存在两种可能的算术类型转换,所以该调用具有二义性。

函数指针

函数指针指向的是函数而非对象。

和其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关,例如:

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

该函数的类型是bool(const string&, const string&)。要想声明一个可以指向该函数类型的指针,只需要用指针替换该函数名即可:

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

从我们声明的名字开始观察,pf前面有个 *,因此pf是指针;右侧是形参列表,表示pf指向的是函数;再观察左侧,发现函数的返回类型是布尔值。因此,pf是一个指向函数的指针,其中该函数的参数是两个const string的引用,返回值是bool类型。

*pf两端的括号必不可少,否则pf是一个返回值为bool指针的函数。

使用函数指针

当我们把函数名作为一个值使用时,该函数自动地转换成指针。例如,按照如下形式我们可以将lengthCompare的地址赋给pf

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

此外,我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针:

1
2
3
bool b1 = pf("hello", "goodbye");				//调用lengthCompare函数
bool b2 = (*pf)("hello", "goodbye"); //一个等价的调用
bool b3 = lengthCompare("hello", "goodbye"); //另一个等价的调用

在指向不同函数类型(返回类型不同或形参类型不同)的指针之间不存在转换规则。但是,我们可以为函数指针赋一个nullptr或者值为0的整型常量表达式,表示该指针没有指向任何一个函数。

重载函数的指针

当我们使用重载函数时,指针类型必须与重载函数中的某一个精准匹配:

1
2
3
4
5
6
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的返回类型不匹配

函数指针形参

Note: Luv认为这一块和下一块的内容跟数组指针做形参的内容极其相似。

和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用:

1
2
3
4
//第三个形参是函数类型,它会自动地转换成指向函数的指针
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 &) );

我们可以直接把函数作为实参使用,此时它会自动转换成指针:

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

正如useBigger的声明语句所示,直接使用函数指针类型显得冗长而烦琐。类型别名和decltype能让我们简化使用了函数指针的代码:

1
2
3
4
5
6
//Func和Func2是函数类型
typedef bool Func(const string&, const string&);
typedef decltype(lengthCompare) Func2; //等价的类型
//FuncP和FuncP2是指向函数的指针
typedef bool (*FuncP)(const string&, const string&);
typedef decltype(lengthCompare) *FuncP2; //等价的类型

decltype返回函数类型,不会自动转为指针类型,需要在结果前面加上 * 才能得到指针。可以按如下形式重新声明useBigger

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

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

返回指向函数的指针

和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。

与往常一样,要想声明一个返回函数指针的函数,最简单的方法就是使用类型别名:

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

将F定义成函数类型,将PF定义成指向函数类型的指针。必须注意的是,和函数类型的形参不同,返回类型不会自动地转换成指针。我们必须显式地将返回类型指定为指针:

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

当然,我们也能用下面的形式直接声明f1

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

按照由内向外的顺序阅读这条声明语句:我们看到f1有形参列表,所以f1是个函数;f1前面有 *,所以f1返回一个指针;进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是int

还可以使用尾置返回类型的方式:

1
auto f1(int) -> int (*)(int*, int);
将auto和decltype用于函数指针类型

如果明确知道返回的函数是哪一个,就能使用decltype简化书写函数指针返回类型的过程:

1
2
3
4
string::size_type sumLength(const string&, const string&);
string::size_type largerLength(const string&, const string&);
//根据其形参的取值,getFun函数返回指向sumLength或者largeLenth的指针
decltype(sumLength) *getFun(const string&);

声明getFun需要注意,decltype返回函数类型而非指针类型,因此必须显式地加上 * 以表示我们需要返回指针,而非函数本身。