第五章——语句
通常情况下,语句是顺序执行的。但除非是最简单的程序,否则仅有顺序执行远远不够。因此,C++语言提供了一组控制流( flow-of-control)语句以支持更复杂的执行路径。
5.1 简单语句
空语句
语句中只有一个一个分号(;)
复合语句(块)
复合语句是指用花括号括起来的(可能为空)语句和声明的序列,也被称作块。
一个块就是一个作用域。
块不以分号作为结束。
5.2 语句作用域
可以在if、switch、while、for语句的控制结构内定义变量。定义在控制结构当中的变量只是在相应语句的内部可见,一旦语句结束就超出其作用范围了。
5.3 条件语句
5.3.1 if语句
悬垂else
当一个if语句嵌套在另一个if语句内部时,很可能if分支会多于else分支。事实上,之前那个成绩转换的程序就有4个if分支,而只有2个else分支。这时候问题出现了:我们怎么知道某个给定的else是和哪个if匹配呢?
这个问题通常称作悬垂else ( dangling else),在那些既有if语句又有if else 语句的编程语言中是个普遍存在的问题。不同语言解决该问题的思路也不同,就C++而言,它规定else与离它最近的尚未匹配的if匹配,从而消除了程序的二义性。
当代码中if分支多于else分支时,程序员有时会感觉比较麻烦。举个例子来说明:
// 错误:实际的执行霍城并非像缩进格式显示的那样,else分之匹配的是内层if语句
if (grade % 10 >= 3)
if (grade % 10 > 7)
lettergrade += '+'; // 末尾是8或者9的成绩添加一个加号
else
lettergrade += '-'; // 末尾是3、4、5、6、7的成绩添加一个减号
从代码的缩进格式来看,程序的初衷应该是希望else和外层的if匹配。然而,不管我们是什么意图,也不管程序如何缩进,这里的else分支其实是内层if语句的一部分。最终,上面的代码的执行过程实际上等价于如下形式:
// 缩进格式与执行过程相符,但与程序员意图不符
if (grade % 10 >= 3)
if (grade % 10 > 7)
lettergrade += '+'; // 末尾是8或者9的成绩添加一个加号
else
lettergrade += '-'; // 末尾是3、4、5、6、7的成绩添加一个减号
使用花括号控制执行路径
要想使else分支和外层的if分支if语句匹配起来,可以在内层if语句的两段加上花括号,使其成为一个块:
if (grade % 10 >= 3){
if (grade % 10 > 7)
lettergrade += '+'; // 末尾是8或者9的成绩添加一个加号
} else
lettergrade += '-'; // 末尾是3、4、5、6、7的成绩添加一个减号
语句属于块,意味着语句一定在块的边界之内,因此内层if语句在关键字else前面的那个花括号处已经结束了。else不会再作为内层if的一部分。此时,最近的尚未匹配的if是外层if,也就是我们希望else匹配的那个。
5.3.2 switch语句
switch语句(switch statement)提供了一条便利的途径使得我们能够在若干固定选项中做出选择。举个例子,假如我们想统计五个元音字母在文本中出现的次数,程序逻辑应该如下所示:
- 从输入的内容中读取所有字符。
- 令每一个字符都与元音字母的集合比较。
- 如果字符与某个元音字母匹配,将该字母的数量加1
- 显示结果。
unsigned aCnt = 0, eCnt = 0, iCnt, oCnt, 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;
}
}
// 输出结果
// ……
任何两个case标签不能相同,另外,default是一种特殊的case标签。
尽管switch语句不是非得在最后一个标签后面写上break,但是为了安全起见,最好这么做。因为这样的话,即使以后再增加新的case分支,也不用再在前面补充break语句了。
switch内部的控制流
如果某个case标签匹配成功,将从该标签开始往后顺序执行所有case分支,除非程序显式地中断了这一过程, 否则直到switch的结尾处才会停下来。要想避免执行后续case分支的代码,我们必须显式地告诉编译器终止执行过程。大多数情况下,在下一个case标签之前应该有一条break语句。
统计所有元音字母出现的总次数:
unsigned vowelCnt = 0;
switch (ch) {
case 'a':
case 'e':
case 'i':
case 'o':
case 'u':
++vowelCnt;
break;
}
default标签
如果没有任何一个case标签能匹配上switch 表达式的值,程序将执行紧跟在default标签(default label)后面的语句。例如,可以增加一个计数值来统计非元音字母的数量,只要在default分支内不断递增名为otherCnt的变量就可以了:
switch (ch) {
case 'a': case 'e': case 'i': case 'o': case 'u':
++vowelCnt;
break;
default:
++otherCnt;
break;
}
switch内部的变量定义
如前所述,switch的执行流程有可能会跨过某些case标签。如果程序跳转到了某个特定的case,则switch结构中该case标签之前的部分会被忽略掉。这种忽略掉一部分代码的行为引出了一个有趣的问题:如果被略过的代码中含有变量的定义该怎么办?
答案是如果在某处一个带有初值的变量位于作用域之外,在另一处该变量位于作用域之内,则从前一处跳转到后处的行为是非法行为:
int main() {
int a = 0;
switch (a) {
case 1: // 无论是case 0还是case 1都不会报错
int b;
cout << "a" << a << endl;
break;
default:
b = 0;
cout << "b" << b << endl;
break;
}
return 0;
}
无论是否执行到有变量定义的case中,只要不进行初始化,就不会报错。
如果需要为某个case分支定义并初始化一一个变量,我们应该把变量定义在块内,从而确保后面的所有case标签都在变量的作用域之外:
case 1:
{
int b = 0;
// ……
}
case 2:
cout << b << endl; // 错误:b不在作用域中
5.4 迭代语句
迭代语句通常称为循环,它重复执行操作直到满足某个条件才停下来。while和for语句在执行循环体之前检查条件,do while 语句先执行循环体,然后再检查条件。
5.4.1 while语句
当不确定到底要迭代多少次时,使用while循环比较合适,比如读取输入的内容就是如此。还有一种情况也应该使用 while 循环,这就是我们想在循环结束后访问循环控制变量。例如:
vector<int> v;
int i;
// 重复读入数据,直至到达文件末尾或者遇到其他输入问题
while(cin >> i)
v.push_back(i);
// 寻找第一个负值元素
auto beg = v.begin();
while(beg != v.end() && *beg >= 0)
++beg();
if (beg == v.end())
// 此时我们知道v中所有的元素都大于等于0
5.4.2 传统的for语句
5.4.3 范围for语句
语法形式:
for (declaration : expression)
statement
expression表示的必须是一个序列。declaration定义的是一个变量,序列中每个元素都得能转换成改变量的类型。确保类型相容最简单的办法是使用auto类型说明符。
每次迭代都会重新定义循环控制变量,并将其初始化成序列中的下一个值,之后才会执行statement。像往常一样,statement可以是一条单独的语句也可以是一个块。所有元素都处理完毕后循环终止。
vector<int> v = {0, 1, 2};
for (auto &r : v) // 范围变量必须是引用类型才能对元素执行写操作
r *= 2; // 将v中每个元素的值翻倍
5.4.4 do while语句
do while语句(do while statement)和while语句非常相似,唯一的区别是,do while语句先执行循环体后检查条件。不管条件的值如何,我们都至少执行一次循环。do while语句的语法形式如下所示:
do
statement
while (condition);
do{
// ……
mumble(foo);
} while(int foo = get_foo());
如果允许在条件部分定义变量,则变量的使用出现在定义之前,这显然是不合常理的!
5.5 跳转语句
5.5.1 break语句
break语句(break statement)负责终止离它最近的while、do while、for 或switch语句,并从这些语句之后的第一条语句开始继续执行。
5.5.2 continue语句
continue语句(continue statement) 终止最近的循环中的当前迭代并立即开始下一次迭代。continue 语句只能出现在for、while 和do while循环的内部,或者嵌套在此类循环里的语句或块的内部。和break语句类似的是,出现在嵌套循环中的continue语句也仅作用于离它最近的循环。和break语句不同的是,只有当switch语句嵌套在迭代语句内部时,才能在switch里使用continue。
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表达式来表示它遇到了无法处理的问题。我们说throw引发了异常
- try语句块(try block),异常处理部分使用try语句块处理异常。try语句块以关键字try开始,并以一个或多个catch子句( catch clause)结束。try 语句块中代码抛出的异常通常会被某个catch子句处理。因为catch子句“处理”异常,所以它们也被称作异常处理代码( exception handler)。
- 一套异常类,用于在throw表达式和相关的catch字句之间传递异常的具体信息。
5.6.1 throw表达式
程序的异常检测部分使用throw表达式引发一个异常。throw表达式包含关键字throw和紧随其后的一个表达式,其中表达式的类型就是抛出的异常类型。throw表达式后面通常紧跟一个分号,从而构成一条表达式语句。
Sales_item item1, item2;
cin >> 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
头文件中。我们必须初始化runtime_error的对象,方式是给它提供一个string对象或者一个C风格的字符串,这个字符串中有一些关于异常的辅助信息。
5.6.2 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子句内也无法访问。
编写处理代码
while(cin >> item1 >> item2) {
try{
// 执行添加两个Sales_item对象的代码
// 如果添加失败,代码跑出一个runtime_error异常
} catch (runime_eror 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异常,接下来执行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函数并终止当前程序的执行。
编写异常安全的代码非常困难
要好好理解这句话:异常中断了程序的正常流程。异常发生时,调用者请求的一部分计算可能已经完成了,另一部分则尚未完成。通常情况下,略过部分程序意味着某些对象处理到一半就戛然而止,从而导致对象处于无效或未完成的状态,或者资源没有正常释放,等等。那些在异常发生期间正确执行了“清理”工作的程序被称作异常安全( exception safe)的代码。然而经验表明,编写异常安全的代码非常困难。
对于一些程序来说,当异常发生时只是简单地终止程序。此时,我们不怎么需要担心异常安全的问题。
但是对于那些确实要处理异常并继续执行的程序,就要加倍注意了。我们必须时刻清楚异常何时发生,异常发生后程序应如何确保对象有效、资源无泄漏、程序处于合理状态,等等。
5.6.3 标准异常
C++标准库定义了一组类,用于报告标准库函数遇到的问题。这些异常类也可以在用户编写的程序中使用,它们分别定义在4个头文件中:
exception
头文件定义了最通用的异常类exception。它只报告异常的发生,不提供任何额外信息。stdexcept
头文件定义了几种常用的异常类,详细信息在表中列出。new
头文件定义了bad_alloc
异常类型type_info
头文件定义了bad_cast
异常类型
标准库异常类只定义了几种运算,包括创建或拷贝异常类型的对象,以及为异常类型的对象赋值。
我们只能以默认初始化的方式初始化exception
、bad_alloc
和bad_cast
对象,不允许为这些对象提供初始值。
其他异常类型的行为则恰好相反:应该使用string对象或者C风格字符串初始化这些类型的对象,但是不允许使用默认初始化的方式。当创建此类对象时,必须提供初始值,该初始值含有错误相关的信息。
异常类型只定义了一个名为what的成员函数,该函数没有任何参数,返回值是一个指向C风格字符串的const char*
。该字符串的目的是提供关于异常的一些文本信息。
what函数返回的C风格字符串的内容与异常对象的类型有关。如果异常类型有一个字符串初始值,则what返回该字符串。对于其他无初始值的异常类型来说,what返回的内容由编译器决定。