目录

条款18~22 智能指针

条款18~22 智能指针

前言

  1. 其实《Effective C++ Item13》已经过时了,所以我们可以在《Effective Modern c++ Item18~Item22》条款中去理解智能指针
  2. 我们不爱裸指针的原因
    • 裸指针的声明没办法告诉我们它指向的是单个对象还是数组
    • 没办法知道用完这个裸指针后要不要销毁它指向的对象
    • 没办法知道怎么销毁这个裸指针,是用operator delete还是什么其它自定义的途径
    • 参照原因1,没办法知道该用delete还是delete[],如果用错了,结果未定义
    • 很难保证调用路径上恰好销毁这个指针一次,可能内存泄露,也可能double free
    • 通常没办法知道裸指针是否是空悬指针,即是否指向已销毁的对象
  3. 智能指针的作用
    • 智能指针就是来解这些问题的,它们用起来像裸指针,但能避免以上的很多陷阱
    • C++11中有4种智能指针:std::auto_ptrstd::unique_ptrstd::shared_ptrstd::weak_ptr
    • 其中std::auto_ptr已经过时了,C++11中可以被std::unique_ptr取代了
  4. 正式看本博客之前,需要去弄懂"PImpl"
    • 具体关于PImpl是什么,可以查看我这个博客《PImpl
    • 还有如果前言都没好好看,导致Item22不知道PImpl是啥,这时候你是不是应该要质疑一下自己,学习是不是太浮躁了?

