プログラミング
PImplイディオムとC++26のstd::indirect型
The PImpl idiom and the C++26 std:indirect type (mariusbancila.ro)
要約
この記事では、C++におけるPImpl(Pointer to implementation)イディオムについて解説し、その従来の生ポインタやstd::unique_ptrを用いた実装方法と、C++26で導入されるstd::indirect型がどのように実装を簡略化するかを説明しています。std::indirectは、PImplのような動的に割り当てられるメンバ変数を値のように扱うための新しい型であり、const伝播やムーブセマンティクスに関する問題を解決します。
全文翻訳
PImpl(Pointer to implementation)は、クラスの実装詳細を別のクラスに分離し、不透明なポインタを介してアクセスすることで、クラス定義から実装の詳細を取り除くプログラミングテクニックです。その目的は、インターフェースと実装を分離し、コンパイル時依存を最小限に抑えることです。この記事では、PImplイディオムがC++でどのように実装されるか、そしてC++26がその実装をどのように簡略化するかを見ていきます。
生ポインタを用いた実装
Pimplイディオムは、生ポインタを使用し、Rule of Five(リソース管理を行う特別なメンバ関数を定義する場合、5つすべて(デストラクタ、コピーコンストラクタ、コピー代入演算子、ムーブコンストラクタ、ムーブ代入演算子)を定義すべきであるというガイドライン)に従うことで実装できます。これを実証するために、ラベルを持ちクリック可能で、クリックごとにカウンタが増加するUI要素を表すウィジェットを使用します。したがって、ウィジェットはカウンタとラベルを持ちますが、これらは実装クラスの後ろに隠された実装の詳細です。この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クラスは5つの特別なメンバ関数すべてを定義しています。ムーブコンストラクタとムーブ代入演算子は、オブジェクトをコピー/削除するだけで何も例外を投げないため、noexceptと定義されています。さらに、std::vector<Widget>のようなコンテナは、ムーブコンストラクタがnoexceptでない場合、強力な例外保証を維持するためにコピーにフォールバックするため、パフォーマンスの問題となります。ImplオブジェクトはWidgetの構築中に作成され、基本的にウィジェットの状態を格納します。Widgetのパブリックインターフェースメソッドは、状態(clicks、name)にアクセスするためにそれを使用します。Widgetは次のように使用できます。
#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"
}
微妙に見える可能性のある問題は、const性がWidgetからImplに伝播しないことです。上記のclickCount()のようなconstメソッドでは、Implポインタがconstであっても、それが指すオブジェクトはconstではないため、状態を変更できます。したがって、以下はコンパイルされ、予期しない結果をもたらす可能性があります。
int Widget::clickCount() const {
return ++pimpl_->clicks;
}
一方、ムーブセマンティクスの結果として、ムーブ元となったWidgetオブジェクトはnullのpimpl_ポインタを持ち、ポインタを使用する関数を呼び出すことは未定義の動作(UB)です。
Widget a("Button A");
Widget b = std::move(a);
a.click(); // 未定義の動作
これに対処するいくつかの方法があります。
* ムーブ元オブジェクトは使用できないことを文書化する
* 使用前にpimpl_がnullでないことをすべてでチェックする
* オブジェクトが有効な状態にあるかどうかを示す関数を提供し、クライアントがウィジェットを使用できるかどうかを照会できるようにする
std::unique_ptrを用いた実装
C++11の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<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;
}
この実装では、コピーコンストラクタとコピー代入演算子は明示的にユーザー定義されています。デストラクタ、ムーブコンストラクタ、ムーブ代入演算子はコンパイラによってデフォルト化されますが、これは.cppファイルで行う必要があります。なぜなら、ヘッダファイルではWidget::Implは不完全型であり、unique_ptrのデリータはImplのサイズを知る必要があるからです。リソースの手動管理はなくなりましたが、const性の問題はそのまま残っており、ムーブ後のpimpl_オブジェクトがnullになる問題も同様です。
std::indirectを用いた実装
ここでstd::indirectが登場します。これはC++26の新しいボキャブラリ型で、<memory>ヘッダで定義されています。動的に割り当てられるクラスメンバでありながら、値のように振る舞う必要があるものに使用することを意図しています。そのセマンティクス(コピー不可、constを伝播しない、nullになりうる)が適切でないstd::unique_ptrの代わりに使うように設計されています。これに最適な例が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<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;
}
これらの実装を比較すると、5つの特別なメンバ関数は依然としてヘッダで宣言されていますが、Impl型がコンパイラに認識される完全型であるソースファイルでデフォルト化されています(これはstd::unique_ptrの場合と同じ要件です)。std::indirect型は、単一のTオブジェクトを所有し、メモリ上に割り当てられます。