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

Effective Modern C++ 条款22 当使用Pimpl Idiom时,在实现文件中定义特殊成员函数

程序员文章站 2022-08-10 20:15:39
这csdn的markdown真心有问题,多次把我的代码缩进吃掉,编辑页面明明有空格,显示效果却没有~ 当使用pimpl idiom时,在实现文件中定义特殊成员函数 如果你曾经与过长的编译时间斗争...

这csdn的markdown真心有问题,多次把我的代码缩进吃掉,编辑页面明明有空格,显示效果却没有~

当使用pimpl idiom时,在实现文件中定义特殊成员函数

如果你曾经与过长的编译时间斗争过,你应该熟悉pimpl(“pointer to implementation”) idiom。这项技术通过把类中的成员变量替换成指向一个实现类(或结构体)的指针,成员变量被放进单独的实现类中,然后通过该指针间接获取原来的成员变量。例如,widget是这样的:
class widget { // 在头文件“widget.h”中
public:
widget();
...
private:
std::string name;
std::vector data;
gadget g1, g2, g3; // gadget是某个用户定义的类型
};

因为widget的成员变量有std::stringstd::vector和gadget,那么这些类型的头文件在widget编译时必须出现,这意味widget的用户必须包含,和“gadget.h”。这些增加的头文件会增加widget用户的编译时间,而且这使得用户依赖于这些头文件,即如果某个头文件的内容被改变了,widget的用户就要重新编译。标准库头文件和不会经常改变,但是“gadget.h”可能会经常修改。

在c++98中使用pimpl idiom,让widget的成员变量替换成一个指向结构体的原生指针,这个结构体只被声明,没有被实现:
class widget { // 依然在头文件“widget.h”中
public:
widget();
~widget();
...
private:
struct impl; // 声明实现类
impl *pimpl; // 声明指针指向实现类
};

因为widget不再提起std::stringstd::vector和gadget类型,所以widget的用户不再需要“#include”那些头文件了。那样加快了编译速度,也意味着当头文件内容改变时,widget的用户不会受到影响。

一个被声明,却没定义的类型称为不完整类型(incomplete type)。widget::impl就是这样的类型,不完整类型能做的事情很少,不过可以声明一个指针指向它们,pimpl idiom就是利用了这个特性。

pimpl idiom的第一部分是声明一个指向不完整类型的指针作为成员变量,第二部分是动态分配和回收一个装有原来成员变量的对象,分配和回收的代码要写在实现文件,例如,对于widget,写在“widget.cpp”中:
#include "widget.h" // 在实现文件“widget.cpp”
#include "gadget.h"
#include
#include

struct widget::impl { // 用原来对象的成员变量来定义实现类
std::string name;
std::vector data;
gadget g1, g2, g3;
};

widget::widget() : pimpl(new impl) {} // 为widget对象动态分配成员变量

widget::~widget() { delete pimpl; } // 销毁这个对象的成员变量

在这里,我展示了“#include”指令,只为了说明所有对头文件的依赖(即std::stringstd::vector和gadget)依然存在。不过呢,依赖已经从“widget.h”(widget用户可见的和使用的)转移到“widget.cpp”(只有widget的实现者才能看见和使用)。不过这个代码是动态分配的,需要在widget的析构函数中回收分配的对象。

不过我展示的是c++98的代码,这代码充满着腐朽的臭味。它使用原生的指针,原生的new和原生的delete,反正就是太原生了。这章节(条款18~22)的建议是智能指针比原生指针好很多很多,那么如果想要的是在widget构造中动态分配widget::impl对象,而且widget销毁时销毁widget::impl,那么std::unique_ptr(看条款18)是一个精确的工具啊。在头文件中用**std::unique_ptr替代原生指针pimpl:
class widget { // 在“widget.h”
public:
widget();
...
private:
struct impl;
std::unique_ptr pimpl; // 用智能指针代替原生指针
};

然后这是实现文件:
#include "widget.h" // 在“widget.cpp”
#include "gadget.h"
#include
#include

struct widget::impl { // 如前
std::string name;
std::vector data;
gadget g1, g2, g3;
};

