《C++编程规范》六、构造、析构与复制

使用这些函数之所以需要小心,其中一个原因是几乎一半的情况下编译器都会为我们生成代码。另一个原因是,C++ 默认时总是将类当作类似于值的类型,但是实际上并非所有的类型都类似于值(见第32条)。 知道何时应该显式地编写(或者禁止)这些特殊成员函数,并遵循本部分中的规则和准则来编写,将有助于确保代码的正确性、可扩展性和防错性。

本部分中我们选出的最有价值条款是第51条:析构函数、释放和交换绝对不能失败。


第47条 以同样的顺序定义和初始化成员变量

与编译器一致:成员变量初始化的顺序要与类定义中声明的顺序始终保持一致,不用考虑构造函数初始化列表中编写的顺序。要确保构造函数代码不会导致混淆地指定不同的顺序。

考虑以下代码:

class Employee {
  string email_, firstName_, lastName_;
public:
  Employee( const char* firstName, const char* lastName )
  : firstName_(firstName), lastName_(lastName), email_(firstName_ + "." + lastName_ + "@acme.com") {}
};

这段代码隐藏着一个错误,危害性很大,而且很难发现。因为类定义中 email_是在 first_和last_之前被声明的,它将首先被初始化,然后试图使用其他未初始化的字段。更糟糕的是,如果构造函数的定义位于另一个文件夹,成员变量声明的顺序对构造函数的正确性的远距离影响就更难确定了。

C++ 语言之所以采取这样的设计,是因为要确保销毁成员的顺序是惟一的;否则,析构函数将以不同顺序销毁对象,具体顺序取决于构造对象的构造函数。为此带来的底层操作开销应该是不可接受的。

解决方案是,总是按成员声明的顺序编写成员初始化语句。这样,任何非法依赖都会显而易见。当然,尽量不让一个成员的初始化依赖于其他成员更好

许多编译器(但不是所有)在我们违反了此条规则时会发出警告。



第48条 在构造函数中用初始化代替赋值

设置一次,到处使用 [1] :在构造函数中,使用初始化代替赋值来设置成员变量,能够防止发生不必要的运行时操作,而输入代码的工作量则保持不变。



第49条 避免在构造函数和析构函数中调用虚拟函数

虚拟函数仅仅“几乎”总是表现得虚拟 :在构造函数和析构函数中,它们并不虚拟。更糟糕的是,从构造函数或析构函数直接或者间接调用未实现的纯虚拟函数,会导致未定义的行为。如果设计方案希望从基类构造函数或者析构函数虚拟分派到派生类,那么需要采用其他技术,比如后构造函数(post-constructor)。

在C++中,一个基类一次只构造一个完整的对象

假设我们有一个基类B和一个B的派生类D。构造D对象,执行B的构造函数时,所构造的对象的动态类型是B。也就是说,对虚拟函数B::Fun的调用将采用B中的Fun定义,无论D是否改写了它;这是一件好事,因为在D对象的成员还没有被初始化时就调用D 的成员函数会产生混乱。只有在B的构造完成之后,才执行D的构造函数体,而D的标识才算建立。作为一条经验规则,请记住,在 B 的构造期间,没有办法说清到底 B 是一个独立对象还是其他派生对象的基类部分。虚拟行为的虚拟函数总是这样的。

从构造函数调用还完全没有定义的纯虚拟函数将是给伤口撒盐,这种情况下的行为是未定义的。这样的代码不仅会令人糊涂,而且维护时也会显得更加脆弱。



第50条 将基类析构函数设为公用且虚拟的,或者保护且非虚拟的

删除,还是不删除,这是个问题:如果允许通过指向基类Base的指针执行删除操作,则Base的析构函数必须是公用且虚拟的。否则,就应该是保护且非虚拟的。

总是为基类编写析构函数,因为隐含生成的析构函数是公用且非虚拟的



第51条 析构函数、释放和交换绝对不能失败

它们的一切尝试都必须成功:决不允许析构函数、资源释放(deallocation)函数(如operator delete)或者交换函数报告错误。说得更具体一些,就是绝对不允许将那些析构函数可能会抛出异常的类型用于C++标准库。

思考C++ 标准中的如下建议和要求:

  • 如果在栈展开期间所调用的析构函数发生异常而退出,将调用terminate (15.5.1)。因此,析构函数应该总是能够捕获异常,并且不会让异常传播到析构函数之外。——[C++03]§15.2(3)
  • C++ 标准库中定义的析构函数操作[包括用来实例化标准库模板的任何类型的析构函数]都不会抛出异常。——[C++03]§17.4.4.8(3)


第52条 一致地进行复制和销毁

既要创建,也要清除:如果定义了复制构造函数、复制赋值操作符或者析构函数中的任何一个,那么可能也需要定义另一个或者另外两个。

