PImpl
"指针到实现" 或 "pImpl" 是一种 C++ 编程技巧,通过将类的实现细节放在一个单独的类中(通过不透明指针访问)来从类的对象表示中移除这些细节。
// -------------------- // interface (widget.h) struct widget { // public members private: struct impl; // forward declaration of the implementation class // One implementation example: see below for other design options and trade-offs std::experimental::propagate_const< // const-forwarding pointer wrapper std::unique_ptr< // unique-ownership opaque pointer impl>> pImpl; // to the forward-declared implementation class }; // --------------------------- // implementation (widget.cpp) struct widget::impl { // implementation details };
此技术用于构建具有稳定 ABI 的 C++ 库接口,并减少编译时依赖项。
内容 |
[编辑] 解释
因为类的私有数据成员参与其对象表示,影响大小和布局,并且因为类的私有成员函数参与 重载解析(在成员访问检查之前发生),对这些实现细节的任何更改都需要重新编译该类的所有用户。
pImpl 消除了这种编译时依赖关系;对实现的更改不会导致重新编译。因此,如果库在其 ABI 中使用 pImpl,则库的较新版本可能会更改实现,同时保持与较旧版本的 ABI 兼容。
[编辑] 权衡取舍
pImpl 习语的替代方法是
- 内联实现:私有成员和公共成员是同一个类的成员。
- 纯抽象类(OOP 工厂):用户获得指向轻量级或抽象基类的唯一指针,实现细节在派生类中,该派生类覆盖其虚成员函数。
[编辑] 编译防火墙
在简单情况下,pImpl 和工厂方法都消除了实现和类接口用户之间的编译时依赖关系。工厂方法创建了对 vtable 的隐藏依赖关系,因此重新排序、添加或删除虚成员函数会破坏 ABI。pImpl 方法没有隐藏的依赖关系,但是如果实现类是类模板的特化,则编译防火墙的优势就会消失:接口用户必须观察整个模板定义才能实例化正确的特化。在这种情况下,一种常见的設計方法是重构实现,以避免参数化,这也是 C++ 核心准则的另一个用例。
例如,以下类模板在其私有成员中或在 push_back
的主体中没有使用类型 T
template<class T> class ptr_vector { std::vector<void*> vp; public: void push_back(T* p) { vp.push_back(p); } };
因此,私有成员可以原封不动地转移到实现中,push_back
可以转发到一个实现中,该实现也不在接口中使用 T
// --------------------- // header (ptr_vector.hpp) #include <memory> class ptr_vector_base { struct impl; // does not depend on T std::unique_ptr<impl> pImpl; protected: void push_back_fwd(void*); void print() const; // ... see implementation section for special member functions public: ptr_vector_base(); ~ptr_vector_base(); }; template<class T> class ptr_vector : private ptr_vector_base { public: void push_back(T* p) { push_back_fwd(p); } void print() const { ptr_vector_base::print(); } }; // ----------------------- // source (ptr_vector.cpp) // #include "ptr_vector.hpp" #include <iostream> #include <vector> struct ptr_vector_base::impl { std::vector<void*> vp; void push_back(void* p) { vp.push_back(p); } void print() const { for (void const * const p: vp) std::cout << p << '\n'; } }; void ptr_vector_base::push_back_fwd(void* p) { pImpl->push_back(p); } ptr_vector_base::ptr_vector_base() : pImpl{std::make_unique<impl>()} {} ptr_vector_base::~ptr_vector_base() {} void ptr_vector_base::print() const { pImpl->print(); } // --------------- // user (main.cpp) // #include "ptr_vector.hpp" int main() { int x{}, y{}, z{}; ptr_vector<int> v; v.push_back(&x); v.push_back(&y); v.push_back(&z); v.print(); }
可能的输出
0x7ffd6200a42c 0x7ffd6200a430 0x7ffd6200a434
[编辑] 运行时开销
- 访问开销:在 pImpl 中,对私有成员函数的每次调用都通过指针进行间接寻址。私有成员对公共成员的每次访问都通过另一个指针进行间接寻址。这两种间接寻址都跨越了翻译单元边界,因此只能通过链接时优化来消除。请注意,OO 工厂需要跨越翻译单元进行间接寻址才能访问公共数据和实现细节,并且由于虚拟调度,它提供的链接时优化机会更少。
- 空间开销:pImpl 在公共组件中添加了一个指针,如果任何私有成员需要访问公共成员,则另一个指针将被添加到实现组件中或作为每个需要它的私有成员调用时的参数传递。如果支持有状态的自定义分配器,则分配器实例也必须存储。
- 生命周期管理开销:pImpl(以及 OO 工厂)将实现对象放在堆上,这在构造和析构时会产生很大的运行时开销。这可以通过自定义分配器部分抵消,因为 pImpl(但不是 OO 工厂)的分配大小在编译时是已知的。
另一方面,pImpl 类是可移动的;将大型类重构为可移动的 pImpl 可能会提高操作包含此类对象的容器的算法的性能,尽管可移动的 pImpl 存在额外的运行时开销来源:任何在已移动对象上允许的并且需要访问私有实现的公共成员函数都会产生空指针检查。
本节尚未完善 原因:微基准测试?) |
[编辑] 维护开销
使用 pImpl 需要一个专门的翻译单元(仅限头文件的库不能使用 pImpl),引入一个额外的类,一组转发函数,以及,如果使用分配器,则在公共接口中公开分配器使用的实现细节。
由于虚拟成员是 pImpl 的接口组件的一部分,模拟 pImpl 意味着仅模拟接口组件。可测试的 pImpl 通常设计为允许通过可用接口实现完整的测试覆盖率。
[编辑] 实现
由于接口类型对象的生存期控制着实现类型对象的生存期,指向实现的指针通常是 std::unique_ptr。
因为 std::unique_ptr 要求指向的类型在析构函数实例化的任何上下文中都是完整类型,因此特殊成员函数必须由用户声明并定义在实现文件之外,在实现类是完整的实现文件中。
因为当 const 成员函数通过非 const 成员指针调用函数时,会调用实现函数的非 const 重载,所以指针必须包装在 std::experimental::propagate_const 或等效类型中。
所有私有数据成员和所有私有非虚成员函数都放在实现类中。所有公有、受保护和虚成员都保留在接口类中(有关替代方案的讨论,请参阅 GOTW #100)。
如果任何私有成员需要访问公有或受保护成员,可以将指向接口的引用或指针作为参数传递给私有函数。或者,可以将反向引用作为实现类的一部分维护。
如果打算为实现对象的分配支持非默认分配器,则可以使用任何常用的分配器感知模式,包括分配器模板参数默认为 std::allocator 和类型为 std::pmr::memory_resource* 的构造函数参数。
[编辑] 注释
本节尚未完善 原因:注意与值语义多态性的联系。 |
[编辑] 示例
展示了具有 const 传播、通过参数传递反向引用、没有分配器感知和在没有运行时检查的情况下启用移动的 pImpl。
// ---------------------- // interface (widget.hpp) #include <experimental/propagate_const> #include <iostream> #include <memory> class widget { class impl; std::experimental::propagate_const<std::unique_ptr<impl>> pImpl; public: void draw() const; // public API that will be forwarded to the implementation void draw(); bool shown() const { return true; } // public API that implementation has to call widget(); // even the default ctor needs to be defined in the implementation file // Note: calling draw() on default constructed object is UB explicit widget(int); ~widget(); // defined in the implementation file, where impl is a complete type widget(widget&&); // defined in the implementation file // Note: calling draw() on moved-from object is UB widget(const widget&) = delete; widget& operator=(widget&&); // defined in the implementation file widget& operator=(const widget&) = delete; }; // --------------------------- // implementation (widget.cpp) // #include "widget.hpp" class widget::impl { int n; // private data public: void draw(const widget& w) const { if (w.shown()) // this call to public member function requires the back-reference std::cout << "drawing a const widget " << n << '\n'; } void draw(const widget& w) { if (w.shown()) std::cout << "drawing a non-const widget " << n << '\n'; } impl(int n) : n(n) {} }; void widget::draw() const { pImpl->draw(*this); } void widget::draw() { pImpl->draw(*this); } widget::widget() = default; widget::widget(int n) : pImpl{std::make_unique<impl>(n)} {} widget::widget(widget&&) = default; widget::~widget() = default; widget& widget::operator=(widget&&) = default; // --------------- // user (main.cpp) // #include "widget.hpp" int main() { widget w(7); const widget w2(8); w.draw(); w2.draw(); }
输出
drawing a non-const widget 7 drawing a const widget 8
本节尚未完善 原因:描述另一种替代方案——“快速 PImpl”。主要区别在于实现的内存是在一个数据成员中保留的,该数据成员是一个不透明的 C 数组(在 PImpl 类定义中),而在 cpp 文件中,该内存被映射(通过 reinterpret_cast 或位置 new )到实现结构。这种方法有自己的优缺点,特别地,一个明显的优点是没有额外的分配,前提是在 PImpl 类的设计时预留了足够的内存。(而缺点是移动友好性降低。) |
[编辑] 外部链接
1. | GotW #28 : 快速 Pimpl 惯用法。 |
2. | GotW #100: 编译防火墙。 |
3. | Pimpl 模式 - 你应该知道的。 |