今天接着开始写《Effective C++》第二章的学习笔记~

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

这点主要是需要知道当你写一个空类 class Empty{}; 时,编译器会根据是否被调用,自动生成一些函数:

class Empty {
  public:
  Empty() {...} //default 构造函数
  Empty(const Emptry& rhs) {...} //copy 构造函数
  ~Empty() {...} // 析构函数
  
  Empty& operator=(const Empty& rhs) {...} //copy assignment 操作符
};

当这些函数被调用但你没有自己声明时,编译器就会帮你生成。

这点本身没有什么要注意的地方,主要是为了之后的几个条款服务。

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

由上一条可知,当你不主动声明时,在调用时 C++ 会自动帮你对应函数,但有时会有不允许使用的情况。针对这种情况,书中给出了两种解决方法。比如当一个类你不希望他被 Copy 时:

  1. 你可以将拷贝构造函数和 operator = 函数声明为 private,并不给出实现。使用 private 的目的是使其他类无法调用,然后不给出声明的话,即使其他类使用 friend class 等方法强制调用了,编译器也会在链接时报错。

  2. 你也可以使用下面这样一个基类:

    class Uncopyable {
      protected:
      Uncopyable() {}
      ~Uncopyable() {}
      private:
      Uncopyable(const Uncopyable&);
      Uncopyable& operator=(const Uncopyable&);
    }

    然后你便只要继承该类,就可以达到和第一种方法一样的效果,而且这种方法有个好处就是假如调用了拷贝函数不需要链接时才暴露,在编译期就会因为对应的类的基类的拷贝构造函数是 private 而报错,而且写起来也很简洁方便。

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

这点应该是学 C++ 时的老生常谈了,若多态基类的析构函数不是虚函数,在 delete 派生类时,若指针类型是基类指针,那派生的部分则不会删除,这部分内存就泄漏了。

不过这条款中,有一点是我之前一直没有在意到的,那就是不准备作为基类去派生的类尽量不要为其声明 virtual 的析构函数。这是因为一旦声明了 virtual 函数这个类就具有了多态的性质,C++ 就会为其创建 vptr,即虚函数表,这部分会占用一定的空间,使对象体积增大,尤其在成员本身比较少的情况下 vptr 的额外开销占比就比较大了。类也会因此失去移植性,因为其他语言中没有 vptr 这种结构。

针对这种不准备作为基类去派生的类,应该使用 final 关键词去限制。(写书时还没有 final 这个关键词,所以书中只是抱怨了一下~)

# 条款 8:别让异常逃离析构函数

当一个类在析构函数中有可能抛出异常并没有 catch,那当多个这种类同时析构(比如所在的 stl 容器析构时),可能会同时抛出多个异常,就会导致不明确的行为。对这种情况比较好的处理方法有两种:

  1. 析构函数中抛出异常时 catch 异常并使用 std::abort() 停止程序。
  2. 上面的方法可以阻止错误的发生,不过直接关闭程序有点暴力,有时抛出的异常可能吞掉也比直接关掉好。此时可以记录日志,吞下异常。不过此时还是有不明确行为带来的风险。更得当的话,可以将析构中需要调用的可能抛出异常的接口提供给使用者,让使用者去处理异常,该类本身设置标记位,根据标记位使得析构时仅在使用者没调的情况下才调用可能抛出异常的接口。

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

这点主要是因为构造一个派生类时,会先调用基类的构造函数,若此时调用 virtual 函数,作为派生的类的特征还没构建起来,因此 virtual 函数调用的是基类的对应函数,大概率会产生错误。

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

赋值可以写成连锁的形式:

int x,y,z;
x = y = z = 15;

上面连锁赋值能实现是因为赋值采用右结合律,赋值语句就相当于 x = (y = (z = 15)) , 而且每次赋值回返回自身引用,即 z = 15 执行后会返回更新了值的 z 所以上面的连锁赋值才能实现。

所以我们实现 operator= 时,也可以像这样返回一个自身引用:

Widget& operator=(const Widget& rhs)
{
  ...
  return *this;
}

这样就可以实现连锁赋值操作了。

这点可以在同时赋相同值时渐变写法,而且很多内置类型和标准程序库中是遵循这个规则的,可以统一写法。不过除此之外没有其他好处,因此作者也说这只是个协议,并无强制性。

# 条款 11:在 operator = 中处理 “自我赋值”

“自我赋值” 指的是将同一个对象赋值给自己。这点其实不仅在 operator = 中需要处理,任何操作两个可能为同一对象的时候,都需要考虑执行是否正确, operator= 中的 “自我赋值” 只是比较典型。

其实使用对象来管理资源的话并不需要太关心这点,这点在自己需要管理资源时才需要特别注意,比如:

class Bitmap {...}; // 位图对象
class Widget {
  ...
private:
  Bitmap* pb = nullptr; // 指针,指向一个从 heap 分配而得的对象
};

上面是一个以用来保存一个位图指针的类 Widget,最简单的 operator= 可能是这么写的:

Widget& operator=(const Widget& rhs)
{
  delete pb;
  pb = new Bitmap(*rhs);
}

这样的写法在 rhs 的 pb 和 this 的 pb 指向相同的对象时,就会把指向的对象释放掉而导致问题。

书中针对 operator = 中的 “自我赋值” 的处理给出了一种方法 ——copy and swap

class Widget {
  ...
  void swap(Widget& rhs); // 交换 * this 和 rhs 的数据
  ...
};
Widget& Widget::operator=(const Widget& rhs){
  Widget temp(rhs); // 为 rhs 数据拷贝一份临时副本
  swap(temp); // 将 * this 和副本的数据交换
  return *this;
}

(swap 实现比较复杂,作者在条款 29 中有详细说明,我这里也不展开了,总之 swap 实现了数据交换,且避免了内存泄漏、处理了对象创建抛出异常的情况。)

使用 copy and swap 的方法可以保证当 rhs 和 this 的 pb 指向相同对象时,不会将指向的对象释放掉,并且 “异常安全”,是处理这类问题比较好的写法。

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

当你使用编译器自动补充的 Copy 构造函数和 operator = 函数时会自动完成这点。但当你自己实现这两个复制相关的函数时需要注意:

  • 复制相关的函数应确保复制对象内的所有成员以及所有 base class 的成分
  • 虽然两者代码成分类似,但不要尝试通过调用另一方来实现自己,应该实现另一个类似 init 这样的初始化函数来供两者调用,从而减少代码重复。

# 参考资料

  • 《Effective C++》—— Scott Meyers
更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Nirvana 支付宝

支付宝