条款18 对于占有性资源使用unique_ptr

  1. std::unique_ptr的特点

    • 默认情况下,std::unique_ptr与裸指针一样大,且对于绝大多数操作来说(包括解引用),他们编译后的指令都是完全一样的,所有裸指针的空间和性能开销能满足要求的场景,std::unique_ptr一样能满足

    • std::unique_ptr体现了显式所有权的语义

    • 非空的std::unique_ptr总是拥有他指向的对象

    • 移动一个std::unique_ptr,所有权会从源指针转移到目的指针(之后源指针会设置为空指针)

    • 拷贝std::unique_ptr是不允许的,因为如果你可以拷贝它,那么就有两个std::unique_ptr指向相同的资源,每一个都认为它拥有(和负责销毁)那份资源

      • 因此std::unique_ptr是只可移动类型
    • 当销毁的时候,一个非空的std::unique_ptr会销毁它的资源

      • 默认情况下,资源销毁是通过对std::unique_ptr内的原生指针使用delete来完成的
    • 一个例子是工厂函数。假设有一个基类和三个派生类,通过一个工厂函数来返回某个派生类的std::unique_ptr,这样调用方就不需要费心什么时候销毁返回的对象了:std::unique_ptr会负责这件事。

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      
      class Investment {...};
      class Stock: public Investment {...};
      class Bond: public Investment {...};
      class RealEstate: public Investment {...};
      
      template <typename... Ts>
      std::unique_ptr<Investment> makeInvestment(Ts&&... params);
      
      auto pInvestment = makeInvestment(args);
      
      // 注意这里实际上有个所有权的转移:工厂函数通过std::unique_ptr将Investment对象的所有权转移给了调用者
      
  2. std::unique_ptr可以传入自定义的销毁器

    • 在构造std::unique_ptr时,我们还可以传入一个自定义的销毁器,它会在std::unique_ptr析构时被调用,来销毁对应的资源

    • 比如我们可能不想只是delete obj,还想输出一条日志

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      
      auto delInvmt = [](Investment* pInvestment) {
          makeLogEntry(pInvestment); // make log
          delete pInvestment; // delete obj
      };
      
      template <typename... Ts>
      std::unique_ptr<Investment, decltype(delInvmt)> makeInvestment(Ts&&... params) {
          std::unique_ptr<Investment, decltype(delInvmt)> pInv(nullptr, delInvmt);
          if (...) {
              pInv.reset(new Stock(std::forward<Ts>(params)...));
          }
          ...
          return pInv;
      }
      
    • 从调用者的角度,你可以放心的处理std::unique_ptr,你可以相信在调用过程中资源只会销毁一次,且按恰当的方式销毁。理解以下几点能帮助你理解这种实现有多漂亮

      • delInvmt是自定义的销毁器,在std::unique_ptr析构时,自定义的销毁器会来完成释放资源必需的操作。这里用lambda表达式来实现delInvmt,不仅更方便,性能还更好

      • 自定义的销毁器的类型必须与std::unique_ptr的第二个模板参数相同,因此我们要用decltype(delInvmt)来声明std::unique_ptr

      • makeInvestment的基本策略是创建一个空的std::unique_ptr,再令它指向合适的类型,再返回。其中我们把delInvmt作为第二个构造参数传给std::unique_ptr,从而将销毁器与pInv关联起来

      • 无法将裸指针隐式转换为std::unique_ptr,需要用reset来修改std::unique_ptr持有的裸指针

      • 我们在创建具体的对象时,使用了std::forwardmakeInvestment的所有参数完美转发给对应的构造函数

      • 注意delInvmt的参数是Investment*,而它的实际类型可能是派生类,因此需要基类Investment有一个虚的析构函数

        1
        2
        3
        4
        5
        6
        
        class Investment {
        public:
            ...
            virtual ~Investment();
            ...
        };
        
    • 前文我们说过在不提供自定义的销毁器时,std::unique_ptr的大小与裸指针相同。但在有了自定义的销毁器后,这个假设不成立了。销毁器的大小取决于它内部保存了多少状态。对于无状态的函数对象(例如捕获列表为空的lambda表达式),销毁器实际不占用任何空间,这就意味着当你需要一个无状态的销毁器时,在lambda表达式和函数间做选择,lambda表达式更好

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      
      auto delInvmt1 = [](Investment* pInvestment) {
          ...
      };
      
      template <typename... Ts>
      std::unique_ptr<Investment, decltype(delInvmt1)> makeInvestment(Ts&&... args); // return type has size of Investment*
      
      void delInvmt2(Investment* pInvestment) {
          ...
      }
      
      template <typename... Ts>
      std::unique_ptr<Investment, void(*)(Investment*)> makeInvestment(Ts&&... args); // return type has size of Investment*
                                                                                      // plus at least size of function pointer
      
    • std::unique_ptr另一个广泛应用的场景是pImpl模式

    • std::unique_ptr的两种形式分别是std::unique_ptr<T>std::unique_ptr<T[]>,其中前者没有定义operator[],后者在默认析构时会调用delete[],且没有定义operator*operator->。但在用到std::unique_ptr<T[]>的地方,你可能需要想一下是不是std::vectorstd::arraystd::string更合适。唯一一个用std::unique_ptr<T[]>更好的场合就是当你需要与C API交互时

    • std::unique_ptr另一个吸引人的地方在于,它可以作为std::shared_ptr的构造参数,因此上面的工厂函数返回std::unique_ptr就再正确不过了:调用者可以根据自己对所有权的需求来决定用std::unique_ptr还是std::shared_ptr,反正都支持

  3. 在C++11中,std::unique_ptr是表达独占所有权的方式,但它最吸引人的一个特性是它能即简单又高效地转化为std::shared_ptr

    1
    2
    
    std::shared_ptr<Investment> sp =    // 把 std::unique_ptr转换为
        makeInvestment(argument);         // std::shared_ptr
    

    这是为什么std::unique_ptr如此适合做工厂函数的关键原因,工厂函数不会知道:独占所有权语义和共享所有权语义哪个更适合调用者。通过返回一个std::unique_ptr,工厂提供给调用者的是最高效的智能指针,但它不妨碍调用者用std::shared_ptr来替换它

Summary

  1. std::unique_ptr是一个具有开销小,速度快,只可移动的智能指针,使用独占所有权语义管理资源
  2. 默认情况下,释放资源由delete来完成,也可以指定自定义的析构函数来替代,但是具有丰富状态的deleters和以函数指针作为deleters增大了std::unique_ptr的存储开销
  3. 很容易将一个std::unique_ptr转化为std::shared_ptr

条款19 需要共享所有权的资源管理,用shared_ptr

  1. 什么是std::shared_ptr
    • 使用std::shared_ptr管理的对象的所有权是共享的,没有哪个std::shared_ptr特别拥有这个对象,而是最后一个std::shared_ptr析构时,销毁这个对象
    • 与垃圾回收类似,调用者不需要手动管理std::shared_ptr管理的对象
    • 与析构函数类似,对象的析构时间是确定的
  2. std::shared_ptr的特点
    • std::shared_ptr内部有引用计数,被复制时,引用计数+1,有std::shared_ptr析构时,引用计数-1,当引用计数为0时,析构持有的对象
  3. 引用计数的存在有以下性能影响
    • *std::shared_ptr的大小是裸指针的两倍:一个指针指向持有的对象,一个指针指向引用计数。*
    • 引用计数使用的内存必须动态分配,原因是std::shared_ptr的引用计数是非侵入式的,必须要独立在对象外面。用std::make_shared能避免这次单独的内存分配
    • 引用计数的加减必须是原子的,因此你必须假设读写引用计数是有成本的
    • 注意,不是所有std::shared_ptr的构造都会增加引用计数,移动构造就不会。因此移动构造一个std::shared_ptr要比复制一个更快

