C++实现pimpl惯用法(非常详细)
pimpl 代表指向实现的指针(也以柴郡猫惯用法或编译器防火墙惯用法知名),是用来将实现细节从接口中分离的不透明指针技术。好处是改变实现不需要修改接口,因此可避免重新编译使用此接口的代码。当只有实现细节变化时,库使用 pimpl 惯用法在其 ABI 上,可使其与老版本向后兼容。
在本节中,我们将看到如何使用现代 C++ 特性实现 pimpl 惯用法。为了以实际示例展示 pimpl 惯用法,我们考虑以下类,然后将其以 pimpl 惯用法重构:
1) 将所有私有成员(数据和函数)放到不同的类中。我们称其为 pimpl 类,原始的类为公共类。
2) 在公共类的头文件中,放置 pimpl 类的前置声明:
3) 在公共类定义中,使用 unique_ptr 声明指向 pimpl 类的指针。这应该是这个类唯一的私有数据成员:
4) 将 pimpl 类定义放在公共类的源文件中。pimpl 类模仿公共类的公共接口:
5) pimpl 类在在公共类的构造函数中实例化:
6) 公共类成员函数调用相应的 pimpl 类的成员函数:
以上提及的好处不是免费的,有几个缺点需要提及:
关于重构 control 类的实现,指向 control_pimpl 对象的指针由 unique_ptr 管理。对于这个指针的声明,我们使用自定义删除器:
使用 unique_ptr 时会有错误,无法删除不完整类型。这个问题可通过两种方法解决:
在本节中,我们将看到如何使用现代 C++ 特性实现 pimpl 惯用法。为了以实际示例展示 pimpl 惯用法,我们考虑以下类,然后将其以 pimpl 惯用法重构:
class control { std::string text; int width = 0; int height = 0; bool visible = true; void draw() { std::cout << "control " << '\n' << " visible: " << std::boolalpha << visible << std::noboolalpha << '\n' << " size: " << width << ", " << height << '\n' << " text: " << text << '\n'; } public: void set_text(std::string_view t) { text = t.data(); draw(); } void resize(int const w, int const h) { width = w; height = h; draw(); } void show() { visible = true; draw(); } void hide() { visible = false; draw(); } };这个类表示控制台,有文本、大小和可见性的属性。每当这些属性变化时,控制台重绘。在这模拟的实现中,绘画意味着将属性的值输出到控制台上。
pimpl惯用法的实现过程
遵循以下步骤来实现pimpl惯用法,通过重构之前显示的 control 类来举例说明:1) 将所有私有成员(数据和函数)放到不同的类中。我们称其为 pimpl 类,原始的类为公共类。
2) 在公共类的头文件中,放置 pimpl 类的前置声明:
// in control.h class control_pimpl;
3) 在公共类定义中,使用 unique_ptr 声明指向 pimpl 类的指针。这应该是这个类唯一的私有数据成员:
class control { std::unique_ptr<control_pimpl, void(*)(control_pimpl*)> pimpl; public: control(); void set_text(std::string_view text); void resize(int const w, int const h); void show(); void hide(); };
4) 将 pimpl 类定义放在公共类的源文件中。pimpl 类模仿公共类的公共接口:
// in control.cpp class control_pimpl { std::string text; int width = 0; int height = 0; bool visible = true; void draw() { std::cout << "control " << '\n' << " visible: " << std::boolalpha << visible << std::noboolalpha << '\n' << " size: " << width << ", " << height << '\n' << " text: " << text << '\n'; } public: void set_text(std::string_view t) { text = t.data(); draw(); } void resize(int const w, int const h) { width = w; height = h; draw(); } void show() { visible = true; draw(); } void hide() { visible = false; draw(); } };
5) pimpl 类在在公共类的构造函数中实例化:
control::control() : pimpl(new control_pimpl(), [](control_pimpl* pimpl) {delete pimpl; }) {}
6) 公共类成员函数调用相应的 pimpl 类的成员函数:
void control::set_text(std::string_view text) { pimpl->set_text(text); } void control::resize(int const w, int const h) { pimpl->resize(w, h); } void control::show() { pimpl->show(); } void control::hide() { pimpl->hide(); }
深度剖析pimpl惯用法
pimpl 惯用法使类的内部实现能够向该类所属的库或模块的客户端隐藏。这有几个好处:- 用户可以看到类清晰的接口;
- 改变内部实现不影响公共接口,库的新版本可向后兼容(当公共接口不变时);
- 当内部实现改变时,使用 pimpl 惯用法的用户不需要重新编译。这使编译时间缩短;
- 头文件不需要包含私有实现中使用的类型和函数的头文件。这也使编译时间缩短。
以上提及的好处不是免费的,有几个缺点需要提及:
- 需要编写和维护更多的代码;
- 代码可以说更不可读,因为间接加了一层,且实现的细节需要在其他文件中查找。在本节中,pimpl类的定义在公共类的源文件中提供,但实际上,它可以出现在不同的文件中;
- 因为从公共类到pimpl类中间接加了一层,所以有一点运行时上的开销,但实际上影响很小;
- 此方法对保护成员不可用,因为成员需要对派生类可用;
- 此方法对必须出现在类中的私有虚函数不可用,因为它们需要覆盖基类中函数或在派生类中覆盖可用。
在本节示例中,control_pimpl 类基本上和原始 control 类一样。实际上,类越大(包括虚函数和保护数据,以及函数和成员),pimpl 类和非 pimpl 类不是完全相同的。而且实际上,pimpl 类可能需要有指向公共类的指针,以便调用没有移动到 pimpl 类上的成员。作为一条经验法则,当实现 pimpl 惯用法时,总是将除了虚函数以外的所有私有数据和函数成员放到 pimpl 类,将保护数据成员与函数和所有私有虚函数放到公共类中。
关于重构 control 类的实现,指向 control_pimpl 对象的指针由 unique_ptr 管理。对于这个指针的声明,我们使用自定义删除器:
std::unique_ptr<control_pimpl, void(*)(control_pimpl*)> pimpl;这么做的原因是 control 类有个由编译器默认定义的析构函数,某种程度上 control_pimpl 类还是不完整的(即在头文件中)。
使用 unique_ptr 时会有错误,无法删除不完整类型。这个问题可通过两种方法解决:
- 在 control_pimpl 类完整定义可用后,提供 control 类显式实现的用户定义的析构函数(即使以 default 声明);
- 正如我们在此示例中所做的,给 unique_ptr 提供自定义删除器。