概述
PIMPL
是Pointer to IMPLementation
的缩写,意思是指向实现的指针。 是一个Cpp独有的设计模式(严格说也不是,是桥接模式的一种特例),该技巧可以避免在头文件中暴露私有细节,同时通过类型擦除的技巧,不必麻烦用户编写继承相关代码,并能包装任意对象,在STL标准库中,这类设施典型的代表就是std::function
。
Cpp语境下的类型擦除,技术上来说,是编写一个类,它提供模板的构造函数和非虚函数接口提供功能;隐藏了对象的具体类型,但保留其行为。简单地说,就是库作者把面向对象的代码写了,而不是推给用户写。
Pimpl惯用法
struct task_base {
virtual ~task_base() {}
virtual void operator()() const = 0;
};
template <typename F>
struct task_model : public task_base {
F functor_;
template <typename U> // 构造函数是函数模板
task_model(U&& f) :
functor_(std::forward<U>(f)) {}
void operator()() const override {
functor_();
}
};
首先,抽象基类task_base
作为公共接口不变;其子类task_model
(角色同上文中的task_impl
)写成类模板的形式,其把一个任意类型F
的函数对象function_
作为数据成员。
子类写成类模板的具体用意是,对于用户提供的一个任意的类型F
,F
不需要知道task_base
及其继承体系,而只进行语法上的duck typing检查。 这种方法避免了继承带来的侵入式设计。换句话说,只要能合乎语法地对F
调用预先定义的接口,代码就可以编译,这个技巧就能运作。此例中,预先定义的接口是void()
,以functor_();
的形式调用。
然后,我们把它包装起来:
class my_task {
std::unique_ptr<task_base> ptr_;
public:
template <typename F>
my_task(F&& f) {
using model_type = task_model<F>;
ptr_ = std::make_unique<model_type>(std::forward<F>(f));
}
void operator()() const {
ptr_->operator()();
}
// 其他部分略
};
why?
首先,初始动机是用一个类型包装不同的函数对象。然后,考虑这些函数对象需要提供的功能(affordance),此处为使用括号运算符进行函数调用。最后,把这个功能抽取为一个接口,此处为my_task
,我们在在这一步擦除了对象具体的类型。这便是类型擦除的本质:切割类型与其行为,使得不同的类型能用同一个接口提供功能。
对my_task
进行简单测试的代码如下:
// 普通函数
void foo() {
std::cout << "type erasure 1";
}
my_task t1{ &foo };
t1(); // 输出"type erasure 1"
// 重载括号运算符的类
struct foo2 {
void operator()() {
std::cout << "type erasure 2";
}
};
my_task t2{ foo2{} };
t2(); // 输出"type erasure 2"
// Lambda
my_task t3{
[](){ std::cout << "type erasure 3"; }
};
t3(); // 输出"type erasure 3"
接口简洁,类型参数全部由编译器推断,实现了上文中的需求,当然这里只是一个简单的核心概念demo,实际上的实现需要考虑的比这多,比如自动类型退化,模板构造函数不匹配或者错误的情况,以及怎么配合SFINAE与concepts都是需要深入的。
注意的点
编写一个类型擦除类,当然也离不开编写它的构造函数、赋值函数和析构函数,而其中大部分纠结都可归为资源所有权的问题:一个对象是否拥有(own)其资源的生命周期;如果拥有,是唯一还是共享地拥有?支持复制,还是只支持移动?
my_task
有一个std::unique_ptr
数据成员,这个智能指针拥有具体任务对象的生命周期;而一个任务也没有创造副本的需求。因此,my_task
不需支持复制,仅支持移动构造或赋值。
析构函数的责任是正确地释放资源。my_task
使用了智能指针,智能指针会正确地调用delete
,所以使用编译器合成的默认析构函数即可,这里强调一下,当手动实现析构函数的时候,一定要自己实现或者删除掉移动构造,否则就会出现二次析构的问题。
task_model
的析构函数会递归调用F
的析构函数,这依赖于用户正确编写了F
的析构函数,因此task_model
也用编译器合成的默认析构函数。
优缺点
Pimpl优点
- 信息隐藏:实现细节可以隐藏到Impl类实现中,保护闭源API专有性。同时,接口头文件也能更干净、清晰表达真正的公有接口,易于阅读和理解。
- 降低耦合:接口类只用知道Impl类即可,不用包含私有成员变量所需头文件,也不必包含平台依赖的windows.h或sys/time.h。
- 加速编译:将实现相关头文件移入.cpp,API的引用层次降低,会导致编译时间减少。
- 更好的二进制兼容性:采用Pimpl的对象大小从不改变,因为对象总是单个指针大小。对私有成员变量做任何修改,都只影响隐藏在cpp文件内的实现类大小。而对象的二进制表示可以不变。
- 惰性分配:Impl类可以在需要时再构造,而不必在接口类构造时立即构造。
Pimpl的缺点
- 必须为你创建的每个对象分配并释放实现对象。这使得对象增加了一个指针(Impl* impl_),同时增加了通过指针访问成员的开销,增加了new和delete对象的开销。_
- 必须通过impl->的形式访问私有成员。
- 编译器不能捕获接口类中const对成员变量修改。因为成员变量现在存在于独立的对象(impl_指针所指对象)中。编译器仅检查impl_指针是否发生变化,而不会检查其成员。
实战篇
假设我们目前有这么几个类:
#pragma once
#include <iostream>
struct MoveMsg {
int x;
int y;
void speak() {
std::cout << "Move " << x << ", " << y << '\n';
}
};
struct JumpMsg {
int height;
void speak() {
std::cout << "Jump " << height << '\n';
}
};
struct SleepMsg {
int time;
void speak() {
std::cout << "Sleep " << time << '\n';
}
};
struct ExitMsg {
void speak() {
std::cout << "Exit" << '\n';
}
};
然后在main中,我们需要用使用到多态,但又不能修改源文件,这时候我们就可以使用P-impl,再配合上工厂模式就可以消灭if-else舒服使用了。
struct MsgBase {
virtual void speak() = 0;
virtual void load() = 0;
virtual ~MsgBase() = default;
using Ptr = std::shared_ptr<MsgBase>;
};
namespace msg_extra_funcs { // 无法为 Msg 们增加成员函数,只能以重载的形式,外挂追加
void load(MoveMsg &msg) {
std::cin >> msg.x >> msg.y;
}
void load(JumpMsg &msg) {
std::cin >> msg.height;
}
void load(SleepMsg &msg) {
std::cin >> msg.time;
}
void load(ExitMsg &) {
}
}
template <class Msg>
struct MsgImpl : MsgBase {
Msg msg;
void speak() override {
msg.speak();
}
void load() override {
msg_extra_funcs::load(msg);
}
};
struct MsgFactoryBase {
virtual MsgBase::Ptr create() = 0;
virtual ~MsgFactoryBase() = default;
using Ptr = std::shared_ptr<MsgFactoryBase>;
};
template <class Msg>
struct MsgFactoryImpl : MsgFactoryBase {
MsgBase::Ptr create() override {
return std::make_shared<MsgImpl<Msg>>();
}
};
template <class Msg>
MsgFactoryBase::Ptr makeFactory() {
return std::make_shared<MsgFactoryImpl<Msg>>();
}
struct RobotClass {
inline static const std::map<std::string, MsgFactoryBase::Ptr> factories = {
{"Move", makeFactory<MoveMsg>()},
{"Jump", makeFactory<JumpMsg>()},
{"Sleep", makeFactory<SleepMsg>()},
{"Exit", makeFactory<ExitMsg>()},
};
void recv_data() {
std::string type;
std::cin >> type;
try {
msg = factories.at(type)->create();
} catch (std::out_of_range &) {
std::cout << "no such msg type!\n";
return;
}
msg->load();
}
void update() {
if (msg)
msg->speak();
}
MsgBase::Ptr msg;
};
int main() {
RobotClass robot;
robot.recv_data();
robot.update();
return 0;
}