std::unique_ptr类似,std::shared_ptr的默认销毁动作也是delete,且也可以接受自定义的销毁器

但与std::unique_ptr不同的是,std::shared_ptr的销毁器类型不必作为它的模板参数之一

1
2
3
4
5
6
7
8
auto loggingDel = [](Widget* pw) {
    makeLogEntry(pw);
    delete pw;
};

std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);

std::shared_ptr<Widget> spw(new Widget, loggingDel);

因此std::shared_ptr要比std::unique_ptr使用更灵活,比如不同销毁器的std::shared_ptr可以放到同一个容器中,而std::unique_ptr则不可以

另外,不同的销毁器不会改变std::shared_ptr的大小。std::shared_ptr内部需要为引用计数单独开辟一块内存,那么这块内存中再放一个销毁器也没什么额外开销。实际上这块内存被称为”控制块”,它里面包含以下元素

  • 引用计数
  • 弱引用计数
  • 其它数据,包括
    • 自定义销毁器
    • 内存分配器
    • 等等

控制块的创建规则为

  • std::make_shared总会创建一个控制块
  • 通过一个独享所有权的指针(如std::unique_ptrstd::auto_ptr)创建出的std::shared_ptr总会创建一个控制块
  • 通过裸指针创建的std::shared_ptr会创建控制块

一个推论就是:通过一个裸指针创建两个std::shared_ptr,会创建两个控制块,进而导致这个裸指针会被析构两次

从中我们可以得到两个教训

  • 不要直接用裸指针构造std::shared_ptr,尽量用std::make_shared。当然在需要自定义的销毁器时不能用std::make_shared
  • 非要用裸指针构造std::shared_ptr的话,尽量直接new,不要传入已有的裸指针变量

控制块会带来哪些开销呢?一个控制块通常只有几个word大,但其中会用到继承,甚至还有虚函数。这也意味着使用std::shared_ptr也会有调用虚函数的开销

但通常来说std::shared_ptr的额外开销是很小的。对于std::make_shared创建的std::shared_ptr,它的控制块只有3个word大,且内存分配上无额外成本。解引用一个std::shared_ptr也不会比解引用一个裸指针开销大。操作引用计数会带来一两次原子操作的开销,但通常也不大

std::shared_ptr的一个缺点是它不支持数组,但在C++11已经提供了std::arraystd::vectorstd::string这些容器类的前提下,还要用std::shared_ptr去管理一个数组,本身就是不好设计的信号

Summary

  1. std::shared_ptr为了管理任意资源的共享式内存管理,提供了自动垃圾回收的便利
  2. std::shared_ptr是原生指针的两倍大小,因为他们内部除了包含一个原生指针以外,还包含了一个引用计数
  3. std::shared_ptrstd::unique_ptr 的两倍大,除了控制块,还有需要原子引用计数操作引起的开销
  4. 避免从原生指针类型变量创建 std::shared_ptr
  5. 引用计数的内存必须被动态分配,当然用make_shared来创建shared_ptr会避免动态内存的开销
  6. 引用计数的递增和递减必须是原子操作
  7. 资源的默认析构一般通过delete来进行,但是自定义的deleter也是支持的。deleter的类型对于 std::shared_ptr 的类型不会产生影响

条款20 在需要共享语义且可能空悬的地方用weak_ptr

有时候我们需要一种类似std::shared_ptr,但又不参与这个共享对象的所有权的智能指针。这样它就需要能知道共享对象是否已经销毁了。这就是`std::weak_ptr``

``std::weak_ptr不是单独存在的,它不能解引用,也不能检测是否为空,它就是配合std::shared_ptr`使用的

通常std::weak_ptr都是通过std::shared_ptr构造的,但它不会影响std::shared_ptr的引用计数

