条款22.当使用Pimpl习惯用法时,将特殊成员函数的定义放到实现文件中

使用Pimpl习惯用法时,将特殊成员函数的定义放到实现文件中

Pimpl用法(“Pimpl”意为“Pointer to implementation”,即指向实现的指针)。这种技巧就是把某类的数据成员用一个指向到某实现类(或结构体)的指针替代,然后把原来在主类中的数据成员放到实现类中,并通过指针间接访问这些数据成员。

例如,考虑Widget类如下

class Widget{	//仍位于头文件“Widget.h”内
public:
	Widget();
    ~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的数据成员你替换成一个原始指针,指向一个已经被声明过却还未被定义的类。如下:

class Widget{	//仍位于头文件“Widget.h”内
public:
	Widget();
    ~Widget();
    
private:
    struct Impl;	//声明实现结构体
    Impl* pImpl;	//以及指向它们的指针
};

由于Widget不再提及std::stringstd::vectorGadget类型,Widget客户不再需要#include这些类型的头文件,这会使得编译速度得到提升,同时也意味着即使这些头文件的内容发生改变,Widget的客户也不会受到影响。

一个已声明但未定义的类型称为非完整类型,Widget::Impl就是这样的类型。针对非完整类型,可以做的事情很有限,但是声明一个指向它的指针是可以的

Pimpl习惯用法的第一部分,是声明一个指针类型的数据成员,指向一个非完整类型,第二部分是动态分配和回收持有从前在原始类中的那些数据成员的对象,而分配和回收代码则放在实现文件中

例如,对Widget而言,这部分代码就位于Widget.cpp内。

#include "Widget.h"			//位于实现文件“Widget.cpp”内
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl{			//Widget::Impl的实现
    std::string name;			//包括此前在Widget中的数据成员
    std::vector<double> data;
    Gadget g1,g2,g3;
};

Widget::Widget():pImpl(new Impl){}		//为本Widget对象分配数据成员所需内存

Widget::~Widget(){ delete pImpl; }		//为本Widget对象析构数据成员

这里,我把#include指令展示出来,是为了说明对std::stringstd::vectorGadget所对应的头文件的总体依赖仍然存在。然而,这些依赖已经从Widget.h转移到了Widget.cpp中。

但是我展示的是C++98的代码,它们散发着腐朽的气息。其中使用了裸指针,裸new运算符和裸delete运算符。本章的主旨在于优先选择智能指针,而非裸指针。如果我们需要的是在Widget构造函数里动态分配一个Widget::Impl对象,同时在Widget析构时自动释放该对象,那么std::unique_ptr正是我们所需要的工具。用std::unique_ptr来替代指向Impl的裸指针后,头文件的代码就会成这样。

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{			//Widget::Impl的实现
    std::string name;			//包括此前在Widget中的数据成员
    std::vector<double> data;
    Gadget g1,g2,g3;
};

Widget::Widget():
pImpl(std::make_unique<Impl>()){}		//为本Widget对象分配数据成员所需内存

在这里,Widget的析构函数不存在了。那是因为,我们无须再为其撰写代码。std::unique_ptr被析构时,它会自动析构它所指向的对象,因此我们不再需要自己析构任何东西。智能指针消除了手动释放资源的要求

这段代码能够通过编译,但遗憾的时,最普通的Widget都通不过编译。

#include "widget.h"

Widget w;		//错误

你所看到的错误信息根据编译器不同会有所不同,但是错误信息文本通常会提及诸如在非完整类型上实施了sizeofdelete运算符,而这些属于不可以实施于该种类型的操作之列。

使用std::unique_ptr来实现Pimpl习惯用法会抛出错误。这令人十分震惊,原因是:(1)std::unique_ptr宣称它支持非完整类型(2)Pimpl习惯用法是std::unique_ptr最广泛应用的场景之一。

在对象w被析构时,例如离开作用域时,所生成的代码引起的。在那一刻,析构函数被调用,在使用了std::unique_ptr的类定义里,我们未声明析构函数,因为无须为其撰写代码。根据编译器生成特种成员函数的基本规则,编译器为我们生成了一个析构函数。在该析构函数内,编译器会插入代码来调用Widget的数据成员pImplpImpl是个std::unique_ptr<Widget::Impl>类型的对象,即一个使用了默认析构器的std::unique_ptr。默认析构器是在std::unique_ptr内部使用delete运算符来针对裸指针实施析构的函数。然而,在实施delete运算符之前,典型的实现会使用C++11中的static_assert去确保裸指针没有指向一个非完整类型。这么一来,当编译器为Widget w的析构函数产生代码时,通过就会遇到一个失效的static_assert,从而导致了错误信息的产生。这个错误信息和w被析构的位置有关,因为Widget的析构函数与其他编译器产生的特种成员函数一样,基本上是隐式inline的。这个编译错误通常会标示在w生成的那一代码行,因为正式这行源代码的显式创建对象导致了后来的隐式构造。

为解决这一问题,只需保证在生成析构std::unique_ptr<Widget::Impl>代码处,Widget::Impl是个完整类型即可。只要类型的定义可以被看到,它就是完整的。而Widget::Impl的定义位于widget.cpp中。因此,成功编译的关键在于让编译器看到Widget的析构函数的函数体(即,编译器将要生成代码来析构std::unique_ptr类型的数据成员之处)的位置在widget.cpp内部的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;			//包括此前在Widget中的数据成员
    std::vector<double> data;
    Gadget g1,g2,g3;
};

