PImpl
"指向实现的指针"或"pImpl"是一种C++ 编程技术 ,通过将类的实现细节放置在通过不透明指针访问的单独类中,从而从其对象表示中移除这些实现细节:
// -------------------- // interface (widget.h) struct widget { // public members private: struct impl; // 实现类的前向声明 // 一种实现示例:其他设计选项和权衡见下文 std::experimental::propagate_const< // 常量转发指针包装器 std::unique_ptr< // 独占所有权的不透明指针 impl>> pImpl; // 指向前向声明的实现类 }; // --------------------------- // implementation (widget.cpp) struct widget::impl { // implementation details };
该技术用于构建具有稳定ABI的C++库接口,并减少编译时依赖。
| 目录 | 
说明
由于类的私有数据成员参与其对象表示,影响大小和布局,并且由于类的私有成员函数参与 重载决议 (该过程发生在成员访问检查之前),对这些实现细节的任何更改都需要重新编译该类的所有使用者。
pImpl 消除了这种编译依赖;对实现的更改不会导致重新编译。因此,如果库在其 ABI 中使用 pImpl,较新版本的库可以在保持与旧版本 ABI 兼容的同时更改实现。
权衡
pImpl惯用法的替代方案是
- 内联实现:私有成员和公有成员属于同一个类的成员。
- 纯抽象类(OOP工厂模式):用户获取指向轻量级或抽象基类的唯一指针,具体实现细节位于重写其虚成员函数的派生类中。
编译防火墙
在简单情况下,pImpl和工厂方法都能消除类接口的实现与使用者之间的编译时依赖。工厂方法会创建对虚函数表的隐藏依赖,因此重新排序、添加或移除虚成员函数会破坏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
       
       的实现:
      
// --------------------- // 头文件 (ptr_vector.hpp) #include <memory> class ptr_vector_base { struct impl; // 不依赖于 T std::unique_ptr<impl> pImpl; protected: void push_back_fwd(void*); void print() const; // ... 特殊成员函数详见实现部分 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(); } }; // ----------------------- // 源文件 (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(); } // --------------- // 用户代码 (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 要求在实例化删除器的任何上下文中,被指向的类型必须是完整类型,因此特殊成员函数必须由用户声明并在实现文件中进行离线定义,此时实现类已是完整类型。
因为当常量成员函数通过非常量成员指针调用函数时,会调用实现函数的非常量重载版本,所以必须将指针包装在 std::experimental::propagate_const 或等效容器中。
所有私有数据成员和所有私有非虚成员函数都被放置在实现类中。所有公开、受保护和虚成员保留在接口类中(关于替代方案的讨论请参阅 GOTW #100 )。
如果任何私有成员需要访问公共或受保护成员,可以将接口的引用或指针作为参数传递给私有函数。或者,可以将反向引用作为实现类的一部分进行维护。
若需支持使用非默认分配器来分配实现对象,可采用任意常规的分配器感知模式,包括将分配器模板参数默认设为 std::allocator ,以及使用类型为 std::pmr::memory_resource* 的构造函数参数。
注释
| 本节内容不完整 原因:需补充与值语义多态性的关联说明 | 
示例
演示了一个具有常量传播的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; // 将被转发给实现的公共API void draw(); bool shown() const { return true; } // 实现必须调用的公共API widget(); // 即使默认构造函数也需要在实现文件中定义 // 注意:在默认构造对象上调用draw()是未定义行为 explicit widget(int); ~widget(); // 在实现文件中定义,其中impl是完整类型 widget(widget&&); // 在实现文件中定义 // 注意:在移动后对象上调用draw()是未定义行为 widget(const widget&) = delete; widget& operator=(widget&&); // 在实现文件中定义 widget& operator=(const widget&) = delete; }; // --------------------------- // implementation (widget.cpp) // #include "widget.hpp" class widget::impl { int n; // 私有数据 public: void draw(const widget& w) const { if (w.shown()) // 此公共成员函数调用需要反向引用 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模式 - 您应该了解的知识 |