1
2
3
4
5
auto spw = std::make_shared<Widget>(); // ref count is 1
...
std::weap_ptr<Widget> wpw(spw);        // ref count remains 1
...
spw = nullptr;  

可以用expired()来检测std::weak_ptr指向的对象是否有效

1
if (wpw.expired()) ...

另一个常用的操作是lock(),它能原子地检测对象是否有效,以及返回这个对象的std::shared_ptr

1
std::shared_ptr<Widget> spw = wpw.lock(); // if wpw's expired, spw is null

与之类似的操作是用std::weak_ptr构造一个std::shared_ptr

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

区别在于,如果wpw已经失效了,这次构造会抛std::bad_weak_ptr的异常

下面我们用几个例子来说明std::weak_ptr的必要性

  • 想象我们要实现一个cache,希望其中的元素在无人使用后被销毁。这里我们用std::unique_ptr并不合适,因为cache天然需要共享的语义。这样每个调用者都可以获得一个cache中元素的std::shared_ptr,它的生命期由调用者控制。cache内还需要保存一份元素的指针,且有能力检测它是不是失效了。这里我们需要的就是std::weak_ptr

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) {
        static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
        auto objPtr = cache[id].lock();
        if (!objPtr) {
            objPtr = loadWidget(id);
            cache[id] = objPtr;
        }
        return objPtr;
    }
    
    // 请不用在意上面的static,这只是个示意
    
  • 第二个例子是设计模式中的“观察者模式”。它的一种典型实现是每个主题对象持有一组观察者的指针,每当主题对象有状态变化时依次通知每个观察者。这里主题对象不需要控制观察者的生命期,但需要知道观察者的指针是否还有效。用std::weak_ptr就可以非常自然的实现出这样的特性

  • 第三个例子是,当A和C都持有B的std::shared_ptr时,如果B也需要持有A的某种指针,该持有什么?

    • 裸指针:如果A析构了,但C还在,B也就还在,此时B持有的A的裸指针就成了空悬指针,不好
    • std::shared_ptr:这样A与B就形成了循环依赖,永远不可能析构了
    • std::weak_ptr:唯一的好选择

但要注意的是,用std::weak_ptr来解std::shared_ptr可能造成的循环依赖,这种特性本身并没有价值。设计良好的数据结构,比如树,父节点控制子节点的生命期,但子节点也需要持有父节点的指针,这里最好的方案是父节点用std::unique_ptr来持有子节点,而子节点直接持有父节点的裸指针。即,严格层次结构,明确生命期的场景,不需要使用std::weak_ptrstd::weak_ptr的价值在于:在生命期不明确的场景,可以知道对象是否还有效

在效率方面,std::weak_ptr的大小与std::shared_ptr是相同的,它们使用相同的控制块,区别在于std::weak_ptr不会影响控制块中的引用计数,只会影响其中的弱引用计数

Summary

  1. std::weak_ptr 用来模仿类似std::shared_ptr的可悬挂指针
  2. 潜在的使用 std::weak_ptr的场景包括缓存,观察者列表,以及阻止 std::shared_ptr 形成的环