widget::widget() // 见条款21
: pimpl(std::make_unique()) // 借助std::make_unique创建
{} // std::unique_ptr

你可能发现widget的析构函数不见了,那是因为我们没有代码要写进析构函数,std::unique_ptr自动销毁指向的对象当它(指的是std::unique_ptr)被销毁时,因此不需要我们自己删除什么东西。这是智能指针吸引人的一个地方:消除手动删除资源的需要。

上面的代码是可以编译的,但是啊,用户这样平常地使用就无法通过编译:
#include "widget.h"

widget w; // 错误

你获得的错误信息取决于你使用的编译器,不过内容一般会提到对不完整类型使用了sizeofdelete。你在构造时根本没有使用这些操作。

这个使用std::unique_ptr的pimpl idiom产生的明显失败让人感到惊慌,因为(1)std::unique_ptr的说明是支持不完整类型的,而且(2)pimpl idiom中的std::unique_ptr的使用是最常规的使用。幸运的是,让这代码工作很容易,不过这需要理解导致这个问题的原理。

这问题的产生是由于w被销毁时(例如,离开作用域)生成的代码,在那个时刻,它的析构函数被调用,而在我们的实现文件中,我们没有声明析构函数。根据编译器生成特殊成员函数的普通规则(看条款17),编译器会为我们生成一个析构函数。在那个析构函数中,编译器调用了widget成员变量pimpl的析构函数。pimpl是个std::unique_ptr<:impl>对象,即一个使用默认删除器的std::unique_ptr,而std::unique_ptr的默认删除器是对原生指针使用delete。虽说优先使用的delete,但默认删除器通常先会使用c++11的static_asssert来确保原生指针不会指向不完整类型。当编译器为widget生成析构函数时,通常会遇到static_assert失败,而这通常会导致错误信息。这信息与w在哪里销毁有关系,因为widget的析构函数,和所有的特殊成员函数一样,都是隐式内联的。这信息通常指向w对象创建的那一行,因为源代码中的显式创建才会导致后来的隐式销毁。

要解决这个办法呢,你只需确保在生成std::unique_ptr的析构函数之前,widget::impl是个不完整类型。只有当编译器看见它的实现,才能变为完整类型,然后widget::impl的定义在“widget.cpp”中。编译成功的关键是:在编译器看到widget析构函数体(即编译器生成销毁std::unique_ptr成员变量的地方)之前,“widget.h”中的widget::impl就已经定义了。

这样做其实很简单,在“widget.h”中声明析构函数,但是不在那里定义:
class widget { // 如前,在"widget.h"
public:
widget();
~widget(); // 只是声明
...
private:
struct impl;
std::unique_ptr pimpl;
};

在“widget.cpp”中,定义了widget::impl之后才定义析构函数:
#include "widget.h" // 如前, 在"widget.cpp"
#include "gadget.h
#include
#include

struct widget::impl { // 如前, 定义widget::impl
std::string name;
std::vector data;
gadget g1, g2, g3;
};

widget::widget() // 如前
: pimpl(std::make_unique())
{}

widget::~widget() {} // 定义析构函数

这样的话代码就可以工作了,这个解决办法打的字最少,不过如果你想强调编译器生成的析构函数是正常工作的,那样你声明析构函数的唯一理由是让析构的定义在widget的实现文件中生成,那么你可以使用“= default”:
widget::~widget() = default; // 和上面的效果一样

使用pimpl idiom的类天生就是支持移动操作的候选人,因此编译器生成的移动操作符合我们的需要:移动类内部的std::unique_ptr。就像条款17所说,声明了widget析构函数会阻止编译器生成移动操作,所以如果你想要支持移动,你必须声明这些函数。倘若编译器生成的移动操作的行为是正确的,你可能会这样实现:
class widget { // 在“widget.h”
public:
widget();
~widget();
widget(widget&& rhs) = default; // 正确的想法
widget& operator=(widget&& rhs) = default; // 错误的代码
...
private:
struct impl; // 如前
std::unique_ptr 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 pimpl;
};

---------------------------------------------------------

#include "widget.h" // 在“widget.cpp”
... // 如前
struct widget::impl { ... }; //如前

