如果你曾经与过长的编译时间抗争过,你必定会熟悉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