
PImpl 惯用法与 C26 的 std::indirect 类型2026 年 7 月 23 日发布。PImpl即 Pointer to implementation指向实现的指针是一种编程技巧它将类的实现细节放在一个单独的类中并通过一个不透明指针来访问从而将这些细节从类中移除。其目的是分离接口和实现并最小化编译时依赖。下面探讨 PImpl 惯用法在 C 中通常是如何实现的以及 C26 如何简化其实现。使用原始指针实现可以使用原始指针并遵循“五法则”来实现 PImpl 惯用法。“五法则”规定当一个类定义了一个特殊成员函数用于资源管理时它应该定义所有五个特殊成员函数析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符。为了演示使用一个 Widget 类它代表一个具有标签的 UI 元素可被点击每次点击时计数器会递增。因此Widget 会有一个计数器和一个标签但这些都是隐藏在实现类背后的实现细节。Widget 类的定义如下#pragma once #include string class Widget { public: Widget(const std::string name); ~Widget(); Widget(const Widget other); Widget operator(const Widget other); Widget(Widget other) noexcept; Widget operator(Widget other) noexcept; void click(); int clickCount() const; std::string label() const; private: struct Impl; Impl* pimpl_; };这里的 Widget::Impl 类是一个不完整类型它在头文件中被前置声明并在 .cpp 文件中定义。Widget 类持有一个指向该类型对象的指针。#include Widget.h struct Widget::Impl { std::string name; int clicks 0; explicit Impl(std::string n) : name(std::move(n)) {} }; Widget::Widget(const std::string name) : pimpl_(new Impl(name)) { } Widget::~Widget() { delete pimpl_; } Widget::Widget(const Widget other) : pimpl_(new Impl(*other.pimpl_)) { } Widget Widget::operator(const Widget other) { if (this ! other) { Impl* tmp new Impl(*other.pimpl_); delete pimpl_; pimpl_ tmp; } return *this; } Widget::Widget(Widget other) noexcept : pimpl_(other.pimpl_) { other.pimpl_ nullptr; } Widget Widget::operator(Widget other) noexcept { if (this ! other) { delete pimpl_; pimpl_ other.pimpl_; other.pimpl_ nullptr; } return *this; } void Widget::click() { pimpl_-clicks; } int Widget::clickCount() const { return pimpl_-clicks; } std::string Widget::label() const { return pimpl_-name; }Widget 类定义了所有五个特殊成员函数。移动构造函数和移动赋值运算符被定义为 noexcept因为它们只是复制或删除对象不会抛出异常。此外这也是一个性能问题因为像 std::vector 这样的容器在重新分配内存时只有当移动构造函数是 noexcept 时才会移动元素否则为了保证强异常安全性它会回退到复制操作。Impl 对象在 Widget 构造时创建它基本上存储了 Widget 的状态。Widget 的公共接口方法通过它来访问状态点击次数、名称。Widget 的使用示例如下#include iostream #include print #include Widget.h int main() { Widget a(Button A); a.click(); a.click(); std::println(clicks {}, a.clickCount()); // 输出 clicks 2 Widget b a; b.click(); std::println(clicks {}, b.clickCount()); // 输出 clicks 3 }一个看似微妙的问题是常量性不会从 Widget 传播到 Impl。在一个 const 方法如上面的 clickCount()中我们可以改变状态因为即使 Impl 指针是 const 的它所指向的对象却不是。因此下面的代码可以编译并产生意外的结果int Widget::clickCount() const { return pimpl_-clicks; }另一方面移动语义的一个后果是被移动后的 Widget 对象的 pimpl_ 指针为空调用任何使用该指针的函数都会导致未定义行为UB。Widget a(Button A); Widget b std::move(a); a.click(); // 未定义行为有几种方法可以解决这个问题文档说明被移动后的对象不能再使用。在使用 pimpl_ 指针之前在每个使用处添加非空检查。提供一个函数来指示对象是否处于有效状态以便客户端可以查询是否可以使用该 Widget。使用 std::unique_ptr 实现可以使用 C11 的 std::unique_ptr 类型来简化实现而不是使用原始指针。std::unique_ptr 会自动管理分配的对象因此无需显式进行资源管理。#pragma once #include memory #include string class Widget { public: Widget(const std::string name); ~Widget(); Widget(Widget) noexcept; Widget operator(Widget) noexcept; Widget(const Widget other); Widget operator(const Widget other); void click(); int clickCount() const; std::string label() const; private: struct Impl; std::unique_ptr pimpl_; }; #include Widget.h struct Widget::Impl { std::string name; int clicks 0; explicit Impl(std::string n) : name(std::move(n)) {} }; Widget::Widget(const std::string name) : pimpl_(std::make_unique(name)) { } Widget::~Widget() default; Widget::Widget(Widget) noexcept default; Widget Widget::operator(Widget) noexcept default; Widget::Widget(const Widget other) : pimpl_(std::make_unique(*other.pimpl_)) { } Widget Widget::operator(const Widget other) { if (this ! other) { *pimpl_ *other.pimpl_; } return *this; } void Widget::click() { pimpl_-clicks; } int Widget::clickCount() const { return pimpl_-clicks; } std::string Widget::label() const { return pimpl_-name; }在这个实现中拷贝构造函数和拷贝赋值运算符是显式用户定义的。析构函数、移动构造函数和移动赋值运算符由编译器默认生成但这必须在 .cpp 文件中进行因为在头文件中 Widget::Impl 是不完整类型而 unique_ptr 的删除器需要知道 Impl 的大小。虽然不再需要手动处理资源但常量性问题仍然存在并且移动后 pimpl_ 对象为空的问题也依旧存在。使用 std::indirect 实现std::indirect 是 C26 中的一个新的词汇类型定义在 头文件中。它旨在用于动态分配但需要表现得像值一样的类成员。当 std::unique_ptr 的语义不可拷贝、不传播常量性、可能为空不合适时就可以使用 std::indirect 来替代。PImpl 就是一个典型的应用场景。下面是使用 std::indirect 实现的 Widget 类#pragma once #include memory #include string class Widget { public: Widget(const std::string name); Widget(const Widget); Widget(Widget) noexcept; Widget operator(const Widget); Widget operator(Widget) noexcept; ~Widget(); void click(); int clickCount() const; std::string label() const; private: struct Impl; std::indirect pimpl_; }; #include widget.h struct Widget::Impl { std::string name; int clicks 0; explicit Impl(std::string n) : name(std::move(n)) {} }; Widget::Widget(const std::string name) : pimpl_(std::in_place, name) {} Widget::Widget(const Widget) default; Widget::Widget(Widget) noexcept default; Widget Widget::operator(const Widget) default; Widget Widget::operator(Widget) noexcept default; Widget::~Widget() default; void Widget::click() { pimpl_-clicks; } int Widget::clickCount() const { return pimpl_-clicks; } std::string Widget::label() const { return pimpl_-name; }对比这些实现五个特殊成员函数仍然在头文件中声明但在源文件中由编译器默认生成编译器实现因为在源文件中 Impl 类型是完整的编译器可以识别这与 std::unique_ptr 的要求相同。std::indirect 类型在堆上拥有一个单独的 T 对象但表现得像一个 T 成员深拷贝复制 std::indirect 对象时会复制其拥有的对象因此类的编译器生成的拷贝构造函数可以正常工作。常量性传播通过 const std::indirect 对象只能对 T 进行常量访问。从不为空它总是持有一个值除非处于被移动后的状态但它有一个名为 valueless_after_move() 的成员函数可以用来检查状态。使用 std::indirect 并不能避免显式声明和默认生成特殊成员函数但有助于传播常量性这意味着代码对意外更改更具健壮性。valueless_after_move() 基本上是 unique_ptr 空检查的替代方法。例如可以在所有使用 pimpl_ 对象的函数中添加断言以确保在 Widget 被移动后不会调用这些函数。void Widget::click() { assert(!pimpl_.valueless_after_move() use of moved-from Widget); pimpl_-clicks; }另一个使用 valueless_after_move() 的例子是从向量中移除被移动的对象。示例如下std::vector widgets ...; std::vector selected; for (auto w : widgets) if (select(w)) selected.push_back(std::move(w)); // 留下一个无值的 widget std::erase_if(widgets, [](const auto w) { return w.valueless_after_move(); // 移除悬空对象 });使用 std::unique_ptr 也可以实现这一点但检查条件将是 w nullptr。因此这只是一种更详细的检查 std::indirect 对象是否仍然持有值的方法。std::indirect 还有一个伙伴 std::polymorphic将在另一篇文章中探讨它。在撰写本文时只有 GCC 16 支持 std::indirect。相关链接PImplPImpl For Compile-Time Encapsulation (Modern C)Modern C: The Pimpl IdiomP3019R14: indirect and polymorphic: Vocabulary Types for Composite Class Design分享本文在 X 上分享在 Reddit 上分享在 LinkedIn 上分享在 Mastodon 上分享在 Facebook 上分享更多分享选项通过电子邮件分享链接给朋友打印点赞点赞中…分类C 标签C、C26、pimplC26 中 std::format 的改进发表评论本网站使用 Akismet 来减少垃圾评论。了解您的评论数据是如何处理的。关于我可在相关平台找到作者。文章存档2026 年 7 月文章存档信息展示。6 月文章链接在 Twitter 上关注我我的推文链接近期文章PImpl 惯用法与 C26 的 std::indirect 类型C26 中 std::format 的改进C 中的矩阵乘法通用唯一字典序可排序标识符 (ULIDs)基于时间的通用唯一标识符 (UUIDs v7)什么是 C 的 nth_element 算法C26 新特性契约第 3 部分C26 新特性第 2 部分C26 新特性第 1 部分错误行为已出现其他信息登录文章订阅评论订阅WordPress.org