18 KiB
条款二十二:当使用Pimpl惯用法,请在实现文件中定义特殊成员函数
Item 22: When using the Pimpl Idiom, define special member functions in the implementation file
如果你曾经与过多的编译次数斗争过,你会对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::string
,std::vector
和Gadget
, 定义有这些类型的头文件在类Widget
编译的时候,必须被包含进来,这意味着类Widget
的使用者必须要#include <string>
,<vector>
以及gadget.h
。 这些头文件将会增加类Widget
使用者的编译时间,并且让这些使用者依赖于这些头文件。 如果一个头文件的内容变了,类Widget
使用者也必须要重新编译。 标准库文件<string>
和<vector>
不是很常变,但是gadget.h
可能会经常修订。
在C++98中使用Pimpl惯用法,可以把Widget
的数据成员替换成一个原始指针,指向一个已经被声明过却还未被定义的结构体,如下:
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惯用法的第一步,是声明一个数据成员,它是个指针,指向一个未完成类型。 第二步是动态分配和回收一个对象,该对象包含那些以前在原来的类中的数据成员。 内存分配和回收的代码都写在实现文件里,比如,对于类Widget
而言,写在Widget.cpp
里:
#include "widget.h" //以下代码均在实现文件“widget.cpp”里
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { //含有之前在Widget中的数据成员的
std::string name; //Widget::Impl类型的定义
std::vector<double> data;
Gadget g1,g2,g3;
};
Widget::Widget() //为此Widget对象分配数据成员
: pImpl(new Impl)
{}
Widget::~Widget() //销毁数据成员
{ delete pImpl; }
在这里我把#include
命令写出来是为了明确一点,对于std::string
,std::vector
和Gadget
的头文件的整体依赖依然存在。 然而,这些依赖从头文件widget.h
(它被所有Widget
类的使用者包含,并且对他们可见)移动到了widget.cpp
(该文件只被Widget
类的实现者包含,并只对他可见)。 我高亮了其中动态分配和回收Impl
对象的部分(译者注:markdown高亮不了,实际高亮的是new Impl
和delete pImpl;
两个语句)。这就是为什么我们需要Widget
的析构函数——我们需要Widget
被销毁时回收该对象。
但是,我展示给你们看的是一段C++98的代码,散发着一股已经过去了几千年的腐朽气息。 它使用了原始指针,原始的new
和原始的delete
,一切都让它如此的...原始。这一章建立在“智能指针比原始指针更好”的主题上,并且,如果我们想要的只是在类Widget
的构造函数动态分配Widget::impl
对象,在Widget
对象销毁时一并销毁它, std::unique_ptr
(见Item18)是最合适的工具。在头文件中用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() //根据条款21,通过std::make_unique
: pImpl(std::make_unique<Impl>()) //来创建std::unique_ptr
{}
你会注意到,Widget
的析构函数不存在了。这是因为我们没有代码加在里面了。 std::unique_ptr
在自身析构时,会自动销毁它所指向的对象,所以我们自己无需手动销毁任何东西。这就是智能指针的众多优点之一:它使我们从手动资源释放中解放出来。
以上的代码能编译,但是,最普通的Widget
用法却会导致编译出错:
#include "widget.h"
Widget w; //错误!
你所看到的错误信息根据编译器不同会有所不同,但是其文本一般会提到一些有关于“把sizeof
或delete
应用到未完成类型上”的信息。对于未完成类型,使用以上操作是禁止的。
在Pimpl惯用法中使用std::unique_ptr
会抛出错误,有点惊悚,因为第一std::unique_ptr
宣称它支持未完成类型,第二Pimpl惯用法是std::unique_ptr
的最常见的使用情况之一。 幸运的是,让这段代码能正常运行很简单。 只需要对上面出现的问题的原因有一个基础的认识就可以了。
在对象w
被析构时(例如离开了作用域),问题出现了。在这个时候,它的析构函数被调用。我们在类的定义里使用了std::unique_ptr
,所以我们没有声明一个析构函数,因为我们并没有任何代码需要写在里面。根据编译器自动生成的特殊成员函数的规则(见 Item17),编译器会自动为我们生成一个析构函数。 在这个析构函数里,编译器会插入一些代码来调用类Widget
的数据成员pImpl
的析构函数。 pImpl
是一个std::unique_ptr<Widget::Impl>
,也就是说,一个使用默认删除器的std::unique_ptr
。 默认删除器是一个函数,它使用delete
来销毁内置于std::unique_ptr
的原始指针。然而,在使用delete
之前,通常会使默认删除器使用C++11的特性static_assert
来确保原始指针指向的类型不是一个未完成类型。 当编译器为Widget w
的析构生成代码时,它会遇到static_assert
检查并且失败,这通常是错误信息的来源。 这些错误信息只在对象w
销毁的地方出现,因为类Widget
的析构函数,正如其他的编译器生成的特殊成员函数一样,是暗含inline
属性的。 错误信息自身往往指向对象w
被创建的那行,因为这行代码明确地构造了这个对象,导致了后面潜在的析构。
为了解决这个问题,你只需要确保在编译器生成销毁std::unique_ptr<Widget::Impl>
的代码之前, Widget::Impl
已经是一个完成类型(complete type)。 当编译器“看到”它的定义的时候,该类型就成为完成类型了。 但是 Widget::Impl
的定义在widget.cpp
里。成功编译的关键,就是在widget.cpp
文件内,让编译器在“看到” Widget
的析构函数实现之前(也即编译器插入的,用来销毁std::unique_ptr
这个数据成员的代码的,那个位置),先定义Widget::Impl
。
做出这样的调整很容易。只需要在先在widget.h
里,只声明类Widget
的析构函数,却不要在这里定义它:
class Widget { //跟之前一样,在“widget.h”中
public:
Widget();
~Widget(); //只有声明语句
…
private: //跟之前一样
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() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() //析构函数的定义(译者注:这里高亮)
{}
这样就可以了,并且这样增加的代码也最少,但是,如果你想要强调编译器自动生成的析构函数做的没错——你声明Widget
的析构函数的唯一原因,是确保它会在Widget
的实现文件内(译者注:指widget.cpp
)被自动生成,你可以把析构函数体直接定义为“= default
”:
Widget::~Widget() = default; //同上述代码效果一致
使用了Pimpl惯用法的类自然适合支持移动操作,因为编译器自动生成的移动操作正合我们所意:对其中的std::unique_ptr
进行移动。 正如Item17所解释的那样,声明一个类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;
};
这样的做法会导致同样的错误,和之前的声明一个不带析构函数的类的错误一样,并且是因为同样的原因。 编译器生成的移动赋值操作符,在重新赋值之前,需要先销毁指针pImpl
指向的对象。然而在Widget
的头文件里,pImpl
指针指向的是一个未完成类型。移动构造函数的情况有所不同。 移动构造函数的问题是编译器自动生成的代码里,包含有抛出异常的事件,在这个事件里会生成销毁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 <string> //跟之前一样,仍然在“widget.cpp”中
…
struct Widget::Impl { … }; //跟之前一样
Widget::Widget() //跟之前一样
: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() = default; //跟之前一样
Widget::Widget(Widget&& rhs) = default; //这里定义
Widget& Widget::operator=(Widget&& rhs) = default;
Pimpl惯用法是用来减少类的实现和类使用者之间的编译依赖的一种方法,但是,从概念而言,使用这种惯用法并不改变这个类的表现。 原来的类Widget
包含有std::string
,std::vector
和Gadget
数据成员,并且,假设类型Gadget
,如同std::string
和std::vector
一样,允许复制操作,所以类Widget
支持复制操作也很合理。 我们必须要自己来写这些函数,因为第一,对包含有只可移动(move-only)类型,如std::unique_ptr
的类,编译器不会生成复制操作;第二,即使编译器帮我们生成了,生成的复制操作也只会复制std::unique_ptr
(也即浅复制(shallow copy)),而实际上我们需要复制指针所指向的对象(也即深复制(deep copy))。
使用我们已经熟悉的方法,我们在头文件里声明函数,而在实现文件里去实现他们:
class Widget { //仍然在“widget.h”中
public:
…
Widget(const Widget& rhs); //只有声明
Widget& operator=(const Widget& rhs);
private: //跟之前一样
struct Impl;
std::unique_ptr<Impl> pImpl;
};
#include <string> //跟之前一样,仍然在“widget.cpp”中
…
struct Widget::Impl { … }; //跟之前一样
Widget::~Widget() = default; //其他函数,跟之前一样
Widget::Widget(const Widget& rhs) //拷贝构造函数
: pImpl(std::make_unique<Impl>(*rhs.pImpl))
{}
Widget& Widget::operator=(const Widget& rhs) //拷贝operator=
{
*pImpl = *rhs.pImpl;
return *this;
}
两个函数的实现都比较中规中矩。 在每个情况中,我们都只从源对象(rhs
)中,复制了结构体Impl
的内容到目标对象中(*this
)。我们利用了编译器会为我们自动生成结构体Impl
的复制操作函数的机制,而不是逐一复制结构体Impl
的成员,自动生成的复制操作能自动复制每一个成员。 因此我们通过调用编译器生成的Widget::Impl
的复制操作函数来实现了类Widget
的复制操作。 在复制构造函数中,注意,我们仍然遵从了Item21的建议,使用std::make_unique
而非直接使用new
。
为了实现Pimpl惯用法,std::unique_ptr
是我们使用的智能指针,因为位于对象内部的pImpl
指针(例如,在类Widget
内部),对所指向的对应实现的对象的享有独占所有权。然而,有趣的是,如果我们使用std::shared_ptr
而不是std::unique_ptr
来做pImpl
指针, 我们会发现本条款的建议不再适用。 我们不需要在类Widget
里声明析构函数,没有了用户定义析构函数,编译器将会愉快地生成移动操作,并且将会如我们所期望般工作。widget.h
里的代码如下,
class Widget { //在“widget.h”中
public:
Widget();
… //没有析构函数和移动操作的声明
private:
struct Impl;
std::shared_ptr<Impl> pImpl; //用std::shared_ptr
}; //而不是std::unique_ptr
这是#include
了widget.h
的客户代码,
Widget w1;
auto w2(std::move(w1)); //移动构造w2
w1 = std::move(w2); //移动赋值w1
这些都能编译,并且工作地如我们所望:w1
将会被默认构造,它的值会被移动进w2
,随后值将会被移动回w1
,然后两者都会被销毁(因此导致指向的Widget::Impl
对象一并也被销毁)。
std::unique_ptr
和std::shared_ptr
在pImpl
指针上的表现上的区别的深层原因在于,他们支持自定义删除器的方式不同。 对std::unique_ptr
而言,删除器的类型是这个智能指针的一部分,这让编译器有可能生成更小的运行时数据结构和更快的运行代码。 这种更高效率的后果之一就是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
所必需的声明——定义(function-definition)这样的麻烦事了。
请记住:
- Pimpl惯用法通过减少在类实现和类使用者之间的编译依赖来减少编译时间。
- 对于
std::unique_ptr
类型的pImpl
指针,需要在头文件的类里声明特殊的成员函数,但是在实现文件里面来实现他们。即使是编译器自动生成的代码可以工作,也要这么做。 - 以上的建议只适用于
std::unique_ptr
,不适用于std::shared_ptr
。