pimpl idiom in c++

Introduction

在C++里面, 经常出现的情况就是头文件里面的类定义太庞大了,而这个类的成员变量涉及了很多 其他文件里面的类,从而导致了其他引用这个类的文件也依赖于这些成员变量的定义。 在这种情况下,就出现了在C++里面特有的一个idiom,叫做Pimpl idiom。

考虑一下下面的情况,假设有一个类A,它包含了成员变量b和c,类型分别为B和C,而如果D类 要使用A类的话,那也变相依赖了B和C。如下:

  
  
  1. 1 #include "B.h"
  2. 2 #include "C.h"
  3. 3
  4. 4 class A
  5. 5 {
  6. 6 private:
  7. 7 B b;
  8. 8 C c;
  9. 9 };

这个时候如果D要使用A类的话,那么D就要像下面那样去写:

  
  
  1. 1 #include "A.h"
  2. 2
  3. 3 class D
  4. 4 {
  5. 5 private:
  6. 6 A a;
  7. 7 };

虽然形式上是只需要include A.h,但是在链接程序的时候,却需要把B和C的模块也一并链接进去。

初步的解决方案可以是把A里面的b和c变成指针类型,然后利用指针声明的时候类型可以是不完全类型, 从而在A.h里面不用include B.h和C.h。当然,这也只是解决的部分的问题。 如果A里面需要用到十几个成员变量的话,这个时候头文件的size就会变得很大,这也是一个问题。 而且有些时候,变成指针类型也不一定是可行的。这个时候,一个简单的想法就是把所有私有的 成员变量的声明都放到cpp文件里面去,这样使用A的类就可以完全不用知道A类的成员变量了。

Pimpl Idiom

而Pimpl idiom就是这样的解决方案。所谓的Pimpl idiom,就是声明一个类中类, 然后再声明一个成员变量,类型是这个类中类的指针。用上面的例子来说明一下会清楚一下, 代码如下:

  
  
  1. 1 class A
  2. 2 {
  3. 3 private:
  4. 4 struct Pimpl;
  5. 5 Pimpl* m_pimpl;
  6. 6 };

有了上面的定义,那么D类就可以完全不用知道A类的细节,而且链接的时候也可以完全不用管B和C了。 然后在A.cpp里面,我们就像下面这样去定义就好了:

  
  
  1. 1 struct A::Pimpl
  2. 2 {
  3. 3 B b;
  4. 4 C c;
  5. 5 };
  6. 6
  7. 7 A::A()
  8. 8 : m_pimpl(new Pimpl)
  9. 9 {
  10. 10 m_impl->b; // 使用b
  11. 11 }

而现在我们STL有auto_ptr,boost有shared_ptr,再要自己来管理内存好像 就有写多次一举了。所以在Herb Sutter的Using auto_ptr Effectively里面, 也提到了用auto_ptr来进行“经典”的Pimpl的编写。

也就是如下面这样:

  
  
  1. 1 #include <memory>
  2. 2
  3. 3 class A
  4. 4 {
  5. 5 public:
  6. 6 A();
  7. 7
  8. 8 private:
  9. 9 struct Pimpl;
  10. 10 std::auto_ptr<Pimpl> m_pimpl;
  11. 11 };

可以当你写了上面的代码之后,编译,Bang! 编译器给你报一个错,说是Pimpl是incomplete type。这下你就蒙了吧?!

其实要fix上面的编译错误,你只需要加上A的destructor的声明,然后在cpp文件里面实现一个 空的destructor就可以了。

但是这个是为什么呢?

auto_ptr的模板特化

其实上面问题的原因,是跟模板特化的这个C++变态特性有关的。

我们先来看一下auto_ptr的简化定义:

  
  
  1. 1 template <typename T>
  2. 2 class auto_ptr
  3. 3 {
  4. 4 public:
  5. 5 auto_ptr()
  6. 6 : m_ptr(NULL)
  7. 7 {}
  8. 8
  9. 9 auto_ptr(T* p)
  10. 10 : m_ptr(p)
  11. 11 {}
  12. 12
  13. 13 ~auto_ptr()
  14. 14 {
  15. 15 if (m_ptr)
  16. 16 {
  17. 17 delete m_ptr;
  18. 18 }
  19. 19 }
  20. 20
  21. 21 private:
  22. 22 T* m_ptr;
  23. 23 };

我们看到auto_ptr在他的构造函数里面自动的delete了他的m_ptr,这个就是比较经典的 利用RAII实现的智能指针了。

然后还要知道,auto_ptr是一个模板类,而模板类的一个特点是, 当他的成员函数只有在被调用的时候才会真正的做函数特化

