Item22 When using the Pimpl, define specific member functions in the implementation file

​   Pimpl idiom这个是C++的惯用法,我相信很多人都知道,通常为了保护我们的头文件,避免在修改类的数据成员的时候导致依赖这个头文件的程序也需要重新编译,常常被人们称为编译防火墙。例如下面这个类的头文件。

class Widget {
  public:
    Widget();
    ....
  private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};

​​   Gadget是一个用户自定义类型,因此需要包含一个gadget.h,那么一旦Gadget有任何修改,那么所有包含了widget.h的程序都需要重新编译,如果Widget类是一个暴露给用户使用的类的话这就会给用户带来很大的困扰。使用Pimpl惯用法改造后如下:

// 头文件
class Widget {
  public:
    Widget();
    ~Widget();
    ...
  private:
    struct Impl;
    Impl *pImpl;
};

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

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

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

​​   看到上面使用了裸指针,在我们学会了智能指针的情况下,应该优先使用智能指针来代替裸指针,来管理内存的分配和释放。因此使用智能指针替换裸指针后的代码如下:

class Widget {
  public:
   Widget();
   ...
  private:
   struct Impl;
   std::unique_ptr<Impl> pImpl;
};
// 具体实现
struct Widget::Impl {
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

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

​​   经过改造后的代码去除了析构函数,真正的内存释放的操作交给了智能指针来管理了,接下来就可以把这个头文件丢给用户了,用户只需要包含这个头文件,就可以使用Widget了。

#include "Widget"
Widget w;       //error 

error: invalid application of 'sizeof' to an incomplete type 'Widget::Impl'
note: expanded from macro 'static_assert'
    typedef __static_assert_check<sizeof(__static_assert_test<(__b)>)> \
note: in instantiation of member function 'std::__1::default_delete<Widget::Impl>::operator()' requested here
            __ptr_.second()(__tmp);
note: in instantiation of member function 'std::__1::unique_ptr<Widget::Impl, std::__1::default_delete<Widget::Impl> >::reset' requested here
    _LIBCPP_INLINE_VISIBILITY ~unique_ptr() {reset();}
./widget.h:5:7: note: in instantiation of member function 'std::__1::unique_ptr<Widget::Impl, std::__1::default_delete<Widget::Impl> >::~unique_ptr' requested here

​   当用户包含了头文件,开始编译,出现了上面的错误,难道是unique_ptr不支持不完全类型(前向声明),查找相关文档发现unique_ptr是支持的不完全类型的。仔细看上面的错误,你会发现错误是因为调用了析构函数,在析构函数delete对象的时候使用了静态断言计算对象的大小。在头文件中明明没有析构相关的操作,为何会这样呢? 这一切都是C++在背后默默的帮我们生成的。因为上面的widget.h中没有声明默认的析构函数,那么C++编译器就会默默的在头文件中帮我们生成了默认析构函数,然后插入调用unique_ptr的析构函数的代码。最终导致出现了上面的错误,解决这个问题的方法也是很简单的,只需要将析构函数的定义放到.cpp文件中即可。这样在编译的时候就不会出现问题了,改造后如下:

class Widget {
  public:
   Widget();
   ~Widget();
   ...
  private:
   struct Impl;
   std::unique_ptr<Impl> pImpl;
};
// 具体实现

struct Widget::Impl {
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};
Widget::~Widget() = default;   // 使用了C++的default关键字
Widget::Widget() : pImpl(std::make_unique<Impl>(new Impl))
{}

​   上面的代码现在就可以正确的进行编译了,很不幸上面的代码虽然通过了编译,但是依旧不完善,因为上面的代码会导致Widget失去移动语义的能力,还记得在Item17中介绍过,当用户自定义了析构函数,会导致不会生成默认的移动构造函数和移动赋值操作符。所以上面的代码中尽管unique_ptr是具有移动语义的,但是因为Widget没有生成默认的移动构造函数,所以最终只会调用Widget的默认拷贝构造函数。修改起来也不难,只要主动的去声明和定义默认的移动构造和移动赋值操作符即可。

class Widget {
  public:
   Widget();
   ~Widget();
   Widget(Widget&& rhs) = default;
   Widget& operator=(Widget&& rhs) = default;
   ...
  private:
   struct Impl;
   std::unique_ptr<Impl> pImpl;
};
// 用户代码
#include "widget.h"
int main() {
  Widget w;
  Widget w1(std::move(w));  // error
}
 In instantiation of ‘void std::default_delete<_Tp>::operator()(_Tp*) const [with _Tp = Widget::Impl]’:
required from ‘std::unique_ptr<_Tp, _Dp>::~unique_ptr() [with _Tp = Widget::Impl; _Dp = std::default_delete<Widget::Impl>]’
widget.h:9:3:   required from here
invalid application of ‘sizeof’ to incomplete type ‘Widget::Impl’
  static_assert(sizeof(_Tp)>0,
                      ^

​   我们的代码正在一步步的完善,但是很可惜上面的代码居然编译没有通过,根据错误信息可以知道的是,编译失败的原因应该也是因为在头文件中包含了unique_prt析构相关的代码,导致在调用默认的删除器delete的时候进行了静态断言并失败,这是因为移动构造在遇到异常的时候会调用了unique_ptr的析构。问题既然找到了,解决这个问题也是很简单的,直接将移动语义相关的实现从头文件移除即可。

class Widget {
 public:
  Widget();
  ~Widget();
  Widget(Widget&& rhs); //具体实现放到.cpp文件中
  Widget& operator=(Widget&& rhs); //具体实现放到.cpp文件中

 private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};

​​   到此为止,上面的代码终于可以编译通过,并且正常运行了,但是因为我们使用的是std::unique_ptr,这会存在一个问题,对于Widget的拷贝是一个浅拷贝,因为uniqe_ptr是一个所有权独享的对象,对它进行拷贝会转移所有权。要想实现深拷贝,就不能依赖默认的拷贝构造函数,需要自己自定义,代码如下:

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

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

​​   如果你感兴趣的话,你完全可以把std::uniqe_ptr换成std::shared_ptr,与此同时你会惊奇的发现,上文中提到的诸多限制(要自定义析构函数,要自定义移动操作等等),在使用shared_ptr的场景下是不存在的。

// widget.h
class Widget {
 public:
  Widget();
  .....
 private:
  struct Impl;
  std::shared_ptr<Impl> pImpl;
};

// widget.cc
struct Widget::Impl {
  std::string name;
  std::vector<double> data;
};

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

// 用户代码
#include "widget.h"
int main() {
  Widget client;
  Widget b(std::move(client));
  return 0;
}

​​   上面的代码可以正常编译通过,并且具备移动语义,深拷贝,看起来是不是很神奇,几乎一样的代码居然差距如此之大,下面具体来分析一下导致这样的结果的本质原因。还记得在Item19Item18 对着两个智能指针的介绍吗?,删除器是属于unique_ptr的一部分的,通过它的源代码也可以看出这一点。

  /// Primary template of default_delete, used by unique_ptr
  template<typename _Tp>
    struct default_delete
    {   
      /// Default constructor
      constexpr default_delete() noexcept = default;

      /** @brief Converting constructor.
       *
       * Allows conversion from a deleter for arrays of another type, @p _Up,
       * only if @p _Up* is convertible to @p _Tp*.
       */
      template<typename _Up, typename = typename
         enable_if<is_convertible<_Up*, _Tp*>::value>::type>
        default_delete(const default_delete<_Up>&) noexcept { } 

      /// Calls @c delete @p __ptr
      void
      operator()(_Tp* __ptr) const
      {   
  static_assert(!is_void<_Tp>::value,
          "can't delete pointer to incomplete type");
  static_assert(sizeof(_Tp)>0,
          "can't delete pointer to incomplete type");
  delete __ptr;
      }   
    };  

​​   这也就是导致问题的最主要的原因了,头文件中包含了unique_ptr的相关头文件,很自然,关于删除器相关的代码也都被包含进去了,可以看到上面代码中的static_assrt它就是引起编译错误的罪魁祸首,如果使用shared_ptr的话那就不一样了,在前面的文章中其实也介绍过,删除器并不是shared_ptr本身所包含的,而是包含在控制块中,shared_ptr通过指针指向这个控制块,所以当你使用shared_ptr的时候自然就不会包含删除器相关的代码了,也就不需要自定义析构函数,来解决编译的问题,没有自定义析构函数,默认的移动构造函数就会生效,自然就不需要显示的声明移动构造函数了,最后,shared_ptr本身其实是值语义,因此对于拷贝来说自然是没啥问题了。不会像unique_ptr那样出现所有权转移的问题。

​​   上文中说了很多,似乎让人觉得在这里应该使用shared_ptr替换unique_ptr,因为至少从上文中可以看出shared_ptr相比于unique_ptr还是很方便的。但是使用shared_ptr会导致生成大量汇编代码,速度上相比于unique_ptr也不客观,unique_ptr则更加轻量级,在不自定义删除器的情况下大小和裸指针是一样的,通过unique_ptr访问对象的成员的开销和裸指针也是一样的,几乎没有太多的开销,所以在pimpl的惯用法下,还是鼓励使用unique_ptr

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
pimpl(Pointer to Implementation)是一种设计模式,用于隐藏类的实现细节。该模式的主要目的是将类的接口和实现分离,以便提高代码的可维护性和可扩展性。 在传统的C++开发中,类的实现细节通常会暴露在类的头文件中,这样会导致头文件的内容变得庞大且混乱。而pimpl模式通过在类中使用指向实现类的指针,将实现细节脱离类的接口部分,使得头文件只需包含一个简单的指针声明,从而实现了隐藏指针的效果。 使用pimpl模式隐藏指针有多个好处。首先,它可以提高编译速度,因为只有头文件的改变才会导致需要重新编译的文件数量减少;其次,它可以减少对外部用户的依赖,当类的实现发生变化时,只需要重新编译实现文件而无需重新编译使用该类的其他文件;此外,pimpl模式还可以提高二进制兼容性,因为只有指针的大小发生变化,而不是整个类的大小。 使用pimpl模式时,首先需要在类的头文件中声明一个指向实现类的指针,并在类的实现文件中定义实现类。然后,在类的构造函数和析构函数中创建和销毁实现类的对象,并在类的成员函数中通过指针访问实现类的成员。 因此,pimpl模式允许将实现细节从类的接口中分离出来,提高了代码的可维护性和可扩展性。使用pimpl隐藏指针的设计模式可以提高编译速度、减少对外部用户的依赖以及提高二进制兼容性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值