C++程序员应了解的那些事(8) PIMPL模式(指向实现的指针)
【1】C++之善用PIMPL技巧解決/改善C++编码时常碰到的2大问题。
(1)class增加private/protected成员时,使用此class的相关 .cpp(s) 需要重新编译。
(2)定义冲突与跨平台编译。
(1)class增加private/protected成员时,使用此class的相关 .cpp(s) 需要重新编译。
假设我们有一个A.h(class A),并且有A/B/C/D 4个.cpp引用他,他们的关系如下图:
如果A class增加了private/protected成员(即A.h文件发生修改),A/B/C/D .cpp全部都要重新编译。因为make是用文件的时间戳记录来判断是否要从新编译,当make发现A.h比A/B/C/D .cpp4个文件新时,就会通知compiler重新编译他们,就算你的C++ compiler非常聪明,知道B/C/D文件只能存取A class public成员,make还是要通知compiler起来检查。三个文件也许还好,那五十个,一百个呢?
<解决方案>
//a.h
#ifndef A_H
#define A_H
#include <memory>
class A
{
public:
A();
~A();
void doSomething();
private:
struct Impl;
std::auto_ptr<impl> m_impl;
};
#endif
有一定C++基础的人都知道,使用前置声明(forward declaration)可以减少编译依赖,这个技巧告诉compile指向 class/struct的指针,而不用暴露struct/class的实现。在这里我们把原本的private成员封裝到struct A::Impl里,用一个不透明的指针(m_impl)指向他,auto_ptr是个smart pointer(from STL),会在A class object销毁时连带将资源销毁还给系统。
//a.cpp
#include <stdio.h>
#include "a.h"
//子类型A::Impl 实现
struct A::Impl
{
int m_count;
Impl();
~Impl();
void doPrivateThing();
};
A::Impl::Impl():
m_count(0)
{
}
A::Impl::~Impl()
{
}
void A::Impl::doPrivateThing()
{
printf("count = %d\n", ++m_count);
}
//CLASS A实现
A::A():m_impl(new Impl)
{
}
A::~A()
{
}
void A::doSomething()
{
m_impl->doPrivateThing();
}
上面我们可以看到A private数据成员和成员函数全部被封裝到struct A::Impl里,如此一来无论private成员如何改变都只会重新编译A.cpp,而不会影响B/C/D.cpp,节约大量编译时间。
(2)定义冲突与跨平台编译(非重点)
如果你运气很好公司配给你8 cores CPU、SSD、32G DDRAM,会觉得PIMPL是多此一举。
但定定义冲突与跨平台编译问题不是电脑牛叉能够解決的,举个例子,你想在Windows上使用framework(例如 Qt)不具备的功能,你大概会这样做:
//foo.h
#ifndef FOO_H
#define FOO_H
#include <windows.h>
class Foo
{
public:
Foo();
~Foo();
void doSomething();
private:
HANDLE m_handle;
};
#endif
Foo private数据成员: m_handle和系统相关,某天你想把Foo移植到Linux,因为Linux是用int来作为file descriptor,为了与Windows相区分,最直接的方法是用宏:
//foo.h
#ifndef FOO_H
#define FOO_H
#ifdef _WIN32
#include <windows.h>
#else
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#endif
class Foo
{
public:
Foo();
~Foo();
void doSomething();
private:
#ifdef _WIN32
HANDLE m_handle;
#else
int m_handle;
#endif
};
#endif
上面的做法会有什么问题?
①windows.h是个巨大的header file,有可能会增加引用此header file的其他.cpp(s)编译时间,而实际上这些.cpp并不需要windows.h里面的内容。
②windows.h会与framework冲突,虽然大部分的framework极力避免发生这种事情,但往往项目变得越来越大后常常出现这类编译错误,(Linux也可能发生)。
③对于Linux用户,Windows那些header file是多余的,对于Windows用户Linux header files是多余的,沒必要也不该知道这些细节。
【2】c++11 条款22:当使用Pimpl(指向实现的指针)时,在实现文件里定义特定的成员函数。
假如你曾经和过多的编译构建时间抗争过,你应该熟悉Pimpl(指向实现的指针)这个术语。这项技术是你可以把类的数据成员替换成一个指向实现类(结构)的指针,把原来在主类中的数据成员放置到实现类中,然后通过指针间接的访问这些数据。比如我们的Widget类是这样的:
class Widget { // in header "widget.h"
public:
Widget();
…
private:
std::string name;
std::vector<double> data;
Gadget g1, g2, g3; // Gadget is some user-
}; // defined type
因为Widget的数据成员包括了std::string,std::vector,Gadget类型,这些类型的头文件必须和Widget去编译,那说明Widget客户端必须#include <string>, <vector>, 以及gadget.h。这些头文件增加了Widget客户端的编译时间,加上它们会使得客户端依赖于那些头文件的内容。假如一个头文件的内容改变了,Widget客户端必须重编译。标准头文件<string>和<vector>不会经常改变,但gadget.h有可能会经常修正。
<探讨1>把C++98中的Pimpl技术应用在这里可以把Widget的数据成员替换成一个指向已声明但未定义的结构的原始指针:
class Widget { // still in header "widget.h"
public:
Widget();
~Widget(); // dtor is needed—see below
…
private:
struct Impl; // declare implementation struct
Impl *pImpl; // and pointer to it
};
因为Widget没有提及std::string,std::vector和Gadget类型,Widget客户端不再需要#include这些类型的头文件。这会加速编译,而且意味着即使这些头文件的内容发生改变,Widget客户端也不受影响。
一个已声明但未定义的类型被称作不完整类型。Widget::Impl 就是这样的类型。对一个不完整类型你可以做的事情很少,但是声明一个指向它的指针就是其中之一可做的事情。Pimpl技巧就利用这点。
Pimpl技巧的第一部分是声明一个指向不完整类型的指针的数据成员,第二部分是动态分配和析构该对象(对象里保存了在原始类里的数据成员)。分配和析构的代码放在实现文件里。比如对Widget,就到到Widget.cpp里:
#include "widget.h" // in impl. file "widget.cpp"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // definition of Widget::Impl
std::string name; // with data members formerly
std::vector<double> data; // in Widget
Gadget g1, g2, g3;
};
Widget::Widget(): pImpl(new Impl) // allocate data members for this Widget object
{
}
Widget::~Widget() // destroy data members for this object
{
delete pImpl;
}
这里显示的#include指令表明对std::string, std::vector和 Gadget的总体依赖依然存在。然而这些依赖关系以及从Widget.h(对Widget客户端可见并被使用)转移到了Widget.cpp(只对Widget实现者可见并被使用)。我也已经标注了动态分配和析构Impl对象的代码。当Widget被销毁时需要析构该对象,这是Widget析构函数所必须做的。
但上面展示的是c++98的代码,这已经是上个世纪的标准了。它使用了原始指针,原始的new和delete,因此都是原始的。这一章的主旨是尽量使用智能指针而不用原始指针,假如我们需要的是在Widget构造函数里动态的分配一个Widget::Impl 对象,同时析构时自动释放对象,那么std::unique_ptr(见条款18)恰恰就是一个我们需要的工具! 用std::unique_ptr代替原始的指向Impl的指针,产生如下的代码:
头文件如下:
class Widget { // in "widget.h"
public:
Widget();
…
private:
struct Impl;
std::unique_ptr<Impl> pImpl; // use smart pointer instead of raw pointer
};
实现文件如下:
#include "widget.h" // in "widget.cpp"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // as before
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
// per Item 21, create std::unique_ptr via std::make_unique !!
Widget::Widget() : pImpl(std::make_unique<Impl>())
{
}
<探讨2>这段代码可以通过编译,但很可惜在少数的客户端不能使用。
#include "widget.h"
Widget w; // error!
错误信息依赖你使用的编译器,但大致信息会提到关于在不完整类型上使用了sizeof或delete。这些操作不能在该类型上使用。使用std::unique_ptr来实现Pimpl技巧的失败而发出告警:因为
①std::unique_ptr宣传是可以支持不完整类型的,而且
②PIMPL技巧是std::unique_ptr最常用的场景之一。
幸运的是,使这些代码工作很容易,需要基本了解这个问题的产生原因。
这个问题主要是w被销毁时(比如超出范围)所执行的代码引起的。被销毁时析构函数被调用,在定义std::unique_ptr的类里,我们没有声明析构函数,因为我们没有代码要放到其中。根据编译器会生成特定成员函数的基本规则(见条款17),编译器为我们产生了一个析构函数。在析构函数内,编译器插入代码调用Widget的数据成员pImpl的析构函数,pImpl是std::unique_ptr<Widget::Impl>,std::unique_ptr使用默认删除器。这个默认删除器会去删除std::unique_ptr内部的原始指针,然而在删除前,c++11中典型的实现会使用static_assert去确保原始指针没有指向不完整类型。当编译器为Widget w的析构函数产生代码时,会遇到一个static_assert失败,于是就导致了错误发生。这个错误产生在w被销毁处,因为Widget的析构函数和其他的编译器产生的特殊的成员函数一样,都是内联函数。这个编译错误通常会指向w生成的代码行,因为正是创建对象的这行源代码导致了隐式析构。
为了修复这个问题,你只需要保证在生成析构std::unique<Widget::Impl>代码的地方,Widget::Impl是个完整类型。当类型的定义可以被看到时类型就是完整的。而Widget::Impl是定义在Widget.cpp文件中的。成功编译的关键是让编译器在widget.cpp中Widget::Impl定义之后看到Widget的析构函数的函数体。
这个很简单,在widget.h中声明Widget的析构函数,但是不在那里定义它:
class Widget { // as before, in "widget.h"
public:
Widget();
~Widget(); // declaration only
…
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};
在widget.cpp中,Impl的定义之后再定义该析构函数:
#include "widget.h" // as before, in "widget.cpp"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // as before, definition of
std::string name; // Widget::Impl
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() // as before
: pImpl(std::make_unique<Impl>())
{}
//或者Widget::~Widget() = default
//强调编译器生成的析构函数适用,在这里定义它只是要使得它的定义出现在Widget的实现文件中
Widget::~Widget() // ~Widget definition
{}
这端代码工作正常,代码也是最简短的。但如果你想强调编译器生成的析构将会做正确的事情,而你这里声明它只是要使得它的定义出现在Widget的实现文件中,那么你可以在析构函数的函数体后写上“=default”:
Widget::~Widget() = default; // same effect as above
<探讨3>
使用Pimpl技巧的类很天然的支持move操作,因为编译器生成的操作完全符合预期:在一个std::unique_ptr上执行move。正如条款17解释的,在Widget里声明了析构函数会阻止编译器产生move操作代码,因此,假如你需要支持move操作,你必须自己声明该函数。既然编译器产生的版本是正确的,你很有可能如下实现:
class Widget { // still in
public: // "widget.h"
Widget();
~Widget();
Widget(Widget&& rhs) = default; // right idea,
Widget& operator=(Widget&& rhs) = default; // wrong code! ××
…
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};
这个实现会导致和类里面没有析构函数一样的问题,编译器生成的move赋值操作需要在重分配前消毁掉pImpl指向的对象,但是在Widget的头文件里,pImpl指向的是一个不完整类型。move构造函数的情况有所不同,问题是编译器产生的代码在move构造函数中去销毁pImpl时会产生异常,而且销毁pImpl需要完整类型的Impl。
因为产生原因相同,所以修复办法也一样。把move操作函数的定义放到实现文件里:
头文件
class Widget { // still in "widget.h"
public:
Widget();
~Widget();
Widget(Widget&& rhs); // declarations
Widget& operator=(Widget&& rhs); // only
…
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};
实现文件
#include <string> // as before,
… // in "widget.cpp"
struct Widget::Impl { … }; // as before
Widget::Widget() // as before
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() = default; // as before
Widget::Widget(Widget&& rhs) = default; // defini-
Widget& Widget::operator=(Widget&& rhs) = default; // tions
Pimpl技巧可以减少类实现和类使用者之间依赖关系的方法,但是理论上,Plimpl技巧并不能改变类本身。最初的Widget类包含了std::string,std::vector以及Gadget数据成员,我们假设Gadget类象std::string和std::vector一样也可以被拷贝,那么很自然的Widget类也会支持copy操作。我们必须自己写这些函数,因为(1)编译器不会给像std::unique_ptr一样的move-only类型产生copy函数,(2)即使产生了,那么产生的函数也只是拷贝std::unique_ptr(也就是浅拷贝),而我们希望的是拷贝指针指向的内容(也就是执行深拷贝)。
根据我们目前熟悉的惯例,我们在头文件里声明在cpp文件里实现它们:
class Widget { // still in "widget.h"
public:
… // other funcs, as before
Widget(const Widget& rhs); // declarations
Widget& operator=(const Widget& rhs); // only
private: // as before
struct Impl;
std::unique_ptr<Impl> pImpl;
};
#include "widget.h" // as before,
… // in "widget.cpp"
struct Widget::Impl { … }; // as before
Widget::~Widget() = default; // other funcs, as before
Widget::Widget(const Widget& rhs) // copy ctor
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}
Widget& Widget::operator=(const Widget& rhs) // copy operator=
{
*pImpl = *rhs.pImpl;
return *this;
}
两个实现都很常规,这两个函数我们都是简单的拷贝Impl结构的域,从源对象(rhs)到目的对象(*this)。我们并没有一个个的拷贝域,是因为我们利用了编译器会为Impl类构造拷贝函数,这些拷贝函数会自动拷贝这些域的。于是我们通过调用Widget::Impl的编译器生成的拷贝函数去实现Widget的拷贝操作。在这个拷贝构造函数中,我们注意到还是遵循了条款21的建议,尽量用std::make_unique而不直接用new。
<探讨4>
为了实现Pimpl技巧,我们使用了std::unique_ptr灵巧指针,因为在对象(这里的Widget)中的pImpl指针对于相关的实现对象(这里的Widget::Impl对象)独享所有权的。更有趣的是如果我们这里用std::share_ptr来代替std::unique_ptr实现pImpl,会发现本款的建议不再适用。不需要再声明Widget的析构函数,也不需要用户声明的移动函数,编译器会很自然的产生一个move操作,会精确的按我们想要的去工作。这里我们看看widget.h里的代码:
class Widget { // in "widget.h"
public:
Widget();
… // no declarations for dtor
// or move operations
private:
struct Impl;
std::shared_ptr<Impl> pImpl; // std::shared_ptr
}; // instead of std::unique_ptr
客户端代码会#includes widget.h,
Widget w1;
auto w2(std::move(w1)); // move-construct w2
w1 = std::move(w2); // move-assign w1
编译正常,运行也如我们所期望:w1默认构造,值被移动到w2,然后值又被移动会w1,最后w1和w2都被销毁(这会导致Widget::Impl类对象被销毁)。
对于实现pImpl指针来说使用std::unique_ptr和std::shared_ptr的区别在于这两种灵巧指针对于定制删除器的支持不同。对于std::unique_ptr,删除器的类型是指针类型的一部分,这使得编译器会产生更小尺寸以及更高效的代码,当然产生的结果也是当编译器产生特定函数时(析构或移动函数),被指向的类型必须是完整的。对于std::shared_ptr,删除器的类型不是指针类型的一部分,这需要更大尺寸的运行时数据结构以及也许更慢的速度,但被指向的类型却不是必须的。
对于Pimpl技巧来说,在std::unique_ptr和std::shared_ptr的特性之间并没有一个妥协性,因为类之间必然像Widget和Widget::Impl之间的关系是独享所有权的,因此这里选择std::unique_ptr是更适合的。然而在其他某些情况下,存在共享所有权(因此std::shared_ptr因此更适合),就没有必要使用std::unique_ptr 。
综上:
①Pimpl技巧通过减少类的使用者和类实现之间的依赖而减少了编译次数。
②对于std::unique_ptr来实现pImpl指针,在文件中定义特定函数,在cpp文件中实现,即使默认的函数是可用的也要这样做。
③上述建议适用于std::unique_ptr,但不适用于std::shared_ptr。