也就是说,如果有下面的这样一个模板类:

  
  
  1. 1 template <typename T>
  2. 2 class TemplateClass
  3. 3 {
  4. 4 public:
  5. 5 void Foo()
  6. 6 {
  7. 7 int a = 1;
  8. 8 return;
  9. 9 }
  10. 10
  11. 11 void Bar()
  12. 12 {
  13. 13 this->m_ptr = "syntax correct, but semantic incorrect.";
  14. 14 }
  15. 15 };
  16. 16
  17. 17 int main(int argc, char *argv[])
  18. 18 {
  19. 19 TemplateClass<int> a;
  20. 20 a.Foo();
  21. 21 return 0;
  22. 22 }

上面的代码,是可以通过编译并且正确运行的。可以看到Foo这个函数是正确的,而Bar函数虽然 语法上是正确的,但是他的语义是错的。但是由于我们只调用了Foo,没有调用Bar, 所以只有Foo被真正的特化并且做了完全的编译,而Bar只是做了语法上的检查, 并没有做语义的检查。所以上面的代码在C++里面是100%的正确的。

所以auto_ptr里面的成员函数,包括构造和析构函数,都是在被调用的时候才进行真正的特化。

Default Destructor

还记得在学C++的刚开始的时候书上这么说过,不定义构造函数或者析构函数, 那么编译器会帮我们造一个默认的。而这个默认的构造或者析构函数只会做成员变量还有父类的 默认初始化或者析构,其他什么都不会做。

那么我们看回利用了Pimpl的A的定义。在这个定义里面,由于我没有写析构函数的声明, 所以编译器自动帮我定义了一个。而A里面有一个auto_ptr成员变量,所以在这个默认的 析构函数里面会析构这个成员变量。所谓的析构,其实就是调用析构函数而已。 所以,在这个默认的析构函数里面,调用了auto_ptr的析构函数,这个时候, auto_ptr的析构函数就被编译器特化了。

而在auto_ptr的析构函数里面,delete了模板参数的指针类型的成员变量。 而在A这个例子里面,模板参数就是Pimpl。而在特化的这一瞬间,Pimpl是被声明了, 但是还没有被定义。

所以例子里面的A在经过编译后是和下面的代码等价的:

  
  
  1. 1 class A
  2. 2 {
  3. 3 public:
  4. 4 A();
  5. 5 ~A()
  6. 6 {
  7. 7 ~auto_ptr<Pimpl>(m_pimpl);
  8. 8 }
  9. 9
  10. 10 private:
  11. 11 struct Pimpl;
  12. 12 std::auto_ptr<Pimpl> m_pimpl;
  13. 13 };
  14. 14
  15. 15 auto_ptr<Pimpl>::~auto_ptr()
  16. 16 {
  17. 17 delete m_ptr; // m_ptr的类型是Pimpl*
  18. 18 }

那为什么当我加上A的析构函数的声明之后,编译就可以通过呢?因为当我们声明了A的析构函数之后, 编译器就不会自动生成析构函数的实现了,而由于我们会在cpp文件里面去写析构函数的实现, 而在此之前,我们就会在cpp文件的开头定义好Pimpl的实现。所以当我们自己写的A的析构函数 被编译器看见的时候,Pimpl就是一个已经定义好的类型,所以就没有问题了。

Pimpl by boost::shared_ptr

其实使用auto_ptr来实现Pimpl Idiom并不是唯一的方法,Pimpl还可以用 boost::scoped_ptr和boost::shared_ptr来实现。而scoped_ptr和auto_ptr 其实是一样的,也是需要用户手工的声明一个析构函数来实现Pimpl Idiom,这里就不说了。

但是通过shared_ptr来实现的话,我们就连析构函数都可以省略!也就是说, 如果我写下面的代码,是完全正确的:

  
  
  1. 1 class A
  2. 2 {
  3. 3 public:
  4. 4 A();
  5. 5
  6. 6 private:
  7. 7 struct Pimpl;
  8. 8 boost::shared_ptr<Pimpl> m_pimpl;
  9. 9 };

需要注意的是,虽然析构函数可以省略,但是构造函数还是必须明确声明的。 这又是为什么呢?为什么auto_ptr不行,但是shared_ptr就可以呢?

答案就在shared_ptr的实现里面。

相信shared_ptr应该是每个较为深入学过C++的人都会理解原理的一个类了,其中shared_ptr 的实现又可以分为侵入式和非侵入式的,而boost::shared_ptr的实现是非侵入式的。 也就是说要用shared_ptr的类不需要任何改动就可以使用了。

