从一个简单类说起
PImpl 想必大家都不陌生:把类的私有成员和较重的头文件依赖移到 .cpp 中,放进一个实现类;公开头文件只保留实现类的前向声明,以及指向它的 指针。这样,使用这个类的代码就不必跟着包含那些实现细节。
这样做的好处是接口与实现之间的依赖隔离,减少编译依赖。修改 Impl 的私有成员或内部逻辑时,只要公开接口不变,通常也不需要重新编译所有使用这个类的代码。
看下面这段 Object.h:
// Object.h
#pragma once
#include <string>
#include <vector>
class Object
{
public:
explicit Object(const std::string& name);
void addValue(int value);
int sum() const;
private:
std::string m_name;
std::vector<int> m_values;
};
实现也很简单:
// Object.cpp
#include "Object.h"
Object::Object(const std::string& name)
: m_name(name)
{
}
void Object::addValue(int value)
{
m_values.push_back(value);
}
int Object::sum() const
{
int result = 0;
for (int value : m_values)
{
result += value;
}
return result;
}
可以像下面这样使用:
// main.cpp
#include "Object.h"
#include <iostream>
int main()
{
Object object("example");
object.addValue(10);
object.addValue(20);
std::cout << object.sum() << '\n';
}
代码运行没有问题,但 Object.h 中的 std::string 和 std::vector 都属于内部实现。使用方不需要知道数据如何保存,却仍然会通过 Object.h 间接包含这些头文件。
问题在于,如果以后修改 Object 的私有成员,例如增加一个缓存:
private:
std::string m_name;
std::vector<int> m_values;
int m_cachedSum = 0;
即使公共接口完全没有变化,Object.h 仍然发生了修改,依赖它的源文件通常也需要重新编译。对于需要保持二进制兼容的库,新增成员还可能改变对象的大小和布局。
也就是说,只要头文件发生修改,依赖它的所有源文件都可能需要重新编译。PImpl 正是用来解决这类问题的。
前面的 Object 直接保存了字符串和容器,编译器需要知道这些成员的完整类型,才能确定 Object 的大小和布局,所以相应的头文件不能省略。
用指针隔离实现
我们可以把这些数据移到一个单独的实现类 Impl 中,让 Object 只保存指向它的指针。头文件中只需要前向声明 Impl,不必包含字符串和容器的定义:
// Object.h
#pragma once
class Object
{
public:
explicit Object(const char* name);
~Object();
Object(const Object&) = delete;
Object& operator=(const Object&) = delete;
void addValue(int value);
int sum() const;
private:
class Impl;
Impl* m_impl;
};
这里的 class Impl; 是前向声明。编译器知道 Impl 是一个类,但还不知道它有哪些成员。不过,声明 Impl* 不需要知道 Impl 的完整定义,因此不会影响 Object 的大小和布局计算。
Impl 的完整定义放在 Object.cpp 中,原来的名称和整数容器也一起移到这里:
// Object.cpp
#include "Object.h"
#include <string>
#include <vector>
class Object::Impl
{
public:
explicit Impl(const char* name)
: m_name(name ? name : "")
{
}
void addValue(int value)
{
m_values.push_back(value);
}
int sum() const
{
int result = 0;
for (int value : m_values)
{
result += value;
}
return result;
}
private:
std::string m_name;
std::vector<int> m_values;
};
Object::Object(const char* name)
: m_impl(new Impl(name))
{
}
Object::~Object()
{
delete m_impl;
}
void Object::addValue(int value)
{
m_impl->addValue(value);
}
int Object::sum() const
{
return m_impl->sum();
}
Object 在构造时创建 Impl,在析构时释放它,对外提供的操作则转发给 Impl。使用方不需要接触实现对象,仍然可以像下面这样使用:
Object object("example");
object.addValue(10);
object.addValue(20);
std::cout << object.sum() << '\n'; // 30
为了移除公开接口对 <string> 的依赖,这里把名称参数改成了 const char*,内部仍然用 std::string 保存,并在构造时复制传入的内容。另外,这个原始指针版本禁用了复制,避免默认复制让两个对象持有同一个 Impl,造成重复释放。
现在,字符串和容器都只出现在 Object.cpp 中。以后增加缓存或更换容器,只要不修改 Object.h,通常就不需要重新编译使用方的源文件。
这就是 PImpl(Pointer to Implementation,指向实现的指针)的基本做法:通过一个指向不完整类型的指针,把具体数据和实现依赖隔离在源文件中。
例如,后面想给 Impl 增加一个缓存成员,只需要在 Object.cpp 中修改它的定义:
private:
std::string m_name;
std::vector<int> m_values;
int m_cachedSum = 0;
虽然 Impl 的定义发生了变化,但 Object.h 没有修改,Object 仍然只保存一个 Impl*。因此,在正常的增量构建中,只需要重新编译 Object.cpp,其他包含 Object.h 的源文件不需要因为这次修改而重新编译。程序仍然需要重新链接,才能使用更新后的实现。
这也是 PImpl 隔离编译依赖的关键:把经常变化的实现留在 .cpp 中,让使用方依赖的头文件保持稳定。
至此,PImpl 思想基本完成。
前置声明放哪
在 PImpl 的实现中,前置声明放置的位置有两种。一种是放在类内部,就像上面这样:
class Object
{
// 公共接口略
private:
class Impl;
Impl* m_impl;
};
这样,Impl 是 Object 的私有嵌套类型,完整名称是 Object::Impl。它不会占用外部命名空间中的名称,也能清楚地表达:这个类型只是 Object 的内部实现,完整定义位于 Object.cpp 中。
对于仅供一个类使用的实现类型,这通常是合适的默认选择。
另一种方式是在类外、命名空间作用域中声明:
class ObjectImpl;
class Object
{
// 公共接口略
private:
ObjectImpl* m_impl;
};
如果实现类型需要被多个内部类使用,或者规模较大,需要单独放到内部头文件和源文件中,这种方式也比较方便。独立测试实现类型时,也可以考虑这样的组织方式。
如果没有这些需求,把 Impl 放在类内部通常更直接,也更清楚地表达了它与 Object 的关系。
以上。在云栈社区的技术讨论中,PImpl 也常被当作控制编译期依赖的典型手段。