Item 22: When using the Pimpl Idiom, define special member functions in the implementation file

Pimpl用法(“pointer to implementation”)有助于缩短编译时间。这种技巧就是把某个类的数据成员用一个指向其实现类(或结构体)的指针替代,并且通过该指针间接的访问那些数据成员。假设,有如下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 , , 和 gadget.h。这些头文件增加了Widget客户代码的编译时间,此外,也使得客户代码依赖于这些头文件中的内容。如果头文件中的内容变更了,Widget客户代码就必须重新编译。虽然便准库和不太会经常变更,但是gadget.h却可能频繁变更。

应用Pimpl用法对上述代码进行改造,用一个指向声明但未定义的结构体(该结构体中包含了原有的所有数据成员)指针替换那些数据成员:

class Widget {          // still in header "widget.h"
public:
    Widget();
    ~Widget();          // dtor is needed—see belowprivate:
    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()                // allocate data members for
: pImpl(new Impl)               // this Widget object
{}

Widget::~Widget()               // destroy data members for
{ delete pImpl; }               // this object

这段代码包含了include , , 和 gadget.h,也就是说,对于这几个头文件的依赖还是存在的。只不过,这个依赖关系从widget.h (which is visible to and used by Widget clients) 转移到了 widget.cpp (which is visible to and used only by the Widget implementer)

上面的代码是C++ 98风格的,它直接应用了裸指针,原始的new和原始delete。而本章节是建立在使用智能指针的基础之上,所以应用std::unique_ptr代替罗指针:

class Widget {                      // in "widget.h"
public:
    Widget();private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;    // use smart 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;
};

Widget::Widget()                     // per Item 21, create
: pImpl(std::make_unique<Impl>())    // std::unique_ptr
{}                                   // via std::make_unique

需要注意的是,上面的Widget是没有显示声明析构函数的。
上面的代码可以通过编译,但如果客户像下面这样应用,就无法通过编译:

#include "widget.h"

Widget w;                   // error!

提示的错误信息依赖于使用的编译器,多数为无法在非完整类型上应用sizeofdelete之类的。
(1)std::unique在_ptr是支持非完整类型的;(2)Pimpl用法也是std::unique_ptr最广泛的应用场景之一;为什么会失败呢,对于这个问题的产生原因有一个基本的了解即可。

该问题是由w被析构(也就是离开作用域时)时所产生的代码引起的,这时,析构函数被调用。在Widget类中,我们使用了std::unique_ptr,没有声明析构函数,因为没什么代码需要放到析构函数里。根据编译器生成特殊成员函数的基本规则(see Item 17),编译器为我们生成了一个析构函数。在该析构函数内,编译器会插入代码来调用Widget的数据成员pImpl的析构函数。pImpl是一个std::unique_ptr<Widget::Impl>,即一个使用默认析构器的std::unique_ptr,在std::unique_ptr内部,默认析构器是使用delete运算符作用于原始指针的。然而,在调用delete运算符之前,通常的实现会利用C++ 11中的static_assert来确保原始指针没有指向一个不完整类型。如此一来,当编译器为Widget w的析构函数生成代码时,通常会遇到一个失败的static_assert,从而导致了错误信息的产生。这个错误信息和w被析构的位置有关,因为Widget的析构函数是隐式内联的(同编译器产生的其他特殊成员函数一样)。这个编译错误通常会标示在w生成的那一行,正是因为这行源代码的显示创建对象导致了随后的隐式析构。

为了解决这个问题,只需确保在生成析构std::unique_ptr<Widget::Impl>代码处,Widget::Impl是一个完整类型即可。只要类型的定义可以被看到,它就是完整的。而Widget::Impl的定义在widget.cpp中。所以只要确保编译器看到Widget析构函数的函数体在Widget::Impl之后就行了。在widget.h中Widget类中显示声明一个析构函数,但是定义放在.cpp中:

class Widget {                      // as before, in "widget.h"
public:
    Widget();
    ~Widget();                      // declaration onlyprivate:                            // as before
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

析构函数的定义放在.cpp中,且位于Widget::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()               // ~Widget definition
{}

如果想强调编译器生成的析构函数会做正确的事情,而声明它的唯一理由只是想要使得其定义出现在Widget的实现文件中,那么可以在析构函数的函数体后加上=default来表达定义:

    Widget::~Widget() = default; // same effect as above

使用了Pimpl用法的类,一般都支持移动操作,因为编译器生成的移动操作完全符合预期:对类内的std::unique_ptr执行移动操作。Item 17解释过,显示的声明析构函数会阻止编译器生生移动操作(移动构造和移动赋值),所以,如果我们想支持移动操作,就必须自己手动声明。但是你可能会写出如下代码:

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;
};

这种方法会导致与没有析构函数声明的类相同的问题,其原因也基本相同。编译器生成的移动赋值操作需要在重新赋值前析构pImpl所指向的对象,但在Widget的头文件里pImpl指向的是一个非完整类型。而移动构造函数的情况有所不同,问题在于,编译器会在移动构造函数内抛出异常的事件中生成析构pImpl的代码,而析构pImpl要求Impl是一个完整的类型。

因为都是同样的问题,所以将移动操作放到.cpp中:

class Widget {                                    // still in "widget.h"
public:
    Widget();
    ~Widget();
    Widget(Widget&& rhs);                         // declarations
    Widget& operator=(Widget&& rhs);              // onlyprivate:                                          // 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用法是一种可以在类实现和类使用者之间减少编译依赖性的放啊,但从概念上来说,Pimpl用法并没有改变类所表达的意思。最初的Widget类包含std::string, std::vector, 和 Gadget类型数据成员,假设Gadget类像std::string和std::vector一样,也可以被拷贝,那么Widget也支持拷贝操作就是很自然的事情。我们需要自己动手写这些函数,原因是:(1) 编译器不会为含有只具备移动类型的类生成拷贝操作,就像含有std::unique_ptrWidget类;(2) 即使编译器可以生成,其生成的函数也仅仅只能拷贝std::unique_ptr(也就是浅拷贝),而我们希望的是拷贝指针所指向的内容(即深拷贝)。

根据目前已经熟悉的规矩,我们在头文件里声明这些函数,并在实现文件里实现它们:

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;
}

两个函数的实现都很方便。每种情况,我们都只是简单地从源对象(rhs)中把Impl结构拷贝到目标对象(*this)。比起一个个地拷贝成员,我们利用了一个事实,也就是编译器会为Impl创造出拷贝操作,然后这些操作会自动地拷贝每一个成员。因此我们是通过调用Widget::Impl的“编译器产生的”拷贝操作来实现Widget的拷贝操作的。值得我们注意的是,在拷贝构造函数中,我们遵循了Item 21的建议:尽量使用std::make_unique而非直接使用new操作符。

为了实现Pimpl机制,我们选用了std::unique_ptr,因为在Widget内部,pImpl拥有专属所有权。尽管如此,有趣的是,如果我们对pImpl使用std::shared_ptr而不是std::unique_ptr,我们会发现这个Item的建议不再适用。不需要在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的对象被销毁)。

std::unique_ptr and std::shared_ptr在实现pImpl行为时的不同,源自它们对于自定义析构器的支持不同。对于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的话是要做的)。

Things to Remember

  • Pimpl机制通过降低类客户和类实现之间的编译依赖性来降低编译时间;
  • 对于采用std::unique_ptr来实现的pImpl指针,须在类的头文件中声明特殊成员函数,但在实现文件中实现它们。即使编译器提供的默认函数实现满足设计需要,也必须这样做;
  • 上述建议仅适用于std::unique_ptr,而不适用于std::shared_ptr;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值