widget::widget() //如前
: pimpl(std::make_unique())
{}

widget::~widget() {} // 如前

widget::widget(widget&& rhs) = default; // 定义
widget& widget::operator=(widget&& rhs) = default; // 定义

pimpl idiom是在类实现和类用户之间减少编译依赖的一个方法,不过,使用这个机制不会改变类代表的东西。最开始的widget类的成员变量有std::stringstd::vector和gadget,然后我们假设gadget像string和vector那样可以被拷贝,那么,为widget实现拷贝操作是有意义的。我们必须自己写这些函数,因为(1)编译器不会为含有只可移动类型(例如std::unique_ptr)的类生成拷贝操作,(2)就算编译器生成代码,生成的代码也只是拷贝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 pimpl;
};

-------------------------------------------------------------

#include "widget.h" // 在“widget.cpp”
... // 其他头文件和以前一样
struct widget::impl { ... }; // 如前

widget::~widget() = default; // 其他函数也和以前一样

widget::widget(const widget& rhs) // 拷贝构造
: pimpl(std::make_unique(*rhs.impl))
{}

widget& widget::operator=(const widget& rhs) // 拷贝赋值
{
*pimpl = *rhs.impl;
return *this;
}

两个函数都是依旧惯例实现的,我们都只是简单地把impl结构从源对象(rhs)拷贝到目的对象(*this),比起把impl的变量单独拷贝,我们利用了编译器会为impl生成拷贝操作这个优势,这些操作会自动地逐一拷贝,因此,我们通过调用编译器生成的widget::impl的拷贝操作来实现widget的拷贝操作。在拷贝构造中,请注意我们采用了条款21的建议,比起直接使用new,更偏向于使用std::make_unique

为了实现pimpl idiom,我们使用了std::unique_ptr这个智能指针,因为对象中(指的是widget)的pimpl指针独占实现对象(指的是widget::impl)的所有权。不过,我们用std::shared_ptr代替std::unique_ptr作为pimpl的类型会是很有趣的,我们会发现本条款的内容不再适用,不再需要在widget中声明析构函数(还有在实现文件定义析构),编译器会很开心的生成移动操作(跟我们期待的操作一样)。代码是这样:
class widget { // 在“widget.h”
public:
widget();
... // 不用声明析构函数和移动操作
private:
struct impl;
std::shared_ptr pimpl // 用的是std::shared_ptr
};

这是用户的代码(已经#include “widget.h”):
widget w1;

auto w2(std::move(w1)); // 移动构造w2

w1 = std::move(w2); // 移动赋值w1

每行代码都可以编译,并且运行得我们期望那样:w1会被默认构造,它的值被移动到w2,然后那个值又被移动回w1,然后w1和w2将会销毁(因此指向的widget::impl对象被销毁)。

在这里,std::unique_ptrstd::shared_ptr之间行为的不同来源于它们对自定义删除器的支持不同。对于std::unique_ptr,删除器的类型是智能指针类型的一部分,这让编译器生成更小的运行时数据结构和更快的运行时代码成为可能。这高效导致的后果是当使用编译器生成的特殊成员函数时,指向的类型必须是完整类型。对于std::shared_ptr,删除器的类型不是智能指针类型的一部分,这在运行时会导致更大的数据结构和更慢的代码,但是当使用编译器生成的特殊成员函数时,指向的类型不需要是完整类型。

对于pimpl idiom,不需要真的去权衡std::unique_ptrstd::shared_ptr的特性,因为widget和widget::impl之间的关系是独占所有权关系,所以std::unique_ptr更适合这份工作,但是呢,值得知道在其他情况下(共享所有权的情况,std::shared_ptr是个适合的设计选择),不需要像std::unique_ptr那样费劲心思处理函数定义。


总结

需要记住的3点:

pimpl idiom通过减少类用户和类实现之间的编译依赖来减少编译时间。 对于类型为std::unique_ptr的pimpl指针,在头文件中声明特殊成员函数,但在实现文件中实现它们。尽管编译器默认生成的函数实现可以满足需求,我们也要这样做。 上一条的建议适用于std::unique_ptr,不适用于std::shared_ptr