Effective C++ 学习笔记 第二章:构造、析构、赋值运算

第一章见 Effective C++ 学习笔记 第一章:让自己习惯 C++

条款 05:了解 C++ 默默编写并调用哪些函数

Know what functions C++ silently writes and calls.

C++ 中,空类并不是空的。

如果你没有指定构造函数,编译器会自动生成 default 构造函数,如果没有指定 copy 构造函数、copy 赋值操作符和析构函数,编译器也会自动生成空的版本。这几个自动生成的函数是 public 和 inline 的,析构函数是非 virtual 的(除非该空类的基类声明了 virtual 的析构函数)。
不过有个前提,只有这些函数被调用时,编译器才会创建。

自动生成的 copy 构造函数和 copy 赋值运算符,是简单的将类的成员全部拷贝赋值。

如果某个未指定 copy 构造函数或 copy 赋值运算符的类内存在引用成员对象或常量成员对象,需要执行类对象的 copy 操作时,编译器会拒绝编译。因为自动生成的 copy 构造函数或 copy 赋值运算符无法处理对引用成员对象和常量成员对象的赋值操作。
示例代码如下:

template<class T>
class DOG {
public:
	DOG(std::string& name, const T& value);
	// 这里我们只声明构造函数,不声明 copy 赋值运算符函数
private:
	std::string& name;    // 引用成员对象
	const T value;		  // 常量成员对象
};

std::string newDog("Persephone");
std::string oldDog("Satch");
DOG<int> p(newDog, 2);          // 作者当时养的狗狗
DOG<int> s(oldDog, 36);         // 作者之前养的已经去世的狗狗
p = s;                          // 编译器对这条代码无能为力

如果某个类的基类将 copy 构造函数 和 copy 赋值运算符函数声明为 private,那么该类中也不会由编译器自动生成这两个函数,编译器认为它没办法处理 copy 操作时,调用基类 copy 方法的操作。

原文建议

  • 编译器可以暗自为 class 创建 default 构造函数、copy 构造函数、copy 赋值运算符和析构函数。

条款 06:若不想使用编译器自动生成的函数,就该明确拒绝

Explicitly disallow the use of compiler-generated functions you do not want.

上个条款中我们知道,如果我们不声明 copy 构造函数和 copy 赋值运算符函数,编译器会在需要的时候自动声明。但如果我们不希望这个类的对象有拷贝操作,如何禁止这种事情发生?

话题 1:主动声明这些函数,并放到 private 中

虽然这样,对象没法拷贝了,但类内成员函数和友元函数还是可以访问,而我们通常不会去实现这些 copy 构造函数和 copy 赋值运算符函数,从而导致链接错误。
C++ iostream 库中的函数就是通过这种办法避免拷贝操作。

话题 2:将这些函数放到基类的 private 中

专门做一个基类,来隐藏 copy 构造函数和 copy 赋值运算符函数。代码如下:

class Uncopyable {
protected:
	Uncopyable() {};
	~Uncopyable() {};
private:
	Uncopyable(const Uncopyable&);
	Uncopyable& operator= (const Uncopyable&);
};
class HFS : private Uncopyable {      // 将想要隐藏拷贝函数的类继承自 Uncopyable
	...
};

当派生类想要做拷贝操作时,因为会调用到基类的 copy 构造函数或 copy 赋值运算符函数,从而被编译器拒绝。
和话题 1 中相比,将问题提前在编译器中暴露。
缺点是可能会导致多重继承。
Boost 库中提供了这样一个函数:noncopyable。

原文建议

  • 为禁止编译器自动生成函数的机制,可将对应成员函数声明为 private 并不实现。
  • Uncopyable 是另一种可行的做法。

条款 07: 为多态基类声明 virtual 析构函数

Declare destructors virtual in polymorphic base classes.

当子类对象经基类指针删除,同时基类中的析构函数是 non-virtual 的,那么很可能最后子类部分的内容不会被析构掉,导致资源泄漏。
virtual 函数也可以修饰其他成员函数,如果一个基类里有 virtual 修饰的成员函数,那么它也必当有一个 virtual 的析构函数。