条款21 优先用make_uniquemake_shared而不是直接new

  1. 前言

    • 先做一下介绍,std::make_shared是在C++11中增加的,但std::make_unique却是在C++14中增加的。如果你想在C++11中就用上std::make_unique,自己写一个简单版的也不难

      1
      2
      3
      4
      
      template <typename T, typename... Ts>
      std::unique_ptr<T> make_unique(Ts&&... params) {
          return std::unique_ptr<T>(new T(std::forward<Ts>(params)...));
      }
      

      这个版本不支持数组,不支持自定义的销毁器,但这些都不重要,它足够用了。但要记住的是,不要把它放到namespace std下面

  2. 优先用这两个make的好处

    • 这两个make函数的功能就不解释了,和它们类似的还有一个std::allocate_shared

      1
      2
      3
      4
      5
      
      auto upw1(std::make_unique<Widget>());
      std::unique_ptr<Widget> upw2(new Widget);
      
      auto spw1(std::make_shared<Widget>());
      std::shared_ptr<Widget> spw2(new Widget);
      

      上面这个例子说明了用make函数的第一个好处:不需要重复写一遍类型。所有程序员都知道:不要重复代码。代码越少,bug越少

  • 异常安全性。想象我们有两个函数

    1
    2
    
    void processWidget(std::shared_ptr<Widget> spw, int priority);
    int computePriority();
    

    调用代码很可能长成这个样子

    1
    
    processWidget(std::shared_ptr<Widget>(new Widget), computePriority()); // potential resource leak!
    

    上面这行代码有内存泄漏的风险,为什么?根据C++标准,在processWidget的参数求值过程中,我们只能确定下面几点:

    • new Widget一定会执行,即一定会有一个Widget对象在堆上被创建。

    • std::shared_ptr<Widget>的构造函数一定会执行。

    • computePriority一定会执行。

      new Widget的结果是std::shared_ptr<Widget>构造函数的参数,因此前者一定早于后者执行。除此之外,编译器不保证其它操作的顺序,即有可能执行顺序为:

    1. new Widget

    2. 执行computePriority

    3. 构造std::shared_ptr<Widget>

      如果第2步抛异常,第1步创建的对象还没有被std::shared_ptr<Widget>管理,就会发生内存泄漏。

      如果这里我们用std::make_shared,就能保证new Widgetstd::shared_ptr<Widget>是一起完成的,中间不会有其它操作插进来,即不会有不受智能指针保护的裸指针出现:

      1
      
      processWidget(std::make_shared<Widget>(), computePriority()); // no potential resource leak
      
  1. 更高效

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

      这行代码中,我们以为只有一次内存分配,实际发生了两次,第二次是在分配std::shared_ptr控制块。如果用std::make_shared,它会把Widget对象和控制块合并为一次内存分配

      但是make函数也有一些缺点。

      • 第一个缺点:无法传入自定义的销毁器。
      • 第二个缺点:make函数初始化时使用了括号初始化,而不是花括号初始化,比如std::make_unique<std::vector<int>>(10, 20)创建了一个有着20个值为10的元素的vector,而不是创建了{10, 20}这么两个元素的vector(参见Item7)。
      • 第三个缺点:对象和控制块分配在一块内存上,减少了内存分配的次数,但也导致对象和控制块占用的内存也要一次回收掉。即,如果还有std::weak_ptr存在,控制块就要在,对象占用的内存也没办法回收。如果对象比较大,且std::weak_ptr在对象析构后还可能长期存在,那么这种开销是不可忽视的。

      如果我们因为前面这三个缺点而不能使用std::make_shared,那么我们要保证,智能指针的构造一定要单独一个语句。回到之前processWidget的例子中,假设我们有个自定义的销毁器void cusDel(Widget* ptr);,因此不能使用std::make_shared,那么我们要这么写来保证异常安全性:

      1
      2
      
      std::shared_ptr<Widget> spw(new Widget, cusDel);
      processWidget(spw, computePriority());
      

      但这么写还不够高效,这里我们明确知道spw就是给processWidget用的,那么可以使用std::move,将其转为右值,来避免对引用计数的修改:

      1
      2
      
      std::shared_ptr<Widget> spw(new Widget, cusDel);
      processWidget(std::move(spw), computePriority());
      

Summary

  1. 和直接使用new相比,使用make函数减少了代码的重复量,提升了异常安全度,并且,对于std::make_shared以及std::allocate_shared来说,产生的代码更加简洁快速
  2. 也会存在使用make函数不合适的场景:包含指定自定义的deleter,以及传递大括号initializer的需要
  3. 对于std::shared_ptr来说,使用make函数的额外的不使用场景还包含
    • 带有自定义内存管理的class
    • 内存非常紧俏的系统,非常大的对象以及比对应的std::shared_ptr活的还要长的std::weak_ptr

条款22 在用到PImpl惯用法时,在实现文件中定义特殊成员函数

我们经常用名为PImpl的方法来实现接口与实现分离,进而大大降低程序构建的时间

PImpl是指把类A中的所有数据成员都移到一个impl类中,A中只留下一个impl类型的指针

举一个例子

1
2
3
4
5
6
7
8
9
class Widget {
public:
    Widget();
    ...
private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;        // Gadget is some user-defined type
};

Widget的数据成员的类型为std::stringstd::vector<double>Gadget,这样就至少要include三个头文件,这也意味着每个需要include了这个包含Widget定义的头文件的地方,都被动引入了三个头文件。如果有一天我们修改了Widget的实现,比如增加或删除了一个成员,即使它们都是private的,即使接口完全没有变化,所有include它的用户文件都要重新编译。我们不想污染用户文件,也不想用户文件因为我们的实现修改而重新编译,我们就可以用PImpl

