PImpl 惯用法与 C++26 中的 std::indirect 类型
The PImpl idiom and the C++26 std:indirect type

原始链接: https://mariusbancila.ro/blog/2026/07/23/the-pimpl-idiom-and-the-cpp26-stdindirect-type/

PImpl(指向实现的指针)惯用法是一种 C++ 技术,它通过将实现细节隐藏在单独的类中来减少编译时依赖。传统上,实现 PImpl 需要手动管理“五大规则”(Rule of Five)特殊成员函数来处理原始指针,这既繁琐又容易出错。 虽然改用 `std::unique_ptr` 可以简化资源管理,但它也带来了挑战,例如需要手动实现深拷贝,以及缺乏自动的 const 传播机制。此外,如果对象被移动后仍被访问,原始指针和 `unique_ptr` 都可能导致未定义行为。 C++26 引入了 `std::indirect`,这是一种专为动态分配的类成员设计的新型词汇类型,使其表现得如同值类型一般。`std::indirect` 通过以下方式显著改进了 PImpl 模式: * **深拷贝语义:** 允许编译器自动生成正确的拷贝操作。 * **Const 传播:** 确保对类的 `const` 访问能正确限制对内部实现的访问。 * **更高的安全性:** 包含 `valueless_after_move()` 函数,提供了一种简洁的标准方法来识别并防止访问已移动的对象。 总而言之,`std::indirect` 使 PImpl 惯用法变得更加稳健、易于维护且语法更为简洁。

这次讨论围绕 C++26 中提议的 `std::indirect` 展开,这是一种旨在简化“PImpl”(指向实现的指针)惯用语的工具。PImpl 常用于通过不透明指针隐藏实现细节,以最大限度地减少编译时依赖并保持二进制(ABI)兼容性。 **辩论要点包括:** * **实用性与复杂性:** 支持者认为 `std::indirect` 提供了一种标准化且符合人体工程学的方式来处理对象的生命周期和所有权,有望减少样板代码和“零规则”(rule-of-zero)带来的冲突。批评者则认为,在现有的裸指针和 `unique_ptr` 之外再增加一种解决长期问题的方法,只会进一步加剧 C++ 在不必要复杂性和编码标准不一致方面的负面评价。 * **ABI 与性能:** 虽然 PImpl 有助于维持稳定的二进制接口,但参与者也指出了其中的代价:潜在的内存开销、负面的缓存影响,以及语言中缺乏“真正的”不透明类型特性,这迫使开发者不得不使用基于指针的变通方案。 * **语言演进:** 这场对话反映了两个阵营之间更广泛的分歧:一方主张由标准库抽象来承担“繁重的工作”,另一方则倾向于更精简、更手动的方法。后者担心 C++ 对向后兼容性的承诺导致了“C++ 方言”碎片化的局面。
相关文章

原文

PImpl (which stands for Pointer to implementation) is a programming tehnicque to remove implementation details from a class by placing them in a separate class that is accessed through an opaque pointer. Its purpose is to separate interfaces and implementations and minimize compile-time dependencies. In this article, we’ll at how the PImpl idiom is typically implemented in C++ and how C++26 simplifies its implementation.

Implementing with a raw pointer

We can implement the Pimpl idiom using raw pointers and abiding to the Rule of Five. This is a guideline that says that when a class defines a special member function (for resource management) it should define all five of them (destructor, copy constructor, copy assignment operator, move constructor, move assignment operator).

To demonstrate this, we will use a widget that represents a UI element that has a label and can be clicked and on each click a counter is incremented. Therefore, a widget would have a counter and a label but these are implementation details that are hidden behind an implementation class.

This Widget class definition looks as follows:

#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_;
};

The Widget::Impl class is an incomplete type here. It’s forward declared and defined in the .cpp file. The Widget class keeps a pointer to an object of this type.

#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; }

The Widget class defines all five special member functions. The move constructor and assignment operator are defined noexcept because they just copy/delete objects and nothing should throw. Moreover, it’s a performance issue because a container such as std::vector<Widget> will only move elements during reallocation if the move constructor is noexcept; otherwise it falls back to copying to preserve its strong exception guarantee.

