Pimpl 的价值

Pimpl 的目的是减少编译依赖、加快编译速度:

  • 传统写法中,Widget的头文件依赖std::string、std::vector、Gadget等头文件,一旦这些依赖变更,所有包含Widget.h的代码都要重新编译;
  • Pimpl 把这些依赖移到.cpp文件中,头文件仅前向声明Impl结构体,用指针指向它,彻底切断 “头文件 - 实现依赖” 的关联。

C++98 用原始指针实现 Pimpl,现代 C++ 优先用std::unique_ptr(因为 pImpl 是 “独占所有权”),但直接用unique_ptr会踩坑。

用std::unique_ptr做 Pimpl 会编译报错

1. 报错现象

头文件仅前向声明Impl,用std::unique_ptr<Impl> pImpl,且未手动声明析构函数时,用户创建Widget对象会编译报错(提示 “sizeof/delete 应用到不完整类型”):

1
2
3
4
5
6
7
8
9
10
11
12
// widget.h(有问题的写法)
class Widget {
public:
Widget() : pImpl(std::make_unique<Impl>()) {} // 仅前向声明Impl
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};

// 客户代码(编译报错)
#include "widget.h"
Widget w; // 错误:Impl是不完整类型,unique_ptr析构时检查失败

2. 报错根源

  • 编译器会自动生成Widget的析构函数(因为你没手动声明),且析构函数是inline的(在头文件里处理);
  • Widget析构时要调用unique_ptr<Impl>的析构函数,而unique_ptr的默认删除器会用static_assert检查:指向的类型必须是完整类型(有完整定义);
  • 但Impl在头文件里只是前向声明(不完整类型),因此触发编译错误。

特殊成员函数 “声明在.h,定义在.cpp”

要让unique_ptr正常工作,必须保证:在处理unique_ptr析构 / 移动逻辑时,Impl是完整类型(已在.cpp 里定义)。因此需要对 3 类特殊成员函数做统一处理:

1. 析构函数

  • 头文件(.h):仅声明析构函数,不定义;
  • 实现文件(.cpp):在Impl完整定义后,定义析构函数(哪怕用= default)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// widget.h
class Widget {
public:
Widget();
~Widget(); // 仅声明
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};

// widget.cpp
#include "widget.h"
#include <string>
#include <vector>
#include "gadget.h"

// 先定义Impl(此时Impl是完整类型)
struct Widget::Impl {
std::string name;
std::vector<double> data;
Gadget g1, g2, g3;
};

// 再定义析构函数(= default等价于编译器自动生成的逻辑)
Widget::~Widget() = default;
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}

2. 移动操作(移动构造 / 移动赋值)

  • 问题:声明析构函数后,编译器不会自动生成移动操作;若在.h 里用= default定义移动操作,会触发和析构一样的 “不完整类型” 报错;
  • 解决:.h 里仅声明移动操作,.cpp 里在Impl定义后用= default定义(编译器生成的移动逻辑对unique_ptr是正确的 —— 移动指针所有权)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// widget.h
class Widget {
public:
Widget();
~Widget();
Widget(Widget&& rhs); // 仅声明
Widget& operator=(Widget&& rhs); // 仅声明
private:
struct Impl;
std::unique_ptr<Impl> pImpl;
};

// widget.cpp
Widget::Widget(Widget&& rhs) = default; // 定义在Impl之后
Widget& operator=(Widget&& rhs) = default;

3. 拷贝操作(拷贝构造 / 拷贝赋值)

  • 问题:

    1. 包含unique_ptr的类,编译器不会自动生成拷贝操作(unique_ptr是 “只可移动” 类型);
    2. 即使生成,也是浅拷贝(仅复制指针,而非指针指向的Impl对象),不符合需求;
  • 解决:.h 里声明拷贝操作,.cpp 里实现深拷贝(复制Impl对象而非指针):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// widget.h
class Widget {
public:
Widget(const Widget& rhs); // 仅声明
Widget& operator=(const Widget& rhs); // 仅声明
// 其他声明...
};

// widget.cpp
// 拷贝构造:创建新的Impl对象,复制源对象的Impl内容
Widget::Widget(const Widget& rhs) : pImpl(std::make_unique<Impl>(*rhs.pImpl)) {}
// 拷贝赋值:直接复制Impl对象的内容(利用Impl自动生成的拷贝逻辑)
Widget& operator=(const Widget& rhs) {
*pImpl = *rhs.pImpl;
return *this;
}

std::unique_ptr 和 std::shared_ptr的差异

上述 “声明在.h、定义在.cpp” 的要求仅针对unique_ptr,用std::shared_ptr时完全不需要:

1
2
3
4
5
6
7
8
9
10
11
12
13
// widget.h(用shared_ptr的写法,无任何报错)
class Widget {
public:
Widget() : pImpl(std::make_shared<Impl>()) {}
// 无需声明析构/移动操作,编译器自动生成即可
private:
struct Impl;
std::shared_ptr<Impl> pImpl;
};

// 客户代码(正常编译)
Widget w1;
auto w2 = std::move(w1); // 移动构造正常

底层原因

  • unique_ptr:删除器的类型是智能指针的一部分,为了效率,析构 / 移动时必须知道指向类型的完整定义;
  • shared_ptr:删除器的类型不是智能指针的一部分,析构 / 移动时不需要指向类型完整,因此无需额外处理。

选型建议

  • Pimpl 的核心是 “独占所有权”,因此unique_ptr是首选(符合语义),哪怕需要额外处理特殊成员函数;
  • shared_ptr仅在需要 “共享所有权” 时使用,优势是无需处理特殊成员函数,但有引用计数的性能开销。

总结

  1. Pimpl 的价值是减少编译依赖、加快编译速度,现代 C++ 优先用std::unique_ptr实现 Pimpl;
  2. 对std::unique_ptr类型的 pImpl 指针,必须将析构、移动操作声明在头文件,定义在实现文件(拷贝操作需手动实现深拷贝),哪怕编译器自动生成的逻辑本身是正确的;
  3. 上述要求仅适用于std::unique_ptr,std::shared_ptr无需额外处理特殊成员函数,但不符合 Pimpl 的 “独占所有权” 语义,仅在共享场景使用。