如果需要定义这三个函数中的任何一个,就意味着我们需要函数默认行为之外的功能,而这三个函数是不对称相关的。为什么会这样呢?

  • 如果编写或者禁用了复制构造函数或者复制赋值操作符,那么可能需要对另一个也如法炮制。如果有一个要做一些“特殊的”工作,那么可能另一个也应该如此,因为这两个函数应该具有类似的效果。(见第53条,该条款专门展开阐述了这一点。)
  • 如果显式地编写了复制函数,那么可能也需要编写析构函数。如果在复制构造函数中所做的“特殊”工作是分配或者复制某些资源(比如,内存、文件、套接字等等),那么应该在析构函数中予以释放。
  • 如果显式地编写了析构函数,那么可能也需要编写或者禁止复制。如果必须编写一个特殊的(nontrivial)析构函数,那么经常是因为需要手工释放对象持有的资源。如果真的如此,那么很可能那些资源需要小心复制,然后需要留意复制和赋值对象的方式,或者完全禁止复制。

在许多情况下,如果能通过 RAII“拥有”对象的方式正确地持有封装起来的资源,我们就没有必要自己编写这些操作了(见第13条)。



第53条 显式地启用或者禁止复制

清醒地进行复制:在下述三种行为之间谨慎选择

  1. 使用编译器生成的复制构造函数和赋值操作符;
  2. 编写自己的版本;
    3.如果不应允许复制的话,显式地禁用前两者。

有一个常见的错误(而且不只在初学者中会出现),就是在定义类的时候忘记了考虑复制和赋值语义。一些小的辅助类经常发生这种错误,比如为了RAII支持(见第13条)而设计的辅助类。

要确保类能够提供合理的复制,否则就根本不要提供。

显式地禁止复制和赋值。如果复制对你的类型没有意义,那就通过将它们声明为私有的未实现函数来禁止复制构造和复制赋值:

class T {// ……
private:           // 使T不能复制
  T(const T&);        // 未实现
  T&operator=(const T&);   // 未实现
};

显式地编写复制和赋值。如果复制和复制赋值适合于 T 对象,但是正确的复制行为又不同于编译器生成的版本,那么就自己编写函数并将它们设为非私有的。

使用编译器生成的版本,最好是加上一个明确的注释。如果复制有意义,而且默认行为也是正确的,那么就不用我们自己声明了,让编译器生成的版本来干吧。最好注释说明默认行为是正确的,这样代码的阅读者就能知道你并不是不小心忽略了其他两种选项。

请注意禁止复制和复制赋值意味着不能将T对象放入标准容器。这未必是一件坏事,很可能,你根本就不想在容器中存放这样的 T对象。(通过用智能指针保存这些对象,还是可以将它们放入容器的,见第79条。)

关键在于:应该对这两种操作采取主动行动,因为编译器喜欢慷慨地替我们生成,而对非值型的类型而言,这种编译器所生成的版本默认情况下经常是不安全的(另见第32条)。



第54条 避免切片。在基类中考虑用克隆代替复制

切片面包很好,切片对象则不然:对象切片是自动的、不可见的,而且可能会使漂亮的多态设计嘎然而止。在基类中,如果客户需要进行多态(完整的、深度的)复制的话,那么请考虑禁止复制构造函数和复制赋值操作符,而改为提供虚拟的Clone成员函数



第55条 使用赋值的标准形式

赋值,你的任务:在实现operator=时,应该使用标准形式——具有特定签名的非虚拟形式。

应该为具有如下签名的类型T声明复制赋值(参阅[Stroustrup00]和 [Alexandrescu03a]):

T&operator=(const T&);       // 传统的
T&operator=(T);          // 可能更方便的优化器(见第27条)

如果需要复制操作符内的参数,比如第56条中基于swap的惯用法,就采用第二个版本好了

不要返回 const T&。虽然这有助于防止 (a=b)=c 这样的奇怪代码,但是它有负作用:你将不能把T对象放入标准库容器中;容器要求赋值返回一个普通的T&。



第56条 只要可行,就提供不会失败的swap(而且要正确地提供)

swap既可无关痛痒,又能举足轻重: 应该考虑提供一个swap函数,高效且绝对无误地交换两个对象。这样的函数便于实现许多惯用法,从流畅地将对象四处移动以轻易地实现赋值,到提供一个有保证的、能够提供强大防错调用代码的提交函数(另见第51条)。

swap函数通常如下所示,其中U是某个用户定义的类型:

class T { // ……
public:
  void swap( T& rhs ) {
    member1_.swap( rhs.member1_ );
    std::swap( member2_, rhs.member2_ );
  }
private:
  U member1_;
  int member2_;
};

对于原始类型和标准容器而言,用std::swap就可以了。其他的类可能需要用各种名字的成员函数来实现交换。

考虑使用 swap 以复制构造来实现复制赋值。下面列出的 operator=实现提供了强大的保证(见第71条),虽然这需要以创建一个额外对象为代价。但是如果能更有效率地为T对象执行防错赋值的话,那么这样做可能就不合适了:

T&T::operator=(const T&other){   // 很好:变体 #1 (传统的)
  T temp( other );
  swap( temp );
  return *this;
}

T&T::operator=(T temp){      // 很好:变体 #2 (见第27条)
  swap(temp);           // 请注意:temp 是通过值传递的
  return *this;
}
posted @ 2024-09-13 17:12  guanyubo  阅读(33)  评论(0编辑  收藏  举报