话题 1:并不是始终需要将析构函数修饰为 virtual

如果一个类没有任何 virtual 的成员函数,那么它理应被认为它不被设计为一个会被继承的类,这时,不应该将析构函数修饰为 virtual。原因是,带有 virtual 之后,编译器会自动生成虚函数表(virtual table),对应声明的对象将带有虚函数表指针(virtual table pointer),这将会占据额外的内存空间。
需要注意,如果你继承的基类没有 virtual 的析构函数,使用它作为基类是很危险的。所有 STL 容器都是不应该被继承的(C++ 没有禁止继承的操作,比如 Java 的 final class,所以这里是个小坑)。
总结一下,并不是所有类都是为多态设计的,多态的设计里,析构函数修饰为 virtual,其他情况下,如 STL 等,并不是为多态而设计的,所以它们的析构函数是 non-virtual 的。

话题 2: 纯虚析构函数需要提供定义

我们知道,如果一个成员函数被修饰为 pure virtual 的,表示所在的这个类是一个抽象类,抽象类不能定义对象。
但是,必须为纯虚析构函数提供一份定义:

class AW {
public:
	virtual ~AW () = 0;
}
AW::~AW() {}       // 空的定义

原因是,派生类对象被析构时,会首先调用基类的析构函数,即使它是 pure virtual 的也同理,所以为了避免链接错误,还是要给一个定义。

原书建议

  • 带多态性质的基类应该声明 virtual 的析构函数。如果类内包含任何 virtual 的成员函数,那它就应该使用 virtual 的析构函数。
  • 类的设计不一定是要继承的,或并不是要用于多态的,这时不应该声明 virtual 析构函数。

条款 08: 别让异常逃离析构函数 (重要)

Prevent exceptions from leaving destructors.

析构函数中抛出异常,会让析构动作停止,这可能会导致部分资源不会被成功析构。尽量在析构函数中解决所有异常。
可以在析构函数中用 try 块捕获异常,并 abort 程序或者忽略异常,都不是特别好的办法,前者会导致程序非正常结束,后者会导致不可预见的问题。
如果某个操作可能在失败时抛出异常,而又必须处理该异常,那这个操作就必须放在析构函数以外的其他函数中。

原书建议

  • 析构函数绝对不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下他们(不传播)或结束程序。
  • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么 class 应该提供一个普通的函数(非析构函数)来执行这个操作。

条款 09: 绝不在构造和析构过程中调用 virtual 函数

Never call virtual functions during construction or destruction.

派生类在构造时,会先调用基类的构造函数,而此时,派生类还不存在,如果基类构造函数中调用了 virtual 函数,那实际上调用的是基类的 virtual 函数,而不是派生类的版本,而一旦基类中的 virtual 函数是 pure virtual 的,就出错了。但如果其他情况下没出错,程序会出现更诡异的问题。
析构函数也是同理。
有些编译器会针对这种情况做警告,但有时它和链接器都无能为力。比如:

class Base {
public:
	Base() { init(); }               // init 是一个普通成员函数
	virtual void log() const; // log 是一个 virtual 成员函数
private:
    void init() {
    	log();                    // 里边调用了 virtual 函数
    }
    void log() {
    	...;                      // 也有一份实现
    }
};
class Derive {
public:
	virtual void log() const;
};

这个程序,我们编译时是正常的,编译器检查不到 Derive() -> Base() -> Base::init() -> Base::log() 这条调用链,但实际运行时你会发现,基类的虚函数被错误的调用了。

唯一的解决办法就是,不要在构造函数和析构函数中调用 virtual 成员函数,如果非得调用,那就把这个 virtual 成员函数改成 non-virtual 的。

原书建议

  • 在构造和析构期间不要调用 virtual 函数,因为这类调用从不下降至 derived class (比起当前执行构造函数和析构函数的那一层)。

条款 10: 令 operator= 返回一个 reference to *this

Have assignment operators return a reference to *this.

