Pimpl
本文最后更新于:April 20, 2022 am
本篇为智能指针系列第五篇,介绍Pimpl的设计模式,以及在使用时的注意事项
系列导航:智能指针
为什么会有Pimpl
如果你曾经在某个工程里无数次地进行构建,那么你对Pimpl原则——“pointer to implementation”——应该不陌生。 这种技巧也不难,只需要将自定义类的成员变量换成指向包裹了成员变量的新的类的实例的指针,然后通过指针间接地访问这些变量。举例而言,假设Widget类本来定义成这样:
1 | |
由于Widget类的成员变量有std::string, std::vector 和 Gadget,只要在使用了类Widget的源文件中,都必须要包含有这几句话——#include<string> , #include<vector>, #include"Gadget.h"
对于Widget类的使用者来说,这些头文件让编译时间变得更长,并且使得源文件的内容依赖于头文件的内容
vector和string的头文件一般不会有改动,问题出在Gadget。一旦Gadget.h的文件内容改变了,所有使用了Widget类的源文件都得重新编译一次。
Pimpl in C++98
Pimpl原则是C++98提出的,让指向对象的指针代指向一个结构体,结构体中有原先的成员变量。不过得在类中声明结构体,但不必定义:
1 | |
Widget不再使用std::string, std::vector和Gadget,Widget的使用者也不再需要include头文件,因此编译此源文件也更快了,同时头文件的改变也不会影响什么。
一个被声明但是没有被定义的类,叫做不完整类型(incomplete type),Widget::Impl就是一个例子。我们能在不完整类型上做的事情不多,但是可以声明一个指向它的指针。Pimpl正好利用了这一点。
上面这部分代码是Pimpl中类的声明,下面部分代码是指针所指向的类的分配和释放代码。Impl类保有原来的string vector 和Gadget类的数据对象。这些代码在Widget的实现文件中,比如Widget.cpp:
1 | |
尽管此处只需要包含gadget.h就可以了,但是为了直观地体现Pimpl对于头文件的解耦合,还是将其它的头文件也写出来了。上述两段代码中,gadget.h的改变就只会影响widget.cpp文件了。此处由于使用的裸指针, 因此需要在dtor中进行资源的释放。
使用std::unique_ptr
上面的是C++98的写法,裸指针的写法充满了年代感,现在都已经是1202年了。如果我们仅仅希望在Widget的构造其中动态分配一个Widget::Impl的对象,然后在Widget的析构过程中将其释放掉,使用std::unique_ptr就可以了
1 | |
1 | |
这便是使用Pimpl再加上智能指针的好处之一,可以让我们少写一行代码,反正我是挺开心的
问题:不完全类型无法析构
虽然这种代码本身能够编译,但是,使用Widget类的使用者却不这么想
1 | |
编译器提示的错误信息因具体的编译器版本不同而不同,但大多数会提到“对不完整类型执行sizeof操作或者delete操作”。的确,这些操作的对象不能是不完整类型。
在使用std::unique_ptr优化Pimpl时,这种明显的失败令人十分紧张:
首先,std::unique_ptr是被标榜可以支持Pimpl中的不完整类型的
其次,Pimpl是std::unique_ptr的最常用情形
原因
幸运的是,要解决这个问题也很简单,这只需要对出现问题的原因进行一点简单的了解:
这段代码在编译期报错的具体时机,是在w被销毁的时候(比如超出作用域)。此时我们需要调用Widget类的析构函数。但由于我们在析构函数中无事可做,所以在Widget.h中,我们也没有去声明这个函数。根据编译器自动生成的特殊成员函数的一般规则(Item17),编译器会替我们生成一份析构函数。
在这个函数中,编译器替我们调用了pImpl的析构函数,此处也就是std::unique_ptr<Widget::Impl>的析构函数,也就是一个使用默认deleter的std::unique_ptr实例。那么我们在Item18中了解到,std::unique_ptr是默认通过delete操作符释放资源的。
在调用delete操作符之前,编译器会使用C++11的static_assert来保证这个裸指针不会指向一个不完整类型。当编译器为Widget w生成销毁代码时,它遇到了这个static_assert,并且断言检测失败了,导致报错。
这个报错和w被销毁的时间点有关,因为Widget的析构函数,被隐式地声明成了inline类型,就像所有编译器生成的特殊成员函数一样。
此外,编译器通常在Widget w这一行报错,因为正是这一行导致了后续的析构中断言检测失败。
要解决这个问题,就需要保证在销毁std::unique_ptr<Widget::Impl>对象的代码生成时,Widget::Impl已经是一个完整的类型了。当一个类型的定义已经被编译器完整地看见时,它就是完整类型了。此处,Widget::Impl被定义在widget.cpp中。进一步可知,编译成功的关键,就是让编译器看见Widget的析构函数体之前(或者编译器在为Widget生成析构函数之前),就看见Widget::Impl类型的定义。
解决方案
这只需要对上面的代码进行一小点调整,我们在widget.h文件中声明Widget类的析构函数,但是不加以定义。随后在widget.cpp文件中,先定义Impl类,再定义Widget类的析构函数
1 | |
这样ok了,需要多写的代码也很少。如果你想强调,编译器自动生成的析构函数没有问题,我们在Widget.h的头文件中声明析构函数的理由,仅仅是希望将析构函数的定义延迟到Widget.cpp文件中去,也可以使用29行的方式去实现析构函数。
使用移动语义
使用Pimpl设计的类天然就很适合移动语义,因为编译器为我们生成的移动赋值运算符版本正好做了正确的事:在Widget类的std::unique_ptr<Widget>变量上执行移动操作。Item17已经解释过,在类Widget中声明析构函数会阻止编译器生成移动赋值运算函数。因此,如果希望Widget类支持移动赋值,需要在头文件中声明之。
考虑到编译器生成的版本就能正常工作了,你可能会以为这样写就是对的:
1 | |
第6行的问题出在移动赋值运算符,这会导致类似于上文讨论的析构函数的错误。编译器帮忙生成的移动赋值函数中,在将pImpl所指向的对象重新赋值之前,需要把此对象销毁掉[1],但是在Widget的头文件中,pImpl还是个不完整的类型。
修改的方式也很简单,就是在头文件中只声明移动赋值函数和移动构造函数,在cpp文件中再定义。
那为什么移动构造函数的实现也被下放到cpp文件中了呢?可以尝试一下,如果不放下去,编译器还是会报错。对于移动构造函数而言,情况就显得不同了。在编译器帮忙生成移动构造函数时,会为异常兜底:
若移动构造函数内部抛出了异常,编译器会生成代码将pImpl析构掉,也就是将Impl对象析构掉,这也需要Impl对象在此时是完整的。
改进后的代码:
1 | |
拷贝操作
Pimpl可以减少类的实现和类的使用者之间的依赖,但是,从概念上说,这种方式并不会改变这个类原先代表的内容。最原来的Widget类有std::string,std::vector和Gadget三个类型的各三个成员变量,如果上述三种类型都支持拷贝操作,那么Widget也要能支持拷贝操作才说得通。这些函数需要我们自己手动去写,因为:
- 对于含有只可移动(move-only)类型(比如
std::unique_ptr)成员变量的类,编译器不会自动生成拷贝赋值函数。 - 即使编译器自动帮我们生成了拷贝赋值函数,也只会对
std::unique_ptr进行浅拷贝,但是这里我们需要做深拷贝。
现在我们已经比较熟悉Pimpl的用法了。通常,我们在头文件中声明函数,然后在实现类的文件中去实现之。
1 | |
两个函数的实现都比较传统。在每种情况下,我们简单地将一个Impl对象的所有成员变量拷贝到新的Impl对象中去(源对象->*this)。我们利用编译器会自动为Impl类生成拷贝构造器和拷贝赋值运算符的特点,没有手动地一个成员变量一个成员变量地去拷贝。此处,我们仍然遵循Item21的提示,尽量使用std::make_unique,而不是new
为了实现Pimpl,必须使用std::unique_ptr,因为pImpl在Widget类里,而每一个Widget的实例都有自己的pImpl实例。通过使pImpl指向Gadget实例,可以对后者进行独占式管理。
使用std::shared_ptr
如果我们使用std::shared_ptr会发生什么情况呢?有趣的是,如果使用std::shared_ptr,那么本篇Item所提供的建议就全都不适用了——不需要在Widget.h中声明析构函数,并且即使用户没有去声明析构函数,编译器也会很开心地自动帮我们生成移动赋值函数,并且做我们希望的事:
1 | |
用户代码这样写:
1 | |
编译运行,一切都会如期进行:w1会通过默认构造器进行构造,然后w2通过移动构造将w1的内容接管了,最后w1通过移动赋值将w2的内容接管了。最后w1,w2都被析构,Impl对象被释放。
为什么会出现这样的不同?原因出在这两种不同的智能指针对自定义deleter的支持方式不同。
回忆Item21中的内容,对于std::unique_ptr而言,deleter的类型是自身的一部分,这使得编译器能够生成更小体积的运行时数据结构和更快的代码。这样追求性能的代价就是,在这些被编译器生成的特殊函数(析构函数,移动赋值)被调用之前,被指向的类型必须是完整定义的。
对于std::shared_ptr而言,deleter的类别并不是自身的一部分。这虽然导致了在运行时的更大的数据结构和稍微更慢的代码,但就不需要保证上述编译器生成的特殊函数被调用时,指针所指向的类型一定要是完整的。
对于Pimpl设计而言,很难在std::unique_ptr和std::shared_ptr之间做出取舍。对于Widget,它当然独占了Impl实例的管理权,std::unique_ptr在语义层面获胜。
但是在一些其它情况中一旦允许共享式管理——因此更应该使用std::shared_ptr,也就不需要忍受刻意地提前编写函数声明再延后实现的折磨了。
总结
Pimpl通过减少类的用户和类的实现之间的依赖,减少构建次数
使用std::unique_ptr时,在类的声明中提前声明特殊的成员函数,再将函数实现后延到类的实现文件中。
即使希望使用编译器自动生成的函数,也要这么做!
上述的建议只适用于std::unique_ptr,而非std::shared_ptr
- 笔者注:由于自身水平不够,暂时不是特别能理解这句话,为什么是先析构再赋值? ↩
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!