effective modern c++ 条款22 使用Pimpl大法时,在实现文件中定义特定成员函数

如果你曾经与过长的编译时间抗争过,你必定会熟悉Pimpl(指向实现)大法。这种技术将数据成员替换成指向实现类(或结构体)的指针,将之前放到主类中的数据转移到了实现类中,然后通过指针来访问。举例,有这样一个Widget类:

class Widget {                     // 定义在widget.h

 public:

   Widget();

 private:

  std::string name;

  std::vector<double> data;

  Gadget  g1,g2,g3;                         //用户自定义类型

};

因为Widget的数据成员包含std::string,std::vector以及Gadget类型, 那么要编译Widget必须包含定义这些类型的头文件。用户必须#include <string>   <vector> 和Gadget.h 。这些头文件增加了Widget的编译时间,而且它们本身包含的头文件也会增加编译时间。如果一个头文件内容改变了,那么Widget就需要重新编译。标准库的<vector><string>不会经常改变,但gadget.h可能会经常变动。

在c++98中使用Pimpl大法会将数据成员移动到实现类中,用一个该实现类(需要声明,而非定义)的指针取而代之。

class Widget {                     // 定义在widget.h

 public:

   Widget();

    ~Widget();                           //需要析构函数,后面解释

 private:

   struct Pimpl;

    Pimpl *pImpl;                      //声明一个实现结构,定义指针指向它

};;

因为Widget不再牵涉到std::string,std::vector和Gadget,所以不需要#include头文件。这便增加了编译速度,而且当这些头文件改变时,不影响Widget。

一个类型只有声明,而没有定义,称为不完全类型。Widget::Impl正是这样的类型。对于不完全类型,你能做的操作很少,而定义一个指向它的指针恰巧是允许的操作。Pimpl大法利用了这一点。

Pimpl大法 part1声明一个指向不完全类型的指针作为数据成员。Part2是这个指针动态分配和释放对象,对象持有原始类中数据成员。分配和释放操作的代码,定义在实现文件中(widget.cpp).

#include "widget.h"

#include <vector>

#include <string>

#include "gadget.h"

struct Widget::Impl {

   std::string name;

    std::vector<double> data;

     Gadget g1,g2,g3;

};

Widget::Widget()

: pImpl(new Impl)                              //创建对象的数据成员

{}

Widget::~Widget() {

    delete pImpl;                                //删除对象数据成员

}

这里的#include表明对std::vector,std::string 和Gadget的依赖仍然存在。但这些依赖已经从widget.h移动到了widget .cpp(只对Widget的实现类可见,也只被其使用)。我对Impl对象的动态申请和释放做了高亮标注。在析构函数中完成对象的删除。

上面我展示的都是c++98的代码,散发着上个一千年的气息。它使用原始指针,原始的new和delete,一切都很原始。这一章我们提到智能指针比原始指针好用,如果我们需要在Widget构造函数里动态创建一个Widget::Impl并且在析构函数中删除该对象,那么unique_ptr(item18)就恰好是我们需要的工具。在头文件中将原始指针替换成unique_ptr后:

class Widget {                     // 定义在widget.h

 public:

   Widget();

 private:

   struct Pimpl;

    std::unique_ptr<Pimpl> pImpl;                      //使用智能指针替代原始指针

};;

在实现文件中:

#include "widget.h"

#include <vector>

#include <string>

#include "gadget.h"

struct Widget::Impl {

   std::string name;

    std::vector<double> data;

     Gadget g1,g2,g3;

};

Widget::Widget()

: pImpl(std::make_unique<Pimpl>())                              // 条款21使用make_unique创建unique_ptr

{}

你可能注意到没有了析构函数。因为我们不再需要在析构时加入自己代码。std::unique 在析构时,自动删除它指向的对象,这样我们不用自己来删除任何东西。这是智能指针最吸引人的地方,不用冒着弄脏双手的风险,来自己释放资源。

这种写法会提示编译错误,因为编译器自动生成的析构函数是inline的,里面会使用static_assert确保指针不是不完全类型。编译器编译析构函数时候,遇到static_assert就失败了。

为了解决这个问题,我们只需在确保生成析构代码的位置,Impl是一个完全类型。可以把析构函数放到实现类中。

class Widget {                     // 定义在widget.h

 public:

   Widget()

    ~Widget();

 private:

   struct Pimpl;

    std::unique_ptr<Pimpl> pImpl;                      //使用智能指针替代原始指针

};;

在widget.cpp:

#include "widget.h"

#include <vector>

#include <string>

#include "gadget.h"

struct Widget::Impl {

   std::string name;

    std::vector<double> data;

     Gadget g1,g2,g3;

};

Widget::Widget()

: pImpl(std::make_unique<Pimpl>())                              // 条款21使用make_unique创建unique_ptr

{}

Widget::~Widget()  {

}

