Item 22 : 当使用 Pimpl 惯用法,请在实现文件中定义特殊成员函数

优质
小牛编辑
121浏览
2023-12-01

如果你曾经与过多的编译次数斗争过,你会对Pimpl(Pointer to implementation)惯用法很熟悉。 凭借这样一种技巧,你可以将一个类数据成员替换成一个指向包含具体实现的类或结构体的指针, 并将放在主类(primary class)的数据成员们移动到实现类去(implementation class), 而这些数据成员的访问将通过指针间接访问。 举个例子,假如有一个类Widget看起来如下:

class Widget()      //定义在头文件`widget.h`
{
public:
    Widget();
    ...
private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;  //Gadget是用户自定义的类型
}

因为类Widget的数据成员包含有类型std::stringstd::vectorGadget, 定义有这些类型的头文件在类Widget编译的时候,必须被包含进来,这意味着类Widget的使用者必须要#include <string>,<vector>以及gadget.h。 这些头文件将会增加类Widget使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget使用者也必须要重新编译。 标准库文件<string><vector>不是很常变,但是gadget.h可能会经常修订。

在C++98中使用Pimpl惯用法,可以把Widget的数据成员替换成一个原始指针(raw pointer),指向一个已经被声明过却还未被定义的类,如下:

class Widget       //仍然在"Widget.h"中
{
public:
    Widget();
    ~Widget();      //析构函数在后面会分析
    ...

    private:
    struct Impl;    //声明一个 实现结构体
    Impl *pImpl;    //以及指向它的指针
}

因为类Widget不再提到类型std:::string,std::vector以及Gadget,Widget的使用者不再需要为了这些类型而引入头文件。 这可以加速编译,并且意味着,如果这些头文件中有所变动,Widget 的使用者不会受到影响。

一个已经被声明,却还未被实现的类型,被称为未完成类型(incomplete type)。 Widget::Impl就是这种类型。 你能对一个未完成类型做的事很少,但是声明一个指向它指针是可以的。 Pimpl手法利用了这一点。

Pimpl惯用法的第一步,是声明一个数据成员,它是个指针,指向一个未完成类型。 第二步是动态分配(dynamic allocation)和回收一个对象,该对象包含那些以前在原来的类中的数据成员。 内存分配和回收的代码都写在实现文件(implementation file)里,比如,对于类Widget而言,写在Widget.cpp里:

#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl     //之前在Widget中声明的Widget::Impl类型的定义
{
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
}

Widget::Widget()        //为此Widget对象分配数据成员
: pImpl(new Impl)
{}

Widget::~Widget()
{delete pImpl;}         //销毁数据成员

在这里我把#include命令写出来是为了明确一点,对于头文件std::string,std::vectorGadget的整体依赖依然存在。 然而,这些依赖从头文件widget.h(它被所有Widget类的使用者包含,并且对他们可见)移动到了widget.cpp(该文件只被Widget类的实现者包含,并只对它可见)。 我高亮了其中动态分配和回收Impl对象的部分(markdown高亮不了,实际是newdelete两部分——译者注)。这就是为什么我们需要Widget的析构函数——我们需要回收该对象。

但是,我展示给你们看的是一段C++98的代码,散发着一股已经过去了几千年的腐朽气息。 它使用了原始指针,原始的new和原始的delete,一切都让它如此的...原始。这一章建立在“智能指针比原始指针更好”的主题上,并且,如果我们想要的只是在类Widget的构造函数动态分配Widget::impl对象,在Widget对象销毁时一并销毁它, std::unique_ptr(见Item 18)是最合适的工具。 在头文件中用std::unique_ptr替代原始指针,就有了如下代码:

class Widget       //在"Widget.h"中
{
public:
    Widget();
    ...

    private:
    struct Impl;    //声明一个 实现结构体
    std::unique_ptr<Impl> pImpl;   //使用智能指针而不是原始指针
}

实现文件也可以改成如下:

#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl     //跟之前一样
{
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
}

Widget::Widget()        //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

你会注意到,Widget的析构函数不存在了。这是因为我们没有代码加在里面了。 std::unique_ptr在自身析构时,会自动销毁它所指向的对象,所以我们自己无需手动销毁任何东西。这就是智能指针的众多优点之一:它使我们从手动资源释放中解放出来。

以上的代码能编译,但是,最普通的Widget用法却会导致编译出错:

#include "widget.h"

Wdiget w;       //编译出错

你所看到的错误信息根据编译器不同会有所不同,但是其文本一般会提到一些有关于把sizeofdelete应用到未完成类型incomplete type的信息。对于未完成类型,使用以上操作是禁止的。

