如果你有和过长构建时间的抗争经历,那么你应该熟悉Pimpl习惯用法。这种技巧就是把某类的数据成员用一个指涉到某实现类的指针代替,然后把原来在主类中的数据成员放置到实现类中,并通过指针间接访问这些数据成员。例如考虑某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才能通过编译。这说明了用户必须#include <string> <vector> gadget.h,但这些头文件增加了客户的编译时间,并且这些头文件变化的时候,客户也必须重新编译。
在C++98中,你一定见过这种常见解决方式:
class Widget { //仍旧位于头文件内
public:
Widget();
~Widget(); //析构函数变的必要
...
private:
struct Impl; //声明实现结构体
Impl *pImpl; //以及指涉到它的指针
};
通过这种方式,头文件中不再需要#include <string> <vector> gadget.h。这样的优势会让便以速度提升,同时意味着这些头文件的改变不会影响客户代码。对于一个已声明但未定义的型参型别能做的操作极其有限,但声明一个指涉到它的指针是没问题的。
Pimpl习惯用法
-
声明一个指针型别的数据成员,指涉到一个非完整型别。
-
动态分配和回收持有原始类里数据成员的对象,而分配和回收代码放在实现文件中。
C++98中的Pimpl写法
//widget.cpp
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() :pImpl(new Impl) {}
Widget::~Widget() {delete pImpl;}
C++11中的Pimpl写法
这里pImpl一般是用来管理动态分配的Widget::Impl对象的,所以在C++11中std::unique_ptr是一个很合适的智能指针。那么写法可能如下:
//widget.h
class Widget {
public:
Widget();
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl; //这里使用了智能指针
};
//widget.cpp
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() :pImpl(std::make_unique<Impl>()) {}
你也许会注意到,析构函数不复存在了,那是因为我们无需再为其撰写代码。
但非常遗憾的是,这样的写法根本无法通过编译。
但这是让人震惊的,原因在于:
-
std::unique_ptr号称可以支持非完整型别;
-
Pimpl习惯用法是std::unique_ptr最广泛应用场景之一。
既然这样为什么不能用呢。编不过去的原因在于:
Widget对象析构的时候,析构函数被调用,但是我们并未声明析构函数。
那么编译器会自动为我们生成默认析构函数,但在该析构函数内,编译器会插入代码来调用Widget的数据成员pImpl的默认析构函数。std::unique_ptr的默认析构器一般是用delete运算符删除对应的裸指针,从而实现析构。而在实施delete之前,典型的实现会使用C++11中的static_assert去确保裸指针并未指涉到非完整型别。这样一来就产生了一个失败的static_assert。这个信息会比较模糊,因为和对象析构的位置有关,因为Widget的析构函数与其他编译器自己产生的函数一样,是隐式inline的。所以这个编译错误通常会表示在Widget对象生成的那一行,因为正是这行源码的显示创建导致了后面的隐式析构。
C++11中的Pimpl的解决方案
由上述根因可知,只需保证在生成析构std::unqiue_ptr<Widget::Impl>代码处,Widget::Impl是个完整型别即可。
那么代码可以写成这样:
//widget.h
class Widget {
public:
Widget();
~Widget(); //仅声明
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl; //这里使用了智能指针
};
//widget.cpp
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};
Widget::Widget() :pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() {} //析构函数的实现,注意一定要在Widget::Impl的实现之后
//也可以这么写
Widget::~Widget() = default;
同样的,如果上述类需要移动操作,那么也需要在头文件声明,在实现文件中=default。
但对于复制构造函数和复制构造运算符则体现略有不同:
//widget.h
class Widget {
public:
...
Widget(const Widget& rhs);
Widget& operator=(const Widget& rhs); //仅声明
...
private:
struct Impl;
std::unique_ptr<Impl> pImpl; //这里使用了智能指针
};
//widget.cpp
#include "widget.h" //同前
...
struct Widget::Impl {
... //同前
};
...
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;
}
特例
需要指出的是,以上的表示方式仅需要在std::unique_ptr时使用,对于std::shared_ptr则无需这样,这是源自于他们对于自定义析构器的支持不同。
-
对于
std::unique_ptr而言,析构器是智能指针型别的一部分,这使得编译器会产生更小尺寸的运行期数据结构以及更快的运行期代码。这种优势的代价是,欲使用编译器生成的特种函数(析构或者移动),就要指涉的型别必须是完整型别。 -
对于
std::shared_ptr而言,析构器型别并非智能指针型别的一部分,这就需要更大的数据结构以及更慢一些的目标代码。不过这样也无需要求特种函数指涉到的型别是完整的。
但话说回来,一般而言对于pImpl手法的关系一般是使用std::unique_ptr的。
| 要点速记 |
|---|
| 1. Pimpl是一种降低类客户和实现之间依赖性的常见手法,可以减少构建次数。 |
2. 对于用std::unique_ptr实现的Pimpl指针,须在类的头文件中声明特种成员函数,但在实现文件中再实现他们,即便默认函数有着正确的行为,也必须这么做。 |
3. 上述建议只适用于std::unique_ptr,对于std::shared_ptr则无需这样。 |
本文探讨了C++中Pimpl习惯用法如何结合std::unique_ptr以降低类客户与实现间的依赖,减少构建时间。文章详细介绍了Pimpl在C++98与C++11中的应用,以及在使用std::unique_ptr时遇到的编译问题及解决方案。

2017

被折叠的 条评论
为什么被折叠?



