您的位置:首页 > 编程语言 > C语言/C++

c++11 条款22:当使用Pimpl(指向实现的指针)时,在实现文件里定义特定的成员函数

2015-02-06 15:41 645 查看
条款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有可能会经常修正。

把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() // allocate data members for

: pImpl(new Impl) // this Widget object

{}

Widget::~Widget() // destroy data members for

{ delete pImpl; } // this object

这里显示的#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;

};

Widget::Widget() // per Item 21, create

: pImpl(std::make_unique<Impl>()) // std::unique_ptr

{} // via std::make_unique

你会注意到,Widget的析构函数不存在了。因为析构里面不需要代码了。当std::unique_ptr被销毁时,它会自动删除掉它所指向的对象,因此我们不需要自己删除任何东西。这也正是灵巧指针吸引人的地方之一:它减少了我们手动释放资源的需求。

这段代码可以通过编译,但很可惜在少数的客户端不能使用:

#include "widget.h"

Widget w; // error!

错误信息依赖你使用的编译器,但大致信息会提到关于在不完整类型上使用了sizeof或delete。这些操作不能在该类型上使用。

使用std::unique_ptr来实现Pimpl技巧的失败告诉发出告警:因为(1)std::unique_ptr宣传是可以支持不完整类型的,而且(2)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() // ~Widget definition

{}

这端代码工作正常,代码也是最简短的。但如果你想强调编译器生成的析构将会做正确的事情,而你这里声明它只是要使得它的定义出现在Widget的实现文件中,那么你可以在析构函数的函数体后写上“=default”:

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

使用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。

为了实现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 。

需要记住的事情

1.Pimpl技巧通过减少类的使用者和类实现之间的依赖而减少了编译次数。

2.对于std::unique_ptr来实现pImpl指针,在文件中定义特定函数,在cpp文件中实现,即使默认的函数是可用的也要这样做。

3.上述建议适用于std::unique_ptr,但不适用于std::shared_ptr。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: 
相关文章推荐