PImpl惯用法与C++26 std::indirect类型

Lobsters Hottest 工具

摘要

解释C++中的PImpl惯用法,以及即将推出的C++26 std::indirect类型如何简化其实现,减少样板代码和手动内存管理。

<p><a href="https://lobste.rs/s/gtxmc4/pimpl_idiom_c_26_std_indirect_type">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/07/24 04:58

# PImpl 惯用法与 C++26 的 std::indirect 类型 来源:https://mariusbancila.ro/blog/2026/07/23/the-pimpl-idiom-and-the-cpp26-stdindirect-type/ PImpl(Pointer to implementation 的缩写)是一种编程技术,通过将实现细节放置在一个通过不透明指针访问的单独类中,从而将这些细节从类中移除。其目的是分离接口与实现,并最小化编译时依赖。在本文中,我们将探讨 PImpl 惯用法在 C++ 中的典型实现方式,以及 C++26 如何简化其实现。 ## 使用原始指针实现 我们可以使用原始指针并遵守五法则来实现 PImpl 惯用法。这是一条指导原则:当一个类定义了某个特殊成员函数(用于资源管理)时,它应当定义全部五个(析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符)。 为了演示这一点,我们将使用一个代表 UI 元素的 `Widget`,它有一个标签,可以被点击,每次点击计数加一。因此,`Widget` 包含一个计数器和标签,但这些是实现细节,隐藏在实现类之后。`Widget` 类的定义如下: ```cpp #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` 类持有一个指向该类型对象的指针。 ```cpp #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` 的使用示例如下: ```cpp #include <print> #include <string> #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" } ``` 一个可能不太明显的问题是:`const` 性质不会从 `Widget` 传播到 `Impl`。在 `const` 方法(如上面的 `clickCount()`)中,我们仍然可以修改状态,因为即使 `Impl` 指针是 `const`,它所指向的对象并非如此。因此,下面的代码可以编译并通过,产生意想不到的结果: ```cpp int Widget::clickCount() const { return ++pimpl_->clicks; } ``` 另一方面,移动语义的后果是:一个被移动过的 `Widget` 对象其 `pimpl_` 指针为 `null`,此时调用任何使用该指针的函数都会导致未定义行为(UB)。 ```cpp Widget a("Button A"); Widget b = std::move(a); a.click(); // 未定义行为 ``` 解决这个问题有几种方法: - 明确文档说明:被移动的对象不可再使用。 - 每次使用前检查 `pimpl_` 是否非空。 - 提供一个函数指示对象是否处于有效状态,以便客户端查询是否可以继续使用该 widget。 ## 使用 std::unique_ptr 实现 我们可以使用 C++11 的 `std::unique_ptr` 类型代替原始指针来简化实现。它能自动管理所分配的对象,从而无需显式处理资源管理。 ```cpp #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<Impl> pimpl_; }; ``` ```cpp #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<Impl>(name)) { } Widget::~Widget() = default; Widget::Widget(Widget&&) noexcept = default; Widget& Widget::operator=(Widget&&) noexcept = default; Widget::Widget(const Widget& other) : pimpl_(std::make_unique<Impl>(*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` 的大小。 尽管不再需要手动管理资源,但 const 性质问题以及移动后 `pimpl_` 为 `null` 的问题依然存在。 ## 使用 std::indirect 实现 这就是 `std::indirect` 登场的地方。它是 C++26 中定义在新的 `<indirect>` 头文件中的一个词汇类型,旨在用于那些需要动态分配但应表现出值语义的类成员。它被设计用来替代 `std::unique_ptr`,当 `unique_ptr` 的语义(不可拷贝、const 不传播、可为 null)不适用时。PImpl 正是这类场景的完美示例。 下面的代码片段展示了使用 `std::indirect` 实现的 `Widget` 类: ```cpp #pragma once #include <indirect> #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<Impl> pimpl_; }; ``` ```cpp #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 传播**:通过 `const std::indirect` 对象,只能获得对 `T` 的 const 访问权限。 - **永不为空**:它始终持有一个值,除了在被移动后的状态(但提供了一个名为 `valueless_after_move()` 的成员函数来检查状态)。 使用 `std::indirect` 并不能避免我们需要显式声明和默认化特殊成员函数,但它确实帮助我们传播 const 性质,从而使代码对意外修改更健壮。`valueless_after_move()` 基本上替代了 `unique_ptr` 中的空值检查。例如,我们可以在所有使用 `pimpl_` 对象的函数中添加一个断言,以确保在 widget 被移动后不会调用它们。 ```cpp void Widget::click() { assert(!pimpl_.valueless_after_move() && "use of moved-from Widget"); ++pimpl_->clicks; } ``` 另一个使用 `valueless_after_move()` 的例子是从 vector 中擦除被移动出的对象: ```cpp std::vector<Widget> widgets = ...; std::vector<Widget> 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`。 ## 参见 - PImpl (https://en.cppreference.com/w/cpp/language/pimpl) - Pimpl For Compile-Time Encapsulation (Modern C++) (https://learn.microsoft.com/en-us/cpp/cpp/pimpl-for-compile-time-encapsulation-modern-cpp?view=msvc-170) - Modern C++: The Pimpl Idiom (https://medium.com/@weidagang/modern-c-the-pimpl-idiom-53173b16a60a) - P3019R14: indirect and polymorphic: Vocabulary Types for Composite Class Design (https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p3019r14.pdf)

相似文章

C++26:减少未定义行为

Lobsters Hottest

C++26 引入了减少未定义行为的更改,特别是使删除指向不完整类型的指针成为格式错误,从而提高了程序安全性。

C++26:更多函数包装器

Lobsters Hottest

C++26 引入了两个新的函数包装器:std::copyable_function(提供了可复制且 const 正确的 std::function 替代品)和 std::function_ref(一个非拥有、可调用的引用,具有引用语义)。

C++26:标准库强化

Lobsters Hottest

C++26 引入了标准化的库强化机制,用于在运行时捕获常见的未定义行为(如越界访问)。基于 Google 的生产经验,此举仅带来 0.30% 的性能开销,同时将段错误减少了 30%。

Fil-C 优化调用约定

Hacker News Top

Fil-C 优化调用约定确保 C 程序即使在恶意滥用情况下也能保持内存安全性,同时通过在常见情况下省略安全检查来保持效率。它解释了通过 panic 或定义明确的行为来处理类型违规的通用优化和寄存器传递优化。