Effective Modern C++读书笔记(二)

智能指针

首先,raw pointer有着诸多缺陷

  • 指向对象的不明确(单个对象or数组?)
  • 析构方式的不明确(deletedelete[]还是一个特殊的析构机制,比如调用某个函数)
  • 析构的时机、次数容易出错
  • 容易出现dangling pointer

所以,c++11有了智能指针。


Item 18

  • 资源紧缺的机器上也可以用std::unique_ptr,因为当它使用默认或者stateless lambda作为删除器的时候,和raw pointer大小一样。而且对于很多操作(比如解引用),它们执行相同的指令。

  • std::unique_ptr体现了独占所有权(exclusive ownership)的语义。只能move,不能copy。

  • 对于层级继承关系来说,可以用工厂函数返回std::unique_ptr。并且调用方可以用std::shared_ptr来接收工厂函数的返回值。

  • std::unique_ptr有两种形式,一个是单独的对象(std::unique_ptr<T>),另一个是数组(std::unique_ptr<T[]>)。不过对于后者来说,c++中已经有了足够多足够好的替代品(比如说std::arraystd::vectorstd::string)。最好在使用返回一个裸指针的C-like API的时候才使用std::unique_ptr<T[]>。


Item 19

  • std::shared_ptr性能影响:

    • 大小是raw pointer的两倍。因为还有一个指向control block的raw pointer。
    • 引用计数的内存是动态分配的。
    • 引用计数增加和减少是原子操作。可能会降低速度。
    • control block用到了虚函数。
  • std::shared_ptr的移动操作比拷贝操作快。因为移动操作会直接让源std::shared_ptr置null,这意味着旧的std::shared_ptr引用计数不用操作。

  • 对于std::unique_ptr来说,删除器是它类型的一部分;而对于std::shared_ptr来说却不是。而且改变std::shared_ptr的删除器不会改变它的大小(因为分配在堆上)。

  • std::shared_ptr包含两个部分:Ptr to T和Ptr to Control Block。

    • Ptr to T指向T Object;

    • Ptr to Control Block指向:

      • Reference Count
      • Weak Count
      • Other Data(e.g.,custon deleter,allocto,etc)
  • 一些关于control block的规则:

    • std::make_shared总是会创建一个control block。
    • 当一个std::shared_ptr用unique_ptr或者auto_ptr构造的时候,也会创建一个control block。
    • 用一个raw pointer构造std::shared_ptr的时候,也会创建一个control block。
  • 基于以上的规则,用同一个raw pointer构造多个std::shared_ptr是未定义行为。会有多个control block,导致对象被多次析构。

  • std::shared_ptr不同于std::unique_ptr,不能很好地工作于数组。没有std::shared_ptr<T[]>的版本。所以默认删除器用的delete,而且没有重载oparetor[]。而且对于单个对象,std::shared_ptr允许从派生类向基类的转化;当作用在数组上时,可能会有奇怪的行为。所以尽量用std::array或者std::vector代替build-in array。


Item 20

  • std::weak_ptr不能解引用。所以一般还得用std::shared_ptr

    • 一种形式

      1
      2
      3
      4
      //从前有一个wpw是std::weak_ptr
      std::shared_ptr<Widget> spw1 = wpw.lock(); //如果wpw悬垂,spw1是空指针。
      auto spw2 = wpw.lock(); //同上。
      //其他操作
    • 另一种形式

      1
      std::shared_ptr<Widget> spw3(wpw);  //如果wpw悬垂,抛出std::bad_weak_ptr异常
  • 使用std::weak_ptr的例子:

    • 由于某个类构造开销比较大,所以设计了cache factory function,将不同的对象构造并缓存,返回std::shared_ptr

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      std::shared_ptr<const Widget> fastLoadWidget(WidgetId id)
      {
      static std::unordered_map<WidgetID, std::weak_ptr<const Widget> cache;

      auto objPtr = cache[id].lock(); //objPtr是指向缓存对象的std::shared_Ptr(当然,对象无缓存就是null了)
      if (!objPtr)
      { //如果没缓存,
      objPtr = loadWidget(id); //构造它
      cache[id] = objPtr; //缓存它
      }
      return objPtr;
      }
    • 观察者设计模式(Observer design pattern)中,subject(状态会变化的对象)通常有一个成员是指向observer(观察者,观察subject的变化)的指针。这个指针对于控制observer的生命周期不感兴趣(即不需要std::shared_ptr),但是需要确定指向的observer是否已经被析构,所以用std::weak_ptr

    • 三个对象A,B,C。A和C通过std::shared_ptr指向B,即共享B的所有权。B需要有一个指针也指向A。有三个选择:

      • Raw pointer。如果A被析构,就悬垂了。
      • std::shared_ptr。A和B都用std::shared_ptr指向对方,这个嵌套会导致A和B都不会被析构。
      • std::weak_ptr。这是个最佳选择,不会有上述的问题。

      ps:用std::weak_ptr阻止std::shared_ptr循环的方法不是很常见。对于有严格分层的数据机构,比如说树来说,子节点属于父节点,父节点析构,子节点也应该被析构。父节点指向子节点用std::unique_ptr,子节点指向父节点用raw pointer。因为子节点总是会随着父节点被析构,不会出现子节点指向的父节点悬垂的情况。

  • std::weak_ptrstd::shared_ptr大小相同。std::weak_ptr不参与对象的所有权,因此不会影响指向对象的reference count。


