PImpl
"PImpl"(Pointer to implementation,指向实现的指针)是一种 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 和工厂方法都消除了实现与类接口用户之间的编译时依赖。工厂方法创建了对虚函数表的隐藏依赖,因此重新排序、添加或删除虚成员函数会破坏 ABI。PImpl 方法没有隐藏依赖,但是如果实现类是类模板特化,则编译防火墙的优势会丧失:接口的用户必须观察整个模板定义才能实例化正确的特化。在这种情况下,一种常见的设计方法是以避免参数化的方式重构实现,这是 C++ Core Guidelines 的另一个用例。
例如,以下类模板在其私有成员或 `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 中,每次调用私有成员函数都会通过指针间接访问。私有成员对公共成员的每次访问都会通过另一个指针间接访问。两次间接访问都跨越翻译单元边界,因此只能通过链接时优化来消除。请注意,面向对象工厂需要跨翻译单元进行间接访问以访问公共数据和实现细节,并且由于虚派发,为链接时优化提供了更少的机会。
- 空间开销:PImpl 为公共组件添加一个指针,如果任何私有成员需要访问公共成员,则要么向实现组件添加另一个指针,要么在每次调用需要它的私有成员时将其作为参数传递。如果支持有状态的自定义分配器,则分配器实例也必须存储。
- 生命周期管理开销:PImpl(以及面向对象工厂)将实现对象放在堆上,这在构造和析构时会产生显著的运行时开销。这可以通过自定义分配器部分抵消,因为 PImpl(但不是面向对象工厂)的分配大小在编译时是已知的。
另一方面,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* 的构造函数参数。
[编辑] 注意
本节不完整 原因:注意与值语义多态的联系 |
[编辑] 示例
演示了一个 PImpl,具有 `const` 传播、将反向引用作为参数传递、不感知分配器,以及无需运行时检查即可移动
// ---------------------- // 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 : The Fast Pimpl Idiom。 |
2. | GotW #100: Compilation Firewalls。 |
3. | Pimpl 模式 - 你应该知道什么。 |