十七、异常处理
改进错误恢复是提高代码健壮性的最有效的方法之一。
不幸的是,忽略错误条件几乎是公认的做法,就好像我们对错误持否定态度一样。毫无疑问,一个原因是检查许多错误的繁琐和代码膨胀。例如,printf()
返回成功打印的字符数,但是实际上没有人检查这个值。光是代码的激增就令人厌恶,更不用说阅读代码的难度了。
C 的错误处理方法的问题可以被认为是耦合——函数的用户必须将错误处理代码与该函数紧密地联系在一起,以至于它变得太笨拙而难以使用。
C++ 的主要特性之一是异常处理,这是思考和处理错误的一种更好的方式。异常处理有几个好处。
- 编写错误处理代码并不那么乏味,也不会与您的“正常”代码混淆。你写下你希望 ?? 发生的代码;稍后在一个单独的部分中,您将编写代码来处理这些问题。如果多次调用一个函数,就可以在一个地方一次性处理该函数的错误。
- 错误不容忽视。如果一个函数需要向该函数的调用者发送一个错误消息,它会将一个表示该错误的对象“抛出”该函数。如果调用者没有“捕获”错误并处理它,它将进入下一个封闭的动态范围,依此类推,直到错误被捕获或者程序因为没有处理程序来捕获这种类型的异常而终止。
本章考察了 C 语言的错误处理方法,讨论了它为什么不适用于 C 语言,并解释了它为什么不适用于 C++。本章还涵盖了支持异常处理的 C++ 关键字try
、throw
和catch
。
传统错误处理
在本书的大部分例子中,我按照预期使用了assert()
:在开发过程中使用代码进行调试,这些代码可以在产品发布时用#define NDEBUG
禁用。运行时错误检查使用在第九章的中开发的require.h
函数(assure()
和require()
)。这些函数是一种方便的表达方式,“这里有一个问题,你可能想用一些更复杂的代码来处理,但是在这个例子中你不需要被它分散注意力。”对于小程序来说,require.h
函数可能已经足够了,但是对于复杂的产品来说,您可能想要编写更复杂的错误处理代码。
当您确切地知道要做什么时,错误处理是非常简单的,因为您在该上下文中有所有必要的信息。您可以在这一点上处理错误。
当 you 在该上下文中没有足够的信息,并且需要将错误信息传递到存在该信息的不同上下文中时,问题就出现了。在 C 中,您可以使用三种方法来处理这种情况。
- 从函数返回错误信息,或者,如果返回值不能这样使用,则设置一个全局错误条件标志。(标准 C 提供了
errno
和perror()
来支持这个*。*)如上所述,程序员很可能会忽略错误信息,因为每个函数调用都必须进行冗长而令人困惑的错误检查。此外,从遇到异常情况的函数返回可能没有意义。 - 使用鲜为人知的标准 C 库信号处理系统,用
signal()
函数(确定事件发生时会发生什么)和raise()
(生成事件)实现。同样,这种方法涉及到高耦合,因为它要求生成信号的任何库的用户理解并安装适当的信号处理机制。在大型项目中,来自不同库的信号编号可能会冲突。 - 使用标准 C 库中的非本地
goto
函数:setjmp()
和longjmp()
。用setjmp()
你在程序中保存一个已知良好的状态,如果你遇到麻烦,longjmp()
将恢复那个状态。同样,在存储状态的位置和发生错误的位置之间存在高度耦合。
当考虑 C++ 的错误处理方案时,还有一个额外的关键问题:signals 和setjmp()
/ longjmp()
的 C 技术不调用析构函数,所以对象没有被适当地清理。(事实上,如果longjmp()
跳过了应该调用析构函数的作用域的末尾,程序的行为就是未定义的*。*)这使得从异常情况中有效恢复变得几乎不可能,因为你总是会留下那些没有被清理并且不能再被访问的对象。清单 17-1 用setjmp/longjmp
演示了这一点。
清单 17-1 。演示异常处理(用 C 的 setjmp() & longjmp())
//: C17:Nonlocal.cpp
// setjmp() & longjmp().
#include <iostream>
#include <csetjmp>
using namespace std;
class Rainbow {
public:
Rainbow() { cout << "Rainbow()" << endl; }
∼Rainbow() { cout << "∼Rainbow()" << endl; }
};
jmp_buf kansas;
void oz() {
Rainbow rb;
for(int i = 0; i< 3; i++)
cout << "there's no place like home" << endl;
longjmp(kansas, 47);
}
int main() {
if(setjmp(kansas) == 0) {
cout << "tornado, witch, munchkins..." << endl;
oz();
} else {
cout << "Auntie Em! "
<< "I had the strangest dream..."
<< endl;
}
} ///:∼
setjmp()
函数很奇怪,因为如果你直接调用它,它会将当前处理器状态的所有相关信息(比如指令指针和运行时堆栈指针的内容)存储在jmp_buf
中,并返回零。在这种情况下,它的行为就像一个普通的函数。然而,如果你用同一个jmp_buf
呼叫longjmp()
,就好像你又从setjmp()
回来了——你正好从setjmp()
的后端出来。这一次,返回值是longjmp()
的第二个参数,因此您可以发现您实际上是从longjmp()
返回的。你可以想象,有了许多不同的jmp_buf
,你可以在程序中的许多不同的地方出现。本地goto
(带标签)和非本地goto
的区别在于,你可以用setjmp()
/ longjmp()
返回到运行时栈中任何更高的预定位置(任何你调用了setjmp()
的地方)。
C++ 的问题是longjmp()
不尊重对象;特别是当它跳出一个作用域时,它不会调用析构函数。析构函数调用是必不可少的,所以这种方法不适用于 C++。事实上,C++ 标准规定,用goto
分支到一个作用域(有效地绕过构造器调用),或者用longjmp()
分支到一个作用域之外,其中堆栈上的一个对象有一个析构函数,组合未定义的行为。
抛出异常
如果您在代码中遇到异常情况——也就是说,如果您在当前上下文中没有足够的信息来决定做什么——您可以通过创建一个包含该信息的对象并将它“抛出”当前上下文,将有关错误的信息发送到一个更大的上下文中。这被称为抛出异常。清单 17-2 展示了它的样子。
清单 17-2 。引发异常
//: C17:MyError.cpp {RunByHand}
classMyError {
const char* const data;
public:
MyError(const char* const msg = 0) : data(msg) {}
};
void f() {
// Here we "throw" an exception object:
throw MyError("something bad happened");
}
int main() {
// As you’ll see shortly, we’ll want a "try block" here:
f();
} ///:∼
MyError
是一个普通的类,在这种情况下,它接受一个char*
作为构造器参数。抛出时可以使用任何类型(包括内置类型),但通常要为抛出异常创建特殊的类。
关键字throw
导致了许多相对神奇的事情发生。首先,它创建一个你抛出的对象的副本,实际上,从包含抛出表达式的函数中“返回”它,即使该对象类型通常不是该函数要返回的类型。考虑异常处理的一个天真的方法是作为一个替代的返回机制(尽管你会发现如果你把这个类比得太远,你会陷入麻烦)。您还可以通过引发异常来退出普通范围。在任何情况下,都会返回一个值,并且函数或作用域会退出。
与return
语句的任何相似之处都到此为止,因为返回的与普通函数调用返回的地方完全不同。
注意你会在代码的一个适当的部分结束——称为异常处理程序——它可能远离抛出异常的地方。
此外,在异常发生时创建的任何本地对象都将被销毁。这种本地对象的自动清理通常被称为栈展开。
此外,您可以投掷任意多种不同类型的对象。通常,您会为每一类错误抛出不同的类型。其思想是将信息存储在对象及其类的名称中,这样在调用上下文中的某个人就可以知道如何处理您的异常。
捕捉异常
如前所述,C++ 异常处理的优势之一是您可以在一个地方集中精力解决问题,然后在另一个地方处理代码中的错误。
try 块
如果你在一个函数中抛出了一个异常(或者一个被调用的函数抛出了一个异常),那么这个函数就会因为抛出的异常而退出。如果你不想让一个throw
离开一个函数,你可以在函数中设置一个特殊的块,在这里你可以尝试解决你实际的编程问题(并且可能产生异常)。这个块被称为 try 块,因为您在那里尝试各种函数调用。try 块是一个普通的作用域,前面有关键字try
,如:
try {
// Code that may generate exceptions
}
如果通过仔细检查所用函数的返回代码来检查错误,则需要用设置和测试代码包围每个函数调用,即使多次调用同一个函数也是如此。使用异常处理,您将所有内容放在一个try
块中,并在try
块之后处理异常。因此,您的代码更容易编写和阅读,因为代码的目标不会与错误处理混淆。
异常处理程序
当然,抛出的异常必须在某个地方结束。这个地方就是异常处理程序,你需要一个异常处理程序来处理你想要捕捉的每一种异常类型。然而,多态也适用于异常,因此一个异常处理程序可以处理一个异常类型和从该类型派生的类。
异常处理程序紧跟在try
块之后,由关键字catch
表示,如:
try {
// Code that may generate exceptions
} catch(type1 id1) {
// Handle exceptions of type1
} catch(type2 id2) {
// Handle exceptions of type2
} catch(type3 id3)
// Etc...
} catch(typeNidN)
// Handle exceptions of typeN
}
// Normal execution resumes here...
catch
子句的语法类似于接受单个参数的函数。标识符(id1
、id2
等等)可以在处理程序中使用,就像函数参数一样,尽管如果处理程序中不需要标识符,也可以省略它。异常类型通常给你足够的信息来处理它。
处理程序必须直接出现在try
块之后。如果抛出异常,异常处理机制会寻找第一个参数与异常类型匹配的处理程序。然后它进入那个catch
子句,异常被认为已经处理。(一旦找到了catch
子句,对处理程序的搜索就会停止。)只执行匹配的catch
子句;然后,控制在与该 try 块关联的最后一个处理程序之后继续。
注意,在try
块中,许多不同的函数调用可能会生成相同类型的异常,但是您只需要一个处理程序。
为了说明try
和catch
,清单 17-3 修改了Nonlocal.cpp
( 清单 17-1 ),用try
块替换了对setjmp()
的调用,用throw
语句替换了对longjmp()
的调用。
清单 17-3 。图示试&抓块
//: C17:Nonlocal2.cpp
// Illustrates exceptions.
#include <iostream>
using namespace std;
class Rainbow {
public:
Rainbow() { cout << "Rainbow()" << endl; }
∼Rainbow() { cout << "∼Rainbow()" << endl; }
};
void oz() {
Rainbow rb;
for(int i = 0; i < 3; i++)
cout << "there's no place like home" << endl;
throw 47;
}
int main() {
try {
cout << "tornado, witch, munchkins..." << endl;
oz();
} catch(int) {
cout << "Auntie Em! I had the strangest dream..."
<< endl;
}
} ///:∼
当oz()
中的throw
语句执行时,程序控制返回,直到找到带int
参数的catch
子句。继续执行那个catch
条款的主体。这个程序和Nonlocal.cpp
最重要的区别是当throw
语句导致执行离开函数oz()
时,对象rb
的析构函数被调用。
终止和恢复
异常处理理论中有两种基本模型:终止和恢复。在终止(这是 C++ 支持的)中,您假设错误非常严重,以至于没有办法从异常发生的地方自动恢复执行。换句话说,抛出异常的人决定没有办法挽回局面,他们不希望回来。
另一种错误处理模型被称为恢复,于 20 世纪 60 年代由 PL/I 语言首次引入。使用恢复语义意味着期望异常处理程序做一些事情来纠正这种情况,然后自动重试出错的代码,假设第二次成功。如果您想在 C++ 中恢复,您必须显式地将执行转移回发生错误的代码,通常是通过重复首先将您送到那里的函数调用。把你的try
块放在一个while
循环中是很常见的,这个循环不断地重新进入try
块,直到结果令人满意。
从历史上看,使用支持恢复性异常处理的操作系统的程序员最终会使用类似终止的代码并跳过恢复。虽然恢复听起来很吸引人,但在实践中似乎并不那么有用。一个原因可能是异常和它的处理程序之间的距离。终止于远处的处理程序是一回事,但是跳到那个处理程序然后再跳回来对于大型系统来说可能在概念上太困难了,因为在大型系统中,异常是从许多点生成的。
异常匹配
当抛出一个异常时,异常处理系统会按照它们在源代码中出现的顺序检查“最近的”处理程序。当它找到一个匹配项时,该异常被视为已处理,不再进行进一步的搜索。
匹配异常并不要求异常和它的处理程序之间有完美的关联。对 derivedclass 对象的对象或引用将匹配基类的处理程序。(但是,如果处理程序是针对对象而不是引用的,则异常对象在传递给处理程序时会被“切片”,即被截断为基类型。这不会造成损害,但是会丢失所有派生类型的信息*。)出于这个原因,也为了避免制作异常对象的另一个副本,通过引用*而不是通过值来捕捉异常总是更好的。如果抛出指针,通常的标准指针转换用于匹配异常。但是,在匹配过程中,不会使用自动类型转换来将一种异常类型转换为另一种异常类型。例如,参见清单 17-4 。
清单 17-4 。说明异常匹配
//: C17:Autoexcp.cpp
// No matching conversions.
#include <iostream>
using namespace std;
class Except1 {};
class Except2 {
public:
Except2(const Except1&) {}
};
void f() { throw Except1(); }
int main() {
try { f();
} catch(Except2&) {
cout << "inside catch(Except2)" << endl;
} catch(Except1&) {
cout << "inside catch(Except1)" << endl;
}
} ///:∼
尽管您可能认为可以通过使用转换构造器将一个Except1
对象转换成一个Except2
来匹配第一个处理程序,但是系统不会在异常处理期间执行这样的转换,您将在Except1
处理程序处结束。
清单 17-5 显示了一个基类处理程序如何捕捉一个派生类异常。
清单 17-5 。说明异常层次结构
//: C17:Basexcpt.cpp
// Exception hierarchies.
#include <iostream>
using namespace std;
class X {
public:
class Trouble {};
class Small : public Trouble {};
class Big : public Trouble {};
void f() { throw Big(); }
};
int main() {
X x;
try {
x.f();
} catch(X::Trouble&) {
cout << "caught Trouble" << endl;
// Hidden by previous handler:
} catch(X::Small&) {
cout << "caught Small Trouble" << endl;
} catch(X::Big&) {
cout << "caught Big Trouble" << endl;
}
} ///:∼
在这里,异常处理机制总是将一个Trouble
对象、或任何属于、Trouble
(通过公共继承、)的对象匹配到第一个处理程序。这意味着第二个和第三个处理程序永远不会被调用,因为第一个处理程序捕获了它们。更有意义的做法是首先捕获派生类型,然后将基类型放在最后来捕获任何不太具体的类型。
请注意,这些示例通过引用捕获异常,尽管对于这些类来说这并不重要,因为派生类中没有额外的成员,而且处理程序中也没有参数标识符。您通常希望在处理程序中使用引用参数而不是值参数,以避免切断信息。
捕捉任何异常
有时你想创建一个处理程序来捕捉任何类型的异常。使用参数列表中的省略号可以做到这一点,例如:
catch(...) {
cout << "an exception was thrown" << endl;
}
因为省略号会捕捉任何异常,所以您会希望将它放在处理程序列表的末尾以避免抢占它后面的任何异常。
省略号不能让你有一个参数,所以你不能知道任何关于异常或其类型的事情。这是一个无所不包的游戏;它通常用于清理一些资源,然后再抛出异常。
再次引发异常
当您有一些资源需要释放时,例如网络连接或需要释放的堆内存,您通常希望重新引发异常。
注详见本章后面的“资源管理”一节。
如果发生异常,您不必关心是什么错误导致了异常,您只需要关闭之前打开的连接。之后,您会希望让更接近用户的其他上下文(即,调用链中更高的位置)来处理异常。在这种情况下,省略号规范正是您想要的。您希望捕获任何异常,清理您的资源,然后重新抛出该异常以便在其他地方处理。您可以在处理程序中使用不带参数的throw
来重新抛出异常,比如:
catch(...) {
cout << "an exception was thrown" << endl;
// Deallocate your resource here, and then rethrow
throw;
}
同一个try
块的任何进一步的catch
子句仍然被忽略——throw
导致异常转到下一个更高上下文中的异常处理程序。此外,关于异常对象的一切都被保留下来,因此捕捉特定异常类型的更高上下文中的处理程序可以提取该对象可能包含的任何信息。
未捕获的异常
正如我在本章开始时解释的那样,异常处理被认为比传统的返回错误代码技术更好,因为异常不能被忽略,也因为错误处理逻辑与手头的问题是分离的。如果某个特定的try
块之后的异常处理程序都不匹配某个异常,那么这个异常就会转移到下一个更高的上下文中,也就是说,没有捕捉到该异常的try
块周围的函数或try
块。(这个try
块的位置乍一看并不总是很明显,因为它在调用链中的位置更高。)这个过程一直持续,直到在某个层次上,一个处理程序匹配到异常为止。此时,该异常被认为是“被捕获的”,不会进行进一步的搜索。
terminate()函数
如果任何级别的处理程序都没有捕捉到异常,则自动调用特殊库函数terminate()
(在<exception>
头文件中声明)。默认情况下,terminate()
调用标准的 C 库函数abort()
,它会突然退出程序。在 Unix 系统上,abort()
也会导致核心转储。当调用abort()
时,不会调用正常的程序终止函数,这意味着全局和静态对象的析构函数不会执行。如果局部对象的析构函数在堆栈展开时抛出异常(中断正在进行的异常*),或者如果全局或静态对象的构造器或析构函数抛出异常,那么terminate()
函数也会执行。(一般情况下*,不允许析构函数抛出异常。)
*函数的作用是
您可以使用标准的set_terminate()
函数安装您自己的terminate()
函数,该函数返回一个指向您正在替换的terminate()
函数的指针(这将是您第一次调用它时的默认库版本),因此如果您愿意,您可以稍后恢复它。您的定制terminate()
必须不带参数,并且有一个void
返回值。此外,您安装的任何terminate()
处理程序都不能返回或抛出异常,而是必须执行某种程序终止逻辑。如果调用terminate()
,问题不可恢复。
清单 17-6 展示了set_terminate()
的用法。在这里,返回值被保存和恢复,以便terminate()
函数可以用来帮助隔离发生未捕获异常的代码部分。
清单 17-6 。使用 set _ termin ate();此外,还演示了未捕获的异常
//: C17:Terminator.cpp
// Use of set_terminate(). Also shows uncaught exceptions.
#include <exception>
#include <iostream>
using namespace std;
void terminator() {
cout << "I'll be back!" << endl;
exit(0);
}
void (*old_terminate)() = set_terminate(terminator);
class Botch {
public:
class Fruit {};
void f() {
cout << "Botch::f()" << endl;
throw Fruit();
}
∼Botch() { throw 'c'; }
};
int main() {
try {
Botch b;
b.f();
} catch(...) {
cout << "inside catch(...)" << endl;
}
} ///:∼
起初,old_terminate
的定义看起来有点混乱:它不仅创建了一个指向函数的指针,还将该指针初始化为set_terminate()
的返回值。尽管您可能很熟悉在指向函数的声明后面看到分号,但这里它只是另一种变量,可以在定义时初始化。
类Botch
不仅在f()
内部抛出异常,还在其析构函数中抛出异常。这导致了对terminate()
的调用,正如你在main()
中看到的。即使异常处理程序说catch(...)
,这似乎捕捉了一切,没有理由调用terminate()
,但是terminate()
还是被调用了。在清理堆栈上的对象以处理一个异常的过程中,调用了Botch
析构函数,这会生成第二个异常,从而强制调用terminate()
。因此,抛出异常或导致异常被抛出的析构函数通常是糟糕的设计或草率编码的标志。
清理
异常处理的神奇之处在于,您可以从正常的程序流跳到适当的异常处理程序中。但是,如果在抛出异常时没有进行适当的清理,那么这样做是没有用的。C++ 异常处理保证当你离开一个作用域时,该作用域中所有构造器已经完成的对象都会被调用它们的析构函数。
清单 17-7 展示了没有完成的构造器不会调用相关的析构函数。它还显示了在创建对象数组的过程中抛出异常时会发生什么。
清单 17-7 。演示异常不会清理不完整的对象
//: C17:Cleanup.cpp
// Exceptions clean up complete objects only.
#include <iostream>
using namespace std;
class Trace {
static int counter;
int objid;
public:
Trace() {
objid = counter++;
cout << "constructing Trace #" << objid << endl;
if(objid == 3) throw 3;
}
∼Trace() {
cout << "destructing Trace #" << objid << endl;
}
};
int Trace::counter = 0;
int main() {
try {
Trace n1;
// Throws exception:
Trace array[5];
Trace n2; // Won't get here.
} catch(int i) {
cout << "caught " << i << endl;
}
} ///:∼
类Trace
跟踪对象,这样你就可以跟踪程序进度。它记录用数据成员counter
创建的对象的数量,并用objid
跟踪特定对象的数量。
主程序创建一个单独的对象,n1
( objid
0),然后尝试创建一个由五个Trace
对象组成的数组,但是在第四个对象(#3)完全创建之前抛出了一个异常。对象n2
从未被创建。你可以在程序的输出中看到结果:
constructing Trace #0
constructing Trace #1
constructing Trace #2
constructing Trace #3
destructing Trace #2
destructing Trace #1
destructing Trace #0
caught 3
成功创建了三个数组元素,但是在第四个元素的构造器中,引发了一个异常。因为main()
中对array[2]
的第四次构造永远不会完成,所以只调用对象array[1]
和array[0]
的析构函数。最后,对象n1
被销毁,但不是对象n2
,因为它从未被创建过。
资源管理
当编写带有异常的代码时,特别重要的是,您总是问:“如果发生异常,我的资源会被适当地清理吗?”大多数时候你是相当安全的,但是在构造器中有一个特殊的问题:如果一个异常在构造器完成之前被抛出,相关的析构函数将不会被调用。因此,在编写构造器时,你必须特别勤奋。
困难在于在构造器中分配资源。如果构造器中出现异常,析构函数就没有机会释放资源。这个问题最常发生在“裸指针上。我称它们为“*裸体”*指针是有道理的。他们的行为就像一个人脱下衣服开始洗澡,但洗完澡后不得不赤身裸体地出来,因为有人拿着他的衣服跑了。因此,在脱下他的衣服后,一个例外发生了,他的衣服被偷了,现在他不得不裸体出来,因为他对这种例外情况没有准备。代码示例见清单 17-8 。
清单 17-8 。演示了裸指针的情况
//: C17:Rawp.cpp
// Naked pointers.
#include <iostream>
#include <cstddef>
using namespace std;
class Cat {
public:
Cat() { cout << "Cat()" << endl; }
∼Cat() { cout << "∼Cat()" << endl; }
};
class Dog {
public:
void* operator new(size_tsz) {
cout << "allocating a Dog" << endl;
throw 47;
}
void operator delete(void* p) {
cout << "deallocating a Dog" << endl;
::operator delete(p);
}
};
class UseResources {
Cat* bp;
Dog* op;
public:
UseResources(int count = 1) {
cout << "UseResources()" << endl;
bp = new Cat[count];
op = new Dog;
}
∼UseResources() {
cout << "∼UseResources()" << endl;
delete [] bp; // Array delete
delete op;
}
};
int main() {
try {
UseResources ur(3);
} catch(int) {
cout << "inside handler" << endl;
}
} ///:∼
输出是
UseResources()
Cat()
Cat()
Cat()
allocating a Dog
inside handler
进入UseResources
构造器,三个数组对象的Cat
构造器成功完成。然而,在Dog::operator new()
内部,会抛出一个异常(模拟内存不足的错误)。突然,你在处理程序中结束,没有调用析构函数。这是正确的,因为UseResources
构造器无法完成,但这也意味着在堆上成功创建的Cat
对象从未被销毁。
让一切都成为物体
为了防止这种资源泄漏,您必须以两种方式中的一种来防止这些“原始”资源分配(出于与上面相同的原因,我将它们称为原始资源分配)。他们表现得像一个“原始”的人,有衣服(?? 资源),但没有做好充分准备,以应对洗澡时有人带着他的衣服(?? 资源)逃跑的特殊情况,最终不得不赤身裸体地出来。
- 您可以在构造器中捕获异常,然后释放资源。
- 可以将分配放在对象的构造器中,将释放放在对象的析构函数中。
使用后一种方法,由于是本地对象生命周期的一部分,每个分配都变成了原子的,如果它失败了,其他资源分配对象在栈展开期间被适当地清除。这种技术被称为资源获取是初始化(简称 RAII),因为它将资源控制等同于对象生存期。使用模板是修改清单 17-8 以获得清单 17-9 中所示代码的一个极好的方法。
清单 17-9 。使用 RAII 说明了安全原子指针&
//: C17:Wrapped.cpp
// Safe, atomic pointers.
#include <iostream>
#include <cstddef>
using namespace std;
// Simplified. Yours may have other arguments.
template<class T, int sz = 1> class PWrap {
T* ptr;
public:
class RangeError {}; // Exception class
PWrap() {
ptr = new T[sz];
cout << "PWrap constructor" << endl;
}
∼PWrap() {
delete[] ptr;
cout << "PWrap destructor" << endl;
}
T& operator[](int i) throw(RangeError) {
if(i >= 0 && i < sz) return ptr[i];
throw RangeError();
}
};
class Cat {
public:
Cat() { cout << "Cat()" << endl; }
∼Cat() { cout << "∼Cat()" << endl; }
void g() {}
};
class Dog {
public:
void* operator new[](size_t) {
cout << "Allocating a Dog" << endl;
throw 47;
}
void operator delete[](void* p) {
cout << "Deallocating a Dog" << endl;
::operator delete[](p);
}
};
class UseResources {
PWrap<Cat, 3> cats;
PWrap<Dog> dog;
public:
UseResources() { cout << "UseResources()" << endl; }
∼UseResources() { cout << "∼UseResources()" << endl; }
void f() { cats[1].g(); }
};
int main() {
try {
UseResources ur;
} catch(int) {
cout << "inside handler" << endl;
} catch(...) {
cout << "inside catch(...)" << endl;
}
} ///:∼
不同之处在于使用了模板来包装指针并使它们成为对象。这些对象的构造器在UseResources
构造器的主体之前被调用*,并且在抛出异常之前完成的这些构造器中的任何一个都将在栈展开期间调用它们相关的析构函数。*
PWrap
模板展示了异常的一个更典型的用法:如果参数超出范围,就会创建一个名为RangeError
的嵌套类在operator[ ]
中使用。因为operator[ ]
返回一个引用,所以不能返回零。
注意没有空引用。
这是一个真正的异常情况——您不知道在当前上下文中该做什么,并且您不能返回一个不可能的值。在清单 17-9 中,RangeError
[5]很简单,假设所有必要的信息都在类名中,但是如果有用的话,你可能还想添加一个包含索引值的成员。
现在输出是
Cat()
Cat()
Cat()
PWrap constructor
allocating a Dog
∼Cat()
∼Cat()
∼Cat()
PWrap destructor
inside handler
再次,Dog
的存储分配抛出了一个异常,但是这次Cat
对象的数组被正确地清理了,所以没有内存泄漏。
使用自动指针
由于动态内存是典型 C++ 程序中使用最频繁的资源,该标准为指向堆内存的指针提供了一个 RAII 包装器,可以自动释放内存。在<memory>
头中定义的auto_ptr
类模板有一个构造器,该构造器接受一个指向其泛型类型的指针(无论您在代码中使用什么)。auto_ptr
类模板还重载了指针操作符*
和->
,将这些操作转发给auto_ptr
对象持有的原始指针。因此您可以像使用原始指针一样使用auto_ptr
对象。清单 17-10 显示了它是如何工作的。
清单 17-10 。演示 auto_ptr 的 RAII 特性
//: C17:Auto_ptr.cpp
// Illustrates the RAII nature of auto_ptr.
#include <memory>
#include <iostream>
#include <cstddef>
using namespace std;
class TraceHeap {
int i;
public:
static void* operator new(size_t siz) {
void* p = ::operator new(siz);
cout << "Allocating TraceHeap object on the heap "
<< "at address " << p << endl;
return p;
}
static void operator delete(void* p) {
cout << "Deleting TraceHeap object at address "
<< p << endl;
::operator delete(p);
}
TraceHeap(int i) : i(i) {}
intgetVal() const { return i; }
};
int main() {
auto_ptr<TraceHeap> pMyObject(new TraceHeap(5));
cout << pMyObject->getVal() << endl; // Prints 5
} ///:∼
TraceHeap
类重载了operator new
和operator delete
,这样你就可以清楚地看到发生了什么。注意,像任何其他类模板一样,您要在模板参数中指定要使用的类型。你没有说TraceHeap*
,但是——auto_ptr
已经知道它将存储一个指向你的类型的指针。main()
的第二行验证了auto_ptr
的operator->()
函数对原始的底层指针应用了间接寻址。最重要的是,即使您没有显式删除原始指针,pMyObject
的析构函数也会在堆栈展开期间删除原始指针,如以下输出所示:
Allocating TraceHeap object on the heap at address 8930040
5
Deleting TraceHeap object at address 8930040
对于指针数据成员,类模板也很方便。因为由值包含的类对象总是被析构,auto_ptr
成员总是在包含对象被析构时删除它们包装的原始指针。
函数级 try 块
由于构造器通常会引发异常,所以您可能希望处理在初始化对象的成员或基子对象时发生的异常。为此,您可以将这些子对象的初始化放在一个函数级的 try 块中。与通常的语法不同,构造器初始化器的try
块是构造器体,相关的catch
块跟在构造器体后面,如清单 17-11 所示。
清单 17-11 。阐释如何处理子对象的异常
//: C17:InitExcept.cpp {-bor}
// Handles exceptions from subobjects.
#include <iostream>
using namespace std;
class Base {
int i;
public:
classBaseExcept {};
Base(int i) : i(i) { throw BaseExcept(); }
};
class Derived : public Base {
public:
class DerivedExcept {
const char* msg;
public:
DerivedExcept(const char* msg) : msg(msg) {}
const char* what() const { return msg; }
};
Derived(int j) try : Base(j) {
// Constructor body
cout << "This won't print" << endl;
} catch(BaseExcept&) {
throw DerivedExcept("Base subobject threw");;
}
};
int main() {
try {
Derived d(3);
} catch(Derived::DerivedExcept& d) {
cout << d.what() << endl; // "Base subobject threw"
}
} ///:∼
请注意,Derived
的构造器中的初始化列表位于try
关键字之后,构造器体之前。如果发生异常,所包含的对象不会被构造,因此返回到创建它的代码是没有意义的。出于这个原因,唯一明智的做法是在函数级catch
子句中抛出一个异常。
尽管不是特别有用,C++ 也允许函数级的块用于任何 ?? 函数,如清单 17-12 ?? 所示。
清单 17-12 。演示函数级 try 块
//: C17:FunctionTryBlock.cpp {-bor}
// Function-level try blocks.
// {RunByHand} (Don’t run automatically by the makefile)
#include <iostream>
using namespace std;
int main() try {
throw "main";
} catch(const char* msg) {
cout << msg << endl;
return 1;
} ///:∼
在这种情况下,catch
块可以以函数体正常返回的方式返回。使用这种类型的函数级try
块与在函数体内的代码周围插入一个try-catch
没有太大区别。
标准例外
标准 C++ 库使用的异常也可供您使用。一般来说,从一个标准的异常类开始比试图定义自己的异常类更容易、更快。如果标准类不能完全满足您的需求,您可以从它派生。
所有标准的异常类最终都是从头文件<exception>
中定义的类exception
中派生出来的。两个主要的派生类是logic_error
和runtime_error
,它们位于<stdexcept>
(它本身包括<exception>
)。类logic_error
表示编程逻辑中的错误,比如传递了一个无效的参数。运行时错误是由于硬件故障或内存耗尽等不可预见的因素导致的。runtime_error
和logic_error
都提供了一个接受std::string
参数的构造器,这样你就可以在异常对象中存储一条消息,然后用exception::what()
提取它,如清单 17-13 所示。
清单 17-13 。演示如何派生异常类
//: C17:StdExcept.cpp
// Derives an exception class from std::runtime_error.
#include <stdexcept>
#include <iostream>
using namespace std;
class MyError : public runtime_error {
public:
MyError(const string& msg = "") : runtime_error(msg) {}
};
int main() {
try {
throw MyError("my message");
} catch(MyError& x) {
cout << x.what() << endl;
}
} ///:∼
尽管runtime_error
构造器将消息插入到它的std::exception
子对象中,std::exception
没有提供接受std::string
参数的构造器。你通常想从runtime_error
或者logic_error
(或者它们的一个派生物)中派生出你的异常类,而不是从std::exception
中。
表 17-1 描述了标准异常类别。
表 17-1。标准异常类
| exception
| C++ 标准库引发的所有异常的基类。您可以询问 what()并检索初始化异常时使用的可选字符串。 |
| logic_error
| 源自exception
。报告程序逻辑错误,这些错误大概可以通过检查发现。 |
| runtime_error
| 源自exception
。报告运行时错误,这些错误可能只有在程序执行时才能被检测到。 |
iostream 异常类ios::failure
也是从exception
派生的,但是它没有进一步的子类。
您可以按原样使用下面两个表中的类,也可以将它们用作基类,从基类派生您自己的更具体类型的异常。参见表 17-2 和 17-3 。
表 17-2。从标准异常类派生的异常类 logic_error
从logic_error 派生的异常类 |
---|
domain_error |
invalid_argument |
length_error |
out_of_range |
bad_cast |
bad_typeid |
表 17-3。从标准异常类派生的异常类- runtime_error
从runtime_error 派生的异常类 |
---|
range_error |
overflow_error |
bad_alloc |
异常规格
你不需要通知使用你的函数的人你可能抛出什么异常。然而,不这样做可以被认为是不文明的,因为这意味着用户不能确定应该编写什么代码来捕捉所有潜在的异常。如果他们有你的源代码,他们可以搜索并寻找throw
语句,但是库通常没有源代码。好的文档可以帮助缓解这个问题,但是有多少软件项目是有良好文档记录的呢?C++ 提供了语法来告诉用户这个函数抛出的异常,这样用户就可以处理它们。这是可选的异常规范,它修饰一个函数的声明,出现在参数列表之后。
异常规范重用关键字throw
,后面是函数可能抛出的所有类型的潜在异常的括号列表。您的函数声明可能如下所示:
void f() throw(toobig, toosmall, divzero);
就异常而言,传统的函数声明
void f();
意味着任何类型的异常都可以从函数中抛出。如果你说
void f() throw();
这个函数不会抛出任何异常(所以你最好确保调用链中更靠下的函数不会让任何异常向上传播!).
为了良好的编码策略、良好的文档以及函数调用方的易用性,在编写抛出异常的函数时,可以考虑使用异常规范。
注本章稍后将讨论该指南的变体。
意外的()函数
如果你的异常规范声明你将抛出一组特定的异常,然后你抛出了不在那组中的东西,惩罚是什么?当您抛出异常规范中没有出现的内容时,会调用特殊函数unexpected()
。如果发生这种不幸的情况,默认的unexpected()
调用本章前面描述的terminate()
函数。
函数的作用是
和terminate()
一样,unexpected()
机制会安装您自己的函数来响应意外的异常。你可以用一个名为set_unexpected()
的函数来实现,这个函数和set_terminate()
一样,接受一个没有参数和void
返回值的函数的地址。此外,因为它返回unexpected()
指针的前一个值,所以您可以保存它并在以后恢复它。要使用set_unexpected()
,包括头文件<exception>
。清单 17-14 显示了到目前为止本节所讨论的特性的一个简单用法。
清单 17-14 。使用异常规范&的意外()机制
//: C17:Unexpected.cpp
// Exception specifications & unexpected(),
//{-msc} (Doesn’t terminate properly)
#include <exception>
#include <iostream>
using namespace std;
class Up {};
class Fit {};
void g();
void f(int i) throw(Up, Fit) {
switch(i) {
case 1: throw Up();
case 2: throw Fit();
}
g();
}
// void g() {} // Version 1
void g() { throw 47; } // Version 2
void my_unexpected() {
cout << "unexpected exception thrown" << endl;
exit(0);
}
int main() {
set_unexpected(my_unexpected); // (Ignores return value)
for(int i = 1; i <= 3; i++)
try {
f(i);
} catch(Up) {
cout << "Up caught" << endl;
} catch(Fit) {
cout << "Fit caught" << endl;
}
} ///:∼
创建类Up
和Fit
只是为了抛出异常。异常类通常很小,但是它们肯定可以保存额外的信息,以便处理程序可以查询这些信息。
f()
函数在其异常规范中承诺只抛出类型为Up
和Fit
的异常,从函数定义来看,这似乎是合理的。由f()
调用的g()
版本一不抛出任何异常,所以这是真的。但是如果有人更改了g()
,使其抛出不同类型的异常(如本例中的第二个版本,它抛出了一个int
),那么就违反了f()
的异常规范。
my_unexpected()
函数没有参数或返回值,遵循自定义unexpected()
函数的正确形式。它只是显示一条消息,这样您就可以看到它被调用了,然后退出程序(这里使用了exit(0)
,这样书的make
进程就不会中止)。您的新unexpected()
函数不应该有return
语句。
在main()
中,try
程序块在一个for
循环中,所以所有的可能性都被执行了。这样就可以达到复盘之类的东西。将try
模块嵌套在for
、while
、do
或if
中,并引发任何异常以尝试修复问题;然后再次尝试try
块。
只有Up
和Fit
异常被捕获,因为这些是f()
的程序员说会被抛出的唯一异常。g()
的版本二导致my_unexpected()
被调用,因为f()
随后抛出一个int
。
在对set_unexpected()
的调用中,返回值被忽略,但是它也可以保存在指向函数的指针中,以后再恢复,就像本章前面的set_terminate()
例子(清单 17-6 )一样。
典型的unexpected
处理程序记录错误并通过调用exit()
终止程序。然而,它可以抛出另一个异常(或者,重新抛出同一个异常)或者调用abort()
。如果它抛出了一个异常,该异常是最初违反了规范的函数所允许的类型,那么搜索将在具有该异常规范的函数的调用处重新开始。
注意这种行为是
unexpected()
独有的。
如果从您的unexpected
处理程序抛出的异常不被原始函数的规范所允许,就会发生两个事件之一。
- 如果
std::bad_exception
(在<exception>
中定义)在函数的异常规范中,从意外处理程序抛出的异常将被替换为一个std::bad_exception
对象,搜索将像以前一样从函数中恢复。 - 如果原始函数的规范不包括
std::bad_exception
,则调用terminate()
。
清单 17-15 说明了这种行为。
清单 17-15 。列举了两个糟糕的例外情况
//: C17:BadException.cpp {-bor}
#include <exception> // For std::bad_exception
#include <iostream>
#include <cstdio>
using namespace std;
// Exception classes:
class A {};
class B {};
// terminate() handler
void my_thandler() {
cout << "terminate called" << endl;
exit(0);
}
// unexpected() handlers
void my_uhandler1() { throw A(); }
void my_uhandler2() { throw; }
// If we embed this throw statement in f or g,
// the compiler detects the violation and reports
// an error, so we put it in its own function.
void t() { throw B(); }
void f() throw(A) { t(); }
void g() throw(A, bad_exception) { t(); }
int main() {
set_terminate(my_thandler);
set_unexpected(my_uhandler1);
try {
f();
} catch(A&) {
cout << "caught an A from f" << endl;
}
set_unexpected(my_uhandler2);
try {
g();
} catch(bad_exception&) {
cout << "caught a bad_exception from g" << endl;
}
try {
f();
} catch(...) {
cout << "This will never print" << endl;
}
} ///:∼
my_uhandler1()
处理程序抛出一个可接受的异常(A
),所以在第一次捕捉成功时执行继续。my_uhandler2()
处理程序没有抛出有效的异常(B
,但是由于g
指定了bad_exception
,所以B
异常被一个bad_exception
对象替换,第二次捕捉也成功了。由于f
的规范中不包含bad_exception
,因此my_thandler()
被作为终止处理程序调用。以下是输出结果:
caught an A from f
caught a bad_exception from g
terminate called
更好的异常规范?
您可能会觉得现有的异常规范规则不太安全
void f();
应该是指这个函数没有抛出异常。如果程序员想抛出任何类型的异常,你可能会认为他或她应该说
void f() throw(...); // Not in C++
这肯定是一种改进,因为函数声明会更加明确。不幸的是,通过查看函数中的代码,您并不总能知道是否会抛出异常——例如,它可能是因为内存分配而发生的。更糟糕的是,在异常处理被引入到语言中之前编写的现有函数可能会发现自己无意中抛出了异常,因为它们调用的函数(可能会链接到新的抛出异常的版本中)。因此,在这种毫无信息的情况下
void f();
意思是,“也许我会抛出一个异常;也许我不会。”这种模糊性对于避免阻碍代码进化是必要的。如果您想指定f
不抛出异常,请使用空列表,如下所示:
void f() throw();
异常规范和继承
类中的每个公共函数本质上都与用户形成了一个契约;如果您向它传递某些参数,它将执行某些操作和/或返回一个结果。同样的约定在派生类中也必须成立;否则,预计的将会违反派生类和基类之间的关系。由于异常规范在逻辑上是函数声明的一部分,它们也必须在继承层次结构中保持一致。例如,如果基类中的成员函数说它将只抛出类型为A
的异常,那么派生类中该函数的重写不能将任何其他异常类型添加到规范列表中,因为这将破坏任何遵循基类接口的程序。然而,你可以指定更少的异常或者根本没有,因为这不需要用户做任何不同的事情。你也可以指定任何东西,在派生函数的规范中,用代替A
。清单 17-16 显示了一个例子。
清单 17-16 。说明协方差(异常规范&继承)
//: C17:Covariance.cpp {-xo}
// Should cause compile error. {-mwcc}{-msc}
#include <iostream>
using namespace std;
class Base {
public:
class BaseException {};
class DerivedException : public BaseException {};
virtual void f() throw(DerivedException) {
throw DerivedException();
}
virtual void g() throw(BaseException) {
throw BaseException();
}
};
class Derived : public Base {
public:
void f() throw(BaseException) {
throw BaseException();
}
virtual void g() throw(DerivedException) {
throw DerivedException();
}
}; ///:∼
编译器应该用一个错误(或至少一个警告)来标记Derived::f()
的覆盖,因为它以一种违反Base::f()
规范的方式改变了它的异常规范。Derived::g()
的规格是可以接受的,因为DerivedException
是-a BaseException
(而不是相反)。你可以把Base/Derived
和BaseException/DerivedException
想象成平行的类层次结构;当你在Derived
时,你可以用DerivedException
替换异常规范和返回值中对BaseException
的引用。这种行为被称为协方差(因为两组类一起沿着它们各自的层次向下变化)。
何时不使用异常规范
如果你仔细阅读整个标准 C++ 库的函数声明,你会发现没有一个异常规范出现在任何地方!虽然这看起来很奇怪,但这种不一致有一个很好的原因:库主要由模板组成,你永远不知道泛型类型或函数会做什么。例如,假设您正在开发一个通用的堆栈模板,并试图为您的 pop 函数附加一个异常规范,如下所示:
T pop() throw(logic_error);
因为您预期的唯一错误是堆栈下溢,所以您可能认为指定一个logic_error
或其他适当的异常类型是安全的。但是类型T
的复制构造器可能会抛出一个异常。然后unexpected()
会被调用,你的程序会终止。你不能做出无法支持的保证。如果您不知道可能会发生什么异常,就不要使用异常规范。这就是为什么组合标准 C++ 库主要部分的模板类不使用异常规范——它们在文档中指定它们知道的关于的异常,剩下的交给你。异常规范主要针对非模板类。
异常安全
标准 C++ 库包括了stack
容器。您会注意到的一件事是,pop()
成员函数的声明如下:
void pop();
你可能会觉得奇怪,因为pop()
没有返回值。相反,它只是移除堆栈顶部的元素。要检索上限值,在调用pop()
之前先调用top()
。这种行为有一个重要的原因,它与异常安全有关,这是库设计中的一个关键考虑因素。异常安全有不同的级别,但最重要的是——顾名思义——异常安全是关于面对异常时的正确语义。
假设你正在用一个动态数组实现一个堆栈(姑且称之为data
和计数器整数count
,你试着写pop()
让它返回值。这种pop()
的代码可能看起来像这样:
template<class T> T stack<T>::pop() {
if(count == 0)
throw logic_error("stack underflow");
else
return data[--count];
}
如果最后一行中为返回值调用的复制构造器在值返回时抛出异常,会发生什么?弹出的元素因为异常而没有返回,然而count
已经被递减,所以你想要的顶部元素永远丢失了!问题是这个函数试图同时做两件事:(1)返回值,和(2)改变堆栈的状态。最好将这两个动作分成两个独立的成员函数,这正是标准的stack
类所做的。(换句话说,遵循衔接的设计惯例——每个功能都要做好一件事。)异常安全代码使对象保持一致的状态,不会泄漏资源。
您还需要小心编写自定义赋值操作符。在第十二章中,你看到operator=
应该遵循以下模式。
- 确保您没有分配给 self。如果是,请转到步骤 6。(这是严格意义上的优化。)
- 分配指针数据成员所需的新内存。
- 将数据从旧内存复制到新内存。
- 删除旧的记忆。
- 通过将新的堆指针分配给指针数据成员来更新对象的状态。
- 返回
*this
。
重要的是,在所有新的部分都被安全地分配和初始化之前,不要改变对象的状态。一个好的技巧是将步骤 2 和 3 移到一个单独的函数中,通常称为clone()
。清单 17-17 为一个有两个指针成员theString
和theInts
的类这样做。
清单 17-17 。阐释异常安全运算符(=)
//: C17:SafeAssign.cpp
// An Exception-safe operator=.
#include <iostream>
#include <new> // For std::bad_alloc
#include <cstring>
#include <cstddef>
using namespace std;
// A class that has two pointer members using the heap
class HasPointers {
// A Handle class to hold the data
struct MyData {
const char* theString;
const int* theInts;
size_t numInts;
MyData(const char* pString, const int* pInts,
size_t nInts)
: theString(pString), theInts(pInts), numInts(nInts) {}
} *theData; // The handle
// Clone and cleanup functions:
static MyData* clone(const char* otherString,
const int* otherInts, size_t nInts) {
char* newChars = new char[strlen(otherString)+1];
int* newInts;
try {
newInts = new int[nInts];
} catch(bad_alloc&) {
delete [] newChars;
throw;
}
try {
// This example uses built-in types, so it won't
// throw, but for class types it could throw, so we
// use a try block for illustration. (This is the
// point of the example!)
strcpy(newChars, otherString);
for(size_t i = 0; i < nInts; ++i)
newInts[i] = otherInts[i];
} catch(...) {
delete [] newInts;
delete [] newChars;
throw;
}
return new MyData(newChars, newInts, nInts);
}
static MyData* clone(const MyData* otherData) {
return clone(otherData->theString, otherData->theInts,
otherData->numInts);
}
static void cleanup(const MyData* theData) {
delete [] theData->theString;
delete [] theData->theInts;
delete theData;
}
public:
HasPointers(const char* someString, constint* someInts,
size_t numInts) {
theData = clone(someString, someInts, numInts);
}
HasPointers(const HasPointers& source) {
theData = clone(source.theData);
}
HasPointers& operator=(const HasPointers& rhs) {
if(this != &rhs) {
MyData* newData = clone(rhs.theData->theString,
rhs.theData->theInts, rhs.theData->numInts);
cleanup(theData);
theData = newData;
}
return *this;
}
∼HasPointers() { cleanup(theData); }
friend ostream&
operator<<(ostream& os, const HasPointers& obj) {
os << obj.theData->theString << ": ";
for(size_t i = 0; i < obj.theData->numInts; ++i)
os << obj.theData->theInts[i] << ' ';
return os;
}
};
int main() {
int someNums[] = { 1, 2, 3, 4 };
size_t someCount = sizeof someNums / sizeof someNums[0];
int someMoreNums[] = { 5, 6, 7 };
size_t someMoreCount =
sizeof someMoreNums / sizeof someMoreNums[0];
HasPointers h1("Hello", someNums, someCount);
HasPointers h2("Goodbye", someMoreNums, someMoreCount);
cout << h1 << endl; // Hello: 1 2 3 4
h1 = h2;
cout << h1 << endl; // Goodbye: 5 6 7
} ///:∼
为了方便起见,HasPointers
使用MyData
类作为两个指针的句柄。每当需要分配更多内存时,无论是在构造还是赋值期间,最终都会调用第一个clone
函数来完成这项工作。如果第一次调用new
操作符时内存失败,就会自动抛出一个bad_alloc
异常。如果它发生在第二次分配时(对于theInts
,你必须为theString
清理内存——因此第一个try
块捕捉到一个bad_alloc
异常。第二个try
块在这里并不重要,因为你只是复制了int
和指针(所以不会发生异常),但是每当你复制对象时,它们的赋值操作符可能会导致异常,所以一切都需要清理。在两个异常处理程序中,请注意您重新抛出了异常和。那是因为你只是在这里管理资源;用户仍然需要知道出错了,所以您让异常沿着动态链向上传播。不默默吞下异常的软件库被称为异常中立。总是努力编写既异常安全又异常中立的库。
如果您仔细检查前面的代码,您会注意到没有一个delete
操作会抛出异常。这个代码取决于这个事实。回想一下,当您在一个对象上调用delete
时,该对象的析构函数被调用。事实证明,如果不假设析构函数不抛出异常,设计异常安全的代码几乎是不可能的。不要让析构函数抛出异常。
注意在本章结束之前,我们将再次提醒你这一点。
异常编程
对于大多数程序员,尤其是 C 程序员,异常在他们现有的语言中是不可用的,需要一些调整。以下是异常编程的指导原则。
何时避免例外
例外不是所有问题的答案;过度使用会带来麻烦。以下章节指出了未授权的例外情况。决定何时使用异常的最佳建议是,只有当函数不符合其规范时才抛出异常。
不适用于异步事件
标准 C signal()
系统和任何类似的系统都处理异步事件——发生在程序流程之外的事件,也就是程序无法预料的事件。您不能使用 C++ 异常来处理异步事件,因为异常及其处理程序在同一个调用堆栈上。也就是说,异常依赖于程序运行时堆栈上函数调用的动态链(它们有动态范围,而异步事件必须由完全独立的代码处理,这些代码不是正常程序流的一部分(通常是中断服务例程或事件循环)。不要从中断处理程序抛出异常。
这并不是说异步事件不能与异常相关联。但是中断处理程序应该尽可能快地完成它的工作,然后返回。处理这种情况的典型方法是在中断处理程序中设置一个标志,并在主线代码中同步检查它。
不适用于良性错误情况
如果您有足够的信息来处理一个错误,它就不是一个异常。在当前的上下文中处理它,而不是在更大的上下文中抛出一个异常。
此外,对于机器级别的事件,如被零除,不会引发 C++ 异常。我们假设一些其他的机制,比如操作系统或者硬件,来处理这些事件。通过这种方式,C++ 异常可以相当有效,并且它们的使用仅限于程序级的异常情况。
不用于控制流
异常看起来有点像替代返回机制,又有点像switch
语句,所以您可能会尝试使用异常来代替这些普通的语言机制。这是一个坏主意,部分原因是异常处理系统的效率明显低于正常的程序执行。异常是一种罕见的事件,所以正常的程序不应该支付它们。此外,除了错误条件之外的任何异常都很容易让类或函数的用户感到困惑。
您不必被迫使用异常
有些程序非常简单(例如,小工具)。您可能只需要接受输入并执行一些处理。在这些程序中,您可能会尝试分配内存但失败,尝试打开文件但失败,等等。在这些程序中,显示一条消息并退出程序是可以接受的,让系统来收拾残局,而不是自己努力捕捉所有异常并恢复所有资源。基本上,如果你不需要异常,你不会被迫使用它们。
新异常,旧代码
出现的另一种情况是修改不使用异常的现有程序。你可能会引入一个使用了异常的库,并且想知道你是否需要修改整个程序中的所有代码。假设您已经有了一个可接受的错误处理方案,最简单的方法就是将使用新库的最大的代码块包围起来(这可能是用一个try
代码块、后跟一个 catch(...)
和基本错误消息来包围main()
中的所有代码)。您可以通过添加更具体的处理程序来将它细化到任何必要的程度,但是,在任何情况下,您必须添加的代码都可以是最少的。更好的做法是将您的异常生成代码隔离在一个try
块中,并编写处理程序将异常转换成您现有的错误处理方案。
当你创建一个供其他人使用的库时,考虑异常是非常重要的,尤其是当你不知道他们需要如何响应关键的错误条件时。
注回想一下前面关于异常安全以及为什么标准 C++ 库中没有异常规范的讨论。
异常的典型用法
务必使用异常来执行以下操作:
- 请修复该问题,然后重试导致异常的函数。
- 修补东西并继续,不要重试该功能。
- 在当前上下文中尽你所能,将相同的异常重新抛出到更高的上下文中。
- 在当前上下文中做你能做的任何事情,并向更高的上下文抛出一个不同的异常。
- 终止程序。
- 包装使用普通错误方案的函数(尤其是 C 库函数),这样它们反而会产生异常。
- 简化。如果你的错误处理方案让事情变得更复杂,那么使用起来会很痛苦,很烦人。异常可以用来使错误处理更简单、更有效。
- 使您的库和程序更加安全。这是一项短期投资(用于调试),也是一项长期投资(用于应用程序健壮性)。
何时使用异常规范
异常规范就像一个函数原型:它告诉用户编写异常处理代码以及处理什么异常。它告诉编译器这个函数可能产生的异常,这样它就可以在运行时检测到违规。
您不能总是查看代码并预测特定函数会引发哪些异常。有时,它调用的函数会产生一个意外的异常,有时,一个没有抛出异常的旧函数会被一个抛出异常的新函数所替换,这样您就会得到一个对unexpected()
的调用。任何时候使用异常规范或调用使用异常规范的函数时,都要考虑创建自己的unexpected()
函数,记录一条消息,然后抛出异常或中止程序。
如前所述,您应该避免在模板类中使用异常规范,因为您无法预料模板参数类可能会抛出什么类型的异常。
从标准异常开始
在创建自己的异常之前,先检查一下标准的 C++ 库异常。如果一个标准的异常满足了你的需要,那么你的用户就很容易理解和处理它。
如果您想要的异常类型不是标准 C++ 库的一部分,请尝试从现有的标准异常中继承一个。如果你的用户总是能够编写他们的代码来期望在exception()
类接口中定义的what()
函数,那就太好了。
嵌套您自己的异常
如果您为您的特定类创建异常,最好将异常类嵌套在您的类中或包含您的类的命名空间中,以便向读者提供一个明确的消息,即该异常仅适用于您的类。此外,它还防止了全局名称空间的污染。即使您是从 C++ 标准异常派生的,您也可以嵌套您的异常。
使用异常层次结构
使用异常层次结构是对您的类或库可能遇到的严重错误类型进行分类的一种有价值的方法。这为用户提供了有用的信息,帮助他们组织代码,并为他们提供了忽略所有特定类型的异常并只捕捉基类类型的选项。此外,以后通过从同一基类继承而添加的任何异常都不会强制重写所有现有代码——基类处理程序将捕获新的异常。
标准 C++ 异常是异常层次结构的一个很好的例子。如果可能的话,在它的基础上构建您的异常。
多重继承(MI)
正如你将在第二十一章中读到的,MI 的唯一本质地方是如果你需要将一个对象指针向上转换到两个不同的基类——也就是说,如果你需要这两个基类的多态行为。原来,异常层次结构是多重继承的有用位置,因为多重继承异常类的任何根的基类处理程序都可以处理异常。
通过引用捕获,而不是通过值
正如您在“异常匹配”一节中看到的,您应该通过引用来捕捉异常,原因有两个:
- 以避免在将异常对象传递给处理程序时对其进行不必要的复制。
- 在捕获作为基类对象的派生异常时避免对象切片。
虽然您也可以抛出和捕捉指针,但这样做会引入更多的耦合——抛出者和捕捉者必须就如何分配和清理异常对象达成一致。这是一个问题,因为异常本身可能是由堆耗尽引起的。如果抛出异常对象,异常处理系统会处理所有存储。
在构造器中抛出异常
因为构造器没有返回值,所以之前你有两种方法在构造过程中报告错误:
- 设置一个非本地标志,并希望用户检查它。
- 返回一个创建不完整的对象,并希望用户检查它。
这个问题很严重,因为 C 程序员期望对象创建总是成功的,这在 C 中不是不合理的,因为类型是如此原始。但是在 C++ 程序中,构造失败后继续执行肯定是一场灾难,所以构造器是抛出异常的最重要的地方之一——现在您有了一种安全有效的方法来处理构造器错误。但是,您还必须注意对象内部的指针,以及在构造器内部引发异常时进行清理的方式。
不要在析构函数中引发异常
因为析构函数是在抛出其他异常的过程中被调用的,所以您绝不会想要在析构函数中抛出一个异常,或者通过在析构函数中执行的某些操作引发另一个异常。如果发生这种情况,在到达现有异常的 catch-clause 之前,可能会抛出一个新的异常*,这将导致对terminate()
的调用。*
如果在析构函数中调用任何可能抛出异常的函数,这些调用应该在析构函数的try
块中,并且析构函数必须自己处理所有异常。任何人都不能从析构函数中逃脱。
避免裸指针
参见清单 17-9 中的Wrapped.cpp
。如果为指针分配了资源,那么裸指针通常意味着构造器中存在漏洞。指针没有析构函数,所以如果在构造器中抛出异常,这些资源不会被释放。对引用堆内存的指针使用auto_ptr
或其他智能指针类型。
开销
当抛出异常时,会有相当大的运行时开销(但是这是很好的开销,因为对象是自动清理的!).出于这个原因,无论异常看起来多么诱人和聪明,您都不应该将它作为正常控制流的一部分。
异常应该很少发生,所以开销堆积在异常上,而不是正常执行的代码上。异常处理的一个重要设计目标是,当不使用时,它可以在不影响执行速度的情况下实现;也就是说,只要您不抛出异常,您的代码就会像没有异常处理时一样快。这是否正确取决于您使用的特定编译器实现。
注参见本节后面对“零成本模式”的描述。
您可以将一个throw
表达式想象成对一个特殊系统函数的调用,该函数将异常对象作为一个参数,并沿着执行链向上回溯。为此,编译器需要将额外的信息放到堆栈中,以帮助展开堆栈。要理解这一点,您需要了解运行时堆栈。
每当调用一个函数时,关于该函数的信息就被推送到运行时堆栈中的一个激活记录实例 ( 【阿里】 ) ,也称为堆栈帧。一个典型的堆栈帧包含调用函数的地址(这样执行可以返回给它),一个指向函数的静态父函数的 ARI 的指针(这个范围在词法上包含被调用的函数,所以可以访问函数的全局变量),一个指向调用它的函数(它的动态父函数)的指针。重复跟踪动态父链接的逻辑结果路径是本章前面提到的动态链或调用链。
这就是抛出异常时执行可以回溯的方式,这种机制使得在不了解彼此的情况下开发的组件可以在运行时交流错误。
为了启用异常处理的堆栈展开,需要为每个堆栈帧提供关于每个函数的额外异常相关信息。该信息描述了需要调用哪些析构函数(以便可以清理本地对象),指示当前函数是否有一个try
块,并列出相关联的 catch 子句可以处理哪些异常。
这些额外的信息会占用空间,所以支持异常处理的程序会比不支持异常处理的程序大一些。甚至使用异常处理的程序的编译时大小也更大,因为如何在运行时生成扩展堆栈帧的逻辑必须由编译器生成。
为了说明这一点,我在 Borland C++ Builder 和 Microsoft Visual C++ 中编译了有和没有异常处理支持的清单 17-18 中的程序。
清单 17-18 。说明有/没有异常处理支持的程序
//: C17:HasDestructor.cpp {O}
*/* shows that programs with exception-handling support are bigger than those without */*
class HasDestructor {
public:
∼HasDestructor() {}
};
void g(); // For all we know, g may throw.
void f() {
HasDestructor h;
g();
} ///:∼
如果启用了异常处理,编译器必须在运行时为f()
在 ARI 中保留关于∼HasDestructor()
的可用信息(这样当g()
抛出异常时,它可以正确地销毁h
)。
表 17-4 根据编译的大小总结了编译的结果。obj)文件(字节)。
表 17-4 。汇编流程结果(汇总)
编译器\模式 | 例外支持 | 无一例外的支持 |
---|---|---|
Borland | Six hundred and sixteen | Two hundred and thirty-four |
Microsoft | One thousand one hundred and sixty-two | Six hundred and eighty |
不要把两种模式的百分比差异看得太重。
请记住,异常(应该是)通常组合程序的一小部分,因此空间开销往往更小(通常在 5%到 15%之间)。
这种额外的内务处理会降低执行速度,但是聪明的编译器实现可以避免这种情况。由于关于异常处理代码和局部对象的偏移量的信息可以在编译时计算一次,所以这些信息可以保存在与每个函数相关联的单个位置,而不是保存在每个 ARI 中。
从本质上消除了每个 ARI 的异常开销,从而避免了将它们压入堆栈的额外时间。这种方法被称为异常处理的零成本模型,前面提到的优化存储被称为影子堆栈。
审查会议
- 错误恢复是你编写的每一个程序的基本关注点。在 C++ 中,当创建程序组件供他人使用时,这一点尤其重要。要创建一个健壮的系统,每个组件都必须健壮。
- C++ 中的异常处理的目标是使用比目前更少的代码来简化大型、可靠程序的创建,更确信你的应用程序没有未处理的错误。这是在很少或没有性能损失的情况下完成的,并且对现有代码的影响很小。
- 基本的例外是不太难学;尽快在你的程序中使用它们。
- 异常是那些为你的项目提供直接和显著利益的特性之一。*
十八、深入字符串
用字符数组处理字符串是 c 语言中最浪费时间的事情之一。字符数组要求程序员跟踪静态引用字符串和在堆栈和堆上创建的数组之间的区别,以及有时你传递一个 char*而有时你必须复制整个数组的事实。
尤其是因为字符串操作如此普遍,字符数组是误解和错误的主要来源。尽管如此,创建字符串类仍然是初学 C++ 程序员多年来的常见练习。标准的 C++ 库string
一劳永逸地解决了字符数组操作的问题,即使在赋值和复制构造期间也能跟踪内存。你根本不需要考虑它。
本章考察了标准的 C++ string
类,首先看看 C++ 字符串是由什么组成的,以及 C++ 版本与传统的 C 字符数组有何不同。您将学习使用string
对象的操作和操纵,您将看到 C++ string
如何适应字符集和字符串数据转换的变化。
处理文本是最古老的编程应用之一,所以 C++ string
大量借鉴了 C 和其他语言中长期使用的思想和术语也就不足为奇了。当你开始熟悉 C++ string
s 时,这个事实应该是令人放心的。无论您选择哪种编程习惯,您可能想用string
做三件常见的事情:
- 创建或修改存储在
string
中的字符序列。 - 检测
string
中是否存在元素。 - 在表示
string
字符的各种方案之间进行翻译。
您将看到这些工作是如何使用 C++ string
对象来完成的。
字符串中有什么?
在 C 语言中,字符串只是一个字符数组,它总是包含一个二进制零(通常称为零终止符)作为它的最终数组元素。C++ string
s 与其 C 祖先之间存在显著差异。首先,也是最重要的,C++ string
隐藏了它们包含的字符序列的物理表示。您不需要关心数组维数或空终止符。一个string
还包含一些关于其数据大小和存储位置的“内务”信息。具体来说,一个 C++ string
对象知道它在内存中的起始位置、它的内容、它的字符长度,以及在它必须调整其内部数据缓冲区之前它可以增长到的字符长度。因此,C++ 字符串大大降低了犯三种最常见和最具破坏性的 C 编程错误的可能性:覆盖数组边界、试图通过未初始化或值不正确的指针访问数组,以及在数组不再占用曾经分配给它的存储空间后让指针“悬空”。
C++ 标准没有定义 string 类的内存布局的确切实现。这“意在足够灵活以允许编译器供应商的不同实现,而保证用户的可预测行为*。特别是,没有定义分配存储来保存字符串对象的数据的确切条件。字符串分配规则被公式化为允许但不要求引用计数实现,但是无论实现是否使用引用计数,语义必须是相同的。换句话说,在 C 语言中,每个char
数组都占据一个唯一的物理内存区域。在 C++ 中,单个的string
对象可能会也可能不会占用内存中唯一的物理区域,但是如果引用计数避免存储数据的重复副本,那么单个对象必须看起来和表现得好像它们独占了唯一的存储区域。例如,见清单 18-1 。*
*清单 18-1 。说明字符串存储
//: C18:StringStorage.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
string s1("12345");
// Set the iterator indicate the first element
string::iterator it = s1.begin();
// This may copy the first to the second or
// use reference counting to simulate a copy
string s2 = s1;
// Either way, this statement may ONLY modify first
*it = '0';
cout << "s1 = " << s1 << endl;
cout << "s2 = " << s2 << endl;
} ///:∼
引用计数可能有助于提高实现的内存效率,但它对string
类的用户是透明的。
创建和初始化 C++ 字符串
创建和初始化字符串是一件简单的事情,而且相当灵活。在清单 18-2 的SmallString.cpp
中,声明了第一个string
、imBlank
,但不包含初始值。与 C char
数组不同,imBlank
数组包含有意义的信息,Cchar
数组在初始化之前包含随机且无意义的位模式。这个string
对象被初始化为保存“no characters ”,并且可以使用类成员函数正确地报告它的零长度和数据元素的缺失。
下一个字符串heyMom
,由文字参数“我的袜子在哪里?”这种形式的初始化使用带引号的字符数组作为string
构造器的参数。相比之下,standardReply
只是通过赋值来初始化。使用现有的 C++ string
对象初始化该组的最后一个字符串useThisOneAgain
。换句话说,清单 18-2 说明了string
对象让你做以下事情:
- 创建一个空的
string
,并推迟用字符数据初始化它。 - 通过将一个带引号的字符数组作为参数传递给构造器来初始化一个
string
。 - 使用等号(
=
)初始化 astring
。 - 使用一个
string
初始化另一个。
清单 18-2 。说明字符串特征
//: C18:SmallString.cpp
#include <string>
using namespace std;
int main() {
string imBlank;
string heyMom("Where are my socks?");
string standardReply = "Beamed into deep "
"space on wide angle dispersion?";
string useThisOneAgain(standardReply);
} ///:∼
这些是最简单的初始化形式,但是变化提供了更多的灵活性和控制。您可以执行以下操作:
- 使用 C
char
数组或 C++string
的一部分。 - 使用
operator+
组合不同来源的初始化数据。 - 使用
string
对象的substr()
成员函数创建一个子串。
清单 18-3 展示了这些特征。
清单 18-3 。说明更多字符串功能
//: C18:SmallString2.cpp
#include<string>
#include<iostream>
using namespace std;
int main() {
string s1("What is the sound of one clam napping?");
string s2("Anything worth doing is worth overdoing.");
string s3("I saw Elvis in a UFO");
// Copy the first 8 chars:
string s4(s1, 0, 8);
cout << s4 << endl;
// Copy 6 chars from the middle of the source:
string s5(s2, 15, 6);
cout << s5 << endl;
// Copy from middle to end:
string s6(s3, 6, 15);
cout << s6 << endl;
// Copy many different things:
string quoteMe = s4 + "that" +
// substr() copies 10 chars at element 20
s1.substr(20, 10) + s5 +
// substr() copies up to either 100 char
// or eos starting at element 5
"with" + s3.substr(5, 100) +
// OK to copy a single char this way
s1.substr(37, 1);
cout << quoteMe << endl;
} ///:∼
string
成员函数substr()
将起始位置作为第一个参数,将选择的字符数作为第二个参数。两个参数都有默认值。如果你用一个空的参数列表说substr()
,你产生了一个整个string
的副本,所以这是一个复制string
的方便方法。
下面是程序的输出:
What is
doing
Elvis in a UFO
What is that one clam doing with Elvis in a UFO?
注意清单 18-3 中的最后一行。C++ 允许在一条语句中混合使用初始化技术,这是一个灵活方便的特性*。*还要注意,最后一个初始化器只从源string
复制了一个字符。
另一个稍微微妙的初始化技术涉及到使用string
迭代器string::begin()
和string::end()
。这种技术将string
视为一个容器对象(您主要以vector
的形式看到它),它使用迭代器来指示一个字符序列的开始和结束。这样你可以给一个string
构造器传递两个迭代器,它从一个迭代器复制到另一个迭代器到新的string
,如清单 18-4 所示。
清单 18-4 。说明字符串迭代器
//: C18:StringIterators.cpp
#include <string>
#include <iostream>
#include <cassert>
using namespace std;
int main() {
string source("xxx");
string s(source.begin(), source.end());
assert(s == source);
} ///:∼
迭代器不限于begin()
和end()
;您可以增加、减少和添加整数偏移量,允许您从源string
中提取字符的子集。
C++ 字符串可能而不是用单个字符或 ASCII 或其他整数值初始化。但是,您可以用单个字符的多个副本来初始化一个字符串;参见清单 18-5 。
清单 18-5 。说明字符串的初始化
//: C18:UhOh.cpp
#include <string>
#include <cassert>
using namespace std;
int main() {
// Error: no single char inits
//! string nothingDoing1('a');
// Error: no integer inits
//! string nothingDoing2(0x37);
// The following is legal:
string okay(5, 'a');
assert(okay == string("aaaaa"));
} ///:∼
第一个参数指示第二个参数在字符串中的副本数。第二个参数只能是单个char
,不能是char
数组。
在字符串上操作
如果你用 C 语言编程,你会习惯于编写、搜索、修改和复制char
数组的函数。处理char
数组的标准 C 库函数有两个不幸的方面。首先,它们有两个组织松散的家族:“普通”组,以及要求您提供手头操作中要考虑的字符数的组。C char
数组库中的函数列表中有一长串神秘的、大多难以发音的名字,让不知情的用户大吃一惊。尽管函数的参数类型和数量有些一致,但要正确使用它们,您必须注意函数命名和参数传递的细节。
标准 C char
数组工具的第二个固有陷阱是,它们都明确依赖于字符数组包含一个空终止符的假设。如果由于疏忽或错误,空值被忽略或覆盖,C char
数组函数很难控制内存超出分配空间的限制,有时会导致灾难性的结果。
C++ 极大地提高了string
对象的便利性和安全性。对于实际的字符串处理操作来说,string
类中的不同成员函数名称的数量与 C 库中的函数数量大致相同,但是由于重载,功能要多得多。再加上合理的命名实践和对默认参数的明智使用,这些特性结合起来使得string
类比 C 库char
数组函数更容易使用。
追加、插入和连接字符串
C++ 字符串最有价值和最方便的方面之一是它们可以根据需要增长,而不需要程序员的干预。这不仅使字符串处理代码本身更值得信赖,而且几乎完全消除了繁琐的日常工作——跟踪字符串所在的存储范围。例如,如果创建一个 string 对象,用一个包含 50 个副本的字符串X对其进行初始化,然后在其中存储 50 个副本的“Zowie”,那么该对象本身将重新分配足够的存储空间来容纳数据的增长。也许没有什么地方比在代码中操作的字符串改变大小时更能体现这种特性,而您不知道这种改变有多大。字符串成员函数append()
和insert()
在字符串增长时透明地重新分配存储,如清单 18-6 中的所示。**
**清单 18-6 。示出了根据字符串大小的存储再分配
//: C18:StrSize.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
string bigNews("I saw Elvis in a UFO. ");
cout << bigNews << endl;
// How much data have we actually got?
cout << "Size = " << bigNews.size() << endl;
// How much can we store without reallocating?
cout << "Capacity = " << bigNews.capacity() << endl;
// Insert this string in bigNews immediately
// before bigNews[1]:
bigNews.insert(1, " thought I");
cout << bigNews << endl;
cout << "Size = " << bigNews.size() << endl;
cout << "Capacity = " << bigNews.capacity() << endl;
// Make sure that there will be this much space
bigNews.reserve(500);
// Add this to the end of the string:
bigNews.append("I've been working too hard.");
cout << bigNews << endl;
cout<< "Size = " << bigNews.size() << endl;
cout << "Capacity = " << bigNews.capacity() << endl;
} ///:∼
下面是一个特定编译器的输出:
I saw Elvis in a UFO.
Size = 22
Capacity = 31
I thought I saw Elvis in a UFO.
Size = 32
Capacity = 47
I thought I saw Elvis in a UFO. I've been
working too hard.
Size = 59
Capacity = 511
清单 18-6 展示了即使你可以安全地放弃分配和管理你的string
所占用的内存的大部分责任,C++ string
为你提供了几个工具来监控和管理它们的大小。请注意,更改分配给字符串的存储大小是多么容易。size()
函数返回当前存储在字符串中的字符数,与length()
成员函数相同。capacity()
函数返回当前底层分配的大小,即字符串在不请求更多存储空间的情况下可以容纳的字符数。reserve()
功能是一种优化机制,表明您打算指定一定量的存储空间以备将来使用;capacity()
总是返回一个至少与最近一次调用reserve()
一样大的值。如果新的大小大于当前的字符串大小,则resize()
函数会添加空格,否则会截断字符串。(resize()
的重载可以指定附加不同的字符。)
string
成员函数为数据分配空间的确切方式取决于库的实现。当测试来自清单 18-6 的代码的一个实现时,似乎在偶数字(即全整数)边界上发生了重新分配,保留了 1 个字节。string
类的设计者已经努力使混合使用 C char
数组和 C++ 字符串对象成为可能,因此StrSize.cpp
报告的容量数据很可能反映出,在这个特定的实现中,留出了一个字节以方便插入空终止符。
替换字符串字符
insert()
函数的 特别好,因为它免除了您确保在字符串中插入字符不会溢出存储空间或覆盖紧跟在插入点之后的字符的责任。空间变大了,现有的角色礼貌地移动以适应新的元素。有时候这可能不是你想要的。如果您希望字符串的大小保持不变,请使用replace()
函数来覆盖字符。有许多重载版本的replace()
,但是最简单的一个有三个参数:一个整数表示在字符串中从哪里开始,一个整数表示从原始字符串中删除多少个字符,以及替换字符串(可以是与删除数量不同的字符数)。一个简单的例子见清单 18-7 。
清单 18-7 。说明字符串字符的替换
//: C18:StringReplace.cpp
// Simple find-and-replace in strings.
#include <cassert>
#include <string>
using namespace std;
int main() {
string s("A piece of text");
string tag("$tag$");
s.insert(8, tag + ' ');
assert(s == "A piece $tag$ of text");
int start = s.find(tag);
assert(start == 8);
assert(tag.size() == 5);
s.replace(start, tag.size(), "hello there");
assert(s == "A piece hello there of text");
} ///:∼
首先将tag
插入到s
(注意插入发生在表示插入点的值之前的*,并且在tag
之后增加了一个额外的空格),然后找到并替换它。*
在执行replace()
之前,你应该检查一下是否有所发现。前面的例子用一个char*
替换,但是有一个重载版本用一个string
替换。清单 18-8 提供了对replace()
更完整的演示。
清单 18-8 。展示了更完整的 replace()演示
//: C18:Replace.cpp
#include <cassert>
#include <cstddef> // For size_t
#include <string>
using namespace std;
void replaceChars(string& modifyMe,
const string& findMe, const string& newChars) {
// Look in modifyMe for the "find string"
// starting at position 0:
size_t i = modifyMe.find(findMe, 0);
// Did we find the string to replace?
if(i != string::npos)
// Replace the find string with newChars:
modifyMe.replace(i, findMe.size(), newChars);
}
int main() {
string bigNews = "I thought I saw Elvis in a UFO. "
"I have been working too hard.";
string replacement("wig");
string findMe("UFO");
// Find "UFO" in bigNews and overwrite it:
replaceChars(bigNews, findMe, replacement);
assert(bigNews == "I thought I saw Elvis in a "
"wig. I have been working too hard.");
} ///:∼
如果replace
没有找到搜索字符串,它返回string::npos
。npos
数据成员是string
类的静态常量成员,表示不存在的字符位置。
与insert()
不同,replace()
不会增加string
的存储空间,如果你将新的字符复制到一个已存在的数组元素序列的中间。然而,如果需要的话,它会增加存储空间,例如,当你进行一次“替换”,将原来的字符串扩展到当前分配的末尾之外,如清单 18-9 中的所示。
清单 18-9 。说明字符串替换和增长
//: C18:ReplaceAndGrow.cpp
#include<cassert>
#include<string>
using namespace std;
int main() {
string bigNews("I have been working the grave.");
string replacement("yard shift.");
// The first argument says "replace chars
// beyond the end of the existing string":
bigNews.replace(bigNews.size() - 1,
replacement.size(), replacement);
assert(bigNews == "I have been working the "
"graveyard shift.");
} ///:∼
对replace()
的调用开始“替换”超出现有数组的末尾,这相当于一个追加操作。注意在清单 18-9 replace()
中相应地扩展了数组。
你可能已经在本章中尝试做一些相对简单的事情,比如用一个不同的字符替换一个字符的所有实例。在找到之前关于替换的材料时,您认为您找到了答案,但是随后您开始看到字符组、计数和其他看起来有点太复杂的东西。难道string
没有办法在任何地方用一个字符替换另一个字符吗?您可以使用find()
和replace()
成员函数轻松编写这样一个函数,如清单 18-10 中的所示。
清单 18-10 。说明 ReplaceAll
//: C18:ReplaceAll.h
#ifndef REPLACEALL_H
#define REPLACEALL_H
#include <string>
std::string& replaceAll(std::string& context,
const std::string& from, const std::string& to);
#endif // REPLACEALL_H ///:∼
//: C18:ReplaceAll.cpp {O}
#include <cstddef>
#include "ReplaceAll.h"// To be INCLUDED from Header FILE above
using namespace std;
string& replaceAll(string& context, const string& from,
const string& to) {
size_t lookHere = 0;
size_t foundHere;
while((foundHere = context.find(from, lookHere))
!= string::npos) {
context.replace(foundHere, from.size(), to);
lookHere = foundHere + to.size();
}
return context;
} ///:∼
这里使用的版本find()
将开始查找的位置作为第二个参数,如果没有找到,则返回string::npos
。将变量lookHere
中的位置提升到替换字符串之后是很重要的,在本例中from
是to
的子字符串。清单 18-11 测试replaceAll
功能。
清单 18-11 。在清单 18-10 中展示了 ReplaceAll 的测试
//: C18:ReplaceAllTest.cpp
//{L} ../C18/ReplaceAll
#include <cassert>
#include <iostream>
#include <string>
#include "ReplaceAll.h"
using namespace std;
int main() {
string text = "a man, a plan, a canal, Panama";
replaceAll(text, "an", "XXX");
assert(text == "a mXXX, a plXXX, a cXXXal, PXXXama");
} ///:∼
如您所见,string
类本身并不能解决所有可能的问题。许多解决方案都留给了标准 C++ 库中的算法,因为string
类看起来就像一个 STL 序列(依靠前面讨论的迭代器)。所有的通用算法都处理容器中“一系列”的元素。通常这个范围只是“从容器的开始到结束”一个string
对象看起来像一个字符容器:要获得范围的开始,使用string::begin()
,要获得范围的结束,使用string::end()
。
使用 STL replace()算法进行简单的字符替换
有没有更简单的方法,把一个字符到处换成另一个字符?是的,string
有它;清单 18-12 显示了使用replace()
算法将单个字符‘X’的所有实例替换为‘Y’。
清单 18-12 。说明字符串替换
//: C18:StringCharReplace.cpp
#include <algorithm>
#include <cassert>
#include <string>
using namespace std;
int main() {
string s("aaaXaaaXXaaXXXaXXXXaaa");
replace(s.begin(), s.end(), 'X', 'Y');
assert(s == "aaaYaaaYYaaYYYaYYYYaaa");
} ///:∼
注意这个replace()
是作为string
的成员函数调用的而不是。此外,与只执行一次替换的string::replace()
函数不同,replace()
算法将一个字符的所有实例替换为另一个字符。
replace()
算法仅适用于单个对象(在本例中为char
对象),不会替换引用的char
数组或string
对象。由于string
的行为类似于 STL 序列,许多其他算法可以应用于它,这可能会解决string
成员函数没有直接解决的其他问题。
使用非成员重载运算符的串联
等待 C 程序员学习 C++ string
处理的最令人愉快的发现之一是使用operator+
和operator+=
可以多么简单地组合和追加string
s。这些操作符使得组合string
在语法上类似于添加数字数据,如清单 18-13 所示。
清单 18-13 。说明字符串的添加
//: C18:AddStrings.cpp
#include <string>
#include <cassert>
using namespace std;
int main() {
string s1("This ");
string s2("That ");
string s3("The other ");
// operator+ concatenates strings
s1 = s1 + s2;
assert(s1 == "This That ");
// Another way to concatenates strings
s1 += s3;
assert(s1 == "This That The other ");
// You can index the string on the right
s1 += s3 + s3[4] + "ooh lama";
assert(s1 == "This That The other The other oooh lala");
} ///:∼
使用operator+
和operator+=
操作符是组合string
数据的一种灵活方便的方式。在语句的右侧,几乎可以使用任何计算结果为一个或多个字符组的类型。
在字符串中搜索
string
成员函数的find
系列定位给定字符串中的一个字符或一组字符。表 18-1 显示了find
家族的成员及其一般用法。
表 18-1 。通过查找字符串成员函数族进行搜索
字符串查找成员函数 | 它发现了什么/如何发现的 |
---|---|
find() | 在字符串中搜索指定的字符或一组字符,并返回找到的第一个匹配项的起始位置,如果没有找到匹配项,则返回npos 。 |
find_first_of() | 搜索目标字符串并返回指定组中第一个匹配的任意字符的位置。如果没有找到匹配,它返回npos 。 |
find_last_of() | 搜索目标字符串并返回指定组中任何字符的的最后一个匹配的位置。如果没有找到匹配,它返回npos 。 |
find_first_not_of() | 搜索目标字符串并返回指定组中第一个不匹配任何字符的元素的位置。如果没有找到这样的元素,它返回npos 。 |
find_last_not_of() | 搜索目标字符串,并返回指定组中与中的任何字符都不匹配的最大下标元素的位置。如果没有找到这样的元素,它返回npos 。 |
rfind() | 在字符串中从头到尾搜索指定的字符或字符组,如果找到匹配项,则返回匹配项的起始位置。如果没有找到匹配,它返回npos 。 |
find()
最简单的用法是在string
中搜索一个或多个字符。这个重载版本的find()
接受一个指定要搜索的字符的参数和一个告诉它从字符串中的什么地方开始搜索子字符串的可选参数。(开始搜索的默认位置是 0。)通过在一个循环中设置对find
的调用,您可以轻松地遍历一个字符串,重复搜索以查找字符串中给定字符或字符组的所有出现。
清单 18-14 使用厄拉多塞的筛子的方法寻找小于 50 的质数*。该方法从数字 2 开始,将所有后续的 2 的倍数标记为非素数,并对下一个素数候选重复该过程。设置字符数组sieveChars
的初始大小,并将值‘P’写入其每个成员。*
*清单 18-14 。图解厄拉多塞的筛子(求质数< 50)
//: C18:Sieve.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
// Create a 50 char string and set each
// element to 'P' for Prime
string sieveChars(50, 'P');
// By definition neither 0 nor 1 is prime.
// Change these elements to "N" for Not Prime
sieveChars.replace(0, 2, "NN");
// Walk through the array:
for(int i = 2;
i <= (sieveChars.size() / 2) - 1; i++)
// Find all the factors:
for(int factor = 2;
factor * i < sieveChars.size();factor++)
sieveChars[factor * i] = 'N';
cout << "Prime:" << endl;
// Return the index of the first 'P' element:
int j = sieveChars.find('P');
// While not at the end of the string:
while(j != sieveChars.npos) {
// If the element is P, the index is a prime
cout << j << " ";
// Move past the last prime
j++;
// Find the next prime
j = sieveChars.find('P', j);
}
cout << "\n Not prime:" << endl;
// Find the first element value not equal P:
j = sieveChars.find_first_not_of('P');
while(j != sieveChars.npos) {
cout << j << " ";
j++;
j = sieveChars.find_first_not_of('P', j);
}
} ///:∼
来自Sieve.cpp
的输出如下所示:
Prime:
2 3 5 7 11 13 17 19 23 29 31 37 41 43 47
Not prime:
0 1 4 6 8 9 10 12 14 15 16 18 20 21 22
24 25 26 27 28 30 32 33 34 35 36 38 39
40 42 44 45 46 48 49
find()
允许您在string
中前进,检测一个字符或一组字符的多次出现,而find_first_not_of()
允许您测试一个字符或一组字符的缺失。
find()
成员对于检测一个string
中一个字符序列的出现也很有用,如清单 18-15 所示。
清单 18-15 。使用 find()检测字符序列
//: C18:Find.cpp
// Find a group of characters in a string
#include <string>
#include <iostream>
using namespace std;
int main() {
string chooseOne("Eenie, meenie, miney, mo");
int i = chooseOne.find("een");
while(i != string::npos) {
cout << i << endl;
i++;
i = chooseOne.find("een", i);
}
} ///:∼
Find.cpp
产生单行输出:
8
这告诉我们,搜索组“een”的第一个“e”是在单词“meenie”中找到的,并且
这是字符串中的第八个元素。注意find
忽略了单词“Eenie”中的“Een”字符组。成员函数find
执行一个区分大小写的搜索。
在string
类中没有改变字符串大小写的函数,但是这些函数可以使用标准的 C 库函数toupper()
和tolower()
很容易地创建,它们一次改变一个字符的大小写。一些小的改变将使Find.cpp
执行不区分大小写的搜索,如清单 18-16 所示。
清单 18-16 。使用 find()进行不区分大小写的搜索
//: C18:NewFind.cpp
#include <string>
#include <iostream>
using namespace std;
// Make an uppercase copy of s:
string upperCase(string& s) {
char* buf = new char[s.length()];
s.copy(buf, s.length());
for(int i = 0; i < s.length(); i++)
buf[i] = toupper(buf[i]);
string r(buf, s.length());
delete buf;
return r;
}
// Make a lowercase copy of s:
string lowerCase(string& s) {
char* buf = new char[s.length()];
s.copy(buf, s.length());
for(int i = 0; i < s.length(); i++)
buf[i] = tolower(buf[i]);
string r(buf, s.length());
delete buf;
return r;
}
int main() {
string chooseOne("Eenie, meenie, miney, mo");
cout << chooseOne << endl;
cout << upperCase(chooseOne) << endl;
cout << lowerCase(chooseOne) << endl;
// Case sensitive search
int i = chooseOne.find("een");
while(i != string::npos) {
cout << i << endl;
i++;
i = chooseOne.find("een", i);
}
// Search lowercase:
string lcase = lowerCase(chooseOne);
cout << lcase << endl;
i = lcase.find("een");
while(i != lcase.npos) {
cout << i << endl;
i++;
i = lcase.find("een", i);
}
// Search uppercase:
string ucase = upperCase(chooseOne);
cout << ucase << endl;
i = ucase.find("EEN");
while(i != ucase.npos) {
cout << i << endl;
i++;
i = ucase.find("EEN", i);
}
} ///:∼
upperCase()
和lowerCase()
函数遵循相同的形式:它们分配存储来保存参数string
中的数据,复制数据,并改变大小写。然后他们用新数据创建一个新的string
,释放缓冲区,并返回结果string
。
因为c_str()
返回一个指向const
的指针,所以c_str()
函数不能用来产生一个指针来直接操作string
中的数据。也就是说,你不能用指针操作string
数据,只能用成员函数。如果你需要使用更原始的char
数组操作,你应该使用上面显示的技术(参见清单 18-16 )。
输出如下所示:
Eenie, meenie, miney, mo
EENIE, MEENIE, MINEY, MO
eenie, meenie, miney, mo
8
eenie, meenie, miney, mo
0
8
EENIE, MEENIE, MINEY, MO
0
8
不区分大小写的搜索在“een”组中找到了这两个事件。
Find.cpp
和NewFind.cpp
不是区分大小写问题的最佳解决方案,所以我们将在“字符串和字符特征”一节中再次讨论这个问题。
反向查找
如果你需要从头到尾搜索一个string
(以后进先出(LIFO)的顺序查找数据),你可以使用字符串成员函数rfind()
,如清单 18-17 所示。
清单 18-17 。使用 rfind()反向查找
//: C18:Rparse.cpp
// Reverse the order of words in a string
#include <string>
#include <iostream>
#include <vector>
using namespace std;
int main() {
// The ';' characters will be delimiters
string s("now.;sense;make;to;going;is;This");
cout << s << endl;
// To store the words:
vector<string> strings;
// The last element of the string:
int last = s.size();
// The beginning of the current word:
int current = s.rfind(';');
// Walk backward through the string:
while(current != string::npos){
// Push each word into the vector.
// Current is incremented before copying to
// avoid copying the delimiter.
strings.push_back(
s.substr(++current,last - current));
// Back over the delimiter we just found,
// and set last to the end of the next word
current -= 2;
last = current;
// Find the next delimiter
current = s.rfind(';', current);
}
// Pick up the first word - it's not
// preceded by a delimiter
strings.push_back(s.substr(0, last - current));
// Print them in the new order:
for(int j = 0; j < strings.size(); j++)
cout << strings[j] << " ";
} ///:∼
下面是清单 18-17 中的输出:
now.;sense;make;to;going;is;This
This is going to make sense now.
rfind()
返回字符串寻找记号,报告匹配字符的数组索引,如果不成功则返回string::npos
。
查找一组字符的第一个/最后一个
可以方便地将find_first_of()
和find_last_of()
成员函数 用于*创建一个小实用程序,去除字符串两端的空白字符。*注意,它并不接触原始字符串,而是返回一个新字符串,如清单 18-18 所示。
清单 18-18 。去除空白,也就是修剪字符串
//: C18:trim.h
#ifndef TRIM_H
#define TRIM_H
#include <string>
// General tool to strip spaces from both ends:
inline std::string trim(const std::string& s) {
if(s.length() == 0)
return s;
int b = s.find_first_not_of(" \t");
int e = s.find_last_not_of(" \t");
if(b == -1) // No non-spaces
return "";
return std::string(s, b, e - b + 1);
}
#endif // TRIM_H ///:∼
第一个测试检查空的string
;在这种情况下,不进行测试,并返回一个副本。
注意,一旦找到了端点,string
构造器用于从旧的构建新的string
,给出起始计数和长度。返回值也是“优化的”
测试这样一个通用工具需要彻底,正如你在清单 18-19 中看到的。
清单 18-19 。测试清单 18-18 中的“trim.h”
//: C18:TrimTest.cpp
#include "trim.h" // To be INCLUDED from Header FILE above
#include <iostream>
using namespace std;
string s[] = {
" \t abcdefghijklmnop \t ",
"abcdefghijklmnop \t ",
" \t abcdefghijklmnop",
"a", "ab", "abc", "a b c",
" \t a b c \t ", " \t a \t b \t c \t ",
"", // Must also test the empty string
};
void test(string s) {
cout << "[" << trim(s) << "]" << endl;
}
int main() {
for(int i = 0; i < sizeof s / sizeof *s; i++)
test(s[i]);
} ///:∼
在string s
的数组中,可以看到字符数组被自动转换为string
对象。这个数组提供了检查从两端删除空格和制表符的情况,以及确保空格和制表符不会从string
的中间删除。
从字符串中删除字符
使用erase()
成员函数删除字符简单而高效,该函数有两个参数:从哪里开始删除字符(默认为0
)以及删除多少字符(默认为string::npos
)。如果您指定的字符多于字符串中剩余的字符,剩余的字符将被删除(因此不带任何参数调用erase()
将删除字符串中的所有字符)。有时,获取一个 HTML 文件并去掉其标签和特殊字符是很有用的,这样您就可以得到一些类似于 web 浏览器中显示的文本的内容,只是作为一个纯文本文件。清单 18-20 使用erase()
来完成这项工作。
清单 18-20 。使用 erase()演示 HTML 剥离器
//: C18:HTMLStripper.cpp {RunByHand}
//{L} ../C18/ReplaceAll
// Filter to remove html tags and markers.
#include <cassert>
#include <cmath>
#include <cstddef>
#include <fstream>
#include <iostream>
#include <string>
#include "ReplaceAll.h" // SEE Above
#include "../require.h" // To be INCLUDED from *Chapter 9*
using namespace std;
string& stripHTMLTags(string& s) {
static bool inTag = false;
bool done = false;
while(!done) {
if(inTag) {
// The previous line started an HTML tag
// but didn't finish. Must search for '>'.
size_t rightPos = s.find('>');
if(rightPos != string::npos) {
inTag = false;
s.erase(0, rightPos + 1);
}
else {
done = true;
s.erase();
}
}
else {
// Look for start of tag:
size_t leftPos = s.find('<');
if(leftPos != string::npos) {
// See if tag close is in this line:
size_t rightPos = s.find('>');
if(rightPos == string::npos) {
inTag = done = true;
s.erase(leftPos);
}
else
s.erase(leftPos, rightPos - leftPos + 1);
}
else
done = true;
}
}
// Remove all special HTML characters
replaceAll(s, "<", "<");
replaceAll(s, ">", ">");
replaceAll(s, "&", "&");
replaceAll(s, " ", " ");
// Etc...
return s;
}
int main(int argc, char* argv[]) {
requireArgs(argc, 1,
"usage: HTMLStripper InputFile");
ifstream in(argv[1]);
assure(in, argv[1]);
string s;
while(getline(in, s))
if(!stripHTMLTags(s).empty())
cout << s << endl;
} ///:∼
这段代码甚至会去掉跨多行的 HTML 标签。这是通过静态标志inTag
来实现的,每当找到一个标签的开始,但是在同一行中没有找到伴随的标签结束时,该标志为true
。erase()
的所有形式都出现在stripHTMLTags()
函数中。这里使用的getline()
版本是一个在<string>
头文件中声明的(全局)函数,因为它在其string
参数中存储了一个任意长的行,所以非常方便。你不需要像使用istream::getline()
那样担心字符数组的维数。注意清单 18-20 中的使用了本章前面的replaceAll()
函数。在下一章中,您将使用字符串流创建一个更优雅的解决方案。
比较字符串
比较字符串本质上不同于比较数字。数字有恒定的、普遍有意义的值。要评估两个字符串的大小之间的关系,必须进行一个词法比较。词法比较意味着当您测试一个字符以查看它是“大于”还是“小于”另一个字符时,您实际上是在比较那些字符的数字表示,正如所使用的字符集的排序序列中所指定的那样。最常见的是 ASCII 排序序列,它为 32 到 127 十进制范围内的英语数字分配可打印字符。在 ASCII 排序序列中,列表中的第一个“字符”是空格,后面是几个常见的标点符号,然后是大写和小写字母。就字母表而言,这意味着靠近前面的字母比靠近末尾的字母具有更低的 ASCII 值。记住这些细节,就更容易记住当一个词汇比较报告s1
大于s2
时,它仅仅意味着当两者被比较时,s1
中第一个不同的字符在字母表中比s2
中相同位置的字符更晚。
C++ 提供了几种比较字符串的方法,每种方法都有优点。最容易使用的是非成员、重载的操作符函数:operator ==
、operator != operator >
、operator <
、operator >=
和operator <=
。参见清单 18-21 中的示例。
清单 18-21 。说明字符串的比较
//: C18:CompStr.cpp
#include <string>
#include <iostream>
using namespace std;
int main() {
// Strings to compare
string s1("This ");
string s2("That ");
for(int i = 0; i < s1.size() &&
i < s2.size(); i++)
// See if the string elements are the same:
if(s1[i] == s2[i])
cout << s1[i] << " " << i << endl;
// Use the string inequality operators
if(s1 != s2) {
cout << "Strings aren't the same:" << " ";
if(s1 > s2)
cout << "s1 is > s2" << endl;
else
cout << "s2 is > s1" << endl;
}
} ///:∼
下面是来自CompStr.cpp
的输出:
T 0
h 1
4
Strings aren't the same: s1 is > s2
重载比较运算符对于比较完整字符串和单个字符串字符元素都很有用。
注意在清单 18-22 中,比较运算符左右两边的参数类型都很灵活。为了提高效率,string
类提供了重载操作符,用于直接比较字符串对象、引用文字和指向 C 风格字符串的指针,而不必创建临时的string
对象。
清单 18-22 。说明字符串比较中的等价性
//: C18:Equivalence.cpp
#include <iostream>
#include <string>
using namespace std;
int main() {
string s2("That"), s1("This");
// The lvalue is a quoted literal
// and the rvalue is a string:
if("That" == s2)
cout << "A match" << endl;
// The left operand is a string and the right is
// a pointer to a C-style null terminated string:
if(s1 != s2.c_str())
cout << "No match" << endl;
} ///:∼
c_str()
函数返回一个const char*
,它指向一个 C 风格的空终止字符串,等价于string
对象的内容。当你想把一个字符串传递给一个标准的 C 函数,比如atoi()
或者任何在<cstring>
头中定义的函数时,这就很方便了。使用c_str()
返回的值作为任何函数的非const
参数都是错误的。
你不会在字符串的运算符中找到逻辑 not ( !
)或逻辑比较运算符(&&
和||
)。(你也找不到重载版本的逐位 C 操作符&
、|
、^
或∼
。)string 类的重载非成员比较运算符仅限于对单个字符或字符组有明确应用的子集。
与非成员操作符集相比,compare()
成员函数提供了更加复杂和精确的比较。它提供了重载版本进行比较
- 两个完整的字符串
- 任一字符串的一部分转换为完整的字符串
- 两个字符串的子集
清单 18-23 比较完整的字符串。
清单 18-23 。比较完整的字符串
//: C18:Compare.cpp
// Demonstrates compare() and swap().
#include <cassert>
#include <string>
using namespace std;
int main() {
string first("This");
string second("That");
assert(first.compare(first) == 0);
assert(second.compare(second) == 0);
// Which is lexically greater?
assert(first.compare(second) > 0);
assert(second.compare(first) < 0);
first.swap(second);
assert(first.compare(second) < 0);
assert(second.compare(first) > 0);
}
///:∼
清单 18-23 中的swap()
函数如其名所示:它交换其对象和参数的内容。要比较一个或两个字符串中的字符子集,可以添加参数来定义从哪里开始比较以及要考虑多少个字符。例如,您可以使用下面的compare()
重载版本:
s1.compare(s1StartPos, s1NumberChars, s2, s2StartPos,s2NumberChars);
参见清单 18-24 中的示例。
清单 18-24 。比较一个或两个字符串中的字符子集
//: C18:Compare2.cpp
// Illustrate overloaded compare().
#include <cassert>
#include <string>
using namespace std;
int main() {
string first("This is a day that will live in infamy");
string second("I don't believe that this is what "
"I signed up for");
// Compare "his is" in both strings:
assert(first.compare(1, 7, second, 22, 7) == 0);
// Compare "his is a" to "his is w":
assert(first.compare(1, 9, second, 22, 9) < 0);
} ///:∼
Indexing with [] vs. at()
在迄今为止的例子中,我使用了 C 风格的数组索引语法来引用字符串中的单个字符。C++ 字符串为s[n]
符号提供了另一种选择:成员at()
。如果一切顺利,这两种索引机制在 C++ 中产生相同的结果;参见清单 18-25 。
清单 18-25 。演示使用[]和 at()的字符串索引之间的相似性
//: C18:StringIndexing.cpp
#include <cassert>
#include <string>
using namespace std;
int main() {
string s("1234");
assert(s[1] == '2');
assert(s.at(1) == '2');
} ///:∼
然而,在[]
和at()
之间有一个重要的区别。当你试图引用一个越界的数组元素时,at()
会帮你抛出一个异常,而普通的[]
下标语法会让你自行处理,如清单 18-26 所示。
清单 18-26 。演示使用[]和 at()进行字符串索引的区别
//: C18:BadStringIndexing.cpp
#include <exception>
#include <iostream>
#include <string>
using namespace std;
int main() {
string s("1234");
// at() saves you by throwing an exception:
try {
s.at(5);
} catch(exception& e) {
cerr << e.what() << endl;
}
} ///:∼
负责任的程序员不会使用错误的索引,但是如果你想要自动索引检查的好处,使用at()
代替[]
将会给你一个机会从对不存在的数组元素的引用中优雅地恢复。在我们的一个测试编译器上执行清单 18-26 给出了以下输出:
invalid string position
成员at()
抛出了一个类out_of_range
的对象,这个类(最终)是从std::exception
派生出来的。通过在异常处理程序中捕获该对象,您可以采取适当的补救措施,如重新计算违规的下标或增大数组。使用string::operator[]()
无法提供这样的保护,并且与 c # 中的char
数组处理一样危险
字符串和字符特征
本章前面的程序Find.cpp
和NewFind.cpp
(分别为清单 18-15 和清单 18-16 )引导我提出一个明显的问题:为什么不区分大小写的比较不是标准string
类的一部分?答案提供了关于 C++ 字符串对象的真实性质的有趣背景。
考虑一下一个角色拥有“格”意味着什么书面希伯来语、波斯语和日本汉字不使用大写和小写的概念,所以对于这些语言来说,这个概念没有任何意义。似乎如果有一种方法可以将一些语言指定为“全大写”或“全小写”,我们就可以设计一个通用的解决方案。然而,一些使用“case”概念的语言也用变音符号改变特定字符的含义,例如西班牙语中的变音符号、法语中的抑扬符号和德语中的变音符号。由于这个原因,任何试图变得全面的区分大小写的排序方案使用起来都非常复杂。
虽然我们通常会把 C++ string
当做一个类来对待,但事实真的不是这样。string
类型是一个更一般的成分的专门化,即basic_string< >
模板。观察string
是如何在标准 C++ 头文件中声明的:
typedef basic_string<char> string;
要理解 string 类的本质,请看basic_string< >
模板:
template<class charT, class traits = char_traits<charT>,
class allocator = allocator<charT>> class basic_string;
现在,只需注意当用char
实例化basic_string
模板时,就创建了string
类型。在basic_string< >
模板声明里面,行
class traits = char_traits<charT>,
告诉你从basic_string< >
模板生成的类的行为是由基于模板char_traits< >
的类指定的。因此,basic_string< >
模板产生面向字符串的类,这些类操纵除了char
以外的类型(例如宽字符)。为此,char_traits< >
模板使用字符比较函数eq()
(等于)、ne()
(不等于)和lt()
(小于)来控制各种字符集的内容和排序行为。basic_string< >
字符串比较函数依赖于这些。
这就是为什么 string 类不包含不区分大小写的成员函数:这不在它的工作描述中。要改变字符串类处理字符比较的方式,您必须提供一个不同的char_traits< >
模板,因为它定义了单个字符比较成员函数的行为。
您可以使用这些信息创建一个新类型的忽略大小写的string
类。首先,您将定义一个新的不区分大小写的char_traits< >
模板,它继承自现有的模板。接下来,您将只覆盖需要更改的成员,以使逐字符比较不区分大小写。(除了前面提到的三个词汇字符比较成员*、*之外,您还将为char_traits
函数find()
、和、compare()
提供一个新的实现)。最后,您将typedef
一个基于basic_string
的新类,但是使用不区分大小写的ichar_traits
模板作为它的第二个参数,如清单 18-27 所示。
清单 18-27 。发展 ichar_traits
//: C18:ichar_traits.h
// Creating your own character traits.
#ifndef ICHAR_TRAITS_H
#define ICHAR_TRAITS_H
#include <cassert>
#include <cctype>
#include <cmath>
#include <cstddef>
#include <ostream>
#include <string>
using std::allocator;
using std::basic_string;
using std::char_traits;
using std::ostream;
using std::size_t;
using std::string;
using std::toupper;
using std::tolower;
struct ichar_traits : char_traits<char> {
// We'll only change character-by-
// character comparison functions
static bool eq(char c1st, char c2nd) {
return toupper(c1st) == toupper(c2nd);
}
static bool ne(char c1st, char c2nd) {
return !eq(c1st, c2nd);
}
static bool lt(char c1st, char c2nd) {
return toupper(c1st) < toupper(c2nd);
}
static int
compare(const char* str1, const char* str2, size_t n) {
for(size_t i = 0; i < n; ++i) {
if(str1 == 0)
return -1;
else if(str2 == 0)
return 1;
else if(tolower(*str1) < tolower(*str2))
return -1;
else if(tolower(*str1) > tolower(*str2))
return 1;
assert(tolower(*str1) == tolower(*str2));
++str1; ++str2; // Compare the other chars
}
return 0;
}
static const char*
find(const char* s1, size_t n, char c) {
while(n-- > 0)
if(toupper(*s1) == toupper(c))
return s1;
else
++s1;
return 0;
}
};
typedef basic_string<char, ichar_traits> istring;
inline ostream& operator<<(ostream& os, const istring& s) {
return os << string(s.c_str(), s.length());
}
#endif // ICHAR_TRAITS_H ///:∼
您提供了一个名为istring
的typedef
,这样您的类在各方面都像一个普通的string
,除了它会进行所有不考虑大小写的比较。为了方便起见,你还提供了一个重载的operator<<()
,这样你就可以打印istring
s。
清单 18-28 。实现清单 18-27 中的头文件
//: C18:ICompare.cpp
#include <cassert>
#include <iostream>
#include "ichar_traits.h" // To be INCLUDED from Header FILE
// above
using namespace std;
int main() {
// The same letters except for case:
istring first = "tHis";
istring second = "ThIS";
cout << first << endl;
cout << second << endl;
assert(first.compare(second) == 0);
assert(first.find('h') == 1);
assert(first.find('I') == 2);
assert(first.find('x') == string::npos);
} ///:∼
这只是一个玩具的例子。为了使istring
完全等同于string
,你必须创建其他必要的函数来支持新的istring
类型。
<string>
头通过下面的typedef
提供了一个宽字符串类:
typedef basic_string<wchar_t> wstring;
宽字符串支持也在宽流(wostream
代替ostream
,也在<iostream>
中定义)和头<cwctype>
(?? 的宽字符版本)中显示出来。这与标准 C++ 库中的char_traits
的wchar_t
特殊化一起,允许你做ichar_traits
的宽字符版本,如清单 18-29 所示。
清单 18-29 。开发 ichar_traits 的宽字符版本
//: C18:iwchar_traits.h {-g++}
// Creating your own wide-character traits.
#ifndef IWCHAR_TRAITS_H
#define IWCHAR_TRAITS_H
#include <cassert>
#include <cmath>
#include <cstddef>
#include <cwctype>
#include <ostream>
#include <string>
using std::allocator;
using std::basic_string;
using std::char_traits;
using std::size_t;
using std::towlower;
using std::towupper;
using std::wostream;
using std::wstring;
struct iwchar_traits : char_traits<wchar_t> {
// We'll only change character-by-
// character comparison functions
static bool eq(wchar_t c1st, wchar_t c2nd) {
return towupper(c1st) == towupper(c2nd);
}
static bool ne(wchar_t c1st, wchar_t c2nd) {
return towupper(c1st) != towupper(c2nd);
}
static bool lt(wchar_t c1st, wchar_t c2nd) {
return towupper(c1st) < towupper(c2nd);
}
static int compare(
const wchar_t* str1, const wchar_t* str2, size_t n) {
for(size_t i = 0; i < n; i++) {
if(str1 == 0)
return -1;
else if(str2 == 0)
return 1;
else if(towlower(*str1) < towlower(*str2))
return -1;
else if(towlower(*str1) > towlower(*str2))
return 1;
assert(towlower(*str1) == towlower(*str2));
++str1; ++str2; // Compare the other wchar_ts
}
return 0;
}
static const wchar_t*
find(const wchar_t* s1, size_t n, wchar_t c) {
while(n-- > 0)
if(towupper(*s1) == towupper(c))
return s1;
else
++s1;
return 0;
}
};
typedef basic_string<wchar_t, iwchar_traits> iwstring;
inline wostream& operator<<(wostream& os,
const iwstring& s) {
return os << wstring(s.c_str(), s.length());
}
#endif // IWCHAR_TRAITS_H ///:∼
如您所见,这主要是在源代码的适当位置放置一个“w”的练习。清单 18-30 包含了测试程序。
清单 18-30 。测试清单 18-29 中开发的头文件
//: C18:IWCompare.cpp {-g++}
#include <cassert>
#include <iostream>
#include "iwchar_traits.h" // To be INCLUDED from Header FILE
// above
using namespace std;
int main() {
// The same letters except for case:
iwstring wfirst = L"tHis";
iwstring wsecond = L"ThIS";
wcout << wfirst << endl;
wcout << wsecond << endl;
assert(wfirst.compare(wsecond) == 0);
assert(wfirst.find('h') == 1);
assert(wfirst.find('I') == 2);
assert(wfirst.find('x') == wstring::npos);
} ///:∼
不幸的是,一些编译器仍然不提供对宽字符的强大支持。
字符串应用程序
如果您仔细阅读了本书中的示例代码,您会注意到注释中的某些标记围绕着代码。Python 程序使用它们将代码提取到文件中,并为构建代码建立 makefiles。例如,行首的双斜杠后跟冒号表示源文件的第一行。该行的其余部分包含描述文件的名称和位置的信息,以及是否应该只编译而不是完全构建到可执行文件中。例如,清单 18-30 中的第一行包含字符串C18:IWCompare.cpp
,表示文件IWCompare.cpp
应该被提取到目录C18
中。
源文件的最后一行包含一个三重斜杠,后跟一个冒号和一个波浪号。如果第一行在冒号后有一个感叹号,则源代码的第一行和最后一行不会输出到文件中(这是针对纯数据文件的)。
注意如果你想知道为什么我避免向你展示这些标记,那是因为我不想破坏应用于书的文本的代码提取器
Python 程序不仅仅是提取代码。如果标记{O}
跟在文件名后面,那么它的 makefile 条目将只用于编译文件,而不用于链接到可执行文件。为了将这样的文件与另一个源示例链接起来,目标可执行文件的源文件将包含一个{L}
指令,如
//{L} ../TestSuite/Test
本节将展示清单 18-31 中的一个程序,提取所有代码,以便你可以手动编译和检查。您可以使用该程序通过将文档文件保存为文本文件(姑且称之为MFCTC++.txt
)并在 shell 命令行上执行如下内容来提取本书中的所有代码:
C:> extractCode MFCTC++.txt /TheCode
该命令读取文本文件MFCTC2.txt
,并将所有源代码文件写入顶层目录/TheCode
下的子目录中。目录树将如下所示:
TheCode/
C0B/
C01/
C02/
C18/
C04/
C05/
C06/
C07/
C08/
C09/
C10/
C11/
TestSuite/
包含每章示例的源文件将位于相应的目录中。
清单 18-31 。说明书中所有源代码的提取
//: C18:ExtractCode.cpp {-edg} {RunByHand}
// Extracts code from text.
#include <cassert>
#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
// Legacy non-standard C header for mkdir()
#if defined(__GNUC__) || defined(__MWERKS__)
#include <sys/stat.h>
#elif defined(__BORLANDC__) || defined(_MSC_VER) \
|| defined(__DMC__)
#include <direct.h>
#else
#error Compiler not supported
#endif
// Check to see if directory exists
// by attempting to open a new file
// for output within it.
bool exists(string fname) {
size_t len = fname.length();
if(fname[len-1] != '/' && fname[len-1] != '\\')
fname.append("/");
fname.append("000.tmp");
ofstream outf(fname.c_str());
bool existFlag = outf;
if(outf) {
outf.close();
remove(fname.c_str());
}
return existFlag;
}
int main(int argc, char* argv[]) {
// See if input file name provided
if(argc == 1) {
cerr << "usage: extractCode file [dir]" << endl;
exit(EXIT_FAILURE);
}
// See if input file exists
ifstream inf(argv[1]);
if(!inf) {
cerr << "error opening file: " << argv[1] << endl;
exit(EXIT_FAILURE);
}
// Check for optional output directory
string root("./"); // current is default
if(argc == 3) {
// See if output directory exists
root = argv[2];
if(!exists(root)) {
cerr << "no such directory: " << root << endl;
exit(EXIT_FAILURE);
}
size_t rootLen = root.length();
if(root[rootLen-1] != '/' && root[rootLen-1] != '\\')
root.append("/");
}
// Read input file line by line
// checking for code delimiters
string line;
bool inCode = false;
bool printDelims = true;
ofstream outf;
while(getline(inf, line)) {
size_t findDelim = line.find("//" "/:∼");
if(findDelim != string::npos) {
// Output last line and close file
if(!inCode) {
cerr << "Lines out of order" << endl;
exit(EXIT_FAILURE);
}
assert(outf);
if(printDelims)
outf << line << endl;
outf.close();
inCode = false;
printDelims = true;
} else {
findDelim = line.find("//" ":");
if(findDelim == 0) {
// Check for '!' directive
if(line[3] == '!') {
printDelims = false;
++findDelim; // To skip '!' for next search
}
// Extract subdirectory name, if any
size_t startOfSubdir =
line.find_first_not_of(" \t", findDelim+3);
findDelim = line.find(':', startOfSubdir);
if(findDelim == string::npos) {
cerr << "missing filename information\n" << endl;
exit(EXIT_FAILURE);
}
string subdir;
if(findDelim > startOfSubdir)
subdir = line.substr(startOfSubdir,
findDelim - startOfSubdir);
// Extract file name (better be one!)
size_t startOfFile = findDelim + 1;
size_t endOfFile =
line.find_first_of(" \t", startOfFile);
if(endOfFile == startOfFile) {
cerr << "missing filename" << endl;
exit(EXIT_FAILURE);
}
// We have all the pieces; build fullPath name
string fullPath(root);
if(subdir.length() > 0)
fullPath.append(subdir).append("/");
assert(fullPath[fullPath.length()-1] == '/');
if(!exists(fullPath))
#if defined(__GNUC__) || defined(__MWERKS__)
mkdir(fullPath.c_str(), 0); // Create subdir
#else
mkdir(fullPath.c_str()); // Create subdir
#endif
fullPath.append(line.substr(startOfFile,
endOfFile - startOfFile));
outf.open(fullPath.c_str());
if(!outf) {
cerr << "error opening " << fullPath
<< " for output" << endl;
exit(EXIT_FAILURE);
}
inCode = true;
cout << "Processing " << fullPath << endl;
if(printDelims)
outf << line << endl;
}
else if(inCode) {
assert(outf);
outf << line << endl; // Output middle code line
}
}
}
exit(EXIT_SUCCESS);
} ///:∼
首先,您会注意到一些条件编译指令。在文件系统中创建目录的mkdir()
函数是由 POSIX 标准在头文件<sys/stat.h>
中定义的。不幸的是,许多编译器仍然使用不同的头,<direct.h>
。mkdir()
各自的签名也不同:POSIX 指定了两个参数,旧版本只有一个。由于这个原因,在程序的后面有更多的条件编译来选择对mkdir()
的正确调用。在本书的例子中,我通常不使用条件编译,但是这个特殊的程序非常有用,不能不做一些额外的工作,因为你可以用它来提取所有的代码。
清单 18-31 中ExtractCode.cpp
的exists()
函数通过打开一个临时文件来测试一个目录是否存在。如果打开失败,则该目录不存在。你可以通过将文件名作为char*
发送给std::remove()
来删除一个文件。
主程序验证命令行参数,然后一次读取输入文件的一行,寻找特殊的源代码分隔符。布尔标志inCode
表示程序在源文件的中间,所以应该输出几行。如果开始标记后面没有感叹号,printDelims
标志将为真;否则不写第一行和最后一行。首先检查结束分隔符是很重要的,因为开始标记是一个子集,首先搜索开始标记在两种情况下都会返回成功的查找结果。如果您遇到结束标记,您将验证您正在处理一个源文件;否则,文本文件中分隔符的布局方式就有问题。如果inCode
为真,则一切正常,您(可选)写下最后一行并关闭文件。找到开始标记后,解析目录和文件名部分并打开文件。本例中使用了以下与string
相关的函数:length()
、append()
、getline()
、find()
( 两个版本)、find_first_not_of()
、substr()
、find_first_of()
、c_str()
,当然还有operator<<()
。
审查会议
- C++ 字符串对象为开发人员提供了比他们的 C 同行更多的优势。在很大程度上,字符串类使得用字符指针引用字符串的变得没有必要。这个消除了一整类软件缺陷,这些缺陷是由于使用未初始化的和不正确赋值的指针而产生的。
- C++ 字符串动态透明地增加其内部数据存储空间,以适应字符串数据大小的增加。当字符串中的数据增长超过最初分配给它的内存限制时,字符串对象将进行内存管理调用,从堆中获取空间并返回空间。
- 一致的分配方案可以防止内存泄漏,并且有可能比*“滚动自己的”内存管理*更加高效。
- string 类成员函数为创建、修改和搜索字符串提供了一套相当全面的工具。
- 字符串比较总是区分大小写的,但是您可以通过将字符串数据复制到 C 风格的空终止字符串并使用不区分大小写的字符串比较函数,将字符串对象中保存的数据临时转换为单个大小写,或者通过创建不区分大小写的字符串类来覆盖用于创建
basic_string object
的字符特征,从而解决这个问题。****