Introduction
在C++里面, 经常出现的情况就是头文件里面的类定义太庞大了,而这个类的成员变量涉及了很多 其他文件里面的类,从而导致了其他引用这个类的文件也依赖于这些成员变量的定义。 在这种情况下,就出现了在C++里面特有的一个idiom,叫做Pimpl idiom。
考虑一下下面的情况,假设有一个类A,它包含了成员变量b和c,类型分别为B和C,而如果D类 要使用A类的话,那也变相依赖了B和C。如下:
1 #include "B.h"
2 #include "C.h"
3
4 class A
5 {
6 private:
7 B b;
8 C c;
9 };
这个时候如果D要使用A类的话,那么D就要像下面那样去写:
1 #include "A.h"
2
3 class D
4 {
5 private:
6 A a;
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 class A
2 {
3 private:
4 struct Pimpl;
5 Pimpl* m_pimpl;
6 };
有了上面的定义,那么D类就可以完全不用知道A类的细节,而且链接的时候也可以完全不用管B和C了。 然后在A.cpp里面,我们就像下面这样去定义就好了:
1 struct A::Pimpl
2 {
3 B b;
4 C c;
5 };
6
7 A::A()
8 : m_pimpl(new Pimpl)
9 {
10 m_impl->b; // 使用b
11 }
而现在我们STL有auto_ptr,boost有shared_ptr,再要自己来管理内存好像 就有写多次一举了。所以在Herb Sutter的Using auto_ptr Effectively里面, 也提到了用auto_ptr来进行“经典”的Pimpl的编写。
也就是如下面这样:
1 #include <memory>
2
3 class A
4 {
5 public:
6 A();
7
8 private:
9 struct Pimpl;
10 std::auto_ptr<Pimpl> m_pimpl;
11 };
可以当你写了上面的代码之后,编译,Bang! 编译器给你报一个错,说是Pimpl是incomplete type。这下你就蒙了吧?!
其实要fix上面的编译错误,你只需要加上A的destructor的声明,然后在cpp文件里面实现一个 空的destructor就可以了。
但是这个是为什么呢?
auto_ptr的模板特化
其实上面问题的原因,是跟模板特化的这个C++变态特性有关的。
我们先来看一下auto_ptr的简化定义:
1 template <typename T>
2 class auto_ptr
3 {
4 public:
5 auto_ptr()
6 : m_ptr(NULL)
7 {}
8
9 auto_ptr(T* p)
10 : m_ptr(p)
11 {}
12
13 ~auto_ptr()
14 {
15 if (m_ptr)
16 {
17 delete m_ptr;
18 }
19 }
20
21 private:
22 T* m_ptr;
23 };
我们看到auto_ptr在他的构造函数里面自动的delete了他的m_ptr,这个就是比较经典的 利用RAII实现的智能指针了。
然后还要知道,auto_ptr是一个模板类,而模板类的一个特点是, 当他的成员函数只有在被调用的时候才会真正的做函数特化。
也就是说,如果有下面的这样一个模板类:
1 template <typename T>
2 class TemplateClass
3 {
4 public:
5 void Foo()
6 {
7 int a = 1;
8 return;
9 }
10
11 void Bar()
12 {
13 this->m_ptr = "syntax correct, but semantic incorrect.";
14 }
15 };
16
17 int main(int argc, char *argv[])
18 {
19 TemplateClass<int> a;
20 a.Foo();
21 return 0;
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 class A
2 {
3 public:
4 A();
5 ~A()
6 {
7 ~auto_ptr<Pimpl>(m_pimpl);
8 }
9
10 private:
11 struct Pimpl;
12 std::auto_ptr<Pimpl> m_pimpl;
13 };
14
15 auto_ptr<Pimpl>::~auto_ptr()
16 {
17 delete m_ptr; // m_ptr的类型是Pimpl*
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 class A
2 {
3 public:
4 A();
5
6 private:
7 struct Pimpl;
8 boost::shared_ptr<Pimpl> m_pimpl;
9 };
需要注意的是,虽然析构函数可以省略,但是构造函数还是必须明确声明的。 这又是为什么呢?为什么auto_ptr不行,但是shared_ptr就可以呢?
答案就在shared_ptr的实现里面。
相信shared_ptr应该是每个较为深入学过C++的人都会理解原理的一个类了,其中shared_ptr 的实现又可以分为侵入式和非侵入式的,而boost::shared_ptr的实现是非侵入式的。 也就是说要用shared_ptr的类不需要任何改动就可以使用了。
来看看简化之后的shared_ptr的实现吧:
1 class sp_counted_base
2 {
3 public:
4 virtual ~sp_counted_base(){}
5 };
6
7 template<typename T>
8 class sp_counted_base_impl : public sp_counted_base
9 {
10 public:
11 sp_counted_base_impl(T *t):t_(t){}
12 ~sp_counted_base_impl(){delete t_;}
13 private:
14 T *t_;
15 };
16
17
18 class shared_count
19 {
20 public:
21 static int count_;
22 template<typename T>
23 shared_count(T *t):
24 t_(new sp_counted_base_impl<T>(t))
25 {
26 count_ ++;
27 }
28 void release()
29 {
30 --count_;
31 if(0 == count_) delete t_;
32 }
33 ~shared_count()
34 {
35 release();
36 }
37 private:
38 sp_counted_base *t_;
39 };
40 int shared_count::count_(0);
41
42 template<typename T>
43 class myautoptr
44 {
45 public:
46 template<typename Y>
47 myautoptr(Y* y):sc_(y),t_(y){}
48 ~myautoptr(){ sc_.release();}
49 private:
50 shared_count sc_;
51 T *t_;
52 };
53
54 int main()
55 {
56 myautoptr<A> a(new B);
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*)
的大小。