C++ 程序员写 PImpl(Pointer to Implementation,指针指向实现)惯用法已经写了二十多年,用来把类的实现细节挪到一个不透明指针背后,减少头文件改动引发的连锁编译。这套写法一直有个不体面的角落:用裸指针要手写全套五个特殊成员函数,用 std::unique_ptr 省了资源管理的代码,却留下三处语义错位——默认不可拷贝、const 传不进去、对象被移动后指针可能变成空的。C++26<memory> 头文件新增的 std::indirect 类型,对应的是标准提案 P3019R14,目的就是把这几个错位焊死。程序员马里乌斯·班奇拉(Marius Bancila)在博客里用一个 Widget 类完整对比了三种写法,思路很清楚:这是一次语义修补,不是性能突破。

PImpl 的老毛病,std::indirect 补上了几处

裸指针版本的 PImpl 必须遵守 Rule of Five:析构、拷贝构造、拷贝赋值、移动构造、移动赋值,一个都不能少,稍有疏漏就是内存泄漏或者悬空指针。std::unique_ptr 接手之后,资源释放自动完成,代码短了不少,但拷贝构造和拷贝赋值仍要手写,因为 unique_ptr 本身不支持拷贝。更麻烦的是两个隐蔽坑:const 方法里通过 unique_ptr 依然能改到内部对象的字段,const 保护形同虚设;对象被移动之后,内部指针变成 nullptr,再调用任何方法都是未定义行为。

std::indirect 的做法是让这个堆上对象表现得像一个值:拷贝 std::indirect<T> 会深拷贝里面的 T,编译器自动生成的拷贝构造直接能用;通过 const std::indirect<T> 访问,拿到的只能是 const Tconst 保护是真的生效了。

PImpl 指针方案对比 std::unique_ptr std::indirect 默认不可拷贝 const 不传播 移后指针为 null 深拷贝:copy 自动生效 const 传播:只读真的只读 移后需查 valueless_after_move() 三处问题仍在 两处修好,一处变成显式检查

移后状态那一项,std::indirect 提供了一个叫 valueless_after_move() 的成员函数,可以在关键位置加断言,或者配合 std::erase_if 从容器里清掉移出后的空对象。这比裸指针判空更清楚,但本质还是同一件事换了个更规范的写法。

std::indirect 修的是语义错配,不是运行时开销。

没解决的部分才是重点

看代码会发现一个容易被忽略的细节:即便用了 std::indirectWidget 类头文件里仍要声明全部五个特殊成员函数,只是把函数体挪到 .cpp 文件里用 = default。原因和 unique_ptr 一样——Impl 在头文件里是不完整类型,编译器生成析构和拷贝逻辑时必须看到完整定义,这个约束没有因为换了新类型而消失。堆分配和间接访问也照样在:每次拷贝 Widget,背后依然是一次堆内存分配加一次内容复制,std::indirect 只是让这次分配和复制自动发生在正确的地方,不代表它比手写版本更省资源。

  • 建议.维护稳定头文件或跨版本 ABI 的团队,可以先在实验分支里替换掉几个典型的 PImpl 类,验证的是代码是否更干净、const 是否真的生效,不是性能收益。
  • 风险.valueless_after_move() 只是把裸指针判空变成了显式接口,移后对象依旧可能不可用,不能写成“绝对不会为空”,调用方该检查还得检查。
std::indirect 没免掉的三层成本 堆分配 + 间接访问 对象仍在堆上,指针间接访问没有变 特殊成员函数仍需在 .cpp 声明 Impl 是不完整类型,析构/拷贝/移动仍要显式 default 移后状态仍需检查 valueless_after_move() 替代空指针判断,不是绝对不可空 无论用 unique_ptr 还是 indirect,这三层都要付

真正卡住普及速度的是编译器支持。班奇拉写这篇文章时,只有 GCC 16 实现了 std::indirect,Clang 和 MSVC 都还没跟上,标准提案里同一批引入的 std::polymorphic 类型也在等实现落地。这意味着任何依赖多编译器兼容的项目,短期内没法把 unique_ptr 换成 std::indirect——工具链支持范围会随版本推进变化,团队评估前应该自己核实当前编译器的实际支持情况,而不是照抄一篇技术博客里某个时间点的说法。对维护公共 C++ 库、插件接口或者要求 ABI 长期稳定的团队来说,这是个值得记在待办列表里的新特性;对还在用多编译器矩阵做兼容性测试的项目,现在谈迁移为时尚早。