欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

C++程序员应了解的那些事(8) PIMPL模式(指向实现的指针)

程序员文章站 2022-03-06 19:05:46
...

【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引用他,他们的关系如下图:

C++程序员应了解的那些事(8) PIMPL模式(指向实现的指针)

       如果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。

相关标签: 程序员应知应会