C++编程语言:基础设施:异常处理(Bjarne Stroustrup)

第 13 章  异常处理(Exception Handling)

目录

13.1  错误处理(Error Handling)

13.1.1  异常(Exceptions)

13.1.2  传统错误处理(Traditional Error Handling)

13.1.3  探索(Muddling Through)

13.1.4  异常的替代观点(Alternative Views of Exceptions)

13.1.4.1  异步事件(Asynchronous Events)

13.1.4.2  不是错误的异常(Exceptions That Are Not Errors)

13.1.5  当你不能使用异常时该怎么做(When You Can’t Use Exceptions)

13.1.6  分级错误处理(Hierarchical Error Handling)

13.1.7  异常和效率(Exceptions and Efficiency)

13.2  异常保证(Exception Guarantees)

13.3  资源管理(Resource Management)

13.3.1  清理语句块(Finally)

13.4  强制不变性(Enforcing Invariants)

13.5  抛出异常和捕获异常(Throwing and Catching Exceptions)

13.5.1  抛出异常(Throwing Exceptions)

13.5.1.1  noexcept函数

13.5.1.2  noexcept运算符

13.5.1.3  异常的规范

13.5.2  捕捉异常(Catching Exceptions)

13.5.2.1  重抛异常(Rethrow)

13.5.2.2  捕获一切异常(Catch Every Exception)

13.5.2.3  异常多处理(Multiple Handlers)

13.5.2.4  函数try块(Function try-Blocks)

13.5.2.5  终止程序

13.5.3  异常和线程

13.6  一个vector的实现

13.6.1  一个简单的vector

13.6.2  显式表示内存

13.6.3  赋值

13.6.4  改变大小

13.6.4.1  reserve()函数

13.6.4.2  resize()函数

13.6.4.3  push_back()函数

13.6.4.4  最后的思考

13.7  建议


13.1  错误处理(Error Handling)

         本章介绍如何使用异常进行错误处理。为了有效地处理错误,必须基于策略使用语言机制。因此,本章介绍了从运行时错误中恢复的关键异常安全保证,以及使用构造函数和析构函数进行资源管理的资源获取即初始化 (RAII)(Resource Acquisition Is Initialization) 技术。异常安全保证和 RAII 都依赖于不变量的规范,因此介绍了断言执行机制。

    这里介绍的语言工具和技术解决了与软件错误处理相关的问题;异步事件的处理是另一个主题。

    错误的讨论重点是无法在局部(在单个小函数内)处理的错误因此需要将错误处理活动分离到程序的不同部分程序的这些部分通常是单独开发的。因此,我经常将调用来执行任务的程序部分称为“库”。库只是普通的代码,但在讨论错误处理的背景下,值得记住的是,库设计者通常甚至不知道库将成为哪种程序的一部分:

• 库的作者可以检测到运行时错误,但通常不知道如何处理它。

• 库的用户可能知道如何应对运行时错误,但无法轻易检测到它(否则它会在用户的代码中处理,而不是留给库来查找)。

    关于异常的讨论主要集中在需要处理长期运行系统、具有严格可靠性要求的系统和库中的问题。不同类型的程序有不同的要求,我们付出的关注和努力应该反映这一点。例如,我不会将这里推荐的每一种技术都应用到一个只为自己编写的两页程序上。但是,这里介绍的许多技术简化了代码,所以我会使用它们。

13.1.1  异常(Exceptions)

    异常的概念是为了帮助获取从检测到错误的点到可以处理错误的点的信息无法处理问题的函数会抛出异常,希望其(直接或间接的)调用者能够处理该问题。想要处理某种问题的函数通过捕获相应的异常(§2.4.3.1)来表示

• 调用组件通过在 try 块的 catch 子句中指定异常来指示它愿意处理的故障类型。

• 无法完成其分配任务的被调用组件通过使用 throw 表达式抛出异常来报告其失败。

考虑一个简化且风格化的例子:

void taskmaster()

{

try {

auto result = do_task();

// use result

}

catch (Some_error) {

// failure to do_task: handle problem

}

}

int do_task()

{

// ...

if (/* could perfor m the task */)

return result;

else

throw Some_error{};

}

taskmaster() 要求 do_task() 执行一项任务。如果 do_task() 可以完成该任务并返回正确的结果,则一切正常。否则,do_task() 必须通过抛出一些异常来报告失败。taskmaster() 准备处理 Some_error,但可能会抛出其他类型的异常。例如,do_task() 可能会调用其他函数来执行许多子任务,其中一个子任务可能会抛出,因为它无法执行其分配的子任务。不同于 Some_error 的异常表示 taskmaster() 无法完成其任务,必须由调用 taskmaster() 的任何代码来处理。

    被调用的函数不能仅仅返回一个错误发生的指示。如果程序要继续工作(而不仅仅是打印错误消息并终止),则返回函数必须使程序处于良好状态并且不会泄漏任何资源。异常处理机制与构造函数/析构函数机制和并发机制集成在一起,以帮助确保异常处理机制:

• 当传统技术不够充分、不够优雅或容易出错时,可以作为传统技术的替代方案。

• 功能齐全;可用于处理普通代码检测到的所有错误。

• 允许程序员明确地将错误处理代码与“普通代码”分开,从而使程序更具可读性,更适合工具。

• 支持更常规的错误处理方式,从而简化单独编写的程序片段之间的协作。

    异常是抛出的一个对象,用于表示发生错误。它可以是任何可以复制的类型,但强烈建议仅使用为此目的专门定义的用户定义类型。这样,我们就可以最大限度地减少两个不相关的库使用相同值(例如17)来表示不同错误的可能性,从而使我们的恢复代码陷入混乱。

    异常由表示有兴趣处理特定类型异常(catch 子句)的代码捕获。因此,定义异常的最简单方法是专门为某种错误定义一个类并抛出该类。例如:

struct Range_error {};

void f(int n)

{

if (n<0 || max<n) throw Rang e_error {};

// ...

}

       如果这变得乏味,标准库定义了一个小的异常类层次结构(§13.5.2)。

    异常可以携带有关其所代表的错误的信息。其类型表示错误的种类,其所保存的任何数据都表示该错误的具体发生情况。例如,标准库异常包含一个字符串值,可用于传输诸如抛出位置(§13.5.2)之类的信息。

13.1.2  传统错误处理(Traditional Error Handling)

       考虑一下异常的替代方案,用于检测无法在局部处理的问题(例如,超出作用域的访问),因此必须向调用者报告错误。每种传统方法都有问题,而且没有一种是通用的:

终止程序。这是一个相当激进的方法。例如:

       if (something_wrong) exit(1);

对于大多数错误,我们可以而且必须做得更好。例如,在大多数情况下,我们至少应该在终止之前写出一个像样的错误消息或记录错误。特别是,不知道其嵌入程序的目的和一般策略的库不能简单地 exit() abort()无条件终止的库不能用于不能崩溃的程序中

返回错误值。这并不总是可行的,因为通常没有可接受的“错误值”。例如:

int get_int(); // get next integer from input

对于此输入函数,每个 int 都是一个可能的结果,因此不可能存在表示输入失败的整数值。至少,我们必须修改 get_int() 以返回一对值。即使这种方法可行,它也常常不方便,因为每次调用都必须检查错误值。这很容易使程序的大小翻倍(§13.1.7)。此外,调用者经常忽略错误的可能性,或者只是忘记测试返回值。因此,这种方法很少被系统地用于检测所有错误。例如,如果发生输出或编码错误,printf()(§43.3)将返回负值,但程序员基本上从不对此进行测试。最后,某些操作根本没有返回值;构造函数就是一个明显的例子。

返回合法值并使程序处于“错误状态”。这会导致调用函数可能没有注意到程序已处于错误状态。例如,许多标准 C 库函数设置非局部变量 errno 来指示错误(§43.4,§40.3):

double d = sqrt(−1.0);

这里,d 的值毫无意义,errno 的设置表明 −1.0 不是浮点平方根函数可接受的参数。但是,程序通常无法以足够的一致性设置和测试 errno 和类似的非局部状态,从而避免由失败调用返回的值导致的后续错误。此外,在并发情况下,使用非局部变量来记录错误条件效果不佳。

调用错误处理函数。例如:

if (something_wrong) something_handler(); // and possibly continue here

这肯定是某种伪装的其它方法,因为问题立即变成了“错误处理函数做什么?”除非错误处理函数能够完全解决问题,否则错误处理函数必须终止程序、返回一些错误发生的指示、设置错误状态或抛出异常。此外,如果错误处理函数可以在不打扰最终调用者的情况下处理问题,那么我们为什么认为这是一个错误?

    在传统上,这些方法的非系统性组合在一个程序中共存。

13.1.3  探索(Muddling Through)

    异常处理方案的一个方面对某些程序员来说似乎很新颖,那就是对未处理的错误(未捕获的异常)的最终响应是终止程序。传统的响应是应付过去并希望得到最好的结果。因此,异常处理使程序更“脆弱”,因为必须付出更多的小心和努力才能使程序正常运行。不过,这比在开发过程的后期(或者在开发过程被认为完成并将程序移交给无辜用户之后)得到错误的结果要好。当终止是不可接受的时,我们可以捕获所有异常(§13.5.2.2)。因此,只有当程序员允许程序终止时,异常才会终止程序。通常,这比传统的不完全恢复导致灾难性错误时发生的无条件终止要好。当终止是可接受的响应时,未捕获的异常将实现这一目标,因为它会变成对terminate() 的调用(§13.5.2.5)。此外,noexcept 说明符(§13.5.1.1)可以明确表达这一愿望。

    有时,人们会尝试通过写出错误消息、设置对话框向用户寻求帮助等方式来缓解“应付”的不愉快方面。这种方法主要用于调试用户是熟悉程序结构的程序员的情况。在非开发人员手中,向(可能不在场的)用户/操作员寻求帮助的库是不可接受的。一个好的库不会以这种方式“喋喋不休(blabber)”。如果需要通知用户,异常处理程序可以编写合适的消息(例如,为芬兰用户编写芬兰语消息,或为错误日志系统编写 XML 消息)。异常为检测到无法恢复的问题的代码提供了一种方法,将问题传递给系统中可能能够恢复的部分。只有对程序运行环境有所了解的系统部分才有机会编写有意义的错误消息。

    请认识到错误处理仍将是一项艰巨的任务,并且异常处理机制(尽管比它所取代的技术更加形式化)与仅涉及本地控制流的语言特性相比仍然相对非结构化。C++ 异常处理机制为程序员提供了一种处理错误的方法,在给定系统结构的情况下,错误处理方式最为自然。异常使错误处理的复杂性变得显而易见。但是,异常并不是造成这种复杂性的原因。小心不要将坏消息归咎于信使

13.1.4  异常的替代观点(Alternative Views of Exceptions)

“异常”这个词对不同的人有不同的含义。C++ 异常处理机制旨在支持处理无法局部处理的错误(“异常情况”)。具体来说,它旨在支持由独立开发的组件组成的程序中的错误处理。鉴于程序的某个部分无法执行其给定任务并没有什么特别异常,因此“异常”一词可能被认为有点误导。程序运行时大多数时间发生的事件可以被视为异常吗?计划和处理的事件可以被视为错误吗?这两个问题的答案都是“是”。 “异常”并不意味着“几乎从不发生”或“灾难性的”。

13.1.4.1  异步事件(Asynchronous Events)

    该机制旨在仅处理同步异常,例如数组作用域检查和 I/O 错误。异步事件(例如键盘中断和电源故障)不一定是异常,也不会由该机制直接处理。异步事件需要与异常(如此处定义)根本不同的机制才能干净高效地处理它们。许多系统都提供处理异步的机制(例如信号),但由于这些机制往往依赖于系统,因此本文不作介绍。

13.1.4.2  不是错误的异常(Exceptions That Are Not Errors)

将异常视为“系统的某些部分无法执行所要求的操作”(§13.1.1,§13.2)。

