C++Primer 第5章
第5章 语句
通常情况下,语句是顺序执行的。但除非是最简单的程序,否则仅有顺序执行远远不够。因此,C++语言提供了一组控制流(flow-of-control)语句以支持更复杂的执行路径。
5.1 简单语句
C++语言中的大多数语句都以分号结束,一个表达式,比如ival + 5,末尾加上分号就变成了表达式语句(expression statement)。表达式语句的作用是执行表达式并丢弃掉求值结果:
ival + 5; //一条没什么实际用处的表达式语句
cout << ival; //一条有用的表达式语句
第一条语句没什么用处,因为虽然执行了加法,但是相加的结果没被使用。比较普遍的情况是,表达式语句中的表达式在求值时附带有其他效果,比如给变量赋了新值或者输出了结果。
空语句
最简单的语句是空语句(null statement),空语句中只含有一个单独的分号:
; //空语句
如果在程序的某个地方,语法上需要一条语句但是逻辑上不需要,此时应该使用空语句。一种常见的情况是,当循环的全部工作在条件部分就可以完成时,我们通常会用到空语句。例如,我们想读取输入流的内容直到遇到一个特定的值为止,除此之外什么事情也不做:
while(cin >> s && s!=sought)
; //空语句
使用空语句时应该加上注释,从而令读这段代码的人知道该语句是有意省略的。
别漏写分号,也别多写分号
因为空语句是一条语句,所以可用在任何允许使用语句的地方。由于这个原因,某些看起来非法的分号往往只不过是一条空语句而已,从语法上说得过去。下面的片段包含两条语句:表达式语句和空语句。
ival = v1 + v2;; //正确:第二个分号表示一条多余的空语句
多余的空语句一般来说是无害的,但是如果在if或者while的条件后面跟了一个额外的分号就可能完全改变程序员的初衷。例如,下面的代码将无休止地循环下去:
//出现了糟糕的情况:额外的分号,循环体是那条空语句
while(iter != svec.end()) ; //while循环体是那条空语句
++iter; //递增运算不属于循环的一部分
虽然从形式上来看执行递增运算的语句前面有缩进,但它并不是循环的一部分。循环条件后面跟着的分号构成了一条空语句,它才是真正的循环体。
多余的空语句并非总是无害的。
复合语句(块)
复合语句(compound statement)是指用花括号括起来的(可能为空的)语句和声明的序列,复合语句也被称作块(block)。一个块就是一个作用域(参见2.2.4节),在块中引入的名字只能在块内部以及嵌套在块中的子块里访问。通常,名字在有限的区域内可见,该区域从名字定义处开始,到名字所在的(最内层)块的结尾为止。
如果在程序的某个地方,语法上需要一条语句,但是逻辑上需要多条语句,则应该使用复合语句。例如,while 或者 for 的循环体必须是一条语句,但是我们常常需要在循环体内做很多事情,此时就需要将多条语句用花括号括起来,从而把语句序列转变成块。
while( val <= 10)
{
sum += val;
++val;
}
程序从逻辑上来说要执行两条语句,但是 while 循环只能容纳一条。此时,把要执行的语句用花括号括起来,就将其转换成了一条(复合)语句。
块不以分号作为结束。
所谓空块,是指内部没有任何语句的一对花括号。空快的作用等价于空语句:
while(cin>>s && s!=sought)
{} //空块
5.2 语句作用域
可以在 if、switch、while 和 for 语句的控制结构内定义变量。定义在控制结构当中的变量只在相应语句的内部可见,一旦语句结束,变量也就超出其作用范围了:
while(int i = get_num()) //每次迭代时创建并初始化i
cout << i << endl;
i = 0; //错误:在循环外部无法访问i
如果其他代码也需要访问控制变量,则变量必须定义在语句的外部:
//寻找第一个负值元素
auto beg = v.begin();
while(beg != v.end() && *beg >= 0)
++beg;
if(beg == v.end())
//此时我们知道v中的所有元素都大于等于0
因为控制结构定义的对象的值马上要由结构本身使用,所以这些变量必须初始化。
5.3 条件语句
C++语句提供了两种按条件执行的语句。一种是if语句,它根据条件决定控制流;另外一种是switch语句,它计算一个整型表达式的值,然后根据这个值从几条执行路径中选择一条。
5.3.1 if语句
if语句(if statement)的作用是:判断一个指定的条件是否为真,根据判断结果决定是否执行另外一条语句。if语句包括两种形式:一种含有else分支,另外一种没有。简单if语句的语法形式是
if(condition)
statement
if else语句的形式是
if(condition)
statement
else
statement2
在这两个版本的if语句中,condition都必须用圆括号包围起来。condition可以是一个表达式,也可以是一个初始化了的变量声明(参见5.2节)不管是表达式还是变量,其类型都必须能转换成(参见4.11节)布尔类型。通常情况下,statement和statement2是块语句。
如果condition为真,执行statement。当statement 执行完成后,程序继续执行if语句后面的其他语句。
如果condition为假,跳过statement。对于简单if语句来说,程序继续执行if语句后面的其他语句;对于if else语句来说,执行statement2。
悬垂else
当一个if语句嵌套在另一个if语句内部时,很可能if分支会多于else分支。这时候问题出现了:我们怎么知道某个给定的else是和哪个if匹配呢?
这个问题通常称作悬垂else(dangling else),在那些既有if语句又有if else语句的编程语言中是个普遍存在的问题。不同语言解决该问题的思路也不同,就C++而言,它规定else与离它最近的尚未匹配的if匹配,从而消除了程序的二义性。
使用花括号控制执行路径
要想使else分支和外层的if语句匹配起来,可以在内层if语句的两端加上花括号,使其成为一个块:
//末尾是8或者9的成绩添加一个加号,末尾是0、1或者2的成绩添加一个减号
if(grade % 10 >= 3)
{
if(grade % 10 > 7)
lettergrade += '+'; //末尾是8或者9的成绩添加一个加号
}else //花括号强迫else与外层if匹配
lettergrade += '-'; //末尾是0、1或者2的成绩添加一个减号
语句属于块,意味着语句一定在块的边界之内,因此内层if语句在关键字else前面的那个花括号处已经结束了。else不会再作为内层if的一部分。此时,最近的尚未匹配的if是外层if,也就是我们希望else匹配的那个。
5.3.2 switch语句
switch语句(switch statement)提供了一条便利的途径使得我们能够在若干固定选项中做出选择。举个例子,假如我们想统计五个元音字母在文本中出现的次数,程序逻辑应该如下所示:
- 从输入的内容中读取所有字符
- 令每一个字符都与元音字母的集合比较
- 如果字符与某个元音字母匹配,将该字母的数量加1
- 显示结果
// 为每个元音字母初始化其计数值
unsigned aCnt = 0,eCnt = 0,iCnt = 0,oCnt = 0,uCnt = 0;
char ch;
while (cin >> ch){
// 如果 ch 是元音字母,将其对应的计数值加1
switch (ch) {
case'a':
++aCnt;
break;
case'e':
++eCnt;
break;
case"i':
++iCnt;
break;
case'o':
++oCnt;
break;
case'u':
++uCnt;
break;
}
}
//输出结果
...
switch语句首先对括号里的表达式求值,该表达式紧跟在关键字switch的后面,可以是一个初始化的变量声明(参见5.2节)。表达式的值转换成整数类型,然后与每个case标签的值比较。
如果表达式和某个case标签的值匹配成功,程序从该标签之后的第一条语句开始执行,直到到达了switch的结尾或者是遇到一条break语句为止。
我们将在5.5.1节详细介绍break语句,简言之,break语句的作用是中断当前的控制流。此例中,break语句将控制权转移到switch语句外面。因为switch是while循环体内唯一的语句,所以从switch语句中断出来以后,程序的控制权将移到while语句的右花括号处。此时 while语句内部没有其他语句要执行,所以while会返回去再一次判断条件是否满足。
如果switch语句的表达式和所有case都没有匹配上,将直接跳转到switch结构之后的第一条语句。刚刚说过,在上面的例子中,退出 switch后控制权回到while语句的条件部分。
case关键字和它对应的值一起被称为case标签 (case label)。case标签必须是整型常量表达式(参见2.4.4节):
char ch = getVal();
int ival = 42;
switch(ch){
case 3.14: //错误:case标签不是一个整数
case ival: //错误:case标签不是一个常量
//...
}
任何两个case标签的值不能相同,否则就会引发错误。另外,default也是一种特殊的case标签。
switch内部的控制流
理解程序在case标签之间的执行流程非常重要。如果某个case标签匹配成功,将从该标签开始往后顺序执行所有case分支,除非程序显式地中断了这一过程,否则直到switch的结尾处才会停下来。要想避免执行后续case分支的代码,我们必须显式地告诉编译器终止执行过程。大多数情况下,在下一个case标签之前应该有一条break语句。
然而,也有一些时候默认的switch行为才是程序真正需要的。每个case标签只能对应一个值,但是有时候我们希望两个或更多个值共享同一组操作。此时,我们就故意省略掉break语句,使得程序能够连续执行若干个case标签。
例如,也许我们想统计的是所有元音字母出现的总次数:
unsigned vowelCnt = 0;
//...
switch(ch)
{
//出现了aeiou中的任意一个都会将vowelCnt的值加1
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
++vowelCnt;
break;
}
在上面的代码中,几个case标签连写在一起,中间没有break语句。因此只要ch是元音字母,不管到底是五个中的哪一个都执行相同的代码。
C++程序的形式比较自由,所以 case 标签之后不一定非得换行。把几个case标签写在一行里,强调这些case代表的是某个范围内的值:
switch(ch)
{
//另一种合法的书写形式
case 'a' : case 'e' : case 'i' : case 'o' : case 'u':
++vowelCnt;
break;
}
一般不要省略case分支最后的break语句。如果没写break语句,最好加一段注释说清楚程序的逻辑。
漏写break容易引发缺陷
有一种常见的错觉是程序只执行匹配成功得到那个case分支的语句。例如,下面程序的统计结果是错误的:
//警告:不正确的程序逻辑!
switch(ch){
case 'a':
++aCnt;
case 'e':
++eCnt;
case 'i':
++iCnt;
case 'o':
+oCnt;
case 'u':
+uCnt;
}
要想理解这段程序的执行过程,不妨假设ch的值是'e'。此时,程序直接执行 case'e'标签后面的代码,该代码把eCnt的值加1。接下来,程序将跨越case标签的边界,接着递增iCnt、oCnt和uCnt。
尽管switch语句不是非得在最后一个标签后面写上break,但是为了安全起见,最好这么做。因为这样的话,即使以后再增加新的case分支,也不用再在前面补充break语句了。
default标签
如果没有任何一个case标签能匹配上switch表达式的值,程序将执行紧跟在default标签(dafault label)后面的语句。例如,可以增加一个计数值来统计非元音字母的数量,只要在default分支内不断递增名为otherCnt的变量就可以了:
switch(ch){
case 'a': case 'e' : case 'i' : case 'o' : case 'u':
++vowelCnt;
break;
default:
++otherCnt;
break;
}
即使不准备在default标签下做任何工作,定义一个default标签也是有用的。其目的在于告诉程序的读者,我们已经考虑到了默认的情况,只是目前什么也没做。
标签不应该孤零零地出现,它后面必须跟上一条语句或者另外一个case标签。如果switch结构以一个空的default标签作为结束,则该default标签后面必须跟上一条空语句或一个空块。
switch内部的变量定义
如前所述,switch的执行流程有可能会跨过某些case标签。如果程序跳转到了某个特定的case,则switch结构中该case标签之前的部分会被忽略掉。这种忽略掉一部分代码的行为引出了一个有趣的问题:如果被略过的代码中含有变量的定义该怎么办?
答案是:如果在某处一个带有初值的变量位于作用域之外,在另一处该变量位于作用域之内,则从前一处跳转到后一处的行为是非法行为。
case true:
//因为程序的执行流程可能绕开下面的初始化语句,所以该switch语句不合法
string file_name; //错误:控制流绕过一个隐式初始化的变量
int ival = 0; //错误:控制流绕过一个显式初始化的变量
int jval; //正确:因为jval没有初始化
break;
case false:
//正确:jval虽然在作用域内,但是它没有被初始化
jval = next_num(); //正确:给jval赋一个值
if(file_name.empty()) //file_name在作用域内,但是没有被初始化
//...
假设上述代码合法,则一旦控制流直接跳到false分支,也就同时略过了变量file_name和ival的初始化过程。此时这两个变量位于作用域之内,跟在false之后的代码试图在尚未初始化的情况下使用它们,这显然是行不通的。因此C++语言规定,不允许跨过变量的初始化语句直接跳转到该变量作用域内的另一个位置。
如果需要为某个case分支定义并初始化一个变量,我们应该把变量定义在块内,从而确保后面的所有case标签都在变量的作用域之外。
case true:
{
//正确:声明语句位于语句块内部
string file_name = get_file_name();
//...
}
break;
case false:
if(file_name.empty()) //错误:file_name不在作用域之内
5.4 迭代语句
迭代语句通常称为循环,它重复执行操作直到满足某个条件才停下来。while和for语句在执行循环体之前检查条件,do while语句先执行循环体,然后再检查条件。
5.4.1 while语句
只要条件为真,while语句(while statement)就重复地执行循环体,它的语法形式是:
while(condition)
statement
在while结构中,只要condition的求值结果为真就一直执行statement(常常是一个块)。condition不能为空,如果condition第一次求值就得fase,statement一次都不执行。
while的条件部分可以是一个表达式或者是一个带初始化的变量声明(参见5.2节)。通常来说,应该由条件本身或者是循环体设法改变表达式的值,否则循环可能无法终止。
定义在while条件部分或者while循环体内的变量每次迭代都经历从创建到销毁的过程。
5.4.2 传统的for语句
for语句的语法形式是
for(init-statement; condition; expression)
statement
关键字for及括号里的部分称作for语句头。
init-statement必须是以下三种形式中的一种:声明语句、表达式语句或者空语句,因为这些语句都以分号作为结束,所以for语句的语法形式也可以看做
for(initializer; condition; expression)
statement
一般情况下,init-statement负责初始化一个值,这个值将随着循环的进行而改变。condition作为循环控制的条件,只要condition 为真,就执行一次statement。如果condition第一次的求值结果就是false,则statement一次也不会执行。expression负责修改init-statement初始化的变量,这个变量正好就是condition检查的对象,修改发生在每次循环迭代之后。statement可以是一条单独的语句也可以是一条复合语句。
for语句头中的多重定义
和其他的声明一样,init-statement也可以定义多个对象。但是init-statement只能有一条声明语句,因此,所有变量的基础类型必须相同(参见2.3节)。举个例子,我们用下面的循环把vector的元素拷贝一份添加到原来的元素后面:
//记录下v的大小,当到达原来的最后一个元素后结束循环
for(decltype(v.size()) i = 0, sz = v.size(); i != sz; ++i)
v.push_back(v[i]);
在这个循环中,我们在init-statement里同时定义了索引i和循环控制变量sz。
省略for语句头的某些部分
for语句头能省略掉init-statement、condition和expression中的任何一个(或者全部)。
如果无须初始化,则我们可以使用一条空语句作为init-statement。例如,对于在vector对象中寻找第一个负数的程序,完全能用for循环改写:
auto beg = v.begin();
for(/*空语句*/; beg != v.end() && *beg >=0; ++beg)
; //什么也不做
省略condition的效果等价于在条件部分写了一个true。因为条件的值永远是true,所以在循环体内必须有语句负责退出循环,否则循环就会无休止地执行下去:
for(int i = 0; /*条件为空*/; ++i)
{
//对i进行处理,循环内部的代码必须负责终止迭代过程!
}
我们也能省略掉for语头中的expression,但是在这样的循环中就要求条件部分或者循环体必须改变迭代变量的值。举个例子,之前有一个将整数读入vector的while循环,我们使用for语句改写它:
vector<int> v;
for(int i; cin >> i; /*表达式为空*/)
v.push_back(i);
因为条件部分能改变i的值,所以这个循环无需表达式部分。其中,条件部分不断检查输入流的内容,只要读取完所有的输入或者遇到一个输入错误就终止循环。
5.4.3 范围for语句
C++11 新标准引入了一种更简单的for语句,这种语句可以遍历容器或其他序列的所有元素。范围for语句(range for statement)的语法形式是:
for (declaration : expression)
statement
expression表示的必须是一个序列,比如用花括号括起来的初始值列表(参见3.3.1节)、数组(参见3.5节)或者vector或string等类型的对象,这些类型的共同特点是拥有能返回迭代器的begin和end成员(参见3.4节)。
declaration定义一个变量,序列中的每个元素都得能转换成该变量的类型(参见4.11节)。确保类型相容最简单的办法是使用auto类型说明符(参见2.5.2节),这个关键字可以令编译器帮助我们指定合适的类型。如果需要对序列中的元素执行写操作,循环变量必须声明成引用类型。
每次迭代都会重新定义循环控制变量,并将其初始化成序列中的下一个值,之后才会执行statement。像往常一样,statement可以是一条单独的语句也可以是一个块。所有元素都处理完毕后循环终止。
vector<int> v = {0,1,2,3,4,5,6,7,8,9};
//范围变量必须是引用类型,这样才能对元素执行写操作
for( auto &r : v)
r *= 2; //将v中每个元素的值翻倍
学习了范围for语句的原理之后,我们也就不难理解为什么在3.3.2节强调不能通过范围for语句增加vector对象(或者其他容器)的元素了。在范围for语句中,预存了end()的值。一旦在序列中添加(删除)元素,end函数的值就可能变得无效了(参见3.4.1节)。关于这一点,将在9.3.6节做更详细的介绍。
5.4.4 do while语句
do while语句(do while statement)和while语句非常相似,唯一的区别是,do while语句先执行循环体后检查条件。不管条件的值如何,我们都至少执行一次循环。do while语句的语法形式如下所示:
do
statement
while(condition);
do while语句应该在括号包围起来的条件后面用一个分号表示语句结束。
在do语句中,求condition的值之前首先执行一次statement,condition不能为空。如果condition的值为假,循环终止;否则,重复循环过程。condition使用的变量必须定义在循环体之外。
因为对于do while来说先执行语句或者块,后判断条件,所以不允许在条件部分定义变量。
5.5 跳转语句
跳转语句中断当前的执行过程。C++语言提供了4种跳转语句:break、continue、goto和return。本章介绍前三种跳转语句,return语句将在6.3节进行介绍。
5.5.1 break语句
break语句(break statement)负责终止离它最近的while、do while、for或switch语句,并从这些语句之后的第一条语句开始继续执行。
break语句只能出现在迭代语句或者switch语句内部(包括套在此类循环里的语句或块的内部)。break语句的作用范围仅限于最近的循环或者switch:
string buf;
while(cin >> buf && !buf.empty())
{
switch(buf[0])
{
case '-':
//处理到第一个空白为止
for(auto it = buf.begin()+1; it != buf.end(); ++it)
{
if(*it == ' ')
break; //#1,离开for循环
//...
}
//break #1将控制权转移到这里
//剩余的'-'处理:
break; //#2,离开switch语句
case '+':
//...
}//结束switch
//结束switch:break #2将控制权转移到这里
}//结束while
5.5.2 continue语句
continue语句(continue statement)终止最近的循环中的当前迭代并立即开始下一次迭代。continue语句只能出现在for、while和do while循环的内部,或者套在此类循环里的语句或块的内部。和break语句类似的是,出现在套循环中的continue语句也仅作用于离它最近的循环。和break语句不同的是,只有当switch语句嵌套在迭代语句内部时才能在switch里使用continue。
continue语句中断当前的迭代,但是仍然继续执行循环。对于while或者do while语句来说,继续判断条件的值;对于传统的for循环来说,继续执行for语句头的expression;而对于范围for语句来说,则是用序列中的下一个元素初始化循环控制变量。
例如,下面的程序每次从标准输入中读取一个单词。循环只对那些以下画线开头的单词感兴趣,其他情况下,我们直接终止当前的迭代并获取下一个单词:
string buf;
while(cin >> buf && !buf.empty())
{
if(buf[0] != '_')
continue;//接着读取下一个输入
//程序执行过程到了这里?说明当前的输入是以下划线开始的;接着处理buf...
}
5.5.3 goto语句
goto语句(goto statement)的作用是从goto语句无条件跳转到同一函数内的另一条语句。
不要在程序中使用goto语句,因为它使得程序既难理解又难修改。
goto语句的语法形式是:
goto label;
其中,label是用于标识一条语句的标示符。带标签语句(labeled statement)是一种特殊的语句,在它之前有一个标示符以及一个冒号:
end : return; //带标签语句,可以作为goto的目标
标签标示符独立于变量或其他标示符的名字,因此,标签标示符可以和程序中其他实体的标示符使用同一个名字而不会相互干扰。goto语句和控制权转向的那条带标签的语句必须位于同一个函数之内。
和switch语句类似,goto语句也不能将程序的控制权从变量的作用域之外转移到作用域之内:
//...
goto end;
int ix = 10; //错误:goto语句绕过了一个带初始化的变量定义
end:
//错误:此处的代码需要使用ix,但是goto语句绕过了它的声明
ix = 42;
向后跳过一个已经执行的定义是合法的。跳回到变量定义之前意味着系统将销毁该变量,然后重新创建它:
//向后跳过一个带初始化的变量定义是合法的
begin:
int sz = get_size();
if(sz <= 0){
goto begin;
}
在上面的代码中,goto语句执行后将销毁sz。因为跳回到begin的动作跨过了sz的定义语句,所以sz将重新定义并初始化。
5.6 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子句之间传递异常的具体信息。
5.6.1 throw表达式
程序的异常检测部分使用throw表达式引发一个异常。throw表达式包含关键字throw和紧随其后的一个表达式,其中表达式的类型就是抛出的异常类型。throw表达式后面通常紧跟一个分号,从而构成一条表达式语句。
举个简单的例子,回忆1.5.2节把两个Sales_item对象相加的程序。这个程序检查它读入的记录是否是关于同一种书籍的,如果不是,输出一条信息然后退出。
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; //表示失败
}
在真实的程序中,应该把对象相加的代码和用户交互的代码分离开来。此例中,我们改写程序使得检查完成后不再直接输出一条信息,而是抛出一个异常:
//首先检查两条数据是否是关于同一种书籍的
if(item1.isbn() != item2.isbn())
throw runtime_error("Data must refer to same ISBN");
//如果程序执行到了这里,表示两个ISBN是相同的
cout << item1 + item2 << endl;
在这段代码中,如果ISBN不一样就抛出一个异常,该异常是类型runtime_error的对象。抛出异常将终止当前的函数,并把控制权转移给能处理该异常的代码。
类型runtime_error是标准库异常类型的一种,定义在stdexcept头文件中。关于标准库异常类型更多的知识将在5.6.3节介绍。我们必须初始化runtime_error的对象,方式是给它提供一个string对象或者一个C风格的字符串(参见3.5.4节),这个字符串中有一些关于异常的辅助信息。
5.6.2 try语句块
try语句块的通用语法形式是:
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对象加法的代码是与用户交互的代码分离开来的。其中与用户交互的代码负责处理发生的异常,它的形式可能如下所示:
while(cin >> item1 >> item2){
try{
//执行添加两个Sales_item对象的代码
//如果添加失败,代码抛出一个runtime_error异常
}catch(runtime_error err){
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,因此能推断what是runtime_error类的一个成员函数(参见1.5.2节)。每个标准库异常类都定义了名为what的成员函数,这些函数没有参数,返回值是C风格字符串(即const char*)。其中,runtime_error的what成员返回的是初始化一个具体对象时所用的string对象的副本。如果上一节编写的代码抛出异常,则本节的catch子句输出
Data must refer to same ISBN
Try Again?Enter y or n
函数在寻找处理代码的过程中退出
在复杂系统中,程序在遇到抛出异常的代码前,其执行路径可能已经经过了多个try语句块。例如,一个try语句块可能调用了包含另一个try语句块的函数,新的try语句块可能调用了包含又一个try语句块的新函数,以此类推。
寻找处理代码的过程与函数调用链刚好相反。当异常被抛出时,首先搜索抛出该异常的函数。如果没找到匹配的catch子句,终止该函数,并在调用该函数的函数中继续寻找。如果还是没有找到匹配的catch子句,这个新的函数也被终止,继续搜索调用它的函数。以此类推,沿着程序的执行路径逐层回退,直到找到适当类型的catch子句为止。
如果最终还是没能找到任何匹配的catch子句,程序转到名为terminate的标准库函数。该函数的行为与系统有关,一般情况下,执行该函数将导致程序非正常退出。
对于那些没有任何try语句块定义的异常,也按照类似的方式处理:毕竟,没有try语句块也就意味着没有匹配的catch子句。如果一段程序没有try语句块且发生了异常,系统会调用terminate函数并终止当前程序的执行。
提示:编写异常安全的代码非常困难
要好好理解这句话,异常中断了程序的正常流程。异常发生时,调用者请求的一部分计算可能已经完成了,另一部分则尚未完成。通常情况下,略过部分程序意味着某些对象处理到一半就戛然而止,从而导致对象处于无效或未完成的状态,或者资源没有正常释放,等等。那些在异常发生期间正确执行了“清理”工作的程序被称作异常安全(exception safe)的代码。然而经验表明,编写异常安全的代码非常困难,这部分知识也(远远)超出了本书的范围。
对于一些程序来说,当异常发生时只是简单地终止程序。此时,我们不怎么需要担心异常安全的问题。但是对于那些确实要处理异常并继续执行的程序,就要加倍注意了。我们必须时刻清楚异常何时发生,异常发生后程序应如何确保对象有效、资源无泄漏、程序处于合理状态,等等。
我们会在本书中介绍一些比较常规的提升异常安全性的技术。但是读者需要注意如果你的程序要求非常鲁棒的异常处理,那么仅有我们介绍的这些技术恐怕还是不够的。
5.6.3 标准异常
C++标准库定义了一组类,用于报告标准库函数遇到的问题。这些异常类也可以在用户编写的程序中使用,它们分别定义在4个头文件中:
- exception 头文件定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息。
- stdexcept 头文件定义了几种常用的异常类,详细信息在表5.1中列出。
- new头文件定义了bad_alloc异常类型,这种类型将在12.1.2节详细介绍。
- type_info头文件定义了 bad_cast 异常类型,这种类型将在 19.2节详细介绍。
![image-20221203193048209](https://image-ym.oss-cn-guangzhou.aliyuncs.com/img/image-20221203193048209.png)
标准库异常类只定义了几种运算,包括创建或拷贝异常类型的对象,以及为异常类型的对象赋值。
我们只能以默认初始化(参见2.2.1节)的方式初始化exception、bad alloc和bad cast对象,不允许为这些对象提供初始值。
其他异常类型的行为则恰好相反:应该使用 string 对象或者C风格字符串初始化这些类型的对象,但是不允许使用默认初始化的方式。当创建此类对象时,必须提供初始值,该初始值含有错误相关的信息。
异常类型只定义了一个名为what的成员函数,该函数没有任何参数,返回值是一个指向C风格字符串(参见3.5.4节)的const char*该字符串的目的是提供关于异常的一些文本信息。
what函数返回的C风格字符串的内容与异常对象的类型有关。如果异常类型有一个字符串初始值,则 what 返回该字符串。对于其他无初始值的异常类型来说,what 返回的内容由编译器决定。
小结
C++语言仅提供了有限的语句类型,它们中的大多数会影响程序的控制流程:
- while、for和do while语句,执行迭代操作。
- if和switch语句,提供条件分支结构。
- continue语句,终止循环的当前一次迭代。
- break 语句,退出循环或者 switch 语句。
- goto语句,将控制权转移到一条带标签的语句。
- try和catch,将一段可能抛出异常的语句序列括在花括号里构成try语句块。catch 子句负责处理代码抛出的异常。
- throw 表达式语句,存在于代码块中,将控制权转移到相关的catch子句。
- return语句,终止函数的执行。我们将在第6章介绍 return 语句。
除此之外还有表达式语句和声明语句。表达式语句用于求解表达式,关于变量的声明和定义在第2章已经介绍过了。