三. 异常
条款9: 利用destructors避免泄露资源
在编程中,"资源"可以指任何系统级的有限资源,如内存、文件句柄、网络套接字等。"泄露"则是指在应用程序中分配了资源,但在不再需要这些资源时没有正确地释放它们。这种状况常常会导致资源过度使用,如内存溢出,这个问题在长时间运行的程序中尤其严重。
而"利用destructors避免泄露资源"则是一种建议。在C++中,析构函数(Destructor) 是一种特殊的成员函数,它会在每次删除所创建的对象时执行。
匆忙地,可以在析构函数中清理和释放资源,这样当对象的生命周期结束时,析构函数会自动调用,阻止资源的泄漏。这是一种被称作RAII(Resource Acquisition is Initialization)的编程技术。
举例来说,如果你的类负责管理一个动态分配的内存块,那你可以在析构函数中使用 delete
来释放这个内存块。这样只要对象存在,你就可以确保它持有的资源会在合适的时机被释放。
所以,"利用destructors避免泄露资源"的意思是:在设计类时,应该充分利用构造函数和析构函数来管理资源,以防止任何可能的资源泄漏。
请记住:
只要坚持这个规则,把资源封装在对象内,通常便可以在exceptions出现时避免内存泄漏。
条款10: 在constructors内阻止资源泄露(resource leak)
"在constructors内阻止资源泄露"这句话的意思是,在设计类的构造函数时需要以一种防止资源泄露(如内存泄露)的方式管理资源。
我们通常在构造函数中获取需要的资源,并在析构函数中释放这些资源,这是一种常用的RAII(资源获取即初始化) 技术。但是,如果在获取资源后和资源释放之间发生异常,可能会导致资源泄漏。
以内存申请为例,如果在构造函数中申请了内存,但后续程序出现异常,且这个异常没有被捕获,那么程序可能在没有执行析构函数(也就是没有释放内存)的情况下终止,从而导致内存泄露。
为了避免这种情况,你可以在构造函数中使用try-catch块来捕获异常,一旦抛出异常,就清理申请的资源,再重新抛出异常。或者利用一些智能指针(如unique_ptr、shared_ptr等),它们在析构时可以自动释放资源,可用来避免资源泄漏。这样做,就可以确保在构造函数执行过程中,无论是否发生异常,申请的资源都能被妥善处理,从而避免资源泄露。
条款11: 禁止异常(exceptions)流出destructors之外
"禁止异常(exceptions)流出destructors之外"这句话的含义是:在编写析构函数(destructors)时,应确保任何可能抛出的异常都被妥善处理,不允许它们传播(或“流出”)到析构函数的外部。
这是一个重要的编程原则,因为在析构函数中允许异常传播可能会引发各种复杂的问题。例如,如果析构函数在清理资源时抛出了一个异常,但是这个异常在外部没有被捕获,那么这个异常会导致程序猝死(即异常终止)。更糟糕的是,如果析构函数是在处理另一个异常时被调用的(比如在堆栈展开过程中),而它又抛出了第二个异常,那么程序会立即被终止。
因此,良好的做法是使析构函数只做它该做的事情,即清理资源,而且要确保它能正确执行,不会抛出异常。在多数情况下,析构函数不应该进行可能会抛出异常的操作。如果无法避免,那么就应该在析构函数内部处理可能抛出的异常,确保它们不会流到析构函数的外部。这通常可以通过添加一个try-catch
块来实现。
条款12: 了解“抛出一个exception”与“传递一个参数”或“调用一个虚函数”之间的差异
主要存在三个差异:
- exception objects总是会被复制,如果以
by value
方式捕捉,他们甚至被复制两次。至于传递给函数参数的对象则不一定得复制。 - “被抛出成为exception”的对象,其被允许的类型转换动作,比“被传递到函数去”的对象少。
catch
子句以其“出现于源代码顺序”被编译器检验对比,其中第一个匹配成功者便被执行;而当我们以某对象调用一个虚函数,被选中执行的是哪那个“与对象类型最佳吻合”的函数,不论它是不是源代码所列的第一个。
解释:
“抛出一个exception”,"传递一个参数"以及"调用一个虚函数"都是编程中常见的操作,不过它们在功能和目标上有着显著的差异。
首先,"抛出一个exception"是一种特殊的程序流控制机制,用于处理程序运行时的异常情况。当程序运行到一个错误状态,无法正常执行时,就可以抛出(throw)一个异常。这将立即中断当前函数的执行,将控制权转移到最近的可以处理该异常的异常处理程序(catch语句)。异常是一种用于处理程序错误的强大工具,但是如果过度使用,可能导致代码逻辑难以理解和维护。
其次,"传递一个参数"是调用函数时的一种操作。通过参数,我们可以将数据从函数调用者传递到被调用的函数中。参数可以是任何类型的数据,如整数、浮点数、字符串、对象等。
最后,"调用一个虚函数"是面向对象编程中的一个概念,出现在如C++这样支持多态性(Polymorphism)的语言中。通过在基类中声明虚函数,派生类可以根据需要覆写(Override)这个函数。在运行时,通过基类指针或引用调用虚函数,会根据实际的对象类型来决定调用哪个版本的虚函数。这是实现运行时多态性的关键。
总的来说,“抛出一个exception”通常用于处理错误,“传递一个参数”则是在函数之间传递数据,“调用一个虚函数”是实现多态性的手段。这三者都是编程中重要的概念,且在使用时目标和上下文有非常大的差异。
针对上面的三点主要差异,这里做一下解释说明:
- 这里的表述涉及到了在C++中对异常(exception)的处理方式以及函数参数的传递方式。
首先,在C++中当你抛出一个异常对象时,这个对象会被复制。系统从throw语句开始,搜索这个异常匹配的catch块,这个过程被称为异常传播。在这个过程中,原始异常对象会被拷贝一次。
然后,当异常被catch块捕获时,如果以by value方式捕捉,即’catch (Exception e)',参数e是原始异常的副本,这样就会发生第二次复制。因此,通过by value的方式捕获异常将导致异常对象被复制两次。
另一方面,函数参数的传递方式可以有两种,一种是by value,一种是by reference。当我们通过by value方式传递参数时,和catch块捕捉异常一样,参数也会被复制。但是,如果我们选择通过by reference形式传递,例如’void function(Exception& e)',则不会发生复制,参数e是原始异常对象的引用。
所以这句话的意思是:异常对象会在传播过程中被复制,如果通过by value方式被捕获,还会发生一次复制。而函数参数是否复制则取决于参数的传递方式。如果参数是通过by value方式传递,就会复制,如果是通过by reference方式传递,就不会复制。
- 这段话的含义是,在C++编程中,当一个对象被抛出为一个异常(exception)时,它可以进行的类型转换比作为函数参数传递的对象要少。
当一个对象被抛出为异常时,仅允许以下类型转换:
- 到基类的转换:如果抛出的是一个派生类对象,catch块可以捕获基类的异常。
- 通过构造函数定义的转换:如果存在一个构造函数,其唯一参数是异常的源类型或者源类型可以转换为这个参数类型。
然而,当一个对象作为函数参数被传递时,除了上述的转换之外,还允许以下几种转换:
- 提升(Promotion):比如将整数字面量提升为整数、将字符提升为整数等。
- 标准类型转换:如将整数转为浮点数或者相反,将指针转为布尔值等。
- 用户定义的转换,例如通过转换函数进行类型转换。
因此,“被抛出成为exception”的对象,其被允许的类型转换动作,比“被传递到函数去”的对象要少。
- 这段话对
catch
子句的处理方式和虚函数的调用进行了比较,并强调了两者在选择执行方式上的不同。
catch
子句以其"出现于源代码顺序"被编译器检验对比,其中第一个匹配成功者便被执行:
这部分是对C++异常处理机制的描述。当一个异常被抛出时,编译器会按照catch
子句在源代码中出现的顺序逐一检验这个异常是否匹配。一旦找到第一个匹配的catch
子句,就会执行这个子句中的代码。这就解释了为什么我们需要从最详尽的异常类型开始写catch
子句,再逐渐写出越来越一般的类型,因为一旦找到匹配的子句,就不会再继续查找。
当我们以某对象调用一个虚函数,被选中执行的是哪那个“与对象类型最佳吻合”的函数,不论它是不是源代码所列的第一个: 这部分是在描绘多态性在面向对象编程中的应用。在C++里,当我们通过一个基类指针或引用来调用派生类的虚函数时,实际上执行的是派生类版本的函数,即使在源代码中,这个虚函数的声明可能并不是第一个出现的。这是因为在编译和运行时,编译器和运行环境会选择那个与对象实际类型最符的函数来执行,这种功能称为多态。
通过对比,这段话强调了catch
子句和虚函数在执行选择上的区别:catch
子句按照源代码顺序选择,而虚函数则是根据对象的实际类型来决定。
条款13: 以by reference方式捕捉exception
请记住:
catch by reference,就可以避开对象删除问题;可以避开exception objects的切割问题;约束了exception objects需要被复制的次数
解释:
在C++中,我们可以用by reference方式来捕捉异常。这就意味着我们会直接使用到原本抛出的异常对象,而不是它的副本。
C++里的异常处理机制中,当我们使用“catch(异常类型 & 引用)” 的方式来捕获异常,这就是以by reference方式捕捉异常。按照这种方式,系统只需要生成一次异常对象副本,即在抛出异常时,不需要在 catch 子句中再复制一次。这对于大对象或者复制对象资源消耗大的情况下,是很有用的策略。
try {// 代码块,可能抛出异常throw MyException();
}
catch (MyException& e) {// 这里捕获的 e 是一个引用, 会直接影响到原本的异常对象// 对 e 的改变也会影响到原先抛出的 MyException 对象
}
此外,采用这种方式捕捉异常还可以解决一些多态问题。如果有一些异常类通过继承关系链接,捕捉父类异常引用可以接到子类的异常。这是由于引用可以保持多态性,允许我们通过基类引用来访问派生类中的重写虚函数。而拷贝的方式会切除对象的派生部分,带来切割问题(slicing problem)。
“切割问题”(slicing problem),源于C++对于对象的处理方式,特别是在处理对象副本和继承关系时常常遇到。
切割问题通常发生在当你有一个派生类(derived class)对象,并且你试图将其拷贝到一个基类(base class)对象时。在这个过程中,派生类中的所有附加信息都会被“切割”掉,只剩下基类部分。这是因为基类只能容纳基类的成员,对应的派生类的成员对其来说是未知的,复制过程中会丢失这部分信息,这就是所谓的切割问题。
当我们处理异常时,如果用基类类型去捕获派生类的异常(这在面向对象设计中是很常见的情况,我们希望用同一段处理代码来处理一类相关的异常),如果我们是按值捕获的,就会发生切割问题,所有的异常都会被处理成基类类型的异常。
但如果我们使用引用方式捕获异常,就可以避免切割问题。因为引用方式不涉及对象的复制,它直接引用到了原始的异常对象,保持了对象的完整性,包括其真正的类型。这样基于类型的动态绑定仍然能够正确工作,我们能够捕获并处理正确的异常类型。
所以,“catch by reference可以避开exception objects的切割问题”这句话的意思就是,通过引用的方式捕获异常可以避免丢失原始异常类型的信息,从而避免了异常切割的问题。
条款14: 明智运用exception sepcifications
“明智运用exception specifications” 这句话关注的是如何适当地使用C++中的异常规范(exception specifications)。
在C++中,异常规范是一种语法,允许函数说明它可能抛出的异常类型。这是在函数声明的尾部,通过"throw(异常类型列表)"来进行说明的。比如,一个函数可能这样声明:“void foo() throw(A, B);”,表示foo()函数可能会抛出类型A和B的异常。如果函数声明时未指定throw列表,或者这个列表为空,那么函数就可以抛出任何类型的异常,或不抛出异常。
然而,在C++11以后,异常规范这一设定已经被废弃,不再建议使用。取而代之的是noexcept关键字,用来指示函数是否可能抛出异常。
在"明智运用exception specifications"这个表述中,"明智"两字暗示了要根据具体情况和需求谨慎使用这类规范,避免滥用。过度使用异常规范可能引起不必要的复杂性,并可能对性能产生影响。另外,也需要注意随着C++标准的更新,异常规范的使用情况也在发生变化。所以,在设计和编写代码时,需要密切关注最新的实践和建议,明智地运用这些规范。
条款15: 了解异常处理(exception handing)的成本
解释:
"了解异常处理(exception handing)的成本"这句话是在说我们应该了解在我们的代码中使用异常处理机制带来的开销。
在大多数编程语言中,异常处理都有自己的开销。这里的"开销"主要包括两部分:时间开销和空间开销。
-
时间开销:当一个异常被抛出时,需要在调用栈上回溯查找匹配的catch块,这个过程需要时间。另外,创建异常对象、复制异常对象到catch块(如果不是以引用方式捕获)以及析构对象都是需要时间的。
-
空间开销:异常处理机制需要在内存中保存足够的信息,以便在异常发生时可以正确地解 unwinding 调用栈。这包括异常对象本身的空间,以及可能需要通过栈回溯的信息。
通常来说,如果异常不频繁发生,这些开销可能不大。但是在一些性能临界的应用中,这些成本可能就相当重要了。
了解这些异常处理的成本,可以帮助我们更好地在性能和可读性、易用性之间做出平衡。例如,一般来说,我们推荐在异常是真正"异常"的情况下使用异常机制,比如函数预计不会失败,但却由于某些无法预计的原因比如内存耗尽而失败。对于预期可能会失败的函数,比如文件打开操作,使用返回错误码可能是更好的方式。这样可以保证性能,但又不牺牲代码的结构。