与函数调用相比,异常抛出应该很少,否则系统的结构就变得模糊了。然而,我们应该预料到大多数大型程序在正常且成功运行的过程中至少会抛出并捕获一些异常。

如果预期会出现异常并被捕获,以至于它不会对程序的行为产生不良影响,那么它怎么会是错误呢?只是因为程序员认为它是一个错误,而将异常处理机制视为处理错误的工具。或者,人们可能会认为异常处理机制只是另一种控制结构,一种向调用者返回值的替代方法。考虑一个二叉树搜索函数:

void fnd(Tree p, const string& s)

{

if (s == p−>str) throw p; // found s

if (p−>left) fnd(p−>left,s);

if (p−>right) fnd(p−>right,s);

}

Tree find(Tree p, const string& s)

{

try {

fnd(p,s);

}

catch (Tree q) { // q->str==s

return q;

}

return 0;

}

这实际上有一些魅力,但应该避免,因为它可能会导致混乱和效率低下。尽可能坚持“异常处理就是错误处理”的观点这样做后,代码就会被清楚地分为两类:普通代码和错误处理代码。这使得代码更容易理解。此外,异常机制的实现是基于这个简单模型是异常使用的基础这一假设而优化的。

错误处理本身就很困难。任何有助于保留错误是什么以及如何处理错误的清晰模型的东西都应该受到珍惜。

13.1.5  当你不能使用异常时该怎么做(When You Can’t Use Exceptions)

    使用异常是处理 C++ 程序中错误的唯一完全通用且系统的方法。但是,我们不得不得出这样的结论:有些程序由于实际和历史原因不能使用异常。例如:

嵌入式系统中时间关键的组件,必须保证操作在特定的最大时间内完成。如果没有能够准确估计异常从抛出传播到捕获的最大时间的工具,则必须使用替代的错误处理方法。

大型旧程序,其中的资源管理是特有的混乱(an ad hoc mess)(例如,使用“裸”指针、newdelete无系统地“管理”自由存储),而不是依赖于某些系统方案,例如资源句柄(例如,stringvector;§4.2,§4.4)。

    在这种情况下,我们只能依靠“传统”(异常前)技术。由于此类程序产生于各种各样的历史背景中,并且是为了应对各种限制,因此我无法就如何处理它们给出一般性建议。但是,我可以指出两种流行的技术:

• 为了模拟 RAII,为每个带有构造函数的类提供一个 invalid() 操作,该操作会返回一些 error_code一个有用的惯例是 error_code == 0 表示成功。如​​果构造函数无法建立类不变量,则可确保不会泄漏任何资源,并且 invalid() 返回非零 error_code。这解决了如何从构造函数中获取错误条件的问题。然后,用户可以在每次构造对象后系统地测试 invalid(),并在失败时进行适当的错误处理。例如:

void f(int n)

{

my_vector<int> x(n);

if (x.invalid()) {

// ... deal with error ...

}

// ...

}

• 为了模拟函数返回值或抛出异常,函数可以返回一对<Value,Error_code>(§5.4.3)。然后,用户可以在每次函数调用后系统地测试error_code,并在发生故障时进行适当的错误处理。例如:

void g(int n)

{

auto v = make_vector(n); // return a pair

if (v.second) {

// ... deal with error ...

}

auto val = v.first;

// ...

}

该方案的变体已经取得了相当的成功,但与系统地使用异常相比,它们显得笨拙。

13.1.6  分级错误处理(Hierarchical Error Handling)

    异常处理机制的目的是为程序的一部分提供一种手段,让其通知另一部分无法执行所请求的任务(检测到“异常情况”)。假设程序的两个部分是独立编写的,并且程序中处理异常的部分通常可以对错误采取合理的措施。

    要在程序中有效地使用处理程序,我们需要一个总体策略。也就是说,程序的各个部分必须就如何使用异常以及在何处处理错误达成一致。异常处理机制本质上是非局部的,因此遵守总体策略至关重要。这意味着最好在设计的最初阶段考虑错误处理策略。它还意味着该策略必须简单(相对于整个程序的复杂性)和明确。在像错误恢复这样本质上棘手的领域,复杂的事情不会得到一致遵守。

    成功的容错系统是多级的。每个级别都会尽可能多地处理错误,而不会变得过于复杂,其余的错误留给更高级别处理。异常支持这种观点。此外,如果异常处理机制本身损坏或使用不充分,从而导致异常未被捕获,terminate() 会通过提供逃避来支持这种观点。类似地,noexcept 为试图恢复似乎不可行时的错误提供了一个简单的逃避方式。

    并非每个函数都应该是防火墙。也就是说,并非每个函数都可以很好地测试其前置条件,以确保没有错误可以阻止其满足后置条件。这行不通的缘由因程序而异,也因程序员而异。但是,对于较大的程序:

[1] 确保“可靠性”这一概念所需的工作量太大,无法始终如一地完成。

[2] 时间和空间上的开销太大,系统无法正常运行(会倾向于一遍又一遍地检查相同的错误,例如无效参数)。

[3] 用其他语言编写的函数不会遵守规则。

[4] 这种纯粹局部的“可靠性”概念会导致复杂性,实际上会成为整个系统可靠性的负担。

    但是,将程序划分为不同的子系统,使其能够以明确定义的方式成功完成或失败,这是必要的、可行的和经济的。因此,主要的库、子系统和关键接口函数应该以这种方式设计。此外,在大多数系统中,设计每个函数以确保它总是能够以明确定义的方式成功完成或失败是可行的。

通常,我们没有从头开始设计系统的所有代码的奢侈。因此,要对程序的所有部分施加通用的错误处理策略,我们必须考虑使用与我们不同的策略实现的程序片段。为此,我们必须解决与程序片段管理资源的方式以及它在发生错误后离开系统的状态有关的各种问题。目的是让程序片段看起来遵循通用的错误处理策略,即使它在内部遵循不同的策略。

有时,需要从一种错误报告样式转换为另一种样式。例如,我们可能会在调用 C 库后检查 errno 并可能引发异常,或者相反,在从 C++ 库返回 C 程序之前捕获异常并设置 errno

void callC() // Call a C function from C++; convert err no to a throw

{

errno = 0;

c_function();

if (errno) {

// ... local cleanup, if possible and necessary ...

Throw C_blewit(errno);

}

}

extern "C" void call_from_C() noexcept // Call a C++ function from C; convert a throw to err no

{

try {

c_plus_plus_function();

}

catch (...) {

// ... local cleanup, if possible and necessary ...

errno = E_CPLPLFCTBLEWIT;

}

}

在这种情况下,系统化很重要,以确保错误报告样式的转换完成。不幸的是,这种转换通常在没有明确错误处理策略的“混乱代码”中最为理想,因此很难系统化。

       错误处理应尽可能分层。如果函数检测到运行时错误,它不应要求其调用者帮助恢复或获取资源。此类请求会在系统依赖关系中设置循环。这反过来会使程序难以理解,并引入了错误处理和恢复代码中无限循环的可能性。

13.1.7  异常和效率(Exceptions and Efficiency)

         在原则上,可以实现异常处理使得当没有异常抛出时不会产生运行时开销(overhead)。此外,这样做还可以使抛出异常与调用函数相比不那么昂贵。这样做不增加大量内存开销,同时保持与 C 调用序列、调试器约定等的兼容性,这是可能的,但很难。但是,请记住,异常的替代方案也不是免费的。在传统系统中,一半的代码用于错误处理并不罕见。

       考虑一个看起来与异常处理无关的简单函数 f()

void f()

{

string buf;

cin>>buf;

// ...

g(1);

h(buf);

}

但是,g() h() 可能会引发异常,因此 f() 必须包含确保在发生异常时正确销毁 buf 的代码。

如果 g() 没有抛出异常,它就必须以其他方式报告其错误。因此,使用普通代码来处理错误而不是异常的可比代码不是上面的普通代码,而是类似这样的代码:

bool g(int);

bool h(const char);

char read_long_string();

bool f()

{

char s = read_long_string();

// ...

if (g(1))

 {

if (h(s))

{

free(s);

return true;

}

else {

free(s);

return false;

}

}

else

{

free(s);

return false;

}

}

使用局部缓冲区可以简化代码,因为无需调用 free(),但这样我们就会使用作用域检查代码。复杂性往往会四处移动,而不是消失。

然而,人们通常不会如此系统地处理错误,而且这样做并不总是必要的。然而,当需要谨慎而系统地处理错误时,这种管理工作最好留给计算机,也就是异常处理机制。

noexcept 说明符(§13.5.1.1)对于改进生成的代码非常有帮助。请考虑:

void g(int) noexcept;

void h(const string&) noexcept;

现在,为 f() 生成的代码可能会得到改进。

传统 C 函数不会抛出异常,因此大多数 C 函数都可以声明为 noexcept。具体来说,标准库实现者知道只有少数标准 C 库函数(如 atexit() 和 qsort())会抛出异常,并可以利用这一事实来生成更好的代码。

在声明“C 函数”noexcept 之前,请花点时间考虑一下它是否可能抛出异常。例如,它可能已被转换为使用 C++ 运算符 new,这可能会抛出 bad_alloc,或者它可能会调用抛出异常的 C++ 库。

一如既往,如果没有测量,关于效率的讨论就毫无意义。

13.2  异常保证(Exception Guarantees)

       要从错误中恢复——即捕获异常并继续执行程序——我们需要知道在尝试恢复操作之前和之后可以假设程序的状态。只有这样恢复才有意义。因此,如果操作在通过抛出异常终止时使程序处于有效状态,我们称该操作为异常安全。但是,为了使其有意义和有用,我们必须准确理解“有效状态”的含义。对于使用异常的实际设计,我们还必须将过于笼统的“异常安全”概念分解为几个具体的保证。

       在推理对象时,我们假设一个类有一个类不变量(§2.4.3.2,§17.2.1)。我们假设这个不变量由其构造函数建立,并由所有有权访问对象表示的函数维护,直到对象被销毁。因此,有效状态是指构造函数已完成且析构函数尚未进入。对于不容易被视为对象的数据,我们必须进行类似的推理。也就是说,如果假设两个非局部数据具有特定关系,我们必须考虑不变量,并且我们的恢复操作必须保留它。例如:

namespace Points { // (vx[i],vy[i]) is a point for all i

vector<int> vx;

vector<int> vy;

};

这里假设 vx.size()==vy.size() 总是为真。然而,这只是在注释中说明的,而编译器不会读取注释。这种隐式不变量可能很难发现和维护。

    在抛出之前,函数必须将所有构造的对象置于有效状态。但是,这种有效状态可能不适合调用者。例如,字符串可能保留为空字符串,或者容器可能未排序。因此,为了完全恢复,错误处理程序可能必须生成比 catch 子句入口处存在的(有效)值更适合/更适合应用程序的值。

    C++ 标准库为设计异常安全程序组件提供了一个通用的概念框架。该库为每个库操作提供以下保证之一:

对所有操作提供基本保证:所有对象的基本不变量都得到维护,并且不会泄漏任何资源(例如内存)。特别是,每个内置和标准库类型的基本不变量都保证您可以在每次标准库操作(§iso.17.6.3.1)之后销毁对象或为其赋值。

关键操作的强保证:除了提供基本保证外,操作要么成功,要么无效。此保证适用于关键操作,例如 push_back()、列表中的单元素 insert() uninitialized_copy()

• 某些操作的 nothrow 保证:除了提供基本保证外,还保证某些操作不会抛出异常。此保证适用于一些简单操作,例如两个容器的 swap()pop_back()

基本保证和强保证均需满足以下条件:

• 用户提供的操作(例如赋值和 swap() 函数)不会使容器元素处于无效状态,

• 用户提供的操作不会泄漏资源,并且