Pimpl惯用法中使用std::unique_ptr会抛出错误,有点惊悚,因为第一std::unique_ptr宣称它支持未完成类型,第二Pimpl惯用法是std::unique_ptr的最常见的用法。 幸运的是,让这段代码能正常运行很简单。 只需要对是什么导致以上代码编译出错有一个基础的认识就可以了。

在对象w被析构时,例如离开了作用域(scope),问题出现了。在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr,所以我们没有声明一个析构函数,因为我们并没有任何代码需要写在里面。根据编译器自动生成的特殊成员函数的规则(见 Item 17),编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类Widget的数据成员Pimpl的析构函数。 Pimpl是一个std::unique_ptr<Widget::Impl>,也就是说,一个带有默认销毁器(default deleter)的std::unique_ptr。 默认销毁器(default deleter)是一个函数,它使用delete来销毁内置于std::unique_ptr的原始指针。然而,在使用delete之前,通常会使默认销毁器使用C++11的特性static_assert来确保原始指针指向的类型不是一个未完成类型。 当编译器为Widget w的析构生成代码时,它会遇到static_assert检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象w销毁的地方出现,因为类Widget的析构函数,正如其他的编译器生成的特殊成员函数一样,是暗含inline属性的。 错误信息自身往往指向对象w被创建的那行,因为这行代码明确地构造了这个对象,导致了后面潜在的析构。

为了解决这个问题,你只需要确保在编译器生成销毁std::unique_ptr<Widget::Imple>的代码之前, Widget::Impl已经是一个完成类型(complete type)。 当编译器"看到"它的定义的时候,该类型就成为完成类型了。 但是 Widget::Impl的定义在wideget.cpp里。成功编译的关键,就是,在widget.cpp文件内,让编译器在"看到" Widget的析构函数实现之前(也即编译器自动插入销毁std::unique_ptr的数据成员的位置),先定义Wdiget::Impl

做出这样的调整很容易。只需要在先在widget.h里,只声明(declare)类Widget的析构函数,却不要在这里定义(define)它:

class Widget {      // as before, in "widget.h"
public:
  Widget();
  ~Widget();     // declaration only
  ...

private:     // as before
  struct Impl;
  std::unique_ptr<Impl> pImpl; 
};

widget.cpp文件中,在结构体Widget::Impl被定义之后,再定义析构函数:

#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl     //跟之前一样,定义Widget::Impl
{
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
}

Widget::Widget()        //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

Widget::~Widget()       //析构函数的定义(译者注:这里高亮)
{}

这样就可以了,并且这样增加的代码也最少,但是,如果你想要强调编译器自动生成的析构函数会工作的很好——你声明Widget的析构函数的唯一原因,是确保它会在Widget的实现文件内(指widget.cpp,译者注)被自动生成,你可以把析构函数体直接定义为=default:

    Widget::~Widget() = default;       //同上述代码效果一致

使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意: 对隐藏的std::unique_ptr进行移动。 正如Item 17所解释的那样,声明一个类Widget的析构函数会阻止编译器生成移动操作,所以如果你想要支持移动操作,你必须自己声明相关的函数。考虑到编译器自动生成的版本能够正常功能,你可能会被诱使着来这样实现:

class Widget       //在"Widget.h"中
{
public:
    Widget();
    ~Widget();
    ...

    Widget(Widget&& rhs) = default;             //思路正确,但代码错误
    Widget& operator=(Widget&& rhs) = default;


    private:
    struct Impl;        //如上
    std::unique_ptr<Impl> pImpl;
}

这样的做法会导致同样的错误,和之前的声明一个不带析构函数的类的错误一样,并且是因为同样的原因。 编译器生成的移动赋值操作符(move assignment operator),在重新赋值之前,需要先销毁指针pImpl指向的对象。然而在Widget的头文件里,pImpl指针指向的是一个未完成类型。情况和移动构造函数(move constructor)有所不同。 移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁pImpl的代码。然而,销毁pImpl需要Impl是一个完成类型。

因为这个问题同上面一致,所以解决方案也一样——把移动操作的定义移动到实现文件里:

class Widget       //在"Widget.h"中
{
public:
    Widget();
    ~Widget();
    ...

    Widget(Widget&& rhs);   //仅声明
    Widget& operator=(Widget&& rhs);


    private:
    struct Impl;        //如上
    std::unique_ptr<Impl> pImpl;
}
#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl     //跟之前一样,定义Widget::Impl
{
    std::string name;
    std::vector<double> data;
    Gadget g1,g2,g3;
}

Widget::Widget()        //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

Widget::~Widget() = default;

Widget(Widget&& rhs) = default;             //在这里定义
Widget& operator=(Widget&& rhs) = default;