这样就正常工作了,而且不需要打多少字。但如果你想强调编译器默认的析构函数就满足要求,重新定义的唯一目的是在实现文件中I生成析构函数代码。可以用”=default“

Widget::~Widget() = default;

使用Pimpl大法的这样的类,自然会成为move操作的候选人。这是因为unique_ptr需要move函数,编译器生成的move函数可以完成所需工作。但像条款17说的,自定义析构函数后,编译器不会生成move函数了。为了支持move,必须自定义move函数。因为编译器生成的可以很好工作,你可能会经不住诱惑这样来写:

class Widget {                                                       //仍然在widget.h

  public:

     Widget();

      ~Widget();

    Widget(Widget &&rhs) = default;                //好的想法

    Widget& operator = (Widget&&rhs) = default; //错误的实现

 private:

   struct Pimpl;

    std::unique_ptr<Pimpl> pImpl

};

这样做会带来两个问题,这两个问题和类中不声明析构函数的问题一样,而且产生的原因也一样。编译器产生的赋值move操作需要先删除原先的对象,再指向新的对象。但在Widget头文件中,pImpl指向了不完全类型。拷贝move操作是另外一种情况,当在move构造函数中发生异常时,编译器会删除pImpl,而删除需要保证pImpl是完全类型。

因为问题和前面的一样,解决方法就是将move操作移动到实现文件中。

class Widget {                                                       //仍然在widget.h

  public:

     Widget();

      ~Widget();

    Widget(Widget &&rhs) ; //只进行定义

    Widget& operator = (Widget&&rhs) ;

 private:

   struct Pimpl;

    std::unique_ptr<Pimpl> pImpl

};

在实现文件中加入:

Widget(Widget &&rhs)  = default

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

Pimpl大法可以减少客户代码对实现类的编译依赖,而从概念上讲并没有改变类所能表示的。原始的类有string,vector以及Gadget,假定Gadget像string,vector一样支持拷贝操作,那么Widget类也应该支持拷贝操作。我们必须自己来实现拷贝构造函数,因为对于含有move only的类,编译器不会提供默认拷贝构造函数。即使提供,也默认构造函数只会拷贝unique_ptr(浅拷贝),而我们需要拷贝指向的内容(深拷贝)。

老规矩,在头文件声明,在实现文件中实现。

class Widget {                                                       //仍然在widget.h

  public:

     Widget();

      ~Widget();

    Widget(const Widget &rhs) ; //只进行定义

    Widget& operator = (const Widget&rhs) ;

 private:

   struct Pimpl;

    std::unique_ptr<Pimpl> pImpl

};

在实现文件中:

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

{

}

Widget& operator=Widget::Widget(const Widget &rhs) 

{

    *pImpl = *rhs.pImpl;

    return *this;

}

 

这两个函数的实现都是符合惯例的。在每个函数中,我们只是把pImpl指向的对象,从源对象拷贝到目的对象(*this)中。我们利用了编译器会自动生成IIpml拷贝构造函数这一事实,不用对对象数据成员一一拷贝,因为拷贝构造函数会对数据成员逐一拷贝。因为我们利用了编译器自动生成的Widget::Impl的拷贝操作,来实现了Widget对象的拷贝。在拷贝过程中,我们仍然遵从了条款21的优先使用std::make_unique而不是new。

为了实现Pimpl大法,使用了unique_ptr, 这是因为pImpl指针指向的对象Widget::Pimpl

对于Widget来说,是独占所有权对象。仍然需要指出,如果这里使用的是shared_ptr而不是unique_ptr,那么上面的条款不会适用了。也不用定义析构函数,在没有用户自定义删除器德情况下,编译器乐意生成move操作代码,做所有我们想要完成德工作。

这种差别源于二者对用户自定义删除器的实现上。对于uniqe_ptr删除器类型是指针类型的一部分,这有利于编译器生成更小的运行数据结构和快的运行代码。巨大效率的代价就是当编译器生成某些特殊代码时候(析构,move),对象类型必须是完全的。而对shared_ptr来说,删除器类型并不是指针类型一部分,这不可避免的增加了运行数据结构大小和代码的运行时间。但编译器在生成特殊代码时,指向类型可以是不完全类型。

对于Pimpl大法,并不需要再unique_ptr和shared_ptr特性之间找个平衡点。这是因为Widget和它的实现类之间的关系是独占所有权关系,使得unique_ptr总是最正确的工具。值得一提的是,在别的一些场景中,存在共享所有权现象,此时shared_ptr就是最佳选择,就不用在unique_ptr的连环函数定义中跳来跳去了。

注意事项:

Pimpl大法靠减少客户代码和实现代码之前的编译依赖来减少编译时间的。

对于unique_ptr 的pImpl而言,即使默认的特殊功能函数满足要求,也要在头文件中定义特殊功能函数,在实现文件中实现

上述建议适用unique_ptr 而不适用于shared_ptr

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值