1
2
3
4
5
6
7
8
9
class Widget {
public:
    Widget();
    ~Widget();
    ...
private:
    struct Impl;
    Impl* pImpl;
};

注意这里出现的Impl类型只是声明,没有定义,称为“不完整类型”,这样的类型只支持很少的操作,其中包括了我们需要的:声明一个不完整类型的指针

对应的实现文件内容为

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct Widget::Impl {
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

Widget::Widget() : pImpl(new Impl) {}

Widget::~Widget()
{
    delete pImpl;
}

有了智能指针后,我们觉得直接newdelete不好,需要用std::unique_ptr

1
2
3
4
5
6
7
8
class Widget {
public:
    Widget();
    ...
private:
    struct Impl
    std::unique_ptr<Impl> pImpl;
};

因为不需要手动的delete,我们没有自己实现Widget的析构函数

看起来都很美好,编译也没问题,但在用户要用时,出事了

1
Widget w; // error!!!

究其原因,是因为我们没有给Widget实现自定义的析构函数,因此编译器为Widget准备了一个。这个析构函数会被放到Widget的定义体内,默认是内联的,因此会有一份实现在用户文件中。~Widget中只做一件事:析构pImpl,即析构一个std::unique_ptr<Impl>。注意,我们隐藏了Impl的实现,在析构std::unique_ptr<Impl>时编译器发现Impl还是个不完整类型,此时对它调用delete是危险的,因此编译器用static_cast禁止了这种行为

解决方案很简单:自己实现Widget的析构函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// widget.h
class Widget {
public:
    Widget();
    ~Widget();
    ...
private:
    struct Impl
    std::unique_ptr<Impl> pImpl;
};
// widget.cpp
...
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}

Widget::~Widget() {}

参考条款17,更好的方法是将析构函数定义为= default

1
Widget::~Widget() = default;

根据条款17,自定义的析构函数会阻止编译器生成移动构造函数和移动赋值函数,因此如果你想要Widget有移动的能力,就要自己实现

1
2
3
4
5
6
7
8
class Widget {
public:
    Widget();
    ~Widget();
    Widget(Widget&& rhs) = default; // right idea, wrong code!
    Widget& operator=(Widget&& rhs) = default;
    ...
};

注意不要在这些特殊成员函数的声明后面加= default,这样会重复上面析构函数的问题:会被内联,因此在用户代码中有一份实现,遇到不完整类型,game over。我们要做的就是在.cpp中将它们的实现定义为= default

接下来就是复制构造函数和复制赋值函数了。我们用std::unique_ptr是为了更好的实现PImpl方法,这也导致了Widget无法自动生成复制函数(std::unique_ptr不支持),但这并不意味着Widget就不能支持复制了,我们还可以自己定义两个复制函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// widget.h
class Widget {
public:
    ...
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs);
    ...
};
// widget.cpp
Widget::Widget(const Widget& rhs)
    : pImpl(nullptr) {
    if (rhs.pImpl) {
        pImpl = std::make_unique<Impl>(*rhs.pImpl);
    }
}
Widget& Widget::operator=(const Widget& rhs) {
    if (!rhs.pImpl) {
        pImpl.reset();
    } else if (!pImpl) {
        pImpl = std::make_unique<Impl>(*rhs.pImpl);
    } else {
        *pImpl = *rhs.pImpl;
    }
}

有意思的是,如果你把pImpl的类型改为std::shared_ptr<Impl>,你会发现上面所有这些注意事项,都不见了。你不需要手动实现析构函数、移动函数、构造函数,程序编译仍然是好的。

这种差异来自于std::unique_ptrstd::shared_ptr对自定义销毁器的支持方式不同。std::unique_ptr的目标是从体积到性能上尽可能与裸指针相同,因此它将销毁器类型作为模板参数的一部分,这样实现起来更高效,代价是各种特殊函数在编译时就要知道元素的完整类型。而std::shared_ptr没有这种性能上的要求,因此它的销毁器不是模板参数的一部分,性能会有一点点影响,但好处是不需要在编译特殊函数时知道元素的完整类型

std::shared_ptr在构造时就把销毁器保存在了控制块中,之后即使传递到了不知道元素完整类型的地方,它仍然能调用正确的销毁器来销毁元素指针。而std::unique_ptr是依靠模板参数提供的类型信息来进行销毁,因此必须要知道元素的完整类型