pImpl惯用法是用来减少类实现者和类使用者之间的编译依赖的一种方法,但是,从概念而言,使用这种惯用法并不改变这个类的表现。 原来的类Widget包含有std::string,std::vectorGadget数据成员,并且,假设类型Gadget,如同std::stringstd::vector一样,允许复制操作,所以类Widget支持复制操作也很合理。 我们必须要自己来写这些函数,因为第一,对包含有只可移动(move-only)类型,如std::unique_ptr的类,编译器不会生成复制操作;第二,即使编译器帮我们生成了,生成的复制操作也只会复制std::unique_ptr(也即浅复制(shallow copy)),而实际上我们需要复制指针所指向的对象(也即深复制(deep copy))。

使用我们已经熟悉的方法,我们在头文件里声明函数,而在实现文件里去实现他们:

class Widget       //在"Widget.h"中
{
public:
    Widget();
    ~Widget();
    ...

    Widget(const Widget& rhs);   //仅声明
    Widget& operator=(const Widget& rhs);


    private:
    struct Impl;        //如上
    std::unique_ptr<Impl> pImpl;
}
#include "widget.h"     //以下代码均在实现文件 widget.cpp里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl     //跟之前一样,定义Widget::Impl
{
    ...
}

Widget::Widget()        //根据Item 21, 通过std::make_shared来创建std::unique_ptr
: pImpl(std::make_unique<Imple>())
{}

Widget::~Widget() = default;
    ...
Widget::Widget(const Widget& rhs)
:pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}

Widget& Widget::operator=(const Widget& rhs)
{
    *pImpl = *rhs.pImpl;
    return *this;
}

两个函数的实现都比较中规中矩。 在每个情况中,我们都只从源对象(rhs)中,复制了结构体Impl的内容到目标对象中(*this)。我们利用了编译器会为我们自动生成结构体Impl的复制操作函数的机制,而不是逐一复制结构体Impl的成员,自动生成的复制操作能自动复制每一个成员。 因此我们通过调用Widget::Impl的编译器生成的复制操作函数来实现了类Widget的复制操作。 在复制构造函数中,注意,我们仍然遵从了Item 21的建议,使用std::make_unique而非直接使用new

为了实现Pimpl惯用法,std::unique_ptr是我们使用的智能指针,因为位于对象内部的pImpl指针(例如,在类Widget内部),对所指向的对应实现的对象的享有独占所有权(exclusive ownership)。然而,有趣的是,如果我们使用std::shared_ptr而不是std::unique_ptr来做pImpl指针, 我们会发现本节的建议不再适用。 我们不需要在类Widget里声明析构函数,也不用用户定义析构函数,编译器将会愉快地生成移动操作,并且将会如我们所期望般工作。代码如下:

//在Widget.h中
class Widget{
public:
    Widget();
    ...         //没有对移动操作和析构函数的声明
    private:
    struct Impl;
    std::shared_ptr<Impl> pImpl;    //使用std::shared_ptr而非std::unique_ptr
}

而类Widget的使用者,使用#include widget.h,可以使用如下代码

Widget w1;
auto w2(std::move(w1));  //移动构造w2
w1 = std::move(w2);      //移动赋值w1

这些都能编译,并且工作地如我们所望: w1将会被默认构造,它的值会被移动进w2,随后值将会被移动回w1,然后两者都会被销毁(因此导致指向的Widget::Impl对象一并也被销毁)。

std::unique_ptrstd::shared_ptrpImpl指针上的表现上的区别的深层原因在于,他们支持自定义销毁器(custom deleter)的方式不同。 对std::unique_ptr而言,销毁器的类型是unique_ptr的一部分,这让编译器有可能生成更小的运行时数据结构和更快的运行代码。 这种更高效率的后果之一就是unique_ptr指向的类型,在编译器的生成特殊成员函数被调用时(如析构函数,移动操作)时,必须已经是一个完成类型。 而对std::shared_ptr而言,销毁器的类型不是该智能指针的一部分,这让它会生成更大的运行时数据结构和稍微慢点的代码,但是当编译器生成的特殊成员函数被使用的时候,指向的对象不必是一个完成类型。(译者注: 知道unique_ptrshared_ptr的实现,这一段才比较容易理解。)

对于pImpl惯用法而言,在std::unique_ptrstd::shared_ptr的特性之间,没有一个比较好的折中。 因为对于类Widget以及Widget::Impl而言,他们是独享占有权关系,这让std::unique_ptr使用起来很合适。 然而,有必要知道,在其他情况中,当共享所有权(shared ownership)存在时,std::shared_ptr是很适用的选择的时候,没有必要使用std::unique_ptr所必需的声明——定义(function-definition)这样的麻烦事了。

记住

  • pImpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
  • 对于std::unique_ptr类型的pImpl指针,需要在头文件的类里声明特殊的成员函数,但是在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。
  • 以上的建议只适用于std::unique_ptr,不适用于std::shared_ptr