条款22:使用Pimpl惯用法时,将特种成员函数的定义放到实现文件中
1 Pimpl用法
- Pimpl(“pointer to implementation,指向实现的指针”)惯用法。通过该技术,可以用指向实现类(或结构)的指针替换类的数据成员:
class Widget { // 在头文件 "widget.h" 中
public:Widget();...
private:std::string name;std::vector<double> data;Gadget g1, g2, g3; // Gadget 是某种用户定义的类型
};
Widget 客户端必须包含 , 和 gadget.h。这些头文件增加了 Widget 客户端的编译时间,并且使这些客户端依赖于头文件的内容(gadget.h 可能会经常修订)。在 C++98 中应用 Pimpl 惯用法可以让 Widget 将其数据成员替换为指向已声明但未定义的结构的原始指针。
class Widget { // 仍然在头文件 "widget.h" 中
public:Widget(); // 构造函数~Widget(); // 析构函数 - 详见下文...
private:struct Impl; // 声明实现结构Impl *pImpl; // 以及指向它的指针//声明但未定义的类型称为不完全类型(incomplete type)
};
- Pimpl 惯用法的第一部分是声明一个数据成员,该成员是指向不完整类型的指针。第二部分是动态分配数据成员,分配和释放代码位于实现文件中。
例如,对于 Widget,在 widget.cpp 中:
#include "widget.h" // 在实现文件 "widget.cpp" 中
#include "gadget.h"
#include <string>
#include <vector>
struct Widget::Impl { // Widget::Impl 的定义,其中包含以前在 Widget 中的数据成员std::string name;std::vector<double> data;Gadget g1, g2, g3;
}
Widget::Widget() // 为这个 Widget 对象分配数据成员: pImpl(new Impl) {
}Widget::~Widget(){// 销毁这个对象的数据成员delete pImpl;
}
上面展示的是 C++98 代码,使用了原始指针。如果希望在 Widget 构造函数中动态分配一个 Widget::Impl 对象,并在 Widget 被销毁的同时销毁它,可以使用std::unique_ptr:
class Widget { // 在 "widget.h" 中
public:Widget();...
private:struct Impl;std::unique_ptr<Impl> pImpl; // 使用智能指针
}; // 而不是原始指针
#include "widget.h"
Widget w; // 错误! 最简单的客户端使用却不行了
1)w被销毁时会调用析构函数。没有自定义的析构函数,编译器将生成一个。在该(inline)析构函数中,编译器会插入代码来调用数据成员pImpl的析构函数。pImpl是使用默认删除器的std::unique_ptr,会在std::unique_ptr内部的原始指针上使用delete。
2)然而,在使用delete之前,默认删除器使用 C++11 的static_assert来确保原始指针不指向不完整的类型。当编译器生成Widget w的销毁代码时,通常会遇到一个失败的static_assert。
#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() : pImpl(std::make_unique<Impl>()) // 通过 std::make_unique创建std::unique_ptr
{}
要解决这个问题,只需要确保在生成销毁std::unique_ptrWidget::Impl的代码时,Widget::Impl是一个完整的类型。成功编译的关键是让编译器只在widget.cpp中看到Widget的析构函数的主体。
class Widget {//与之前一样,在"widget.h"中
public:Widget();~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;std::vector<double> data;Gadget g1, g2, g3;
};
Widget::Widget() // 与之前一样: pImpl(std::make_unique<Impl>())
{}
Widget::~Widget() {}// ~Widget 的定义
如果想强调编译器生成的析构函数会做正确的事情——声明它的唯一原因是为了在 Widget 的实现文件中生成它的定义,那么可以使用“= default”。
Widget::~Widget() = default;
在 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;
};
1)编译器生成的移动赋值运算符需要在重新分配之前销毁 pImpl 指向的对象。
2)对于移动构造函数,编译器通常会生成代码来销毁 pImpl,以预防移动构造函数内部出现异常。
3)而销毁 pImpl 需要 Impl 是完整的类型,但在 Widget 头文件中,pImpl 指向不完整的类型。
4)由于问题与之前相同,因此修复方法也相同——将移动操作的定义移动到实现文件中。
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; // 定义
1)尽管使用了Pimpl惯用法,类所表示的概念并没有改变。原本的Widget类包含了std::string、std::vector和Gadget数据成员,并且假设Gadget与std::string和std::vector一样可以被拷贝,那么Widget也该支持拷贝。
2)由于编译器不会为具有只移动类型(如std::unique_ptr)的类生成拷贝操作,即使生成了,生成的函数也只会复制std::unique_ptr(即进行浅复制),而需要的往往是进行深拷贝。所以需要自己编写这些函数。
class Widget { // 仍然在 "widget.h" 中
public:... // 其他函数,如之前所示Widget(const Widget& rhs); // 仅声明Widget& operator=(const Widget& rhs);
private: // 如之前所示struct Impl;std::unique_ptr<Impl> pImpl;
#include "widget.h" // 在 "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) { *pImpl = *rhs.pImpl;return *this;
}
使用std::shared_ptr时,不需要声明析构函数,编译器会愉快地生成移动操作:
class Widget { // 在 "widget.h" 中
public:Widget(); ... // 无析构函数或移动操作
private:struct Impl;std::shared_ptr<Impl> pImpl; // 使用 std::shared_ptr 而不是 std::unique_ptr
};
包含了widget.h的客户端代码:
Widget w1;
auto w2(std::move(w1)); // 移动构造 w2
w1 = std::move(w2); // 移动赋值 w1
1)对于std::unique_ptr,删除器的类型是智能指针类型的一部分,这使得编译器能够生成更小的运行时数据结构和更快的运行时代码。当使用编译器生成的特殊函数(如析构函数或移动操作)时,指向的类型必须是完整的。
2)对于std::shared_ptr,删除器的类型不是智能指针类型的一部分。在使用编译器生成的特殊函数时,指向的类型不需要是完整的。
3)因为Widget和Widget::Impl这样的类之间的关系是独占所有权,所以std::unique_ptr成为适合该工作的正确工具。
2 要点速记
- Pimpl 惯用法通过减少类客户代码和类实现之间的编译依赖关系来减少构建时间。
- 对于 std::unique_ptr pImpl 指针,在类头文件中声明特殊成员函数,但在实现文件中实现它们。即使默认函数的实现是可接受的也要这样做。
- 上述建议适用于 std::unique_ptr,而不适用于 std::shared_ptr。