• 析构函数不会引发异常(§iso.17.6.5.12)。

    违反标准库要求(例如通过抛出异常退出析构函数)在逻辑上等同于违反基本语言规则(例如解引用空指针)。实际效果也相当,而且往往是灾难性的。

    基本保证和强保证都要求不存在资源泄漏。这对于每个无法承受资源泄漏的系统都是必要的。特别是,抛出异常的操作不仅必须使其操作数处于明确定义的状态,还必须确保它获取的每个资源(最终)都被释放。例如,在抛出异常时,所有分配的内存都必须被释放或由某个对象拥有,而这又必须确保内存被正确释放。例如:

void f(int i)

{

int p = new int[10];

// ...

if (i<0) {

delete[] p; // delete before the throw or leak

throw Bad();

}

// ...

}

请记住,内存并不是唯一可能泄漏的资源。我认为任何必须从系统其他部分获取并(显式或隐式)返回的东西都是资源。文件、锁、网络连接和线程都是系统资源的示例。函数可能必须释放这些资源或将它们移交给某个资源处理程序,然后才能抛出异常。

    C++ 语言的部分构造和析构规则确保在构造子对象和成员时抛出的异常将得到正确处理,而无需标准库代码(§17.2.3)的特别关注。此规则是所有处理异常的技术的基本基础。

    一般来说,我们必须假设每个可能抛出异常的函数都会抛出异常。这意味着我们必须构造代码,以免迷失在复杂的控制结构和脆弱的数据结构中。在分析代码中是否存在潜在错误时,简单、高度结构化、“风格化”的代码是理想的;§13.6 包含此类代码的实际示例。

13.3  资源管理(Resource Management)

    当一个函数获取资源时(即打开一个文件、从自由存储中分配一些内存、获取互斥锁等),正确释放资源对于系统未来的运行通常至关重要。通常,这种“正确释放”是通过让获取资源的函数在返回给调用者之前释放资源来实现的。例如:

void use_file(const char fn) // naive code

{

FILE f = fopen(fn,"r");

// ... use f ...

fclose(f);

}

这看起来似乎很有道理,直到您意识到,如果在调用 fopen() 之后和调用 fclose() 之前出现问题,异常可能会导致 use_file() 退出而不调用 fclose()。在那些不支持异常处理的语言中也会出现完全相同的问题。例如,标准 C 库函数 longjmp() 可能会导致同样的问题。即使是普通的 return 语句也可能退出 use_file 而不关闭 f

    首次尝试使 use_file() 具有容错功能如下所示:

void use_file(const char fn) // 笨拙代码

{

FILE f = fopen(fn,"r");

try {

// ... use f ...

}

catch (...) { // 捕获每一个可能的代码

fclose(f);

throw;

}

fclose(f);

}

使用该文件的代码包含在一个 try 块中,该块捕获每个异常、关闭文件并重新抛出异常。

    这种解决方案的问题在于它冗长、乏味,而且可能成本高昂。更糟糕的是,当必须获取和释放多个资源时,这种代码会变得更加复杂。幸运的是,有一个更优雅的解决方案。问题的一般形式如下:

void acquire()

{

// acquire resource 1

// ...

// acquire resource n

// ... use resources ...

// release resource n

// ...

// release resource 1

}

通常,重要的是,资源的释放顺序应与获取顺序相反。这与由构造函数创建并由析构函数销毁的局部对象的行为非常相似。因此,我们可以使用具有构造函数和析构函数的类的对象来处理此类资源获取和释放问题。例如,我们可以定义一个类 File_ptr,其作用类似于 FILE

class File_ptr {

FILE p;

public:

File_ptr(const char n, const char a) // open file n

: p{fopen(n,a)}

{

if (p==nullptr) throw runtime_error{"File_ptr: Can't open file"};

}

File_ptr(const string& n, const char a) // open file n

:File_ptr{n.c_str(),a}

{ }

explicit File_ptr(FILEpp) //assume ownership of pp

:p{pp}

{

if (p==nullptr) throw runtime_error("File_ptr: nullptr"};

}

// ... suitable move and copy operations ...

˜File_ptr() { fclose(p); }

operator FILE() { return p; }

};

    我们可以通过给定 FILE fopen() 所需的参数来构造 File_ptr。无论哪种情况,

File_ptr 都会在其作用域结束时被销毁,其析构函数将关闭文件。如果 File_ptr 无法打开文件,则会抛出异常,因为否则文件句柄上的每个操作都必须测试是否为 nullptr。我们的函数现在缩小到这个最小值:

void use_file(const char fn)

{

File_ptr f(fn,"r");

// ... use f ...

}

析构函数的调用与函数正常退出或因抛出异常而退出无关。也就是说,异常处理机制使我们能够从主算法中删除错误处理代码。与传统代码相比,生成的代码更简单,更不容易出错。

    这种使用局部对象管理资源的技术通常称为“资源获取即初始化”(RAII;§5.2)。这是一种通用技术,依赖于构造函数和析构函数的属性及其与异常处理的交互。

    人们经常认为编写“处理类”(RAII 类)很繁琐,因此为 catch(...) 操作提供更好的语法将提供更好的解决方案。这种方法的问题在于,您需要记住在以无纪律的方式获取资源时“捕获并纠正”问题(通常在大型程序中数十或数百个地方),而处理程序类只需编写一次。

    对象在其构造函数完成之前不被视为已构造。只有到那时,堆栈展开(unwinding)(§13.5.1) 才会调用对象的析构函数。由子对象组成的对象构造的程度取决于其子对象的构造程度。数组构造的程度取决于其元素的构造程度(并且只有完全构造的元素才会在展开期间被销毁)。

    构造函数试图确保其对象完整且正确地构造。当无法实现这一点时,编写良好的构造函数会尽可能将系统状态恢复到创建之前的状态。理想情况下,设计良好的构造函数始终会实现这些替代方案之一,而不会使其对象处于某种“半构造”状态。这可以通过将 RAII 技术应用于成员来轻松实现。

    考虑一个类 X,它的构造函数需要获取两个资源:文件 x 和互斥锁 y(§5.3.4)。此获取可能会失败并引发异常。类 X 的构造函数绝不能只获取文件而不获取互斥锁(或只获取互斥锁而不获取文件,或两者都不获取)。此外,这应该在不给程序员带来复杂性负担的情况下实现。我们使用两个类的对象 File_ptr 和 std::unique_lock(§5.3.4)来表示获取的资源。资源的获取由表示资源的局部对象的初始化来表示:

class Locked_file_handle {

File_ptr p;

unique_lock<mutex> lck;

public:

X(const char file, mutex& m)

: p{file ,"rw"}, // acquire ‘‘file’’

lck{m} //acquire ‘‘m’’

{}

// ...

};

现在,与局部对象的情况一样,实现会处理所有的簿记工作。用户完全不必跟踪。例如,如果在构造 p 之后但在构造 lck 之前发生异常,则将调用 p 的析构函数,而不会调用 lck 的析构函数。

    这意味着,如果遵循这种简单的资源获取模型,构造函数的作者就不需要编写显式的异常处理代码。

    最常见的资源是内存,stringvector和其他标准容器使用 RAII 来隐式管理获取和释放。与使用 new(可能还有delete)的临时内存管理相比,这节省了大量工作并避免了大量错误。

    当需要指向对象而非局部对象的指针时,请考虑使用标准库类型 unique_ptr shared_ptr(§5.2.1、§34.3)以避免泄漏。   

13.3.1  清理语句块(Finally)

    将资源表示为具有析构函数的类的对象所需的规则让一些人感到困扰。人们一次又一次地发明了“finally”语言结构,用于编写任意代码来在异常后进行清理。这些技术通常不如 RAII,因为它们是临时的,但如果你真的想要临时的,RAII 也可以提供。首先,我们定义一个类,它将从其析构函数中执行任意操作。

template<typename F>

struct Final_action {

Final_action(F f): clean{f} {}

˜Final_action() { clean(); }

F clean;

};

    “最终动作”作为构造函数的参数提供。

    接下来,我们定义一个可以方便地推断动作类型的函数:

template<class F>

Final_action<F> finally(F f)

{

return Final_action<F>(f);

}

最后,我们可以验证 finally:

       void test()

// handle undiciplined resource acquisition

// demonstrate that arbitrar y actions are possible

{

int p = new int{7}; // probably should use a unique_ptr (§5.2)

int buf = (int)malloc(100sizeof(int)); // C-style allocation

auto act1 = finally([&]{ delete p;

free(buf); //C-style deallocation

cout<< "Goodby, Cruel world!\n";

}

);

int var = 0;

cout << "var = " << var << '\n';

// nested block:

{

var = 1;

auto act2 = finally([&]{ cout<< "finally!\n"; var=7; });

cout << "var = " << var << '\n';

} // act2 is invoked here

cout << "var = " << var << '\n';

} // act1 is invoked here

这将产生:

var = 0

var = 1

finally!

var = 7

Goodby, Cruel world!

此外,pbuf 分配并指向的内存被适当删除并释放。

    通常,将保护放置在靠近其所保护内容的定义的位置是一个好主意。这样,我们就可以一目了然地看到什么被视为资源(即使是临时的)以及在其作用域结束时要做什么。与使用 RAII 进行资源处理相比,finally() 操作与它们操作的资源之间的联系仍然是临时的和隐式的,但使用 finally() 比在块中散布清理代码要好得多。

    基本上,finally() 对块的作用相当于 for 语句的增量部分对 for 语句的作用(§9.5.2):它在块顶部指定最终操作,该操作很容易被看到,并且从规范的角度来看它在逻辑上属于该位置。它说明了退出作用域时要做什么,从而使程序员不必在控制线程可能退出作用域的多个潜在位置编写代码。

13.4  强制不变性(Enforcing Invariants)

不要这样做:满足前置条件是调用者的工作,如果调用者不这样做,就让坏结果发生——最终这些错误将通过改进设计、调试和测试从系统中消除。

终止程序:违反前置条件是一个严重的设计错误,程序在存在此类错误的情况下不得继续运行。希望整个系统能够从一个组件(该程序)的故障中恢复——最终可以通过改进设计、调试和测试从系统中消除此类故障。

    为什么有人会选择这些替代方案之一?第一种方法通常与性能需求有关:系统地检查先决条件可能会导致对逻辑上不必要的条件进行重复测试(例如,如果调用者正确验证了数据,则数千个被调用函数中的数百万个测试可能在逻辑上是多余的)。性能成本可能很高。为了获得这种性能,在测试期间遭受反复崩溃可能是值得的。显然,这假设您最终会从系统中消除所有关键的先决条件违规。对于某些系统(通常是完全由单个组织控制的系统),这可能是一个现实的目标。

    第二种方法通常用于无法完全及时地从前置条件故障中恢复的系统。也就是说,确保恢复完全会给系统设计和实现带来不可接受的复杂性。另一方面,程序终止被认为是可以接受的。例如,如果使用输入和参数很容易重新运行程序,从而避免重复故障,那么认为程序终止是可以接受的,这并不是不合理的。一些分布式系统就是这样(只要终止的程序只是整个系统的一部分),我们为自己使用而编写的许多小程序也是如此。

    实际上,许多系统都混合使用异常和这两种替代方法。这三种方法都认为应该定义和遵守前置条件;不同之处在于如何执行以及恢复是否可行。程序结构可能会有很大不同,这取决于是否以(局部化)恢复为目标。在大多数系统中,有些异常是在没有真正期望恢复的情况下抛出的。例如,我经常抛出异常以确保记录一些错误或在终止或重新初始化进程之前生成一条合适的错误消息(例如,从 main() 中的 catch(...))。

    有多种技术可用于表达对所需条件和不变性的检查。当我们想对检查的逻辑原因保持中立时,我们通常使用断言(assert)这个词,通常缩写为断言。断言只是一种假定为true的逻辑表达式。然而,为了让断言不仅仅是一种注释,我们需要一种表达如果断言为false会发生什么的方式。通过查看各种系统,我发现在表达断言方面存在各种需求:

我们需要在编译时断言(由编译器估算)和运行时断言(在运行时估算)之间进行选择。

