开场白
相比于第一周,第二周刚开始就举步维艰。
课余的空闲时间减少、课程作业变多、阅读的信息量增大……
同样是三个章节的跨度,第二周需要新学的东西多了很多,笔记的篇幅也相应地增长,甚至已经没法做到“一天20面”的可能了。
预期是第二周完成第七章,看来也必须放在第三周了。
不过,
那又如何呢?
无非是完成得更晚一点罢了。
~o( =∩ω∩= )m
万一禅关砉然破,美人如玉剑如虹。
Week 1: 潜龙勿用
点击跳转第一期内容:潜龙勿用
Week 2: 见龙在田
表达式
表达式由一个或多个运算对象(operand)组成,对表达式求值将得到一个结果(result)。字面值和变量是最简单的表达式(expression),其结果就是字面值和变量的值。把一个运算符(operator)和一个或多个运算对象组合起来可以生成较复杂度表达式。
本章节在书中的内容与C语言基础知识高度重合,笔记也因此省略了很多。
基本概念
重载运算符
当运算符作用于类类型的运算对象时,用户可以自行定义其含义,成为重载运算符(overloaded operator)。IO库的 >> 和 << 运算符以及string对象、vector对象和迭代器使用的运算符都是重载的运算符。
我们使用重载运算符时,其包括运算对象的类型和返回值的类型,都是由该运算符定义的;但是运算对象的个数、运算符的优先级和结合律都是无法改变的。
左值和右值
左值(rvalue)和右值(lvalue)的概念是从C语言继承过来的。一般来说,在需要右值的地方可以用左值来代替,但是不能把右值当成左值使用。
使用decltype关键字时,左值和右值有所不同。如果表达式的求值结果是左值,decltype作用于该表达式(不是变量)得到一个引用类型。例如:
1 | int a1 = 42 , a2 = 21; |
另一方面,因为取地址运算符生成右值,所以decltype(&p)
的结果是int**
,也就是说,结果是一个指向整形指针的指针。
算术运算符
算术运算符的运算对象和求值结果都是右值。
除法和取余
C++11新标准规定:
除法运算中,商一律向0取整(即直接切除小数部分)。
加入取余运算后,除了-m导致溢出的特殊情况,其他时候(-m)/n
和m/(-n)
都等于-(m/n)
,m%(-n)
等于m%n
,(-m)%n
等于-(m%n)
。
1 | 21 % 6; /* 结果是3 */ 21 / 6; /* 结果是3 */ |
如果m%n不等于0,则它的符号和m相同。
逻辑和关系运算符
逻辑与和逻辑或运算符
逻辑与(&&)和逻辑或(||)运算符都是先求左侧运算对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时才会计算右侧运算对象的值。这种策略称为短路求值(short-circuit evaluation)。
举一个使用逻辑或的例子:
假定有一个存储着若干string
对象的vector
对象,要求输出string
对象的内容并且在遇到空字符串或者以句号结束的字符串时进行换行。使用基于范围的for
循环处理string
对象中的每个元素:
1 | //s是对常量的引用;元素既没有被拷贝也没有被改变 |
关系运算符
关系运算符比较运算对象的大小并返回布尔值。关系运算符都满足左结合律。
赋值运算符
赋值运算的结果是它的左侧运算对象,并且是一个左值,类型即左侧运算对象的类型。如果左右运算对象类型不同,则右侧运算对象转换成左侧运算对象的类型。
1 | int k = 0; |
C++11新标准允许使用花括号括起来的初始值列表作为赋值运算语句的右侧运算对象:
1 | k = {3.14}; //错误:窄化转换 |
如果左侧运算对象是内置类型,那么初始值列表最多只能包含一个值,而且该值即使转换的话也不应该大于目标类型的空间。
vector模板重载了赋值运算符并且可以接受初始值列表,当赋值发生时用右侧运算对象的元素替换左侧运算对象的元素。
赋值运算满足右结合律:
1 | int ival , jval; |
赋值运算优先级较低
因为赋值运算优先级相对较低,所以将其放在条件中时,需要加上括号。
将赋值语句放在条件当中是很好的写法,以下是示例,其中循环的目的是反复调用一个函数直到返回期望的值为止:
1 | //这是一种繁琐、容易出错的写法 |
第二个版本的while
条件更容易表达我们的真实意图:不断循环读取数据直到遇到42
为止。
递增和递减运算符
Tip: 非必需情况下,不使用递增递减运算符的后置版本。由于需要保存被改变量的原始值,在不需要原始值的情况下使用后置版本的操作就是一种浪费。这种浪费对于复杂的迭代器类型可能开销巨大。
建议养成使用前置版本的习惯。
在一条语句中混用解引用和递增运算符
这里使用需要后置版本的例子:
1 | auto pbeg = v.begin(); |
后置递增运算符的优先级高于解引用运算符。因此 *pbeg++
等价于 *(pbeg++)
。
Tip: 形如 *pbeg++ 的表达式一开始可能不太容易理解,但其实这是一种被广泛使用的、有效的写法。当这种形式熟悉之后,书写
1 cout << *iter++ << endl;要比书写下面的等价语句更简洁、也更少出错
1
2 cout << *iter << endl;
++iter;
条件运算符
条件运算符( ? : )允许我们把简单的if-else逻辑嵌入到单个表达式当中,按如下形式使用:
1 | cond ? expr1 : expr2; |
cond
是判断条件的表达式,而expr1
和expr2
是两个类型相同或可能转换为某个公共类型的表达式。条件运算符同样使用短路求值的策略。
嵌套条件运算符
允许在条件运算符的内部嵌套另一个条件运算符:
1 | string finalgrade = ( grade > 90 ) ? "high pass" |
条件运算符满足右结合律,意味着运算对象(一般)按照从右向左的顺序组合。因此在上述代码中,靠右边的条件运算构成了靠左边的条件运算的 : 分支
在输出表达式中使用条件运算符
条件运算符的优先级非常低,通常需要在两端加上括号:
1 | cout << ( ( grade < 60 ) ? "fail" : "pass" ); //输出pass或者fail |
其中,第二条表达式等价于:
1 | cout << ( grade < 60 ); //输出0或1 |
第三条表达式等价于:
1 | cout << grade; //小于运算符的优先级低于移位运算符,所以先输出grade |
位运算符
位运算符作用于整数类型的运算对象,并把运算对象看成是二进制位的集合。
一般来说,如果运算对象是“小整型”,则它的值会被自动提升成较大的整数类型。如果运算对象是带符号的且它的值为负,那么位运算符如何处理运算对象的“符号位”依赖于机器。而且,此时的左移操作可能会改变符号的值,因此是一种未定义的行为。
移位运算符(又叫IO运算符)满足左结合律。
sizeof运算符
sizeof运算符返回一条表达式或一个类型名字所占的字节数。sizeof
满足右结合律,其所得到的值是一个size_t
类型的常量表达式。
该运算符的运算对象有两种形式:
1 | sizeof (type) sizeof expr |
在第二种形式中,sizeof
返回的是表达式结果类型的大小,而且sizeof
并不会实际计算其运算对象的值:
1 | Sales_data data , *p; |
注意
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 | vector<int>::size_type cnt = ivec.size(); |
类型转换
如果两种类型之间可以相互转换(conversion),那么它们就是关联的。
算术转换
把一种算术类型转换成另一种算术类型。
整型提升
整型提升(integral promotion)负责把小整数类型转换成较大的整数类型。对于bool、char、signed char、unsigned char、short、和unsigned short等类型来说,只要它们所有可能的值都能在int中,就会提升成int类型,否则提升成unsigned int类型。
布尔值false提升成0、true提升成1。
较大的char类型(wchar_t、char16_t、char32_t)提升成int、unsigned int、long、unsigned long、long long和unsigned long long中最小的一种类型,前提是转换后的类型要能容纳原类型所有可能的值。
无符号类型运算对象
如果一个运算对象是无符号类型,另一个是带符号类型:
① 若无符号类型不小于带符号类型,那么有符号类型转变成无符号的。
例如unsigned int和int运算,int转换为unsigned int。此时就可能导致运算错误。
② 若带符号类型大于无符号类型,此时转换结果依赖于机器。并再细分为两种情况:
(1) 无符号类型的所有值均能存在带符号类型中,则“无”转“带”。
例如long和unsigned int,long占用空间比int大,则unsigned int转long。
(2) 否则,“带”转“无”
例如long和unsigned int,int和long大小相同,则long转unsigned int。
其他隐式转换
数组转换成指针
在大多数用到数组的表达式中,数组自动转换成指向数组首元素的指针:
1 | int ia[10]; |
当数组被用作decltype
关键字的参数、或者作为取地址符(&)、sizeof
及typeid
等运算符的运算对象时,上述转换不会发生。同样,如果用一个引用来初始化数组,上述转换也不会发生。
指针转换
C++还规定了几种其他的指针转换方式:
- 常量整数值
0
或者字面值nullptr
能转换成任意指针类型 - 指向任意非常量的指针可以转换成
void*
- 指向任意对象的指针能转换成
const void*
- (有继承关系的类型间还有另外一种指针转换方式)
转换成布尔类型
如果指针或算术类型的值为0
,转换结果为false
;否则为true
。
转换成常量
允许将指向非常量类型的指针转换成指向相应类型的常量类型的指针。也就是说,如果T是一种类型,我们就能将指向 T 的指针或引用分别转换成指向 const T 的指针或引用。
1 | int i; |
相反的转换并不存在,因为它试图删掉底层const
。
类类型定义的转换
类类型能定义由编译器自动执行的转换,不过编译器每次只能执行一种类类型的转换。(如果同时提出多个转换请求,这些请求将被拒绝。)
已有的例子:
1 | string s , t = "a value"; //字符串字面值转换成string类型 |
IO库定义了从istream向布尔值的转换规则。
显式转换
有时我们希望显式地将对象强制转换成另外一种类型,这种方法称作强制类型转换(cast)。
Tip: 虽然有时不得不使用强制类型转换,但这种方法本质上是非常危险的,干扰了正常的类型检查,应尽量避免使用。
命名的强制类型转换
一个命名的强制类型转换具有如下形式:
1 | cast-name <type> (expression); |
其中,type是转换的目标类型而expression是要转换的值。如果type是引用类型,则结果为左值。cast-name
是static_cast
、dynamic_cast
、const_cast
、reinterpret_cast
中的一种,指定了执行的是那种转换。
(dynamic_cast
支持运行时类型识别,此处先掠过)
static_cast
任何具有明确定义的类型转换,只要不包含底层const,都可以使用static_cast
。例如,通过将一个运算对象强制转换成double
类型就能使表达式执行浮点数除法:
1 | //进行强制类型转换以便执行浮点数除法 |
当需要把一个较大的算术类型赋值给较小的类型时,static_cast
非常有用。此时相当于告诉编译器:我们知道且不在乎潜在的精度损失。
一般来说,如果编译器发现了一个较大的算术类型试图赋值给一个较小的类型,就会发出警告信息;但是当我们执行了显示的类型转换后,警告信息就被关闭了。
static_cas
t对于编译器无法自动执行的类型转换也非常有用。例如,我们可以使用static_cast
找回存在于void*
的指针:
1 | void* p = &d; //正确,任何非常量对象的地址都能存入void* |
我们必须保证转换后所得的类型就是指针所指的类型。类型一旦不符,将产生未定义的后果。
const_cast
const_cast
只能改变运算对象的底层const
。
1 | const char *pc; |
对于常量对象转换为非常量对象的行为,一般称为“去掉const性质(cast away the const)”。一旦去掉某个对象的const
性质,编译器就不再阻止我们对该对象进行写操作了。如果该对象本身不是个常量,使用强制类型转换是合法的行为;然而如果对象是个常量,在使用const_cast
执行写操作就会产生未定义的后果。
只有const_cast能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。
同样地,const_cast
不能用于改变表达式类型:
1 | const char *cp; |
const_cast常用于有函数重载的上下文中
reinterpret_cast
reinterpret_cast
通常为运算对象的位模式提供较低层次上的重新解释。
假设有如下转换:
1 | int *ip; |
必须牢记pc
所指的是int
而非字符,如果把pc
当成普通的字符指针使用就可能在运行时发生错误:
1 | string str(pc); |
使用reinterpret_cast非常危险。
reinterpret_cast本质上依赖于机器,要想安全使用reinterpret_cast必须对涉及的类型和编译器实现转换的过程都非常了解。
旧式的强制类型转换
在早期版本的C++语言中,显式地进行强制类型转换包含两种形式:
1 | type (expr); //函数形式的强制类型转换 |
根据所涉及的类型不同,旧式的强制类型转换分别与const_cast
、static_cast
和reinterpret_cast
有相似的行为。当我们执行旧式的强制类型转换时,如果换成const_cast
和static_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 | char ch = getVal(); |
任何两个case
标签的值不能相同,否则就会引发错误。另外,default
也是一种特殊的case
标签。
如果没有任何一个case
标签能匹配上switch
表达式的值,程序将执行紧跟在default标签(default label)后面的语句。
Tip: default部分程序的最后,如果不加break语句,也会像其它case一样继续向下执行,而不是跳出当前的控制流。
for语句
这一部分for语句以及不在这里呈现的while语句、dowhile语句都继承了C语言的性质,只提少部分重点。
传统for语句
1 | for ( init-statement ; condition ; expression ) |
关键字for以及括号里的部分称作for语句头。
Tip: 和其他的声明一样,init-statement也可以定义多个对象。但是init-statement只能有一条声明语句,因此,所有变量的基础类型必须相同。
范围for语句
1 | for ( declaration : expression ) |
expression
表示的必须是一个序列,比如用花括号括起来的初始值列表、数组或者vector
或string
等类型的对象。这些类型的共同特点是拥有能返回迭代器的begin
和end
成员。
范围for
语句的定义来源于与之等价的传统for
语句:
1 | for ( auto beg = v.begin(), end = v.end() ; beg!= end ; ++beg ) { |
这也是为什么不能通过范围for语句增加vector对象(或其他容器)的元素的原因。
在范围for语句中,预存了end()的值。一旦在序列中添加(删除)元素,end函数的值就可能变得无效了。
跳转语句
跳转语句中断当前的执行过程。C++ 语言提供了4种跳转语句:break、continue、goto和return。(这里只介绍goto语句。)
goto语句
goto语句(goto statement)的作用是从goto
语句无条件跳转到同一函数内的另一条语句。
不要在程序中使用goto语句,因为它使得程序既难理解又难修改。
goto
的语法形式是:
1 | goto label; |
其中,label
是用于标识一条语句的标示符。带标签语句(labeled statement)是一种特殊的语句,在它之前有一个标示符以及一个冒号:
1 | end: return; //带标签语句,可以作为goto的目标 |
标签标示符独立于变量或其他标示符的名字,因此,标签标示符可以和程序中其他实体的标示符使用同一个名字而不会相互干扰。goto
语句和控制权转向的那条带标签的语句必须位于同一个函数内。
和switch
类似,goto
语句也不能将程序的控制权从变量的作用域外转移到作用域之内:
1 | //... |
向前跳过一个已经执行的定义是合法的。跳回到变量定义之前意味这系统将销毁该变量,然后重新创建它:
1 | //向后跳过一个带初始化的变量定义是合法的 |
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 | Sales_item item1, item2; |
在真实的程序中,应该把对象相加的代码和用户交互的代码分离开来。此例中,我们改写程序使得检查完成后不在直接输出一条信息,而是抛出一个异常:
1 | //首先检查两条数据是否是关于同一种书籍的 |
如果ISBN
不一样,就抛出一个异常。该异常是类型runtime error的对象。抛出异常将终止当前的函数,并把控制权转移给能处理该异常的代码。
类型runtime_error
是标准库异常类型的一种,定义在stdexpr头文件中。我们必须初始化runtime_error
的对象,方式是给它提供一个string
对象或者一个C风格字符串,这个字符串中有一些关于异常的辅助信息。
try语句块
try
语句块的通用语法形式是:
1 | try { |
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 | while ( cin >> item1 >> item2 ) { |
程序本来要执行的任务出现在try
语句块中,这是因为这段代码可能会抛出一个runtime_error
类型的异常。
try
语句块对应一个catch
子句,该子句负责处理类型为runtime_error
的异常。如果try语句块的代码抛出了runtime_error
异常,接下来执行catch
块内的语句。在我们书写的catch
子句中,输出一段提示信息要求用户指定程序是否继续。如果用户输入’n’,执行break
语句并退出while
循环;否则,直接执行while
循环的右侧花括号,意味着程序控制权跳回到while
条件部分准备下一次迭代。
给用户的提示信息中输出了err.what()
的返回值。我们知道err
的类型是runtime_error
,因此能推断what
是runtime_error
的一个成员函数。每个标准库异常类都定义了名为what
的成员函数,这些函数没有参数,返回值是C风格字符串(即const char*
)。其中,runtime_error
的what
成员返回的是初始化一个具体对象时所用的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
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 | 逻辑错误:使用一个超出有效范围的值 |
标准异常类只定义了几种运算,包括创建或拷贝异常类型的对象,以及为异常类型的对象赋值。
我们只能以默认初始化的方式初始化exception
、bad_alloc
和bad_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 | void f1 () { /* ... */ } //隐式地定义空形参列表 |
形参列表中的形参通常用逗号隔开,其中每个形参都是含有一个声明符的声明。即使两个形参的类型一样,也必须把两个类型都写出来:
1 | int f3 ( int v1 , v2 ) { /* ... */ } //错误 |
任意两个形参不能同名,而且函数最外层作用域中的局部变量也不能使用与函数形参一样的名字。
形参名是可选的(可以不写)。但是由于我们无法使用未命名的形参,所以形参一般都应该有个名字。是否设置未命名的形参并不影响调用时提供的实参数量,即使某个形参不被函数使用,也必须为它提供一个形参。
函数返回类型
函数的返回类型不能是数组类型或函数类型,但可以是指向数组或函数的指针。
局部对象
在C++语言中,名字有作用域,对象有生命周期(lifetime)。
- 名字的作用域是程序文本的一部分,名字在其中可见。
- 对象的生命周期是程序执行过程中该对象存在的一段时间。
局部对象的生命周期依赖于定义的方式。
自动对象
我们把只存在于块执行期间的对象称为自动对象(automatic object)。当块的执行结束后,块中创建的自动对象被销毁,值也变成未定义的。
形参是一种自动对象。函数开始时为形参申请存储空间,因为形参定义在函数体作用域之内,所以一旦函数终止,形参也就被销毁。
局部静态对象
某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成static
类型从而获得这样的对象。局部静态对象(local static object)在程序的执行路径第一次经过定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
函数声明
和其他名字一样,函数的名字也必须在使用之前声明。类似于变量,函数只能定义一次,但可以声明多次。
如果一个函数永远也不会被我们用到,那么它可以只用声明没有定义
因为函数的声明不包含函数体,所以也就无须形参的名字。事实上,在函数的声明中经常省略形参的名字。尽管如此,写上形参的名字还是有用处的,它可以帮助使用者更好地理解函数的功能:
1 | //我们选择beg和end作为形参的名字以表示这两个迭代器划定了输出值的范围 |
函数的三要素(返回类型、函数名、形参类型)描述了函数的接口,说明了调用该函数所需的全部信息。函数声明也称作函数原型(function prototype)。
在头文件中进行函数声明
与变量类似,函数也应该在头文件中声明而在源文件中定义,方便确保同一函数的所用声明保持一致。
定义函数的源文件应该把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。
分离式编译
为了允许编写程序时按照逻辑关系将其划分开来,C++语言支持所谓的分离式编译(separate compilation)。分离式编译允许我们把程序分隔到几个文件中去,每个文件独立编译。
编译和链接多个源文件
举个例子,假设fact
函数的定义位于一个名为fact.cc
的文件中,它的声明位于名为Chapter6.h
的头文件中。显然与其他所用用到fact
函数的文件一样,fact.cc
应该包含Chapter6.h
头文件。另外,我们在名为factMain.cc
的文件中创建main
函数,main
函数将调用fact
函数。要生成可执行文件(executable file),必须告诉编译器我们用到的代码在哪里。
对于上述几个文件来说,编译的过程如下所示:
1 | $ CC factMain.cc fact.cc # generates factMain.exe or a.out |
其中,CC是编译器的名字,$ 是系统提示符, # 后面是命令行下的注释语句。接下来运行可执行文件,就会执行我们定义的main
函数。
如果我们修改了其中一个源文件,那么只需重新编译那个改动了的文件。大多数编译器提供了分离式编译每个文件的机制,这一过程通常会产生一个后缀名是 .obj(Windows) 或 .o(UNIX) 的文件,后缀名的含义是该文件包含对象代码(object code)。
接下来编译器负责把对象文件联结在一起形成可执行文件。在我们的系统中,编译的过程如下所示:
1 | $ CC -c factMain.cc # generates factMain.o |
参数传递
当形参是引用类型时,我们说它对应的实参被引用传递(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 | void fcn (const int i) { /* fcn能够读取i,但是不能向i写值 */ } |
在C++语言中,允许我们定义若干具有相同名字的函数,不过前提是不同函数的形参列表应该有明显的区别。因为顶层const
被忽略掉了,所以在上面的代码中传入两个fcn
函数的参数可以完全一样,因此第二个fcn
是错误的。
指针或引用形参与const
形参的初始化方式和变量的初始化方式是一样的,我们可以用一个非常量初始化一个底层const
对象,但是反过来不行:
1 | int i = 42; |
将同样的初始化规则应用到参数传递上可得如下形式:
1 | void reset (int *p){ |
要想调用引用版本的reset
,只能使用int
类型的对象,而不能使用字面值、求值结果尾int
的表达式、需要转换的对象或者const int
类型的对象。类似的,要想调用指针版本的reset
只能使用int*
。
另一方面,我们能传递一个字符串字面值作为find_char
的第一个实参,这是因为该函数的引用形参是常量引用,而C++允许我们用字面值初始化常量引用。
尽量使用常量引用
把函数不会改变的形参定义成(普通的)引用是一种比较常见的错误,这么做带给函数的调用者一种误导,即函数可以修改它的实参的值。此外使用引用而非常量引用也会极大地限制函数所能接受的实参类型。
Tip: 这个函数编写习惯很重要。
数组形参
尽管不能以值传递的方式传递数组,但是我们可以把形参写成类似数组的形式:
1 | //尽管形式不同,但这三个print函数是等价的 |
尽管表达形式不同,但上面的三个函数是等价的:每个函数的唯一形参都是const int*
类型的。当编译器处理对print
函数的调用时,只检查传入的参数是否是const int*
类型:
1 | int i = 0 , j[2] = { 0 , 1 }; |
如果我们传给print
函数的是一个数组,则实参自动地转换成指向数组首元素的指针,数组的大小对函数调用没有影响。
因为数组是以指针的形式传递给函数的,所以一开始函数并不知道数组的确切尺寸,调用者应该为此提供一些额外的信息。管理指针形参有三种常用的技术:
使用标记指定数组长度
要求数组本身包含了一个结束标记,典型示例是C风格字符串。函数在处理C风格字符串时遇到空字符停止:
1 | void print(const char *cp) |
使用标准库规范
传递指向数组首元素和尾后元素的指针,这种方法收到了标准库技术的启发:
1 | void print( const int *beg , const int *end ) |
显示传递一个表示数组大小的形参
专门定义了一个表示数组大小的形参,在C程序和过去的C++程序中常常使用这种方法:
1 | //const int ia[]等价于const int *ia |
以上三个print函数都把数组形参定义成了指向const的指针。当函数不需要对数组元素执行写操作的时候,数组形参应该是指向const的指针。
数组引用形参
C++语言允许将变量定义成数组的引用,基于同样的道理,形参也可以是数组的引用。此时,引用形参绑定到对应的实参上,也就是绑定到数组上:
1 | //正确,形参是数组的引用,维度是类型的一部分 |
&arr 两端的括号必不可少。
因为数组的大小是构成数组类型的一部分,所以只要不超过维度,在函数体内就可以放心地使用数组。但是,这一用法也无形中限制了print
函数的可用性,我们只能将函数作用于大小为10的数组。
传递多维数组
和所有数组一样,当将多维数组传递给函数时,真正传递的是指向数组首元素的指针。因为我们处理的是数组的数组,所以首元素本身就是一个数组,指针就是一个指向数组的指针。数组第二维(以及后面所有维度)的大小都是数组类型的一部分,不能省略:
1 | //matrix指向数组的首元素,该数组的元素是由10个整数构成的数组 |
第二种声明看起来是一个数组,实际上形参是指向含有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 | argv[0] = "prog"; //或者argv[0]也可以指向一个空字符串 |
Tip: 当使用argv中的实参时,一定要记得可选的实参从argv[1]开始;argv[0]保存程序的名字,而非用户输入。
含有可变形参的函数
为了编写能处理不同数量实参的函数,C++11新标准提供了两种主要的方法:
- 如果所有的实参类型相同,可以传递一个名为
initializer_list
的标准库类型; - 如果实参的类型不同,我们可以编写一种特殊的函数,也就是所谓的可变参数模板,其细节我们在后面章节讲解。
initializer_list形参
如果函数的实参数量未知但是全部实参的类型相同,我们可以使用initializer_list
类型的形参。initializer_list
是一种标准库类型,用于表示某种特定类型的值的数组。其定义在同名的头文件中,提供的操作如下表所示:
提供的操作 | |
---|---|
initializer_list |
默认初始化;T类型元素的空列表 |
initializer_list |
lst的元素数量和初始值一样多;lst的元素是对应初始值的副本;列表中的元素是const |
lst2 (lst) , lst2 = lst | 拷贝或赋值一个initializer_list对象;拷贝后,原是列表和副本共享元素 |
lst.size() | 列表中的元素数量 |
lst.begin() | 返回指向lst中首元素的指针 |
lst.end() | 返回1指向lst中尾元素下一位置的指针 |
和vector
一样,initializer_list
也是一种模板类型。定义initializer_list
对象时,必须说明列表中所含元素的类型:
1 | initializer_list <string> ls; //元素类型是string |
和vector
不一样的是,initializer_list
对象中的元素永远是常量值,我们无法改变initializer_list
对象中元素的值。
我们使用如下形式编写错误信息的函数,使其可以作用域可变数量的实参:
1 | void error_msg ( initializer_list<string> il ) |
如果想向initializer_list
形参中传递一个值的序列,则必须把序列放在一对花括号内:
1 | //expected和actual是string对象 |
含有initializer_list
形参的函数也可以同时拥有其他形参。例如,调试系统可能有个名为ErrCode
的类用来表示不同类型的错误,因此我们可以改写之前的程序,使其包含一个initializer_list
形参和一个ErrCode
形参:
1 | void error_msg (ErrCode e, initializer_list<string> il) |
因为initializer_list
包含begin
和end
成员,所以我们可以使用范围for
循环处理其中的元素。为了调用该版本的error_msg
函数,需要额外传递一个ErrCode
实参:
1 | if ( expected != actual ) |
省略符形参
C++ 还有一种特殊的形参类型(即省略符),可以用它传递可变数量的实参。这种功能是为了便于 C++ 程序访问某些特殊的C代码而设置的,这些代码使用了名为varargs
的C标准库功能。通常,省略符形参不应用于其他目的。你的C编译器文档会描述如何使用varargs
。
省略符形参应该仅用于C和C++通用的类型。特别应该注意的是,大多数类类型的对象在传递给省略符形参时都无法正确拷贝。
省略符形参只能出现在形参列表的最后一个位置,它的形式无非以下两种:
1 | void foo (parm_list, ...); |
第一种形式制定了foo
函数的部分形参的类型,对应于这些形参的实参将会执行正常的类型检查。省略符形参对应的实参无须类型检查。在第一种形式中,形参声明后面的逗号是可选的。
返回类型和return语句
值是如何被返回的
返回一个值的方式和初始化一个变量或形参的方式完全一样:返回的值用于初始化调用点的一个临时量,该临时量就是函数的调用结果。
同其他引用类型一样,如果函数返回引用,则该引用仅是它所引对象的一个别名。举个例子,假定某函数挑出两个string
形参中较短的那个并返回其引用:
1 | //挑出两个string对象中较短的那个,返回其引用 |
其中形参和返回类型都是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 | //调用string对象的size成员,该string对象是由shorterString函数返回的 |
引用返回左值
函数的返回类型决定函数调用是否是左值。调用一个返回引用的函数得到左值,其他返回类型得到右值。
可以像使用其他左值那样来使用返回引用的函数的调用,特别地,我们能为返回类型是非常量引用的函数的结果赋值:
1 | char &get_val (string &str , string::size_type ix) |
列表初始化返回值
C++11新标准规定,函数可以返回花括号包围的值的列表。类似于其他返回结果,此处的列表也用来对表示函数返回的临时量进行初始化。如果列表为空,临时量执行值初始化;否则,返回的值由函数的返回类型决定。
例如,在下面的函数中,我们返回一个vector
对象,用它存放表示错误信息的string
对象:
1 | vector<string> process() |
第一条return
语句返回一个空列表,此时,process
函数返回的vector
对象是空的。如果expected
不为空,根据expected
和actual
是否相等,函数返回的vector
对象分别用两个或三个元素初始化。
如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间。如果函数返回的是类类型,由类本身定义初始值如何使用。
主函数main的返回值
main
函数的返回值可以看做是状态指示器。返回0表示执行成功,返回其他值表示执行失败,其中非0值的具体含义依机器而定。
为了使返回值与机器无关,cstdlib
头文件定义了两个预处理变量,我们可以使用这两个变量分别表示成功和失败:
1 | int main(){ |
因为它们是预处理变量,所以既不能在前面加上std::
,也不能在using
声明中出现。
返回数组指针
因为数组不能被拷贝,所以函数不能返回数组。不过,函数可以返回数组的指针或引用。虽然从语法上来说,要想定义一个返回数组的指针或引用的函数比较烦琐,但是有一些方法可以简化这一任务,其中最直接的方法是使用类型别名:
1 | typedef int arrT[10]; //arrT是一个类型别名,它表示的类型是含有10个整数的数组 |
其中arrT
是含有10个整数的数组的别名。因为我们无法返回数组,所以将返回类型定义成数组的指针。因此,func
函数接受一个int
实参,返回一个指向包含10个整数的数组的指针。
声明一个返回数组指针的函数
要想在声明func
时不使用类型别名,我们必须记住,数组的维度应跟随在要定义的数组名之后:
1 | int arr[10]; //arr是一个含有10个整数的数组 |
和这些声明一样,如果我们想定义一个返回数组指针的函数,则数组的维度必须跟在函数名字之后。然而,函数的形参列表也跟在函数名字后面且形参列表应该先于数组的维度。因此,返回数组指针的函数形式如下所示:
1 | Type (*function(parameter_list)) [dimension] |
类似于数组的声明,Type
表示元素的类型,dimension
表示数组的大小。(*function(parameter_list))
两端的括号必须存在,就像我们定义p2
时两端必须有括号一样。如果没有这对括号,函数的返回类型将是指针的数组。
举个更具体的例子,线面这个func
函数的声明没有类型别名:
1 | 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
:
1 | //func接受一个int类型的实参,返回一个指针,该指针指向含有10个整数的数组 |
因为我们把函数的返回类型放在了形参列表之后,所以可以清楚地看到func
函数返回的是一个指针,并且该指针指向了含有10个整数的数组。
使用decltype
还有一种情况,如果我们知道函数返回的指针将指向哪个数组,就可以使用decltype
关键字声明返回类型。例如,下面的函数返回一个指针,该指针根据参数 i
的不同指向两个已知数组中的某一个:
1 | int odd[] = { 1, 3, 5, 7, 9 }; |
arrPtr
使用关键字decltype
表示它的返回类型是个指针,并且该指针所指的对象与odd
的类型一致。因为odd
是数组,所以arrPtr
返回一个指向含有5个整数的数组的指针。有一个需要注意的地方:decltype
并不负责把数组类型转换成对应的指针,所以decltype
的结果是个数组,要想表示arrPtr
返回指针还必须在函数声明时加一个 * 符号。
Note: Luv个人觉得此处的decltype作用相当于前面的typedef取别名的方法。
函数重载
如果同一作用域内的几个函数名字相同但形参列表不同,我们称之为重载(overloaded)函数。
函数的名字仅仅是让编译器知道它调用的是哪个函数,而函数重载可以在一定程度上减轻程序员起名字、记名字的负担。
main函数不能重载。
定义重载函数
对于重载的函数来说,它们应该在形参数量或形参类型上有所不同。
不允许两个函数除了返回类型外其他所有的要素都相同。假设有两个函数,它们的形参列表一样但是返回值类型不同,则第二个函数的声明是错误的:
1 | Record lookup(const Account&); |
1
2
3 typedef Phone Telno;
Record lookup(const Phone&);
Record lookup(const Telno&); //Telno和Phone类型相同类型别名不是创建新类型,两个声明是一样的。
重载和const形参
顶层const
不影响传入函数的对象。一个拥有顶层const
的形参无法和另一个没有顶层const
的形参区分开来:
1 | Record lookup(Phone); |
1 | Record lookup(Phone*); |
两组声明分别是等价的。
另一方面,如果形参是某种类型的指针或引用,则通过区分其指向的是常量对象还是非常量对象可以实现函数重载,此时的const
是底层的:
1 | //对于接受引用或指针的函数来说,对象是常量还是非常量对应的形参不同。 |
以上,我们只能把const
对象传给使用const
形参的函数;而4个函数都能作用于非常量对象或者指向非常量对象的指针。
不过,当我们传递一个非常量对象或者指向非常量对象的指针时,编译器会优先选用非常量版本的函数。
const_cast和重载
const_cast
在重载函数的情境中最有用。例如以下的shortString
函数:
1 | //比较两个string对象的长度,返回较短的那个引用 |
这个函数的参数和返回类型都是const string
的引用。我们可以对两个非常量的string
实参调用函数,但返回的结果仍是const string
的引用。因此我们需要一种新的shortString
函数,当它的实参不是常量时,得到的结果是一个普通的引用,使用const_cast
可以做到这一点:
1 | string &shortString(string &s1, string &s2){ |
在这个版本的函数中,首先将它的实参强制转换成对const
的引用,然后调用了shortString
函数的const
版本。const
版本返回对const string
的引用,这个引用事实上绑定在某个初始的非常量实参上。因此,我们可以再将其转换回一个普通的string&
,这显然是安全的。
调用重载的函数
函数匹配(function matching)是指一个过程,在这个过程中我们把函数调用和一组重载函数中的某一个关联起来,函数匹配也叫做重载确定(overload resolution)。编译器首先将调用的实参与重载集合中每一个函数的形参进行比较,然后根据比较的结果决定到底调用哪个参数。
调用重载函数时可能有三个结果:
- 编译器找到一个与实参最佳匹配(best match)的函数,并生成调用该函数的代码。
- 找不到一个函数与调用的实参匹配,此时编译器发出无匹配(no match)的错误信息。
- 有多于一个函数可以匹配,但是每一个都不是明显的最佳选择。此时也将发生错误,称为二义性调用(ambiguous call)。
Tip: 不要将函数声明在局部作用域内。尤其当重载函数时,内层的函数声明会隐藏外层的同名函数声明,使得作用域内不能准确匹配重载函数。
特殊用途语言特性
以下是一些对程序非常有用的语言特性,分别是:默认实参、内联函数和constexpr函数,以及在程序调试过程中常用的一些功能。
默认实参
调用含有默认实参(default argument)的函数时,可以包含该实参,也可以省略该实参。
例如,我们使用string
对象表示窗口的内容。一般情况下,我们希望窗口的高、宽和背景字符都使用默认值。但是同时我们也应该允许用户为这几个参数自由指定于默认值不同的数值:
1 | typedef string::size_type sz; |
其中我们为每一个形参都提供了默认实参,默认实参作为形参的初始值出现在形参列表中。我们可以为一个或多个形参定义默认值,不过需要注意的是,一旦某个形参被赋予了默认值,它后面的所有形参都必须有默认值。
使用默认实参
使用默认实参只要在调用函数时省略该实参就可以了:
1 | string window; |
函数调用时实参按其位置解析,默认实参负责填补函数调用缺少的尾部实参(靠右侧位置)。例如想要覆盖backgrnd
的默认值,必须为ht
和wid
提供实参:
1 | window = screen( , , '?'); //错误,只能省略尾部的实参 |
需要注意,第二个调用传递一个字符值,是合法的调用。但是它的实际效果却与书写的意图不符。
当设计含有默认实参的函数时,尽量让不怎么使用默认值的形参出现在前面,而让那些经常使用默认值的形参出现在后面。
默认实参声明
在给定的作用域中一个形参只能被赋予一次默认实参。换句话说,函数的后续声明只能为之前那些没有默认值的形参添加默认实参,而且该形参右侧的所有形参必须都有默认值。假如给定:
1 | //表示高度和宽度的形参没有默认值 |
我们不能修改一个已经存在的默认值:
1 | string screen(sz, sz, char = '*'); //错误,重复声明 |
但是可以按照如下形式添加默认实参:
1 | string screen(sz = 24, sz = 80, char); //正确,添加默认实参 |
Tip: 通常,应该在函数声明中指定默认实参,并将该声明放在合适的头文件中。
设置和改变默认实参初始值
局部变量不能作为默认初始值。除此之外,只要表达式的类型能转换成形参所需的类型,该表达式就能作为默认实参:
1 | //wd、def和ht的声明必须出现在函数之外 |
用作默认实参的名字在函数声明所在的作用域内解析,而这些名字的求值过程发生在函数调用时:
1 | void f2(){ |
我们在函数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 | //内联版本:寻找两个string对象中较短的那个 |
一般来说,内联机制用于规模较小、流程直接、频繁调用的函数。
内联说明只是向编译器发出的一个请求,编译器可以选择忽略这个请求。
constexpr函数
constexpr
函数(constexpr function)是指能用于常量表达式的函数。
定义constexpr
函数的方法与其他函数类似,不过要遵守两项约定:
- 函数的返回类型及所有形参的类型都得是字面值类型。
- 函数体中有且仅有一条
return
语句。
1 | constexpr int new_sz() { return 42; } |
我们把new_sz
定义成无参数的constexpr
函数。
因为编译器能在程序编译时验证new_sz
函数返回的是常量表达式,所以可以用new_sz
函数初始化constexpr
类型的变量foo
。
以上展示的是constexpr
函数返回常量表达式的情况,但实际上,constexpr
函数也能返回非常量表达式,这取决于调用该类型函数传递的实参是否有常量表达式:
1 | //如果arg是常量表达式,则scale(arg)也是常量表达式 |
当scale
的实参是常量表达式时,它的返回值也是常量表达式;反之则不然:
1 | int arr[scale(2)]; //正确,scale(2)是常量表达式 |
如上例所示,当我们给scale
函数传入一个形如字面值2的常量表达式时,它的返回类型也是常量表达式。此时,编译器用相应的结果值替换对scale
函数的调用。
如果我们用一个非常量表达式调用scale
函数,比如int
类型的对象i
,则返回值是一个非常量表达式。当把scale
函数用在需要常量表达式的上下文中时,由编译器负责检查函数的结果是否符合要求。如果结果恰好不是常量表达式,编译器将发出错误消息。
constexpr函数体内实际也可以包含其他语句,只要这些语句在运行时不执行任何操作就行。例如constexpr函数中可以有空语句、类型别名以及using声明。
Tip: 内联函数和constexpr函数通常定义在头文件中。
调试功能
C++程序员有时会用到一种类似于头文件保护的技术,以便有选择地执行调试代码。基本思想是,程序可以包含一些用于调试的代码,但是这些代码只在开发程序时使用。当应用程序编写完成准备发布时,要先屏蔽掉调试代码。这种方法用到两项预处理功能:assert和NDEBUG。
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
未定义,将执行#ifnde
f和#endif
之间的代码;如果定义了NDEBUG
,这些代码将被忽略掉:
1 | void print( const int ia[], size_t size) |
Note: 以下所有的变量名中的空格只是为了书写展示,在实际代码中都是没有的。
在这段代码中,我们使用变量 _ _ func _ _
输出当前调试的函数的名字。编译器为每个函数都定义了 _ _ func _ _
,它是const char
的一个静态数组,用于存放函数的名字。
除了 C++ 编译器定义的 _ _ func _ _
之外,预处理器还定义了4个对于程序调试很有用的名字:
变量名 | 用途 |
---|---|
_ _ FILE _ _ | 存放文件名的字符串字面值 |
_ _ LINE _ _ | 存放当前行号的整型字面值 |
_ _ TIME _ _ | 存放文件编译时间的字符串字面值 |
_ _ DATA _ _ | 存放文件编译日期的字符串字面值 |
可以使用这些常量在错误消息中提供更多信息:
1 | if ( word.size() < threshold ) |
如果我们给程序提供了一个长度小于threshold
的string
对象,将得到下面的错误消息:
1 | Error: Wdebug.cpp : in function print at line 19 |
函数匹配
- 先选定候选函数:与被调用函数同名、其声明在调用点可见。
- 再选出可行函数:实参数量足够(允许默认实参),每个实参的类型与对应的形参类型相同或者能转换成形参的类型。
- 在可行函数中找最佳匹配(如果有的话)。
最佳匹配
如果有且只有一个函数满足下列条件,则匹配成功:
- 该函数每个实参的匹配都不劣于其他可行函数需要的匹配。
- 至少有一个实参的匹配优于其他可行函数提供的匹配。
如果在检查了所有实参之后没有任何一个函数脱颖而出,则该调用是错误的。编译器将报告二义性调用错误。
所有算数类型转换的级别是一样的。例如,从int向unsigned int的转换并不比从int向double的转换级别高:
1
2
3 void manip(long);
void manip(float);
manip(3.14); //错误,二义性调用字面值3.14的类型是double,它既能转换成float。因为存在两种可能的算术类型转换,所以该调用具有二义性。
函数指针
函数指针指向的是函数而非对象。
和其他指针一样,函数指针指向某种特定类型。函数的类型由它的返回类型和形参类型共同决定,与函数名无关,例如:
1 | //比较两个string对象的长度 |
该函数的类型是bool(const string&, const string&)
。要想声明一个可以指向该函数类型的指针,只需要用指针替换该函数名即可:
1 | //pf指向一个函数,该函数的参数是两个const string的引用,返回值是bool类型 |
从我们声明的名字开始观察,pf
前面有个 *,因此pf
是指针;右侧是形参列表,表示pf
指向的是函数;再观察左侧,发现函数的返回类型是布尔值。因此,pf
是一个指向函数的指针,其中该函数的参数是两个const string
的引用,返回值是bool
类型。
*pf
两端的括号必不可少,否则pf是一个返回值为bool指针的函数。
使用函数指针
当我们把函数名作为一个值使用时,该函数自动地转换成指针。例如,按照如下形式我们可以将lengthCompare
的地址赋给pf
:
1 | pf = lengthCompare; //pf指向名为lengthCompare的函数 |
此外,我们还能直接使用指向函数的指针调用该函数,无须提前解引用指针:
1 | bool b1 = pf("hello", "goodbye"); //调用lengthCompare函数 |
在指向不同函数类型(返回类型不同或形参类型不同)的指针之间不存在转换规则。但是,我们可以为函数指针赋一个nullptr
或者值为0的整型常量表达式,表示该指针没有指向任何一个函数。
重载函数的指针
当我们使用重载函数时,指针类型必须与重载函数中的某一个精准匹配:
1 | void ff(int*); |
函数指针形参
Note: Luv认为这一块和下一块的内容跟数组指针做形参的内容极其相似。
和数组类似,虽然不能定义函数类型的形参,但是形参可以是指向函数的指针。此时,形参看起来是函数类型,实际上却是当成指针使用:
1 | //第三个形参是函数类型,它会自动地转换成指向函数的指针 |
我们可以直接把函数作为实参使用,此时它会自动转换成指针:
1 | //自动将函数lengthCompare转换成指向该函数的指针 |
正如useBigger
的声明语句所示,直接使用函数指针类型显得冗长而烦琐。类型别名和decltype
能让我们简化使用了函数指针的代码:
1 | //Func和Func2是函数类型 |
decltype
返回函数类型,不会自动转为指针类型,需要在结果前面加上 * 才能得到指针。可以按如下形式重新声明useBigger
:
1 | //useBigger的等价声明,其中使用了类型别名 |
这两个声明语句声明的是同一个函数,在第一条语句中,编译器自动地将Func
表示的函数类型转换成指针。
返回指向函数的指针
和数组类似,虽然不能返回一个函数,但是能返回指向函数类型的指针。然而,我们必须把返回类型写成指针形式,编译器不会自动地将函数返回类型当成对应的指针类型处理。
与往常一样,要想声明一个返回函数指针的函数,最简单的方法就是使用类型别名:
1 | using F = int(int*, int); //F是函数类型,不是指针 |
将F定义成函数类型,将PF
定义成指向函数类型的指针。必须注意的是,和函数类型的形参不同,返回类型不会自动地转换成指针。我们必须显式地将返回类型指定为指针:
1 | PF f1(int); //正确,PF是指向函数的指针,f1返回指向函数的指针 |
当然,我们也能用下面的形式直接声明f1
:
1 | int (*f1(int)) (int*, int); |
按照由内向外的顺序阅读这条声明语句:我们看到f1
有形参列表,所以f1
是个函数;f1
前面有 *,所以f1
返回一个指针;进一步观察发现,指针的类型本身也包含形参列表,因此指针指向函数,该函数的返回类型是int
。
还可以使用尾置返回类型的方式:
1 | auto f1(int) -> int (*)(int*, int); |
将auto和decltype用于函数指针类型
如果明确知道返回的函数是哪一个,就能使用decltype
简化书写函数指针返回类型的过程:
1 | string::size_type sumLength(const string&, const string&); |
声明getFun
需要注意,decltype
返回函数类型而非指针类型,因此必须显式地加上 * 以表示我们需要返回指针,而非函数本身。