Item 21

  • 有三个make函数,作用都是完美转发参数然后构造某个对象,并让智能指针指向它。分别是

    • std::make_unique.c++14才加入了标准库。不过自己也可以写个类似很简单的实现

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
              template<typename T, typename... Ts>
      std::unique_ptr<T> make_unique(Ts&&... params)
      {
      return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
      }
      ````
      * `std::make_shared`。和`std::make_unique`大同小异。
      * `std::allocate_shared`。和`std::make_shared`作用几乎相同。但是它的第一个参数是一个allocator对象。

      * 用make函数的好处

      * 一是不用重复类型

      ```cpp
      auto upw1(std::make_unique<Widget>()); //with make func
      std::unique_ptr<Widget> upw2(new Widget); //without make func
    • 而是异常安全。考虑以下这个代码:

      1
      2
      3
      4
      5
      //从前有两个函数
      void processWidget(std::shared_ptr<Widget> spw, int priority);
      int computePriority();

      processWidget(std::shared_ptr<Widget>(new Widget), computePriority());

      将会导致可能的内存泄露。因为上述最后一行代码的参数完成操作需要:

      • new Widget,即一个Widget对象被创建在堆上。
      • std::shared_ptr被构造。
      • computePriority必须执行一次。

      构造std::shared_ptr<Widget>肯定会发生在new Widget之后,毕竟后者作为前者的参数。但是computePriority函数执行的时间不确定。编译器可能会让computePriority正好在new Widget之后被调用。如果在运行期间,computePriority函数抛出异常,那么就内存泄漏了。所以应该用make函数

      1
      2
      3
      4
      5
      //两个函数同上
      void processWidget(std::shared_ptr<Widget> spw, int priority);
      int computePriority();

      processWidget(std::make_shared<Widget>(), computePriority);
  • 用make函数性能上也可能会更好。考虑一下代码

    1
    std::shared_ptr<Widget> spw(new Widget);

    它总共分配了两次空间。一次是new Widget,另一个是构造std::shared_ptr<Widget>时,分配cotrol block。

    而对于make函数

    1
    auto spw = std::make_shared<Widget>();

    只需要申请一次空间。因为会一次分配能容纳Widget对象和control block大小之和的空间,然后在分别初始化它们。不仅如此,根据cpu cache局部性,以后缓存命中率还可能更高。

  • 不过make函数也有一些局限性:

    • 不能指定删除器。而直接用智能指针的构造函数却可以。

    • 当你小要用花括号作为对象构造函数的时候,不能用make函数。或者这么用:

      1
      2
      3
      4
      //create std::initalizer_list
      auto initList = { 10, 20 };
      //createstd::vector using std::initializer_list ctor
      auto spv = std::make_shared<std::vector<int>>(initList);

      对于std::unique_ptr,它的make函数有以上缺点。但是对于std::shared_ptr和它的make函数来说,还有另外两个缺点(都是极端情况

    • 如果一个对象的类型有自定义版本的operator new和operator delete,那么使用make函数来创建他们通常是一个糟糕的想法。

    • 上面也提到过,当使用make函数的时候,会一次分配能容纳Widget对象和control block大小之和的空间。而control block有reference count和weak count。只要weak count大于0,引用快就必须存在。那么显然,会有对象所占的内存无法及时释放的情况出现:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class ReallyBigType{...};
      auto pBigObj = std::make_shared<ReallyBigType>(); //通过make_shared创建一个很大的对象

      ... //创建若干个指向此对象的std::shared_ptr和std::weak_ptr并使用它们

      ... //最后一个指向此对象的std::shared_ptr在这被销毁。但是std::weak_ptr仍然保持

      ... //在这期间,先前创建的大对象所占的内存仍然没有被回收

      ... //最后一个指向此对象的std::weak_ptr在这被销毁。对象和control所占的内存才被释放。

      这种情形直接用new反而更好。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class ReallyBigType{...};                                       //和之前一样
      std::shared_ptr<ReallyBigType> pBigObj(new ReallyBigType); //通过new创建一个很大的对象

      ... //和之前一样,创建若干个指向此对象的std::shared_ptr和std::weak_ptr并使用它们

      ... //最后一个指向此对象的std::shared_ptr在这被销毁。但是std::weak_ptr仍然保持。为对象分配的空间已经被释放

      ... //在这期间,只有control block的空间还没释放

      ... //最后一个指向此对象的std::weak_ptr在这被销毁。control block所占的内存才被释放。

Item 22

Pimpl(“pointer to implementation”) Idiom是医用用来缩短编译时间的技术。用一个指向某个class(or struct)的指针来代替具体的成员对象。举个例子

1
2
3
4
5
6
7
8
9
10
//in header "widget.h"
class Widget {
public:
Widget();
...
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; //Gadget是用户自定义的类型
};

每次Gadget.h改变,都得重新编译。所以运用Pimpl Idiom:

1
2
3
4
5
6
7
8
9
10
//still in header "widget.h"
class Widget {
public:
Widget();
~Widget();
...
private:
struct Impl; // declare implementation struct
Impl *pImpl; // and pointer to it
};

每次Gadget.h改变,Widget不受影响。
不过学了智能指针,你可能互相到把raw pointer换成std::unique_ptr。就想下面这样:

1
2
3
4
5
6
7
8
// in "widget.h"
class Widget {
public:
Widget();
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// in "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>()) {}

可能注意到了,由于用智能指针管理资源,似乎用编译器生成的析构函数就能正常工作了的样子。编译没问题。不过,当你写下WIdget w;,想要使用它的时候,却会编译出错。

问题出在试图析构w,生成代码的时候。此时,析构函数将会被调用。根据编译器生成特殊成员函数的规则(详情见Item 17),编译器试图生成一个析构函数。这个生成的析构函数会调用plmpl(一个使用默认删除器的std::unique_ptr)的析构函数。在这个指针的析构函数中,将会直接用delete作用于一个raw pointer。要注意到,在这时,Impl还是个不完全类型。但是,由于在实现在实现作用,默认deleter通常会用c++11的static_assert来确保指针指向完整类型。所以当编译器生成析构函数的时候,它就碰到了一个失败的static_assert。

要解决这个问题也很简单,做到在析构的时候Impl是完整类型就可以了。

1
2
3
4
5
6
7
8
9
10
11
// in file "widget.h"
class Widget {
public:
Widget();
~Widget(); // 只声明
...

private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// in file "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() // ~Widget的定义
{}

如果想要强调编译器生成的析构函数就可以工作(声明它的唯一理由就是为了在Widget的实现中生成),你也可以这么干:

1
Widget::~Widget() = default;  //same effect as above

处于同样的考量,还能加上move和copy操作。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// in file "widget.h"
class Widget {
public:
Widget();
~Widget(); // 只声明
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs);
Widget(Widget&& rhs);
Widget& operator=(Widget&& rhs);
...

private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// in file "widget.cpp"
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

Widget::Widget()
: pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default;
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;

Widget::Widget(const Widget& rhs)
: pImpl(std::make_unique<Impl>(*rhs.pImpl));

Widget& Widget::operator=(const Widget& rhs)
{
*pImpl = *rhs,pImpl;
return *this;
}

可以看到,如果Pimpl采用std::unique_ptr,即使编译器生成的函数能工作,也需要明确的把比声明和实现分开来。

但是如果采用std::shared_ptr,上述的建议就不需要了。直接用编译器生成的函数就能够工作。

造成不同的根本原因是它们支持custom deleter的不同方式。

  • 对于std::unique_ptr,deleter是类型的一部分,使得编译器能够生成更小的运行期数据结构和更快的运行期代码。这个带来的后果就是,当编译器生成特殊函数的时候,指向的类型必须完整。
  • 对于std::shared_ptr,deleter不是类型的一部分,使得需要更大的运行期数据结构和更慢的代码。但是当编译器产生特殊函数的时候,指向的类型不需要是完整的。