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 或 placement-new )到实现结构。这种方法有其自身的优点和缺点,特别是一个明显的优点是在最初为 PImpl 类设计时保留了足够的内存的条件下,无需额外分配。(而缺点之一是降低了移动友好性。) |
[编辑] 外部链接
1. | GotW #28 : 快速 Pimpl 惯用法。 |
2. | GotW #100: 编译防火墙。 |
3. | Pimpl 模式 - 你应该知道的。 |