• 对于运行时断言,我们需要选择抛出、终止或忽略。

• 除非某些逻辑条件为true,否则不应生成任何代码。例如,除非逻辑条件为true,否则不应估算某些运行时断言。通常,逻辑条件是调试标志、检查级别或用于在要执行的断言中选择的掩码。

• 断言不应冗长或复杂(因为它们可能非常常见)。

并非每个系统都需要或支持每种替代方案。

标准提供了两种简单的机制:

• 在<cassert>中,标准库提供了assert(A)宏指令,当且仅当宏指令 NDEBUG(“未调试”)未定义时,该宏指令才会在运行时检查其断言A(§12.6.2)。如果断言失败,编译器会写出一条错误消息,其中包含(失败的)断言、源文件名和源文件行号,并终止程序。

• 该语言提供 static_assert(A,message),它在编译时无条件检查其断言A(§2.4.3.3)。如果断言失败,编译器会写出消息,编译失败。

assert()static_assert()不够用时,我们可以使用普通代码进行检查。例如:

void f(int n)

// n should be in [1:max)

{

if (2 < debug_level && (n<=0 || max<n)

throw Asser t_error("rang e problem");

// ...

}

然而,使用这种“普通代码”往往会掩盖正在测试的内容。我们是否:

• 估算我们测试的条件?(是的,2 < debug_level 部分。)

•估算预期对某些调用为true而对其他调用不为true的条件?(不是,因为我们抛出了异常——除非有人试图将异常用作另一种返回机制;§13.1.4.2。)

• 检查永远不会失败的预置的条件?(是的,异常只是我们选择的响应。)

     更糟糕的是,前提条件测试(或不变测试)很容易分散到其他代码中,因此更难发现,也更容易出错。我们想要的是一种可识别的检查断言的机制。下面是一种(可能略微过于繁琐的)机制,用于表达各种断言和对失败的各种响应。首先,我定义了决定何时测试和决定断言失败时该做什么的机制:

namespace Assert {

enum class Mode { throw_, terminate_, ignore_ };

constexpr Mode current_mode = CURRENT_MODE;

constexpr int current_level = CURRENT_LEVEL;

constexpr int default_level = 1;

constexpr bool level(int n)

{ return n<=current_level; }

struct Error : runtime_error {

Error(const string& p) :runtime_error(p) {}

};

// ...

}

这个思想是,只要断言的“级别”低于或等于 current_level,就进行测试。如果断言失败,则使用 current_mode 在三个备选方案中进行选择。current_level current_mode 是常量,因为这个思想是,除非我们已做出决定,否则不会为断言生成任何代码。想象一下,CURRENT_MODE CURRENT_LEVEL 将在程序的构建环境中设置,可能是作为编译器选项。

    程序员将使用 Assert::dynamic() 来做出断言:

namespace Assert {

// ...

string compose(const char file, int line, const string& message)

// compose message including file name and line number

{

ostringstream os ("(");

os << file << "," << line << "):" << message;

return os.str();

}

template<bool condition =level(default_level), class Except = Error>

void dynamic(bool assertion, const string& message ="Asser t::dynamic failed")

{

if (assertion)

return;

if (current_mode == Assert_mode::throw_)

throw Except{message};

if (current_mode == Assert_mode::terminate_)

std::terminate();

}

template<>

void dynamic<false ,Error>(bool, const string&) // do nothing

{

}

void dynamic(bool b, const string& s) // default action

{

dynamic<true ,Error>(b,s);

}

void dynamic(bool b) // default message

{

dynamic<true ,Error>(b);

}

}

我选择了 Assert::dynamic 这个名称(意思是“在运行时估算”)来与 static_assert(意思是“在编译时估算”;§2.4.3.3)进行对比。

    可以使用进一步的实现技巧来最大限度地减少生成的代码量。或者,如果需要更大的灵活性,我们可以在运行时进行更多测试。此断言不是标准的一部分,主要作为问题和实现技术的说明。我怀疑对断言机制的要求差异太大,以至于无法在任何地方使用单一机制。

    我们可以像这样使用 Assert::dynamic

void f(int n)

// n should be in [1:max)

{

Assert::dynamic<Asser t::level(2),Asser t::Error>(

(n<=0 || max<n), Assert::compose(__FILE__,__LINE__,"rang e problem");

// ...

}

__FILE__ __LINE__ 是宏,它们在源代码中出现时会展开(§12.6.2)。我无法通过将它们放置在 Assert 的实现中来隐藏它们,使它们不被用户看到。

    Assert::Error 是默认异常,因此我们不需要明确提及它。同样,如果我们愿意使用默认断言级别,我们不需要明确提及级别:

void f(int n)

// n should be in [1:max)

{

Assert::dynamic((n<=0 || max<n),Assert::compose(__FILE__,__LINE__,"rang e problem");

// ...

}

我不建议过分关注表达断言所需的文本量,但通过使用命名空间指令(§14.2.3)和默认消息,我们可以将文本量降至最低:

void f(int n)

// n should be in [1:max)

{

dynamic(n<=0||max<n);

// ...

}

可以通过构建选项(例如,控制条件编译)和/或程序代码中的选项来控制已完成的测试和对测试的响应。这样,您就可以拥有一个经过广泛测试并进入调试器的系统的调试版本,以及一个几乎不进行任何测试的生产版本。

    我个人倾向于在程序的最终(交付)版本中至少保留一些测试。例如,对于 Assert,明显的惯例是标记为零级的断言将始终被检查。我们永远无法在持续开发和维护的大型程序中找到最后一个错误。此外,即使所有其他工作都完美无缺,留下一些“健全性检查”来处理硬件故障也是明智的。

    只有最终完整系统的构建者才能决定失败是否可以接受。库或可重用组件的编写者通常没有无条件终止的奢侈。我的理解是,对于一般的库代码,报告错误(最好是通过抛出异常)是必不可少的。

    通常,析构函数不应该抛出,所以不要在析构函数中使用抛出 Assert()

13.5  抛出异常和捕获异常(Throwing and Catching Exceptions)

    本节从语言技术的角度介绍例外情况。

13.5.1  抛出异常(Throwing Exceptions)

    我们可以抛出任何可以复制或移动的类型的异常。例如:

class No_copy {

No_copy(const No_copy&) = delete; // 禁止复制 (§17.6.4)

};

class My_error {

// ...

};

void f(int n)

{

switch (n) {

case 0: throw My_error{}; // OK

case 1: throw No_copy{}; // 错: 不能复制 No_copy

case 2: throw My_error; // 错: My_error 是一种类型,而不是对象

}

}

捕获的异常对象(§13.5.2)原则上是被抛出的异常对象的副本(尽管优化器可以最小化复制);也就是说,抛出 x;用 x 初始化 x 类型的临时变量。这个临时变量在被捕获之前可能会被进一步复制几次:异常从被调用函数传递(返回)到调用函数,直到找到合适的处理程序。异常的类型用于在某些 try 块的 catch 子句中选择处理程序。异常对象中的数据(如果有)通常用于生成错误消息或帮助恢复。将异常从抛出点“向上”传递到处理程序的过程称为堆栈展开。在每个退出的作用域中,都会调用析构函数,以便正确销毁每个完全构造的对象。例如:

void f()

{

string name {"Byron"};

try {

string s = "in";

g();

}

catch (My_error) {

// ...

}

}

void g()

{

string s = "excess";

{

string s = "or";

h();

}

}

void h()

{

string s = "not";

throw My_error{};

string s2 = "at all";

}

h() 中抛出之后,所有已构造的字符串都按其构造的相反顺序被销毁:“not”、“or”、“excess”、“in”,但不包括“at all”,控制线程从未到达过它,也不包括“Byron”,它不受影响。

由于异常在被捕获之前可能会被复制多次,因此我们通常不会在其中放入大量数据。包含几个单词的异常非常常见。异常传播的语义是初始化的语义,因此具有移动语义的类型的对象(例如字符串)的抛出成本并不高。一些最常见的异常不包含任何信息;类型的名称足以报告错误。例如:

struct Some_error { };

void fct()

{

// ...

if (something_wrong)

throw Some_error{};

}

有一个小型标准库层次结构,其中包含异常类型(§13.5.2),可以直接使用或作为基类使用。例如:

struct My_error2 : std::runtime_error {

const char what() const noexcept { return "My_error2"; }

};

标准库异常类(例如 runtime_error out_of_range )将字符串参数作为构造函数参数,并具有将重新生成该字符串的虚拟函数 what()。例如:

void g(int n) // throw some exception

{

if (n)

throw std::runtime_error{"I give up!"};

else

throw My_error2{};

}

void f(int n) // see what exception g() throws

{

try {

void g(n);

}

catch (std::exception& e) {

cerr << e.what() << '\n';

}

}

13.5.1.1  noexcept函数

有些函数不会抛出异常,而有些函数确实不应该抛出异常。为了表明这一点,我们可以声明这样的函数为 noexcept。例如:

double compute(double) noexcept; // 可能不会抛出异常

现在 compute() 不会再出现任何异常。

    声明函数 noexcept 对于程序员推理程序和编译器优化程序来说可能是最有价值的。程序员不必担心提供 try 子句(用于处理 noexcept 函数中的故障),优化器也不必担心异常处理的控制路径。

    但是,编译器和链接器并没有完全检查 noexcept。如果程序员“撒谎”,让 noexcept 函数故意或无意地抛出一个在离开 noexcept 函数之前未被捕获的异常,会发生什么情况?考虑一下:

double compute(double x) noexcept;

{

string s = "Courtney and Anya";

vector<double> tmp(10);

// ...

}

Vector 构造函数可能无法为其十个双精度数获取内存并抛出 std::bad_alloc 异常。在这种情况下,程序终止。它通过调用 std::terminate()(§30.4.1.3) 无条件终止。它不会从调用函数调用析构函数。是否从 throw noexcept 之间的范围(例如,对于 compute() 中的 s )调用析构函数是实现定义的。程序即将终止,因此无论如何我们不应该依赖任何对象。通过添加 noexcept 指定符,我们表明我们的代码不是为应对 throw 而编写的。

13.5.1.2  noexcept运算符

    可以将函数声明为有条件的 noexcept。例如:

template<typename T>

void my_fct(T& x) noexcept(Is_pod<T>());

noexcept(Is_pod<T>()) 表示如果谓词(译注:即判断方法) Is_pod<T>() 为真,则 My_fct 可能不会抛出异常,但如果谓词 Is_pod<T>() 为假,则可能会抛出异常。如果 my_fct() 复制其参数,我可能想要编写此代码。我知道复制 POD 不会抛出异常,而其他类型(例如 string vector )可能会抛出异常。

    noexcept() 规范中的谓词必须是常量表达式。普通的 noexcept 表示noexcept(true)

    标准库提供了许多类型谓词,可用于表达函数可能引发异常的条件(§35.4)。

    如果我们想要使用的谓词不能仅使用类型谓词轻松表达,该怎么办?例如,如果可能或不可能抛出的关键操作是函数调用 f(x),该怎么办?noexcept() 运算符以表达式作为其参数,如果编译器“知道”它不能抛出,则返回 true,否则返回 false。例如:

template<typename T>

void call_f(vector<T>& v) noexcept(noexcept(f(v[0]))

{

for (auto x : v)

f(x);

}

两次提及 noexcept 看起来有点奇怪,但 noexcept 并不是一个常见的运算符。

    noexcept() 的操作数未经估算,因此在示例中,如果我们传递一个空vectorcall_f(),则不会出现运行时错误。

    noexcept(expr) 运算符不会费尽心思去确定 expr 是否会抛出异常;它只是查看 expr 中的每个操作,如果它们都具有 noexcept 规范且求值为 true,则返回 truenoexcept(expr) 不会查看 expr 中使用的操作的定义。

    条件 noexcept 规范和 noexcept() 运算符在适用于容器的标准库操作中很常见且很重要。例如(§iso.20.2.2):

template<class T, siz e_t N>

void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(a, b)));

13.5.1.3  异常的规范

    在较旧的 C++ 代码中,您可能会发现异常规范。例如:

void f(int) throw(Bad,Worse); // 仅可能抛出 Bad 或 Worse 异常

void g(int) throw(); // 可能不会抛异常

空异常规范 throw() 被定义为等同于 noexcept (§13.5.1.1)。也就是说,如果抛出异常,程序就会终止。

    非空异常规范(例如 throw(Bad,Worse))的含义是,如果函数(此处为 f())抛出列表中未提及的任何异常,或者从列表中提及的异常公开派生的任何异常,则将调用意外处理程序。意外异常的默认效果是终止程序(§30.4.1.3)。非空抛出规范很难使用,并且意味着可能需要昂贵的运行时检查来确定是否抛出了正确的异常。此功能尚未成功,已被弃用。不要使用它。

    如果您想动态检查抛出了哪些异常,请使用 try 块。

13.5.2  捕捉异常(Catching Exceptions)

    考虑:

void f()

{

try {

throw E{};

}

catch(H) {

// when do we get here?

}

}

在如下情况时调用句柄 H:

[1] 如果 HE 是同一类型

[2] 如果 HE 的明确公共基数

[3] 如果 HE 是指针类型,且 [1] 或 [2] 适用于它们引用的类型

[4] 如果 H 是引用,且 [1] 或 [2] 适用于 H 引用的类型

要原则上,异常在抛出时会被复制(§13.5)。实现可以应用多种策略来存储和传输异常。但是,可以保证有足够的内存允许 new 抛出标准的内存不足异常(out-of-memory)(bad_alloc)(§11.2.3)。

请注意通过引用捕获异常的可能性。异常类型通常被定义为类层次结构的一部分,以反映它们所代表的错误类型之间的关系。有关示例,请参见§13.5.2.3 和 §30.4.1.1。将异常类组织成层次结构的技术非常常见,以至于一些程序员更喜欢通过引用捕获每个异常。

try 块的 try 部分和 catch 子句中的 {} 都是真实作用域。因此,如果要在 try 块的两个部分或外部使用名称,则必须在 try 块外部声明该名称。例如:

void g()

{

int x1;

try {

int x2 = x1;

// ...

}

catch (Error) {

++x1; // OK

++x2; // error : x2 not in scope

int x3 = 7;

// ...

}

catch(...) {

++x3; // error : x3 not in scope

// ...

}

++x1; //OK

++x2; //error : x2 not in scope

++x3; //error : x3 not in scope

}

“捕获一切”子句 catch(...) 在 §13.5.2.2 中有解释。

13.5.2.1  重抛异常(Rethrow)

    捕获到异常后,处理程序通常会认为它无法完全处理错误。在这种情况下,处理程序通常会执行可以在局部完成的操作,然后再次抛出异常。因此,可以在最合适的地方处理错误。即使处理错误所需的信息不在单个位置可用,情况也是如此,因此最好将恢复操作分布在多个处理程序上。例如:

void h()

{

try {

// ... code that might throw an exception ...

}

catch (std::exception& err) {

if (can_handle_it_completely)

{

// ... handle it ...

return;

}

else {

// ... do what can be done here ...

throw; // rethrow the exception

}

}

}

重新抛出表示为不带操作数的抛出。重新抛出可能发生在 catch 子句中,也可能发生在从 catch 子句调用的函数中。如果在没有异常需要重新抛出的情况下尝试重新抛出,则会调用 std::terminate() (§13.5.2.5)。编译器可以检测并警告部分此类情况,但并非所有此类情况。

    重新抛出的异常是捕获的原始异常而不仅仅是可作为异常访问的部分。例如,如果抛出了 out_of_rangeh() 会将其作为普通异常捕获,但 throw; 仍会将其作为 out_of_range 重新抛出。如果我编写的是 throw err; 而不是更简单的 throw;,异常将被分片(§17.5.1.4),并且 h() 的调用者无法将其作为 out_of_range 捕获。

13.5.2.2  捕获一切异常(Catch Every Exception)

    在 <stdexcept> 中,标准库提供了一个具有通用基本异常(§30.4.1.1)的小型异常类层次结构。例如:

void m()

{

try {

// ... do something ...

}

catch (std::exception& err) { //处理一切标准异常

// ... cleanup ...

throw;

}

}

这会捕获所有标准库异常。但是,标准库异常只是一组异常类型因此,您无法通过捕获 std::exception 来捕获所有异常。如果有人(不明智地)抛出 int 或来自某些特定于应用程序的层次结构的异常,则 std::exception& 的处理程序不会捕获它。

    但是,我们经常需要处理各种异常。例如,如果 m() 应该将某些指针保留在发现它们时的状态,那么我们可以在处理程序中编写代码来为它们赋予可接受的值。对于函数,省略号 ... 表示“任何参数”(§12.2.4),因此 catch(...) 表示“捕获任何异常”。例如:

void m()

{

try {

// ... something ...

}

catch (...) { // handle every exception

// ... cleanup ...

throw;

}

}

13.5.2.3  异常多处理(Multiple Handlers)

         try 块可以有多个 catch 子句(处理程序)。由于派生异常可以被多种异常类型的处理程序捕获,因此处理程序在 try 语句中的编写顺序非常重要。处理程序按顺序尝试。例如:

void f()

{

try {

// ...

}

catch (std::ios_base::failure) {

// ... handle any iostream error (§30.4.1.1) ...

}

catch (std::exception& e) {

// ... handle any standard-librar y exception (§30.4.1.1) ...

}

catch (...) {

// ... handle any other exception (§13.5.2.2) ...

}

}

编译器知道类的层次结构,因此它可以警告许多逻辑错误。例如:

void g()

{

try {

// ...

}

catch (...) {

// ... 处理一切异常 (§13.5.2.2) ...

}

catch (std::exception& e) {

// ...处理一切标准库异常 (§30.4.1.1) ...

}

catch (std::bad_cast) {

// ... 处理 dynamic_cast失败(§22.2.1) ...

}

}

这里,异常从未被考虑过。即使我们删除了“catch-all”处理程序,bad_cast 也不会被考虑,因为它是从异常派生出来的。将异常类型与 catch分句匹配是一种(快速)运行时操作,并不像(编译时)重载解析那样通用。

13.5.2.4  函数try块(Function try-Blocks)

    函数体可以是 try 块。例如:

int main()

try

{

// ... do something ...

}

catch (...) {

// ... handle exception ...

}

对于大多数函数,使用函数 try 块所能获得的好处只是符号上的一些便利。但是,try 块 允许我们处理构造函数中基类或成员初始化器抛出的异常(§17.4)。默认情况下,如果基类或成员初始化器抛出异常,则该异常将传递给调用成员类的构造函数的任何人。但是,构造函数本身可以通过将完整的函数体(包括成员初始化器列表)封装在 try 中来捕获此类异常。例如:

class X {

vector<int> vi;

vector<string> vs;

// ...

public:

X(int,int);

// ...

};

X::X(int sz1, int sz2)

try:vi(sz1), // construct vi with sz1 ints

vs(sz2), // construct vs with sz2 strings

{

// ...

}

catch (std::exception& err) { //vi和vs抛出的异常在此处捕获

// ...

}

因此,我们可以捕获成员构造函数抛出的异常。同样,我们可以在析构函数中捕获成员析构函数抛出的异常(尽管析构函数永远不应抛出)。但是,我们无法“修复”对象并正常返回,就像异常没有发生一样:成员构造函数中的异常意味着该成员可能不处于有效状态。此外,其他成员对象要么不会被构造,要么已经在堆栈展开过程中调用了它们的析构函数。

    在构造函数或析构函数的函数 try 块的 catch 子句中我们能做的最好的事情就是抛出异常。当我们“脱离 catch 子句的末尾”时,默认操作是重新抛出原始异常(§iso.15.3)。

    普通函数的try块没有这样的限制。

13.5.2.5  终止程序

    在某些情况下,必须放弃异常处理,而采用不太精细的错误处理技术。指导原则是:

处理异常时不要抛出异常

不要抛出无法捕获的异常

    如果异常处理实现发现您正在执行其中任何一项,它将终止您的程序。

如果您设法同时激活两个异常(在同一个线程中,但您不能这样做),系统将不知道要尝试处理哪个异常:您的新异常还是它已经尝试处理的异常。请注意,异常在进入 catch 子句时被视为立即处理。重新抛出异常(§13.5.2.1)或在 catch 子句中抛出新异常被视为在原始异常处理后完成的新抛出。您可以在析构函数中抛出异常(即使在栈展开期间),只要您在它离开析构函数之前捕获它即可。

调用terminate()的具体规则是(§iso.15.5.1):

• 当未找到适合处理抛出的异常的处理程序时

• 当 noexcept 函数尝试通过 throw 退出时

• 当在堆栈展开期间调用的析构函数尝试通过 throw 退出时

• 当调用以传播异常的代码(例如,复制构造函数)尝试通过 throw 退出时

• 当有人尝试重新抛出(throw;)而当前没有正在处理的异常时

• 当静态分配或线程局部对象的析构函数尝试通过 throw 退出时

• 当静态分配或线程局部对象的初始化程序尝试通过 throw 退出时

• 当作为 atexit() 函数调用的函数尝试通过 throw 退出时

    在这种情况下,将调用函数 std::terminate()。此外,如果不太激进的方法不可行,用户可以调用terminate()

    我所说的“尝试通过抛出而退出”是指在某处抛出异常但未被捕获,因此运行时系统尝试将其从函数传到其调用者。

    默认情况下,terminate() 将调用 abort()(§15.4.3)。此默认值对于大多数用户来说都是正确的选择——尤其是在调试期间。如果这不可接受,用户可以通过从 <exception> 调用 std::set_terminate() 来提供终止处理程序函数:

using terminate_handler = void()(); // 来自 <exception>

[[noreturn]] void my_handler() // 终止处理器不能 return

{

// 按我的方式处理终止

}

void dangerous() // 非常危险!

{

terminate_handler old = set_terminate(my_handler);

// ...

set_terminate(old); // restore the old terminate handler

}

    返回值是先前赋予 set_terminate() 的函数。

例如,终止处理程序可用于中止进程或重新初始化系统。terminate() 的目的是,当异常处理机制实现的错误恢复策略失败并且需要进入容错策略的另一个级别时,应采取严厉措施。如果输入了终止处理程序,则基本上不能对程序的数据结构进行任何假设;必须假设它们已损坏。甚至使用 cerr 编写错误消息也必须假设是危险的。另外,请注意,由于 danger() 的编写方式,它不是异常安全的。在 set_terminate(old) 之前抛出或返回都会使 my_handler 留在原地,而这并不是应该的。如果您一定要滥用 terminate()不可,至少应使用 RAII(§13.3)。

终止处理程序无法返回其调用者。如果尝试这样做,terminate() 将调用 abort()

请注意,abort() 表示程序异常退出。exit() 函数可用于退出程序,并返回一个值,该值向周围系统指示退出是正常退出还是异常退出(§15.4.3)。

当程序因未捕获的异常而终止时,是否调用析构函数由实现定义。在某些系统上,一定不调用析构函数,以便程序可以从调试器中恢复。在其他系统上,从架构上讲,在搜索处理程序时不调用析构函数几乎是不可能的。

如果您想确保在发生未捕获的异常时进行清理,除了您真正关心的异常处理程序之外,您还可以向 main() 添加一个捕获所有异常的处理程序(§13.5.2.2)。例如:

int main()

try {

// ...

}

catch (const My_error& err) {

// ... handle my error ...

}

catch (const std::range_error&)

{

cerr << "range error: Not again!\n";

}

catch (const std::bad_alloc&)

{

cerr << "new ran out of memory\n";

}

catch (...) {

// ...

}

这将捕获所有异常,除了命名空间和线程局部变量的构造和销毁所引发的异常(§13.5.3)。无法捕获在初始化或销毁命名空间和线程局部变量期间引发的异常。这是尽可能避免使用全局变量的另一个原因。

当捕获异常时,通常无法知道异常被抛出的确切位置。与调试器可能了解的程序状态相比,这代表信息丢失。因此,在某些 C++ 开发环境中,对于某些程序和某些人来说,最好不要捕获程序无法恢复的异常。

请参阅 Assert(§13.4)以获取有关如何将 throw 的位置编码到抛出的异常中的示例。

13.5.3  异常和线程

如果在线程上未捕获异常(§5.3.1,§42.2),则系统会调用 std::terminate()(§13.5.2.5)。因此,如果我们不希望线程中的错误停止整个程序,我们必须捕获我们想要恢复的所有错误,并以某种方式将它们报告给对线程结果感兴趣的程序部分。“捕获一切异常”构造 catch(...)(§13.5.2.2)对此非常有用。

我们可以使用标准库函数 current_exception() (§30.4.1.2) 将一个线程上抛出的异常转移到另一个线程上的处理程序。例如:

try {

// ... do the wor k ...

}

catch(...) {

prom.set_exception(current_exception());

}

这是 packaged_task 用来处理用户代码异常的基本技术(§5.3.5.2)。

13.6  一个vector的实现

    标准 vector 提供了编写异常安全代码的技术的精彩示例:它的实现说明了许多情况下出现的问题以及广泛适用的解决方案。

    显然,vector实现依赖于许多语言工具,这些工具支持类的实现和使用。如果您还不熟悉 C++ 的类和模板,您可能希望推迟学习此示例,直到您阅读完第 16 章、第 25 章和第 26 章。但是,要很好地理解 C++ 中异常的使用,我们需要比本章迄今为止的代码片段更广泛的示例。

编写异常安全代码可用的基本工具是

try 块(§13.5)。

• 支持“资源获取即初始化”技术(§13.3)。

要遵循的一般原则是

• 在其替代品可供使用之前,切勿放弃某条信息。

• 抛出或重新抛出异常时,始终让对象处于有效状态。

    编写库时,理想的做法是力求提供强大的异常安全保证(§13.2),并始终提供基本保证。编写特定程序时,可能不太关心异常安全。例如,如果我为自己编写一个简单的数据分析程序,我通常很愿意让程序在内存耗尽的不太可能发生的情况下终止。

    正确性和基本异常安全性密切相关。具体来说,提供基本异常安全性的技术(例如定义和检查不变量(§13.4))与使程序小巧而正确的技术类似。因此,提供基本异常安全性保证(§13.2)甚至强保证的开销可能很小,甚至微不足道。

13.6.1  一个简单的vector

    vector (§4.4.1,§31.4) 的典型实现将由一个句柄组成,该句柄保存指向第一个元素、最后一个元素以及最后一个分配空间 (§31.2.1) 的指针(或以指针加偏移量表示的等效信息):

此外,它还拥有一个分配器(此处为 alloc),vector 可以从中为其元素获取内存。默认分配器(§34.4.1)使用 newdelete 来获取和释放内存。

    以下是简化的vector声明,仅显示讨论异常安全性和避免资源泄漏所需的内容:

template<class T, class A = allocator<T>>

class vector {

private:

T elem; // star t of allocation

T space; // 元素序列尾, 可能的扩展空间分配的起始地址

T last; // 分配的空间尾

A alloc; // allocator

public:

using size_type = unsigned int; // 用于vector大小的类型

explicit vector(size_type n, const T& val = T(), const A& = A());

vector(const vector& a); // copy constr uctor

vector& operator=(const vector& a); // copy assignment

vector(vector&& a); // move constr uctor

vector& operator=(vector&& a); // move assignment

˜vector();

size_type siz e() const { return spaceelem; }

size_type capacity() const { return lastelem; }

void reserve(siz e_typen); //increase capacity to n

void resize(siz e_type n, const T& = {}); // 递增大小至n

void push_back(const T&); // 追加元素至序列尾

// ...

};

    首先考虑构造函数的一个简单实现,该构造函数将vector初始化为 n 个元素,并初始化为 val

template<class T, class A>

vector<T,A>::vector(siz e_type n, const T& val, const A& a) // 警告: 裸实现

:alloc{a} //复制分配器

{

elem = alloc.allocate(n); // 获得元素内存 (§34.4)

space = last = elem+n;

for (T p = elem; p!=last; ++p)

a.construct(p,val); // 用 *p 构造 val 的副本(§34.4)

}

这里有两个潜在的异常来源:

[1] 如果没有可用内存,allocate() 可能会抛出异常。

[2] 如果无法复制 valT 的复制构造函数可能会抛出异常。

    那么分配器的副本呢?我们可以想象它会抛出异常,但标准明确要求它不能这样做(§iso.17.6.3.5)。无论如何,我已经编写了即使它抛出异常也无影响的代码。

在两种抛出情况下,都不会创建vector对象,因此不会调用vector的析构函数(§13.3)。

    当 allocate() 失败时,throw 将在获取任何资源之前退出,因此一切正常。

    当 T 的复制构造函数失败时,我们获得了一些必须释放的内存,以避免内存泄漏。更糟糕的是,T 的复制构造函数可能会在正确构造几个元素之后但在构造所有元素之前抛出异常。这些 T 对象可能拥有随后将被泄漏的资源。

    为了解决这个问题,我们可以跟踪已构建的元素,并在发生错误时销毁这些元素(且只销毁这些元素):

template<class T, class A>

vector<T,A>::vector(siz e_type n, const T& val, const A& a) // 细致的实现

:alloc{a} //copy the allocator

{

elem = alloc.allocate(n); // 获得元素所需内存

iterator p;

try {

iterator end = elem+n;

for (p=elem; p!=end; ++p)

alloc.construct(p,val); // 构造元素 (§34.4)

last = space = p;

}

catch (...) {

for (iterator q = elem; q!=p; ++q)

alloc.destroy(q); //销毁构造元素

alloc.deallocate(elem,n); // free memory

throw; // rethrow

}

}

请注意,p 的声明在 try 块之外;否则,我们将无法在 try 部分和 catch 分句中访问它。

该构造函数的主要部分是 std::uninitialized_fill() 的实现的重复:

template<class For, class T>

void uninitialized_fill(For beg, For end, const T& x)

{

For p;

try {

for (p=beg; p!=end; ++p)

::new(static_cast<void>(&p)) T(x); // 用 *p 构造 val 的副本(§11.2.4)

}

catch (...) {

for (For q = beg; q!=p; ++q)

(&q)>˜T(); //destroy element (§11.2.4)

throw; // rethrow (§13.5.2.1)

}

}

奇怪的构造 &p 负责处理非指针的迭代器。在这种情况下,我们需要获取通过解引用获得的元素的地址来获取指针。与显式全局 ::new 一起,显式转换为 void 可确保使用标准库放置函数 (§17.2.4) 来调用构造函数,而不是 Ts 的某些用户定义的运算符 new()vector构造函数中对 alloc.construct() 的调用只是此放置 new 的语法糖(syntactic sugar)。类似地,alloc.destroy() 调用只是隐藏了显式销毁(如 (&q)>˜T())。此代码在相当低的级别上运行,因此编写真正通用的代码可能很困难。

幸运的是,我们不必发明或实现 uninitialized_fill(),因为标准库提供了它(§32.5.6)。初始化操作通常是必不可少的,要么成功完成,初始化每个元素,要么失败,不留下任何构造元素。因此,标准库提供了 uninitialized_fill()uninitialized_fill_n() uninitialized_copy()(§32.5.6),它们提供了强有力的保证(§13.2)。

uninitialized_fill() 算法无法防止元素析构函数或迭代器操作(§32.5.6)抛出的异常。这样做成本过高,甚至可能不可能。

uninitialized_fill() 算法可应用于多种序列。因此,它采用前向迭代器 (§33.1.2),并且不能保证以与构造顺序相反的顺序销毁元素。

使用 uninitialized_fill(),我们可以简化我们的构造函数:

template<class T, class A>

vector<T,A>::vector(siz e_type n, const T& val, const A& a)//仍有一点混乱

:alloc(a) //复制allocator

{

elem = alloc.allocate(n); // 获得元素内存

try {

uninitialized_fill(elem,elem+n,val); // 复制元素

space = last = elem+n;

}

catch (...) {

alloc.deallocate(elem,n); // 释放内存

throw; // 重抛

}

}

这是对该构造函数的第一个版本的重大改进,但下一节将演示如何进一步简化它。

    构造函数重新抛出捕获的异常。目的是使 vector 对异常透明,以便用户可以确定问题的确切原因。所有标准库容器都具有此属性。异常透明性通常是模板和其他“薄”软件层的最佳策略。这与系统的主要部分(“模块”)形成对比,后者通常需要对所有抛出的异常负责。也就是说,此类模块的实现者必须能够列出模块可能抛出的每个异常。实现这一点可能涉及将异常分组为层次结构(§13.5.2)并使用 catch(...)(§13.5.2.2)。

13.6.2  显式表示内存

    经验表明,使用显式 try 块编写正确的异常安全代码比大多数人预期的要困难。事实上,这没有必要那么困难,因为还有另一种选择:“资源获取即初始化”技术(§13.3)可用于减少必须编写的代码量并使代码更加程式化(stylized)。在这种情况下,vector所需的关键资源是用于保存其元素的内存。通过提供一个辅助类来表示vector使用的内存概念,我们可以简化代码并减少意外忘记释放它的机会:

template<class T, class A = allocator<T> >

struct vector_base { // memor y str ucture for vector

A alloc; // allocator

T elem; // 应用起点

T space; // 元素序列终点,可能的扩展空间分配的起点

T last; // 分配空间的终点

vector_base(const A& a, typename A::size_type n)

: alloc{a}, elem{alloc.allocate(n)}, space{elem+n}, last{elem+n} { }

˜vector_base() { alloc.deallocate(elem,lastelem); }

vector_base(const vector_base&) = delete; // 无复制操作

vector_base& operator=(const vector_base&) = delete;

vector_base(vector_base&&); // 移动操作

vector_base& operator=(vector_base&&);

};

只要 elem last 正确,vector_base 就可以被销毁。vector_base 类处理的是类型 T 的内存,而不是类型 T 的对象。因此,vector_base 的用户必须在分配的空间中显式构造所有对象,然后在 vector_base 本身被销毁之前销毁 vector_base 中所有构造的对象。

    vector_base 是专门为 vector 实现的一部分而设计的。总是很难预测一个类将在何处以及如何使用,因此我确保 vector_base 不能被复制,并且 vector_base 的移动可以正确转移为元素分配的内存的所有权:

template<class T, class A>

vector_base<T,A>::vector_base(vector_base&& a)

: alloc{a.alloc},

elem{a.elem},

space{a.space},

last{a.space}

{

a.elem = a.space = a.last = nullptr; // 不再拥有任何内存

}

template<class T, class A>

vector_base<T,A>::& vector_base<T,A>::operator=(vector_base&& a)

{

swap(this,a);

return this;

}

    此移动赋值定义使用 swap() 来转移为元素分配的任何内存的所有权。没有要销毁的 T 类型对象:vector_base 处理内存并将对 T 类型对象的关注留给 vector

    给定vector_basevector可以像这样定义:

template<class T, class A = allocator<T> >

class vector {

vector_base<T,A> vb; // 数据在这儿

void destroy_elements();

public:

using size_type = unsigned int;

explicit vector(size_type n, const T& val = T(), const A& = A());

vector(const vector& a); // 复制构造函数

vector& operator=(const vector& a); // 复制赋值

vector(vector&& a); // 移动构造函数

vector& operator=(vector&& a); // 移动赋值

˜vector() { destroy_elements(); }

size_type siz e() const { return vb.spacevb.elem; }

size_type capacity() const { return vb.lastvb.elem; }

void reserve(siz e_type); // increase capacity

void resize(siz e_type, T = {}); //改变元素数目

void clear() { resize(0); } // vector置空

void push_back(const T&); // 在尾部追加元素

// ...

};

template<class T, class A>

void vector<T,A>::destroy_elements()

{

for (T p = vb.elem; p!=vb.space; ++p)

p>˜T(); //destroy element (§17.2.4)

vb.space=vb.elem;

}

向量析构函数会为每个元素显式调用 T 析构函数。这意味着,如果元素析构函数抛出异常,向量析构就会失败。如果在由异常引起的栈展开期间发生这种情况并调用了terminate()(§13.5.2.5),则可能会造成灾难。在正常析构的情况下,从析构函数抛出异常通常会导致资源泄漏和依赖于对象合理行为的代码的不可预测行为。目前没有真正好的方法来防止从析构函数抛出的异常,因此如果元素析构函数抛出异常,库将不提供任何保证(§13.2)。

    现在可以简单地定义构造函数:

template<class T, class A>

vector<T,A>::vector(siz e_type n, const T& val, const A& a)

:vb{a,n} // 为 n 个元素分配空间

{

uninitialized_fill(vb.elem,vb.elem+n,val); // 执行val的n个复制

}

此构造函数所实现的简化延续到处理初始化或分配的每个 vector 操作。例如,复制构造函数的主要不同之处在于使用 uninitialized_copy() 而不是 uninitialized_fill()

template<class T, class A>

vector<T,A>::vector(const vector<T,A>& a)

:vb{a.alloc,a.size()}

{

uninitialized_copy(a.begin(),a.end(),vb.elem);

}

这种构造函数样式依赖于基本语言规则,即当构造函数抛出异常时,已完全构造的子对象(包括基类)将被正确销毁(§13.3)。uninitialized_fill() 算法及其同类算法(§13.6.1)为部分构造的序列提供了等效保证。

    移动操作更加简单:

template<class T, class A>

vector<T,A>::vector(vector&& a) // 移动构造函数

:vb{move(a.vb)} // 转移所有权

{

}

vector_base 移动构造函数将参数的表示置为“空”。

    对于移动赋值,我们必须注意目标的旧值:

template<class T, class A>

vector<T,A>::& vector<T,A>::operator=(vector&& a) // 移动赋值

{

clear(); //销毁 元素

swap(this,a); // 转移所有权

}

严格来说,clear() 是多余的,因为我可以假设右值 a 将在赋值后立即被销毁。但是,我不知道是否有程序员一直在玩 std::move() 的游戏。

13.6.3  赋值

    像往常一样,赋值与构造不同,因为必须处理旧值。首先考虑一个简单的实现:

template<class T, class A>

vector<T,A>& vector<T,A>::operator=(const vector& a) //提供强保证(§13.2)

{

vector_base<T,A> b(alloc,a.size()); // get memory

uninitialized_copy(a.begin(),a.end(),b.elem); // copy elements

destroy_elements(); //destroy old elements

swap(vb,b); //transfer ownership

return this; //隐式销毁旧值

}

    这个 vector 赋值提供了强保证,但它重复了大量构造函数和析构函数的代码。我们可以避免重复:

template<class T, class A>

vector<T,A>& vector<T,A>::operator=(const vector& a) //提供强保证(§13.2)

{

vector temp {a}; // 复制分配器

std::swap(this,temp); // swap表示

return this;

}

旧元素被 temp 的析构函数销毁,用于保存它们的内存被 temp vector_base 的析构函数释放。

    标准库 swap() (§35.5.2) 适用于 vector_bases 的原因是我们定义了vector_base 移动操作供 swap() 使用。

两个版本的性能应该是相同的。在本质上,它们只是指定同一组操作的两种不同方式。但是,第二种实现更短,并且不会复制相关 vector 函数的代码,因此以这种方式写入赋值应该不容易出错,并且维护起来更简单。

    请注意,我没有测试自我赋值,例如 v=v= 的这种实现通过首先构造一个副本然后交换表示来工作。这显然可以正确处理自我赋值。我认为,在极少数自我赋值情况下通过测试获得的效率被在分配不同vector的常见情况下的成本所抵消。

    无论哪种情况,都缺少两个潜在的重要优化:

[1] 如果被赋值的 vector 的容量足够大,可以容纳给到赋值的vector,我们就不需要分配新的内存。

[2] 元素赋值可能比元素销毁后再构造元素更有效率。

实施这些优化后,我们得到:

template<class T, class A>

vector<T,A>& vector<T,A>::operator=(const vector& a) // 优化,仅是基本保证 (§13.2)

{

if (capacity() < a.size()) { // 分配新的 vector 表示:

vector temp {a}; // 复制分配器

swap(this,temp); // swap 表示

return this; //隐式销毁旧值

}

if (this == &a) return this; //优化自我赋值

size_type sz = size();

size_type asz = a.size();

vb.alloc = a.vb.alloc; // 复制分配器

if (asz<=sz) {

copy(a.begin(),a.begin()+asz,vb.elem);

for (T p = vb.elem+asz; p!=vb.space; ++p) // 销毁余下元素(§16.2.6)

p>˜T();

}

else {

copy(a.begin(),a.begin()+sz,vb.elem);

uninitialized_copy(a.begin()+sz,a.end(),vb.space); //构造额外元素

}

vb.space = vb.elem+asz;

return this;

}

这些优化并非毫无代价。显然,代码的复杂性要高得多。在这里,我还测试了自我赋值。不过,我这样做主要是为了展示它是如何完成的,因为这里只是一种优化。

    copy() 算法(§32.5.1)不提供强异常安全保证。因此,如果 T::operator=() copy() 期间抛出异常,则赋值的 vector 不必是被赋值的 vector 的副本,也不必保持不变。例如,前五个元素可能是被赋值的 vector 元素的副本,其余元素保持不变。元素( T::operator=() 抛出异常时被复制的元素)最终的值既不是旧值也不是被赋值的vector中相应元素的副本,这也是合理的。但是,如果 T::operator=() 在抛出异常之前将其操作数保持在有效状态(应该如此),则 vector 仍处于有效状态——即使它不是我们想要的状态。

    标准库 vector 赋值提供了最后一种实现的(较弱的)基本异常安全保证——以及其潜在的性能优势。如果您需要一个在抛出异常时保持vector不变的赋值,则必须使用提供强保证的库实现或提供您自己的赋值操作。例如:

template<class T, class A>

void safe_assign(vector<T,A>& a, const vector<T,A>& b) // simple a = b

{

vector<T,A> temp{b}; // 复制 b 的元素进临时变量

swap(a,temp);

}

或者,我们可以简单地使用按值调用(§12.2):

template<class T, class A>

void safe_assign(vector<T,A>& a, vector<T,A> b) //复制a=b(注: b通过值传递)

{

swap(a,b);

}

我从来都无法确定这个最新版本对于真正的(可维护的)代码来说是否仅仅是漂亮还是太聪明了。

 

13.6.4  改变大小

    vector 最有用的方面之一是,我们可以根据需要更改其大小。用于更改大小的最常用函数是 v.push_back(x),它在 v 的末尾添加一个 x,以及 v.resize(s),它使 s 成为 v 中元素的数量。

13.6.4.1  reserve()函数

    简单实现此类函数的关键是 reserve(),它在末尾添加可用空间,以供向量增长。换句话说,reserve() 增加了向量的 capacity()。如果新分配的内存大于旧分配的内存,reserve() 需要分配新内存并将元素移入其中。我们可以尝试未优化赋值中的技巧(§13.6.3):

template<class T, class A>

void vector<T,A>::reser ve(size_type newalloc) // 第一次尝试失败

{

if (newalloc<=capacity()) return; // 从不减少分配

vector<T,A> v(capacity()); // 用新的容量创建一个 vector

copy(elem,elem+siz e(),v.begin()) //复制元素

swap(this,v); //设置新值

} // 隐式释放旧值

这具有提供强保证的良好特性。但是,并非所有类型都有默认值,因此此实现存在缺陷。此外,循环遍历元素两次,首先是默认构造,然后是复制,这有点奇怪。所以让我们优化一下:

template<class T, class A>

void vector<T,A>::reser ve(size_type newalloc)

{

if (newalloc<=capacity()) return; // 永不减少分配

vector_base<T,A> b {vb.alloc,newalloc}; // 获得新空间

uninitialized_move(elem,elem+siz e(),b.elem); //移动元素

swap(vb,b); //设置新值

}   // 隐式释放旧空间

问题是标准库不提供 uninitialized_move(),所以我们必须自己编写:

template<typename In, typename Out>

Out uninitialized_move(In b, In e, Out oo)

{

for (; b!=e; ++b,++oo) {

new(static_cast<void>(&oo)) T{move(b)}; // 移动构造

b>˜T(); //destroy

}

return b;

}

    一般来说,没有办法从失败的移动中恢复原始状态,所以我不会尝试。这个 uninitialized_move() 只提供基本保证。但是,它很简单,而且在绝大多数情况下它都很快。此外,标准库 reserve() 只提供基本保证。

    每当 reserve() 移动元素时vector 中的任何迭代器都可能失效(§31.3.3)。

请记住,移动操作不应抛出异常。在极少数情况下,移动的明显实现可能会抛出异常,我们通常会竭尽全力避免这种情况。移动操作抛出的异常很少见、出乎意料,并且会损害代码的正常推理。如果可能的话,请避免这种情况。标准库 move_if_noexcept() 操作可能对此有所帮助(§35.5.1)。

需要明确使用 move(),因为编译器不知道 element[i] 即将被销毁。

13.6.4.2  resize()函数

    vector 成员函数 resize() 可改变元素的数量。给定 reserve(),实现 resize() 相当简单。如果元素数量增加,我们必须构造新的元素。相反,如果元素数量减少,我们必须销毁多余的空元素空间:

template<class T, class A>

void vector<T,A>::resiz e(size_type newsiz e, const T& val)

{

reserve(newsiz e);

if (size()<newsiz e)

uninitialized_fill(elem+size(),elem+newsiz e,val); // 构造新的元素: [size():newsize]

else

destroy(elem.siz e(),elem+newsiz e); // 销毁空元素空间: [newsize:size()]

vb.space = vb.last = vb.elem+newsize;

}

没有标准的 destroy() 方法,但是可以很容易地写出来:

template<typename In>

void destroy(In b, In e)

{

for (; b!=e; ++b) // destroy [b:e)

b>˜T();

}

13.6.4.3  push_back()函数

    从异常安全的角度来看,push_back() 与赋值类似,我们必须注意,如果我们无法添加新元素,vector 仍保持不变:

template< class T, class A>

void vector<T,A>::push_back(const T& x)

{

if (capacity()==size()) // 没有更多自由空间; 重分配:

reserve(sz?2sz:8); //增长或从8开始

vb.alloc.construct(&vb.elem[size()],val); // 在尾部添加 val

++vb.space; //递增大小

}

当然,用于初始化 space 的复制构造函数可能会抛出异常。如果发生这种情况,vector的值保持不变,空间不会增加。但是,reserve() 可能已经重新分配了现有元素。

   push_back() 的定义包含两个“魔法数字”(2 和 8)。工业级实现不会这样做,但它仍然具有确定初始分配大小(此处为 8)和增长率(此处为 2,表示每次向量溢出时大小加倍)的值。实际上,这些值并非不合理或不常见的值。假设,一旦我们看到一个vectorpush_back(),我们几乎肯定会看到更多。因子 2 大于数学上最小化平均内存使用量的最佳因子(1.618),从而为内存不小的系统提供更好的运行时性能。

13.6.4.4  最后的思考

    请注意,vector 实现中没有 try 块(除了隐藏在 uninitialized_copy() 中的块)。状态的改变是通过仔细排序操作来完成的,这样如果抛出异常,vector 保持不变或至少有效。

    通过排序和 RAII 技术 (§13.3) 获得异常安全性的方法往往比使用 try 块明确处理错误更优雅、更高效。程序员以不合适的方式对代码进行排序比缺乏特定的异常处理代码更容易引发异常安全性问题。排序的基本规则是,在构建替换信息完成且无需异常可能性就可赋值之前不要破坏信息。

异常会以意外的控制流形式引入意外的可能性。对于具有简单局部控制流的一段代码,例如 reserve()safe_assign()push_back() 示例,发生意外的机会有限。查看此类代码并问“这行代码会抛出异常吗?如果抛出异常会发生什么?”相对简单。对于具有复杂控制结构(例如复杂的条件语句和嵌套循环)的大型函数,这可能很难。添加 try 块会增加这种局部控制结构的复杂性,因此可能成为混乱和错误的根源(§13.3)。我推测,与更广泛使用 try 块相比,排序方法和 RAII 方法的有效性源于局部控制流的简化。简单、格式化的代码更容易理解、更容易正确,也更容易生成好的代码。

vector 实现是作为异常可能造成的问题和解决这些问题的技术的示例而提出的。该标准不要求实现与此处介绍的实现完全相同。但是,该标准确实需要示例提供的异常安全保证。

13.7  建议

[1] 在设计早期制定错误处理策略;§13.1。

[2] 抛出异常以表明您无法执行分配的任务;§13.1.1。

[3] 使用异常进行错误处理;§13.1.4.2。

[4] 使用专门设计的用户定义类型作为异常(而非内置类型);§13.1.1。

[5] 如果由于某种原因不能使用异常,请模拟它们;§13.1.5。

[6] 使用分层错误处理;§13.1.6。

[7] 保持错误处理的各个部分简单;§13.1.6。

[8] 不要试图在每个函数中捕获每个异常;§13.1.6。

[9] 始终提供基本保证;§13.2、§13.6。

[10] 除非有理由不提供强有力的保证;§13.2、§13.6。

[11] 让构造函数建立一个不变量,如果不能,则抛出;§13.2。

[12] 在抛出异常之前释放本地拥有的资源;§13.2。

[13] 确保在构造函数中抛出异常时释放构造函数中获取的每个资源;§13.3。

[14] 如果本地控制结构足够,则不要使用异常;§13.1.4。

[15] 使用“资源获取即初始化”技术来管理资源;§13.3。

[16] 尽量减少使用 try 块;§13.3。

[17] 并非每个程序都需要异常安全;§13.1。

[18] 使用“资源获取即初始化”和异常处理程序来维护不变量;

§13.5.2.2。

[19] 优先使用适当的资源句柄,而不是结构较差的 finally;§13.3.1。

[20] 围绕不变量设计错误处理策略;§13.4。

[21] 可以在编译时检查的内容通常最好在编译时检查(使用 static_assert);§13.4。

[22] 设计错误处理策略以允许不同级别的检查/执行;§13.4。

[23] 如果您的函数可能不会抛出,请将其声明为 noexcept;§13.5.1.1

[24] 不要使用异常规范;§13.5.1.3。

[25] 捕获可能通过引用成为层次结构一部分的异常;§13.5.2。

[26] 不要假设每个异常都来自类 exception;§13.5.2.2。

[27] 让 main() 捕获并报告所有异常;§13.5.2.2、§13.5.2.4。

[28] 在准备好替换信息之前,不要销毁信息;§13.6。

[29] 在从赋值中抛出异常之前,将操作数保持在有效状态;§13.2。

[30] 永远不要让异常从析构函数中逃逸; §13.2.

[31] 将普通代码和错误处理代码分开;§13.1.1、§13.1.4.2。

[32] 谨防因发生异常而未释放 new 分配的内存而导致的内存泄漏;§13.3。

[33] 假设函数可能抛出的每个异常都将被抛出;§13.2。

[34] 库不应单方面终止程序。相反,抛出异常并让调用者决定;§13.4。

[35] 库不应生成针对最终用户的诊断输出。相反,抛出异常并让调用者决定;§13.1.3。

 

内容来源:

<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.xdnf.cn/news/147096.html

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

相关文章

DAY78服务攻防-数据库安全RedisCouchDBH2database未授权访问CVE 漏洞

知识点&#xff1a; 1、数据库-Redis-未授权RCE&CVE 2、数据库-Couchdb-未授权RCE&CVE 3、数据库-H2database-未授权RCE&CVE 前置知识 1、复现环境&#xff1a;Vulfocus(官方在线的无法使用&#xff0c;需要自己本地搭建) 官方手册&#xff1a;https://fofapr…

老牛码看JAVA行业现状

一、坏消息深化与反思&#xff1a; 1、技术瓶颈与框架局限&#xff1a;尽管低代码平台崭露头角&#xff0c;为开发效率带来新气象&#xff0c;但其全面普及尚需时日&#xff0c;Java技术栈的进化似乎陷入了暂时的停滞。开发者们渴望突破&#xff0c;却发现传统框架与模式已难以…

博图V16升级V19前后内存对比

升级V19后固件版本更新到4.6 存储存储空间拓展50% 下图是官方解释 打开博图查看前后对比

[笔记]某变频器,功能列表及参数表

产品代号&#xff1a;INVT GOODDRIVE&#xff0c;这家公司我的产品我似乎在特检院看到过&#xff1f;或者在某个地铁建设工地看到过。是深圳的。 1.产品功能点&#xff1a; 变频锥形电机控制、抱闸转矩验证&#xff1f;抱闸反馈零位检测行程限位超载防护轻载升速&#xff08;…

【超详细】基于YOLOv8训练无人机视角Visdrone2019数据集

主要内容如下&#xff1a; 1、Visdrone2019数据集介绍 2、下载、制作YOLO格式训练集 3、模型训练及预测 4、Onnxruntime推理 运行环境&#xff1a;Python3.8&#xff08;要求>3.8&#xff09;&#xff0c;torch1.12.0cu113&#xff08;要求>1.8&#xff09;&#xff0c…

8. 防火墙

8. 防火墙 (1) 防火墙的类型和结构 防火墙的类型和结构可以根据其在网络协议栈中的过滤层次和实现方式进行分类。常见的防火墙类型包括: 包过滤防火墙:工作在网络层(OSI模型的第3层),主要检查IP包头的信息,如源地址、目的地址、端口号等。电路级网关防火墙:工作在会话层…

idea2021git从dev分支合并到主分支master

1、新建分支 新建一个名称为dev的分支&#xff0c;切换到该分支下面&#xff0c;输入新内容 提交代码到dev分支的仓库 2、切换分支 切换到主分支&#xff0c;因为刚刚提交的分支在dev环境&#xff0c;所以master是没有 3、合并分支 点击push&#xff0c;将dev里面的代码合并到…

对时间序列SOTA模型Patch TST核心代码逻辑的解读

前言 Patch TST发表于ICLR23&#xff0c;其优势在于保留了局部语义信息&#xff1b;更低的计算和内存使用量&#xff1b;模型可以关注更长的历史信息&#xff0c;Patch TST显著提高了时序预测的准确性&#xff0c;Patch可以说已成为时序模型的基本操作。我在先前的一篇文章对P…

【掘金量化使用技巧】用日线合成长周期k线

掘金API中的接口最长的周期是‘1d’的&#xff0c;因此周线/月线/年线等数据需要自己进行合成。 基本思路 用日线合成长周期的k线只需要确定好合成的周期以及需要的数据即可。 周期: 一般行情软件上提供年k、月k、周k&#xff0c;我也选择年、月、周再加一个季度频率。 数据:…

Linux:终端(terminal)与终端管理器(agetty)

终端的设备文件 打开/dev目录可以发现其中有许多字符设备文件&#xff0c;例如对于我的RedHat操作系统&#xff0c;拥有tty0到tty59&#xff0c;它们是操作系统提供的终端设备。对于tty1-tty12使用ctrlaltF*可以进行快捷切换&#xff0c;下面的命令可以进行通用切换。 sudo ch…

GPU加速时代:如何用CuPy让你的Python代码飞起来?

你是不是也有这样的感受:明明写的Python代码很简洁,用NumPy处理数据也很方便,可是一跑起来就慢得像乌龟?尤其是当你面对庞大的数据集时,光是等结果出来,就已经耗掉大半天了。其实,我以前也是这么干的,直到我发现了CuPy,一个能让NumPy飞速跑起来的GPU加速神器。 你…

10. 排序

一、排序的概念及引用 1. 排序的概念 排序&#xff1a;所谓排序&#xff0c;就是使一串记录&#xff0c;按照其中的某个或某些关键字的大小&#xff0c;递增或递减的排列起来的操作。 稳定性&#xff1a;假定在待排序的记录序列中&#xff0c;存在多个具有相同的关键字的记录…

基于SpringBoot的医院管理系统【附源码】

基于SpringBoot的医院管理系统&#xff08;源码L文说明文档&#xff09; 目录 4 系统设计 4.1 系统概述 4系统概要设计 4.1概述 4.2系统结构 4.3.数据库设计 4.3.1数据库实体 4.3.2数据库设计表 5系统详细实现 5.1 医生模块的实现 5.1.…

Mybatis 返回 Map 对象

一、场景介绍 假设有如下一张学生表&#xff1a; CREATE TABLE student (id int NOT NULL AUTO_INCREMENT COMMENT 主键,name varchar(100) NOT NULL COMMENT 姓名,gender varchar(10) NOT NULL COMMENT 性别,grade int NOT NULL COMMENT 年级,PRIMARY KEY (id) ) ENGINEInnoD…

【RocketMQ】一、基本概念

文章目录 1、举例2、MQ异步通信3、背景4、Rocket MQ 角色概述4.1 主题4.2 队列4.3 消息4.4 生产者4.5 消费者分组4.6 消费者4.7 订阅关系 5、消息传输模型5.1 点对点模型5.2 发布订阅模型 1、举例 以坐火车类比MQ&#xff1a; 安检大厅就像是一个系统的门面&#xff0c;接受来…

整合多方大佬博客以及视频 一文读懂 servlet

参考文章以及视频 文章&#xff1a; 都2023年了&#xff0c;Servlet还有必要学习吗&#xff1f;一文带你快速了解Servlet_servlet用得多吗-CSDN博客 【计算机网络】HTTP 协议详解_3.简述浏览器请求一个网址的过程中用到的网络协议,以及协议的用途(写关键点即可)-CSDN博客 【…

大数据可视化-三元图

三元图是一种用于表示三种变量之间关系的可视化工具&#xff0c;常用于化学、材料科学和地质学等领域。它的特点是将三个变量的比例关系在一个等边三角形中展示&#xff0c;使得每个点的位置代表三个变量的相对比例。 1. 结构 三个角分别表示三个变量的最大值&#xff08;通常…

TikTok流量不佳:是网络环境选择不当还是其他原因?

TikTok&#xff0c;作为全球短视频社交平台的佼佼者&#xff0c;每天都有海量的内容被上传和分享。然而&#xff0c;很多用户和内容创作者发现&#xff0c;他们的TikTok视频流量并不理想。这引发了一个问题&#xff1a;TikTok流量不佳&#xff0c;是因为网络环境选择不当&#…

Lumos学习王佩丰Excel第十五讲:条件格式与公式

一、使用简单的条件格式 1、为特定范围的数值标记特殊颜色 条件格式-需选择设定范围&#xff08;大于/小于/介于/......&#xff09;&#xff1a; 数值会动态根据条件判断更新颜色&#xff1a; 模糊匹配&#xff0b;条件格式&#xff1a;选择包含部分文本的特殊值 2、查找重复…

【BurpSuite】Cross-site scripting (XSS 学徒部分:1-9)

&#x1f3d8;️个人主页&#xff1a; 点燃银河尽头的篝火(●’◡’●) 如果文章有帮到你的话记得点赞&#x1f44d;收藏&#x1f497;支持一下哦 【BurpSuite】Cross-site scripting (XSS 学徒部分:1-9&#xff09; 实验一 Lab: Reflected XSS into HTML context with nothing…