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。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值