本文主要介绍一个C++中非常有用的设计模式。
介绍一个C++中非常常用的模式:pImpl:
// MyClass.h
class MyClass
{
public:
void func1();
void func2();
private:
void func3();
void func4();
int a;
int b;
};
假设我们在开发一个SDK,或者设计某个模块,需要暴露出去一个MyClass.h头文件,并向用户提供func1和func2两个功能。
但是MyClass中还有一些private函数和字段,这些函数和字段我们本意可能是不想被用户知道,因为可能里面有些隐私内容,用户有可能通过这些private方法和字段就能猜到我们的架构及实现。
这也是我们平时设计模块需要注意的一点:只暴露出该暴露的东西。
那怎么做呢?答案就是pimpl模式。[单例模式]
修改之后代码:
// MyClass.h
class MyClass
{
public:
void func1();
void func2();
private:
class impl;
impl* pimpl;
};
// MyClass.cpp
class MyClass::impl
{
public:
void func1();
void func2();
private:
void func3();
void func4();
int a;
int b;
};
MyClass::MyClass()
{
pimpl = new impl;
}
void MyClass::func1()
{
pimpl->func1();
}
将类的private属性隐藏进一个内部类,然后通过一个指针访问(提前声明)它的接口。
在头文件中只暴露出应该暴露的功能,然后持有一个Impl的指针,
而Impl则具体在MyClass.cc中定义,用户什么都看不到。然后所有的功能都通过Impl完成。
头文件里的Impl的指针也可以通过智能指针(unique_ptr)来代替,但这不是本文的重点。
“指向实现的指针”或者“ pImpl”是一种 c + + 编程技术[1] ,它通过一个不透明的指针将一个类的实现细节从其对象表示中移除,将它们放置在一个单独的类中:
// --------------------
// interface (widget.h)
class 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 和 factory 方法都消除了类接口的实现和用户之间的编译时依赖关系。
Factory 方法在 vtable 上创建一个隐藏依赖项,因此重新排序、添加或删除虚拟成员函数会破坏 ABI。
PImpl方法没有隐藏的依赖关系,但是如果实现类是类模板专门化,编译防火墙的好处就丧失了: 接口的用户必须观察整个模板定义,以便实例化正确的专门化。在这种情况下,一个常见的设计方法是以避免参数化的方式重构实现,这是 c + + 核心指南的另一个用例:
例如,下面的类模板不在其私有成员或 T
push _ back
的正文中使用类型:
template<class T>
class ptr_vector
{
std::vector<void*> vp;
public:
void push_back(T* p)
{
vp.push_back(p);
}
};
因此,私有成员可以按原样转移到实现,并可以转发到接口中不使用的实现: push _ back
t
.
Running this code:
// ---------------------
// header (ptr_vector.hpp)
#include
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
#include
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();
}
Possible output:[结果输出]
0x7ffd6200a42c
0x7ffd6200a430
0x7ffd6200a434
运行时的开销:
访问开销: 在 pImpl
中,对私有成员函数的每个调用都通过一个指针定向。私有成员对公共成员的每次访问都通过另一个指针指向。两个间接方向都跨越了翻译单元的边界,因此只能通过链路时间优化来优化。请注意,OO 工厂要求跨转换单元间接访问公共数据和实现细节,由于虚拟分派,为链接时间优化器提供的机会更少。
空间开销: pImpl 将一个指针添加到公共组件,如果任何私有成员需要访问公共成员,则将另一个指针添加到实现组件,或者作为对需要它的私有成员的每次调用的参数传递。如果支持有状态自定义分配器,则还必须存储分配器实例。
生存期管理开销: pImpl (以及 OO 工厂)将实现对象放在堆上,这在构建和销毁过程中增加了大量的运行时开销。这可能会被自定义分配器部分抵消,因为 pImpl (但不是 OO 工厂)的分配大小在编译时是已知的。
另一方面,pImpl 类对移动非常友好; 将一个大类重构为 movable pImpl 可以提高操作装有这些对象的容器的算法的性能,尽管可移动的 pImpl 有额外的运行时开销来源: 任何允许移动对象上的公共成员函数并且需要访问私有实现都需要进行空指针检查。
由于接口类型的对象控制实现类型的对象的生存期,因此指向实现的指针通常是 std::unique_ptr
。
因为 std::unique_ptr
要求在删除器实例化的任何上下文中,pointed-to 类型都是完整的类型,所以特殊的成员函数必须在实现文件中用户声明,并且在实现类已经完成的情况下超行定义。
因为当 const 成员函数通过非常量成员指针调用函数时,将调用实现函数的非常量重载,因此指针必须包装在 std::experimental::propagate_const
或等效内容中。
所有私有数据成员和所有私有非虚拟成员函数都放在实现类中。所有公共、受保护和虚拟成员仍然保留在接口类中(参见 GOTW # 100以了解其他选项的讨论)。
如果任何私有成员需要访问公有成员或受保护成员,则可以将对接口的引用或指针作为参数传递给私有函数。或者,可以将后向引用作为实现类的一部分来维护。
如果在分配实现对象时打算支持非默认分配器,那么可以使用任何常见的分配器感知模式,包括分配器模板参数 defaulting to std::allocator
和类型 std::pmr::memory_resource*
的构造函数参数。
演示了一个带有 const 传播的 pImpl,将反向引用作为参数传递,没有分配器感知,并且启用了 move 而不进行运行时检查:
running this code:
// ----------------------
// interface (widget.hpp)
#include
#include
#include
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();
}
output:
drawing a non-const widget 7
drawing a const widget 8
pimpl模式的优点:
非常适合隐藏private实现:如果想要在头文件中暴露public接口,但又不想暴露private实现的细节,则可以使用pimpl模式来隐藏细节。
pimpl模式也被称为编译防火墙,是一种用来减少编译时间的方法。通常来讲,如果头文件里的某些内容变更了,意味着所有引用该头文件的代码都要被重新编译,即使变更的是无法被用户类访问的私有成员。将这部分代码从被引用多次的头文件里移除到只被引用编译一次的源文件中,更改此文件就不会付出太长的编译时间。