Widget::Widget():
pImpl(std::make_unique<Impl>()){}		//为本Widget对象分配数据成员所需内存

Widget::~Widget(){}		//~Widget的定义

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

Widget::~Widget() = default;

使用了Pimpl用法的类,自然支持移动操作,因为编译器生成的移动操作完全符合预期,即针对std::unique_ptr执行移动操作。在条款17中解释过,在Widget中声明析构函数的举动会阻止编译器产生移动操作,所以假如你需要支持移动操作,就必须自己声明该函数。既然编译器产生的版本是正确的,你很有可能会尝试如下实现。

class Widget{
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 "Widget.h"			//位于实现文件“Widget.cpp”内
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl{			//Widget::Impl的实现
    std::string name;			//包括此前在Widget中的数据成员
    std::vector<double> data;
    Gadget g1,g2,g3;
};

Widget::Widget():
pImpl(std::make_unique<Impl>()){}		//为本Widget对象分配数据成员所需内存

Widget::~Widget(){}		//~Widget的定义

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

Pimpl习惯用法是一种可以在类实现和类使用者之间减少编译依赖性的方法,但从概念上说,Pimpl习惯用法并不能改变类所代表的事物。最初的Widget包含了std::stringstd::vectorGadget类型数据成员。如果假设Gadgetstd::stringstd::vector一样可以复制,那么自然Widget也支持复制操作。我们需要自己撰写这些函数,原因是(1)编译器不会为像std::unique_ptr那样的只移类型生成复制操作;(2)即使编译器可以生成,其生成的函数也只能复制std::unique_ptr(即,实施的是浅复制),而我们希望的则是复制指针所指向的内容(即,实施深复制)。

class Widget{			//在Widget.h中
public:
    Widget();
    ~Widget();
    ...
    Widget(Widget&& rhs);		
    Widget& operator=(Widget&& rhs);	
    
    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的实现
    std::string name;			//包括此前在Widget中的数据成员
    std::vector<double> data;
    Gadget g1,g2,g3;
};

Widget::Widget():
pImpl(std::make_unique<Impl>()){}		//为本Widget对象分配数据成员所需内存

Widget::~Widget(){}		//~Widget的定义

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

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

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

两个函数的实现都比较中规中矩。在每个情况中,我们都只从源对象(*this)中简单复制了Impl结构体的内容。我们并未逐项地复制每个字段,而是利用了编译器为Impl类创建的复制操作会自动逐项复制这些字段的特性。总之,我们通过采用Widget::Impl的编译器生成的复制操作实现了Widget的赋值操作。值得注意的是,在此复制构造函数中,我们还遵循了条款21的建议,尽量使用std::make_unique而不是直接使用new表达式。

为达到实现Pimpl习惯用法的目的,应该选用的是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<Impl> pImpl;		//适用std::shared_ptr
};

适用#include指令包含Widget.h以后的客户代码。

Widget w1;

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

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

一切编译和运行结果都如我们所期望:w1将会被默认构造,其值被移动到w2,然后该值又被移回w1,最后w1w2都被析构(从而导致指向的Widget::Impl对象一并也被销毁)。

std::unique_ptrstd::shared_ptr这两种智能指针在实现pImpl指针行为时的不同,源自它们对于自定义析构器的支持不同,对于std::unique_ptr而言,析构器类型是智能指针类型的一部分,这使得编译器会产生更小尺寸的运行期数据结构以及更快的运行期代码。如此高效带来有的后果是,欲使用编译器生成的特种函数(例如,析构函数或移动操作),就要求其指向的类型必须是完整类型。而对于std::shared_ptr而言,析构器的类型并非智能指针类型的一部分,这就需要更大尺寸的运行期数据结构以及一些更慢的目标代码,但在使用编译器生成的特种函数时,其指向的类型却并不要求是完整类型。

Pimpl习惯用法而言,并不需要在std::shared_ptrstd::unique_ptr的特性之间做出权衡,因为WidgetWidget::Impl这样的类之间的关系是专属所有权,所以在此处std::unique_ptr就是完成任务的合适工具。话说回来,还是需要了解,在其他场景下——存在共享所有权的情境下,就大可不必忍受std::unique_ptr所带来的必须自行撰写一些列函数定义的煎熬。

要点速记

  • Pimpl习惯用法通过降低类的客户和类实现者之间的依赖性,减少了构建遍数
  • 对于采用unique_ptr实现的Pimpl指针,必须在类的头文件中声明特种成员函数,但在实现文件中实现它们。即使默认函数实现有着正确的行为,也必须这样做
  • 上述建议仅适用于std::unique_ptr,但不适用于std::shared_ptr
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值