The Impl object is created during the construction of the Widget and basically stores the state of the widget. The public interface methods of Widget use it to access the state (clicks, name).

A Widget can be used as follows:

#include <iostream>
#include <print>

#include "Widget.h"

int main()
{
	Widget a("Button A");
	a.click();
	a.click();

	std::println("clicks {}", a.clickCount()); // prints "clicks 2"

	Widget b = a;
	b.click();

	std::println("clicks {}", b.clickCount()); // prints "clicks 3"
}

A problem that may seem subtle is that constness is not propagated from Widget to Impl. In a const method, such as clickCount() above, we can change the state because even if the Impl pointer is const, the object it points to is not. Therefore, the following would compile and produce unexpected results:

int  Widget::clickCount() const   { return ++pimpl_->clicks; }

On the other hand, a consequence of the move semantics is that a moved-from Widget object has a null pimpl_ pointer and calling any function that use the pointer is undefined behavior (UB).

Widget a("Button A");
Widget b = std::move(a);
a.click();  // undefined behavior

There are several ways to address this:

  • document that moved-from objects cannot be used
  • add a check that pimpl_ is not null everywhere before its usage
  • provide a function that indicates whether the object is in a valid state so clients can query whether they can use the widget or not

Implementing using std::unique_ptr

We can simplify the implementation by using the C++11 std::unique_ptr type instead of a raw pointer. This automatically manages the allocated object so we don’t have to do resource management explicitly.

#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_;
};
#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; }

In this implementation, the copy constructor and copy assignment are explicitly user-defined. The destructor, move constructor, and move assignment operator are defaulted to the compiler but this must happen in the .cpp file because in the header Widget::Impl is an incomplete type and the unique_ptr‘s deleter needs to know the size of Impl.

Although there is no manual handling of resources anymore, the constness issue remains the same as well as the null pimpl_ object after a move.

Implementing using std::indirect

This is where the std::indirect enters the scene. This is a new vocabulary type from C++26, defined in the <memory> header. It is intended to be used for class members that are dynamically allocated but need to behave like values. It’s designed to be used instead of std::unique_ptr where its semantics (not copyable, does not propagate const, and can be null) are not appropriate. The perfect example for this is Pimpl.

The following snippet shows the Widget class implemented using std::indirect:

#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<Impl> 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; }

If we compare these implementation, we’ll see that the five special member functions are still declared in the header but they are defaulted (for compiler implementation) in the source file, where the Impl type is complete and known to the compiler (this is the same requirement as for std::unique_ptr).

The std::indirect type owns a single T object allocated on the heap, but behaves like a T member:

  • Deep copy: copying the std::indirect<T> object copies the owned object, so your class’s compiler-generated copy constructor just works.
  • Const propagation: through a const std::indirect<T> object, you only get const access to the T.
  • Never null: it always holds a value, except in the moved-from state (but features a member function called valueless_after_move() that lets you check the state).

Using std::indirect does not prevent us from having to explicitly declare and default the special member functions but does help us with propagating constness, which means the code is more robust towards accidental changes.

The valueless_after_move() is basically a replacement for null check from unique_ptr. For instance, we can add an assert to all the functions that use the pimpl_ object to ensure they are not invoked after the widget has been moved.

void Widget::click() 
{
    assert(!pimpl_.valueless_after_move() && "use of moved-from Widget");
    ++pimpl_->clicks;
}

Another example of using valueless_after_move() is erase-remove from a vector from which we moved out objects. Here is an example:

std::vector<std::indirect<Widget>> widgets = ...;
std::vector<std::indirect<Widget>> selected;

for (auto& w : widgets)
    if (select(w))
        selected.push_back(std::move(w));   // leaves a valueless widget behind

std::erase_if(widgets, [](const auto& w) {
    return w.valueless_after_move();        // remove the dangling object
});

This is possible with std::unique_ptr too, but the check would be a w == nullptr. Therefore, it’s just a more verbose way of checking that an std::indirect object still holds a value or not.

std::indirect comes with a companion, std::polymorphic, but we will look at that in another article.

At the time of writing this, only GCC 16 supports std::indirect.

See also

联系我们 contact @ memedata.com