GotW #21 Code Complexity – Part II
著者:Herb Sutter
翻译:K ][ N G of @rk™
[声明]:本文内容取自www.gotw.ca网站上的Guru of the Week栏目,其著作权归原著者本人所有。译者kingofark在未经原著者本人同意的情况下翻译本文。本翻译内容仅供自学和参考用,请所有阅读过本文的人不要擅自转载、传播本翻译内容;下载本翻译内容的人请在阅读浏览后,立即删除其备份。译者kingofark对违反上述两条原则的人不负任何责任。特此声明。
Revision 1.0
Guru of the Week 条款21:代码的复杂性(第二部分)
难度:7 / 10
(大挑战:修改GotW#20中那个只有三行代码的函数,使其成为强异常安全的(strongly exception-safe)。这个练习给我们上了关于异常安全性(exception-safety)的重要一课。)
[问题]
让我们来考虑GotW#20里的那个函数。这个函数是异常安全的(exception-safe)(再出现异常时仍能正常工作)还是异常中立的(exception-neutral)(能将所有异常都转给调用者)?
String EvaluateSalaryAndReturnName( Employee e )
{
if( e.Title() == "CEO" || e.Salary() > 100000 )
{
cout << e.First() << " " << e.Last()
<< " is overpaid" << endl;
}
return e.First() + " " + e.Last();
}
请对你的回答做出解释。如果它是异常安全的,那它是支持basic guarantee还是支持strong guarantee?[译注:basic guarantee,基本保证;strong guarantee,强力保证] 如果它不是异常安全的,那该如何对其进行修改以使其支持basic guarantee或strong guarantee?
这里我们假设所有被调用的函数都是异常安全的(即可能抛出异常,但在抛出异常时没有任何副作用),并且假设所使用的任何对象(包括临时对象在内)也都是异常安全的(即当这些对象被销毁时,其占用的资源也能被清理)。
[背景知识:Basic Guarantee和Strong Guarantee]
关于basic guarantee和strong guarantee的详细论述,请看我在C++ Report Sep/Nov/Dec 1997中的文章。简单来说,basic guarantee保证可销毁性(destructibility)且没有泄漏;而strong guarantee除此之外还保证完全的commit-or-rollback(译注:即“要么执行,要么不执行”的原子规则)语义。
[解答]
让我们来考虑GotW#20里的那个函数。这个函数是异常安全的(exception-safe)(再出现异常时仍能正常工作)还是异常中立的(exception-neutral)(能将所有异常都转给调用者)?
String EvaluateSalaryAndReturnName( Employee e )
{
if( e.Title() == "CEO" || e.Salary() > 100000 )
{
cout << e.First() << " " << e.Last()
<< " is overpaid" << endl;
}
return e.First() + " " + e.Last();
}
[关于假设的一点说明]
[A Word About Assumptions]
如题所述,我们假设所有被调用的函数——包括流函数(stream function)在内——都是异常安全的(即可能抛出异常,但在抛出异常时无副作用),并且假设所使用到的所有对象——包括临时对象在内——也都是异常安全的(即当这些对象被销毁时,其占用的资源也都能被清理)。
然而流(stream)却偏偏要对此使个拌儿——这缘于其可能产生的“un-rollbackable(不可回复)”副作用。例如,运算符<<可能会在输出(emitting)了string的一部分之后抛出一个异常,而此时已经被输出的那部分是无法被“反输出(un-emitted)”的;同样,流(stream)的错误状态也会在此时被设置(译注:即产生了错误状态的改变)。在大部分情况下,我们都忽略这些情形;本次讨论的重点是考查「当函数具有两个互不相同的副作用时,如何使函数成为异常安全的」。
[Basic Guarantee vs. Strong Guarantee]
由题可知,该函数满足basic guarantee:当出现异常的时候,函数不会产生资源泄漏。
该函数不满足strong guarantee。strong guarantee意即:如果函数由异常而造成失败,程序的状态必须仍保持不变。然而这里的函数有两个互不相同的副作用(正如函数的名称所暗示的那样):
1.一个“…overpaid…”消息被送到cout;
2.一个名称字符串被返回。
若考虑到第2点,那函数就可以满足strong guarantee了,因为当异常产生时,值将不会被返回。若考虑到第1点,函数则仍然不是异常安全的,原因有两个:
1.如果在欲输出消息的第一部分被送到cout之后、整个消息被完全送出到cout之前的时候有异常被抛出(比如,代码中的第4个<<抛出异常),那么此时已经有一部分消息被输出了。[注1]
2.如果消息被成功的输出,但在成功输出之后函数产生异常(),那么这个消息也的确已经(无法挽回的)被送到cout了,尽管该函数因为一个异常而宣告失败。
要满足strong guarantee,函数的行为应该是:要么两件事(译注:即输出到cout和传值返回)都圆满完成,要么就是遇到该函数抛出异常,两件事都不做。
我们可以达成这样的要求吗?下面是一种我们可能会尝试的方式(不妨称其为第一次尝试):
String EvaluateSalaryAndReturnName( Employee e )
{
String result = e.First() + " " + e.Last();
if( e.Title() == "CEO" || e.Salary() > 100000 )
{
String message = e.First() + " " + e.Last()
+ " is overpaid\n";
cout << message;
}
return result;
}
这段代码还算不坏。应当注意到,为了让整个string只使用一个<<调用,我们用换行符代替了endl(虽然两者并不完全等同)。(当然,这样做并不能保证「底层的流系统本身不会在对消息施以写操作的时候失败,从而造成不完整的输出」——但我们在这样的高层次已经做了力所能及的努力。)
[一个稍微有点揪心的问题]
[A Little Bothersome Issue]
到现在,我们仍然有一个微小的瑕疵,它如下面的用户代码(client code)所示:
String theName;
theName = EvaluateSalaryAndReturnName( bob );
由于函数的结果采用了return by value(传值返回)方式返回,因此String的拷贝构造函数(copy constructor)被唤起;拷贝赋值运算符(copy assignment operator)也被唤起,用来将结果拷贝到theName。如果这两个拷贝操作中有任一个失败了,那么函数的副作用就已发生效应(因为消息已被完全输出,返回值也已被完全构造好了),而其结果也就无法挽回的丢失了(噢欧!)。
我么能否做得更好一些?可以通过「避免拷贝操作」来避免这个问题吗?这即是说,我们让函数接受一个non-const的String之引用参数,并将返回值放在这个参数中:
void EvaluateSalaryAndReturnName( Employee e, String& r );
{
String result = e.First() + " " + e.Last();
if( e.Title() == "CEO" || e.Salary() > 100000 )
{
String message = e.First() + " " + e.Last()
+ " is overpaid\n";
cout << message;
}
r = result;
}
然而不幸的是,对r的赋值仍然可能失败,这将造成其中一个副作用被完成而另一个没被完成。最关键的问题在于,这第二次尝试并没有给我们带来多大好处。
于是我们可能会尝试着使用auto_ptr来返回结果(不妨把这一次称为第三次尝试):
auto_ptr<String>
EvaluateSalaryAndReturnName( Employee e );
{
auto_ptr<String> result
= new String( e.First() + " " + e.Last() );
if( e.Title() == "CEO" || e.Salary() > 100000 )
{
String message = e.First() + " " + e.Last()
+ " is overpaid\n";
cout << message;
}
return result; // rely on transfer of ownership
}
这正是解题的诀窍之所在——我们有效的隐藏了产生第二个副作用(返回值)的操作,同时也保证了「在第一个副作用(打印消息)被完成后,只使用不抛出异常的(nonthrowing)操作把结果安全的返回给函数调用者」。那么这样做的代价呢?正如在实现强异常安全性(strong exception safety)时经常发生的那样,这种强安全性以效率为代价——我们使用了额外的动态内存分配。
[异常安全性和多重副作用]
[Exception Safety and Multiple Side Effects]
从本次讨论可以看出,在第三次尝试中有可能以基本的commit-or-rollback语义来完成那两个副作用(与流有关的那个除外)。究其原因,是因为这两个副作用看起来应该可以通过某种技术而被自动完成——这即是说,为两个副作用所做的全部“真正的”工作能够以这样一种方式被完成:即可见的副作用能够只通过不抛出异常的(nonthrowing)操作来完成。
尽管这一次我们还算比较幸运,但情况并不总是那么简单:要编写强异常安全的函数,且让该函数包含两个或多个能被自动完成的、互不相关的副作用(例如,当两个副作用中一个向cout送消息,另一个向cerr送消息,那会怎么样呢?)——这是不可能的,因为strong guarantee要求在出现异常时“程序的状态保持不变”;换句话说,意即只要有异常出现就不能有副作用产生。通常当你遇到两个副作用无法被自动完成的情况时,要实现强异常安全性的唯一方法就是把函数分成两个能自动完成副作用的函数。
本期GotW意在描述3个重点:
1. 要对强异常安全性提供保证,经常(但并不总是)需要你以放弃一部分性能为代码。
2. 如果一个函数含有多重的副作用,那么其总是无法成为强异常安全的。此时,唯一的方法就是将函数分为几个函数,以使得每一个分出来的函数之副作用能被自动完成。
3. 并不是所有函数都需要具有强异常安全性。本条款中的原始代码和第一次尝试的代码已经能够满足basic guarantee了。在许多情况下,第一次尝试的代码已经足够好用,能够将副作用在异常情况下发生的可能性减到最小,而并不需要像第三次尝试那样非要损失一定的性能。
[又及:流和副作用]
[Postscript: Streams and Side Effects]
正如本条款所示,我们对「被调用的函数没有副作用」之假设并不完全是真实的情况。特别的,我们根本无法保证「流在输出一部分结果之后不会突然失败」。这意味着,我们无法在执行流输出的函数中实现真正的commit-or-rollback语义——至少在这些标准流中是不可能的。另外还有一点是,如果流输出失败了,流的状态也将会改变。目前我们不去检查这种情况,也不尝试对其予以恢复——但我们仍可以对函数进行修改,以使其能够捕获由于流而引起的异常,并在重新向调用者抛出异常之前重置cout的error flags。
[注1]:如果你觉得「担心一条消息是否能够被完全施以cout操作」这样的事情多少有点过分学究的味道,那么可以说你的想法并不错。在这里,可能没有人会关心这个。然而任何试图完成两个副作用的函数都是遵循着同样的原理——这也就是为什么我们后续的讨论还有意义的原因。
(完)