Item22:使用Pimpl习惯用法时,将特殊成员函数的定义放到实现文件中
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 | // widget.h(有问题的写法) |
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 | // widget.h |
2. 移动操作(移动构造 / 移动赋值)
- 问题:声明析构函数后,编译器不会自动生成移动操作;若在.h 里用
= default定义移动操作,会触发和析构一样的 “不完整类型” 报错; - 解决:
.h里仅声明移动操作,.cpp里在Impl定义后用= default定义(编译器生成的移动逻辑对unique_ptr是正确的 —— 移动指针所有权)。
1 | // widget.h |
3. 拷贝操作(拷贝构造 / 拷贝赋值)
问题:
- 包含
unique_ptr的类,编译器不会自动生成拷贝操作(unique_ptr是 “只可移动” 类型); - 即使生成,也是浅拷贝(仅复制指针,而非指针指向的
Impl对象),不符合需求;
- 包含
解决:
.h里声明拷贝操作,.cpp里实现深拷贝(复制Impl对象而非指针):
1 | // widget.h |
std::unique_ptr 和 std::shared_ptr的差异
上述 “声明在.h、定义在.cpp” 的要求仅针对unique_ptr,用std::shared_ptr时完全不需要:
1 | // widget.h(用shared_ptr的写法,无任何报错) |
底层原因
unique_ptr:删除器的类型是智能指针的一部分,为了效率,析构 / 移动时必须知道指向类型的完整定义;shared_ptr:删除器的类型不是智能指针的一部分,析构 / 移动时不需要指向类型完整,因此无需额外处理。
选型建议
- Pimpl 的核心是 “独占所有权”,因此
unique_ptr是首选(符合语义),哪怕需要额外处理特殊成员函数; shared_ptr仅在需要 “共享所有权” 时使用,优势是无需处理特殊成员函数,但有引用计数的性能开销。
总结
- Pimpl 的价值是减少编译依赖、加快编译速度,现代 C++ 优先用
std::unique_ptr实现 Pimpl; - 对
std::unique_ptr类型的 pImpl 指针,必须将析构、移动操作声明在头文件,定义在实现文件(拷贝操作需手动实现深拷贝),哪怕编译器自动生成的逻辑本身是正确的; - 上述要求仅适用于
std::unique_ptr,std::shared_ptr无需额外处理特殊成员函数,但不符合 Pimpl 的 “独占所有权” 语义,仅在共享场景使用。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 肖恩的博客!
评论