来看看简化之后的shared_ptr的实现吧:

  
  
  1. 1 class sp_counted_base
  2. 2 {
  3. 3 public:
  4. 4 virtual ~sp_counted_base(){}
  5. 5 };
  6. 6
  7. 7 template<typename T>
  8. 8 class sp_counted_base_impl : public sp_counted_base
  9. 9 {
  10. 10 public:
  11. 11 sp_counted_base_impl(T *t):t_(t){}
  12. 12 ~sp_counted_base_impl(){delete t_;}
  13. 13 private:
  14. 14 T *t_;
  15. 15 };
  16. 16
  17. 17
  18. 18 class shared_count
  19. 19 {
  20. 20 public:
  21. 21 static int count_;
  22. 22 template<typename T>
  23. 23 shared_count(T *t):
  24. 24 t_(new sp_counted_base_impl<T>(t))
  25. 25 {
  26. 26 count_ ++;
  27. 27 }
  28. 28 void release()
  29. 29 {
  30. 30 --count_;
  31. 31 if(0 == count_) delete t_;
  32. 32 }
  33. 33 ~shared_count()
  34. 34 {
  35. 35 release();
  36. 36 }
  37. 37 private:
  38. 38 sp_counted_base *t_;
  39. 39 };
  40. 40 int shared_count::count_(0);
  41. 41
  42. 42 template<typename T>
  43. 43 class myautoptr
  44. 44 {
  45. 45 public:
  46. 46 template<typename Y>
  47. 47 myautoptr(Y* y):sc_(y),t_(y){}
  48. 48 ~myautoptr(){ sc_.release();}
  49. 49 private:
  50. 50 shared_count sc_;
  51. 51 T *t_;
  52. 52 };
  53. 53
  54. 54 int main()
  55. 55 {
  56. 56 myautoptr<A> a(new B);
  57. 57 }

从上面的代码可以看到,shared_ptr里面不单存了一个模板类型的指针, 还存了一个shared_count。 这个shared_count的作用就是用来作为引用计数还有自动管理指针用的。 而shared_count里面又存了一个sp_counted_base,而sp_counted_base_impl 是一个模板类,其继承于sp_counted_base。这其实是一个模板技巧,也就是声明一个 通用的基类,然后定义一个模板类来继承于这个基类,而其他类通过基类的指针来使用这个模板类, 这样就可以在编译时确定一些类型信息,而同时把一些通用的实现细节推迟到运行时。这句话什么意思呢? 看完接下来的解释你就明白了。

接下来我们又要注意到,shared_ptr和shared_count的构造函数都是模板成员函数, 模板类型由参数决定,而这个技巧和上面的模板继承技巧组合在一起,就是这节开始的时候, 例子中不用写析构函数的理由。

首先,当我们声明一个shared_ptr<int>的时候,它只是把里面的t_成员给特化了, 而shared_count里面存的是什么类型的指针仍然没有确定。

而当我们调用shared_ptr<int>(new int(3))的时候,他就调用了shared_ptr的构造函数。 这个时候就特化了模板构造函数,然后这个构造函数里面又调用了shared_count的构造函数, 所以shared_count的构造函数也被特化,而又同时特化了sp_counted_base_impl, 这个时候里面的指针就完全被特化了。

而我们看到,在shared_ptr被析构的时候,它调用的是shared_count的release函数, release函数里面又delete了它的类型为sp_counted_base的指针, 所以调用的是sp_counted_base的析构函数(虚函数)。因为是虚函数,当具体类型确定之后, 是会具体调用到具体的析构函数的。但是在编译的时候,不需要知道具体的类型。

说了那么多,其实就是一句话,调用shared_ptr的析构函数的时候,它不需要知道具体的指针类型。 也就是说这个类型即使incomplete也没有关系。而在调用shared_ptr的构造函数的时候, shared_ptr就是会知道这个类型的所有信息,从而使得delete的时候调用到具体的析构函数。

所以对于shared_ptr来说,构造函数需要知道所有的类型信息,而析构函数是不要知道类型信息的。 回到例子里面,当我们不声明析构函数的时候,编译器为我们定义了一个默认的析构函数, 这个时候shared_ptr的析构函数就会被特化并定义,同时也调用sp_counted_base 的析构函数也就被编译了。但是这个时候并不许要具体的类型信息, 所以类型是incomplete也是可以的。当我们定义A的构造函数的时候,这个时候shared_ptr 的构造函数就被特化,从而shared_count的构造函数被特化,而sp_counted_base_impl 也就是被特化了。这个时候shared_ptr也就有了所有必要的类型信息, 他的析构函数就可以正常的工作了。

这就是为什么用shared_ptr来实现Pimpl可以不用写析构函数的原因了, 为了实现这个功能,shared_ptr牺牲了一点点的空间来完成上面的概念,比普通的shared_ptr 多了一个sizeof(sp_counted_base*)的大小。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值