因为赋值支持连锁操作,比如: a = b = c;,所以重载赋值运算符必须返回一个指向操作数左侧对象的指针,也就是 *this。
这不只适用于标准赋值运算符,也适用于任何赋值相关的运算符重载,比如 +=。
内置类型和标准程序库都满足这种规范。

原书建议

  • 令赋值(assignment)操作符返回一个 reference to *this。

条款 11: 在 operator= 中处理 “自我赋值” (重要)

Handle assigment to self in operaotr=.
同一个对象自己等于自己一般不会有人这么写,但有些时候不一定能看出来,比如:

a[i] = a[j]; // i == j
*px = *py;   // px == py

有些时候,你要自己实现 operator=,如下:

class B {...};
class Widget {
    ...
private:
    B* pb;
};

Widget&
Widget::operator=(const Widget& rhs)
{
    delete pb;  // pb 是 Wdiget 类中的一个指针对象
    pb = new B(*rhs.pb);
    return *this;
}

这个代码中,当operator= 的左值和右值是同一个对象时,就出错了,返回的对象中 pb 指向了一段已经被销毁的内存。

话题 1:在 operator= 中,做证同测试

也就是开头加一段:if (this == &rhs) return *this;。判断如果是同一个对象,就直接返回。
但仍然可能有问题,比如 new B 时出异常了,导致 pb 赋值失败,那返回的对象中 pb 指向的位置也是被销毁的。

话题 2:使用临时对象

先将 pb 赋给临时对象,再 new B,然后把临时对象 delete 掉。
这是一个行得通的办法。

Widget&
Widget::operator=(const Widget& rhs)
{
    B* temp = pb;
    pb = new B(*rhs.pb);
    delete temp;
    return *this;
}

话题 3: 更好的办法,copy and swap 技术

使用交换技术代替赋值。

class Widget {
...
void swap(Widget& rhs);        // 用于交换 rhs 和 *this 的数据
...
};

Widget&
Widget:: operator=(const Widget& rhs)
{
    Widget temp(rhs);         // 仍然需要临时变量
    swap(temp);
    return *this;
}
Widget::operator=(Widget rhs) // 另一种变体,传值方式会复制一份副本
{
    swap(rhs);               // ths 本身是副本,直接交换
    return *this;
}

原书建议

  • 确保当对象自我赋值时 operator= 有良好行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及 copy-and-swap。
  • 确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

条款 12: 复制对象时勿忘其每一个成分

Copy all parts of an object.

Copy 函数包括 copy 构造函数和 copy 赋值运算符,这两个函数不定义的话,编译器会生成默认版本,我们需要确保我们自己定义的 copy 函数一切正常。

如果你先写完了构造函数和 copy 函数,之后又新增了成员变量,那么你需要自己留意在所有这些构造函数和 copy 函数中添加这个新的成员变量的操作,编译器不会提醒你。一旦你忘记了,你的初始化或赋值操作就是不完整的。

如果是一个派生类中的 copy 函数,除了处理好派生类内的成员对象的 copy 操作,还要负责基类中对象的 copy 操作,也就是在初始化列表中完成对基类的 copy 操作。如下代码:

class PC : public Base {
public:
  ...
  PC(const PC& rhs);
  PC& operator=(const PC& rhs);
private:
  int p;
};

PC::PC(const PC& rhs)
  : Base(rhs),  // 注意这里,调用基类的 copy 构造函数
    p(rhs.p)
{
  ...
}

PC&
PC::operator=(const PC& rhs)
{
  ...
  Base::operator=(rhs);  // 注意这里,手动调用基类的 copy 赋值运算符函数
  p = rhs.p;
  return *this;
}   

另外,如果两个 copying 函数中的内容基本一致,比如上面代码 … 部分的内容很多且一致,不要想着用一个 copying 函数调用另一个。应该另外写一个成员函数,在包装这些共同的代码。

原书建议

  • Copying 函数应该确保复制 “对象内的所有成员变量” 及 “所有 base class 成分”。
  • 不要尝试以某个 copying 函数实现另一个 copying 函数。应该将共同机能放进第三个函数中,并由两个 copying 函数共同调用。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章