複合オブジェクトと C++26 の std::polymorphic 型
Composite objects and C++26 the std:polymorphic type (mariusbancila.ro)
要約
C++26 で導入される `std::polymorphic` 型は、複合オブジェクト(has-a 関係でコンポーネントを所有するクラス)におけるポリモーフィックなメンバーの管理を簡素化します。従来の `unique_ptr` と `clone()` メカニズムでは、所有クラスのコピー可能性の喪失やインターフェースの複雑化といった問題がありましたが、`std::polymorphic` はこれらの問題を解決し、値セマンティクス、ディープな const 性、常に値が存在することなどを保証します。
全文翻訳
複合オブジェクトと C++26 の std::polymorphic 型
以前の記事で、C++ の std::indirect 型と PImpl イディオムの実装におけるその役割について説明しました。しかし、std::indirect 型は、std::polymorphic という別の型と共に標準に導入されました。これは複合クラスの実装に役立ちます。複合クラスとは、has-a 関係でメンバーオブジェクトで構成され、複合クラスが値の一部であるコンポーネントを所有するクラスのことです。例としては、戦略オブジェクト、AST/式ツリーノード、形状スタイルの階層、プラグインで設定された動作などが挙げられます。そして、所有クラスを通常の、コピー可能な値のままにしておきたい場合です。この記事では、std::polymorphic をこの目的にどのように使用できるかを見ていきます。
std::polymorphic の必要性
以下のクラス階層を考えてみましょう。
struct Event {
virtual std::string describe() const = 0;
};
struct TemperatureReadingEvent : public Event {
std::string describe() const override { }
};
struct SmokeDetectedEvent : public Event {
std::string describe() const override { }
};
struct CarbonMonoxideDetectedEvent : public Event {
std::string describe() const override { }
};
class EventLog {
??? events_;
}
スマートハウスのさまざまなセンサーからのさまざまな種類のイベントがあり、それらをイベントログに保存したいとします。どうすればよいでしょうか? いくつかの選択肢があります。
Event event– スライスが発生します。TemperatureReadingEventをこれに代入すると、温度固有の部分が失われます。Eventは抽象クラスなので、そもそもコンパイルもされません。unique_ptr<Event>– 動的型は保持されますが、クラスはムーブ専用(またはコピーを手書きする必要がある)になり、constが伝播せず、null が潜んでいます。variant<Event, TemperatureReadingEvent>– 値セマンティクスを示しますが、閉じたセットを維持します。CarbonMonoxideDetectedEventを追加するには、バリアントとすべてのビジターに触れる必要があります。unique_ptr<Event>と仮想clone()関数 – コピーのための従来の「修正」であり、階層内のすべてのクラスにボイラープレートコードを押し付けます。
リストの最後のオプションを実装してみましょう。Event クラスから始めます。
#include <iostream>
#include <print>
#include <chrono>
#include <memory>
#include <string>
#include <vector>
using Clock = std::chrono::system_clock;
using TimePoint = Clock::time_point;
class Event
{
public:
virtual ~Event() = default;
TimePoint when() const { return ts_; }
virtual std::string describe() const = 0;
virtual void apply(class HomeStatus& home) const = 0;
virtual std::unique_ptr<Event> clone() const = 0;
protected:
TimePoint ts_ = Clock::now();
// ベースポインタ経由のコピー/代入をスライディングハザードから保護するため
Event() = default;
Event(const Event&) = default;
Event& operator=(const Event&) = default;
};
イベントから派生した家の状態の全体的なステータスを次のように定義できます。
class HomeStatus
{
public:
double lastTemperature = 0.0;
bool smokeAlarm = false;
bool coAlarm = false;
int alertCount = 0;
bool evacuationNeeded() const { return smokeAlarm || coAlarm; }
};
次に、実際のイベントクラスを定義できます。たとえば、次のようになります。
class TemperatureReadingEvent : public Event
{
std::string room_;
double celsius_;
public:
TemperatureReadingEvent(std::string room, double celsius)
: room_(std::move(room)), celsius_(celsius) {}
double celsius() const { return celsius_; }
std::string describe() const override {
return std::format("[{}] temperature {} C", room_, celsius_);
}
void apply(HomeStatus& home) const override {
home.lastTemperature = celsius_;
}
std::unique_ptr<Event> clone() const override {
return std::make_unique<TemperatureReadingEvent>(*this);
}
};
class SmokeDetectedEvent : public Event
{
std::string room_;
double density_;
public:
SmokeDetectedEvent(std::string room, double density)
: room_(std::move(room)), density_(density) {}
std::string describe() const override {
return std::format("[{}] SMOKE detected, density {} %/m", room_, density_);
}
void apply(HomeStatus& home) const override {
home.smokeAlarm = true;
++home.alertCount;
}
std::unique_ptr<Event> clone() const override {
return std::make_unique<SmokeDetectedEvent>(*this);
}
};
class CarbonMonoxideDetectedEvent : public Event
{
std::string room_;
int ppm_;
public:
CarbonMonoxideDetectedEvent(std::string room, int ppm)
: room_(std::move(room)), ppm_(ppm) {}
std::string describe() const override {
return std::format("[{}] CARBON MONOXIDE {} ppm", room_, ppm_);
}
void apply(HomeStatus& home) const override {
home.coAlarm = true;
++home.alertCount;
}
std::unique_ptr<Event> clone() const override {
return std::make_unique<CarbonMonoxideDetectedEvent>(*this);
}
};
センサー読み取りイベントで構成されるイベントログは、次のように定義される可能性があります。
class EventLog {
std::vector<std::unique_ptr<Event>> events_;
public:
EventLog() = default;
// unique_ptr メンバーがコピー操作を削除するため、Rule of Five が必要
EventLog(const EventLog& other) {
events_.reserve(other.events_.size());
for (const auto& e : other.events_)
events_.push_back(e->clone());
}
EventLog& operator=(const EventLog& other) {
if (this != &other) {
std::vector<std::unique_ptr<Event>> tmp; // copy-then-commit
tmp.reserve(other.events_.size()); // 例外安全性のため
for (const auto& e : other.events_)
tmp.push_back(e->clone());
events_ = std::move(tmp);
}
return *this;
}
EventLog(EventLog&&) noexcept = default;
EventLog& operator=(EventLog&&) noexcept = default;
~EventLog() = default;
template <typename E, typename... Args>
void record(Args&&... args) {
events_.push_back(std::make_unique<E>(std::forward<Args>(args)...));
}
HomeStatus replay() const {
HomeStatus home;
for (const auto& e : events_)
e->apply(home);
return home;
}
void print() const {
for (const auto& e : events_)
std::println(" {}\n", e->describe());
}
std::size_t size() const { return events_.size(); }
};
これらのクラスをまとめると、次のようなスニペットでこれらのクラスを使用できます。
int main()
{
EventLog live;
live.record<TemperatureReadingEvent>("kitchen", 21.5);
live.record<TemperatureReadingEvent>("kitchen", 48.0);
live.record<CarbonMonoxideDetectedEvent>("garage", 120);
std::println("Live log has {} events", live.size());
live.print();
HomeStatus now = live.replay();
std::println("Current home status: lastTemp={}, smoke={}, co={}, alerts={}, evacuate={}",
now.lastTemperature,
now.smokeAlarm,
now.coAlarm,
now.alertCount,
now.evacuationNeeded());
}
この実装には一連の問題があります。
- 所有者はコピー可能性を失います。
unique_ptrはムーブ専用なので、複合クラスのコピー操作は削除されます。それらを取り戻すには、すべてのコンポーネントをクローンするコピーコンストラクタとコピー代入演算子を手書きする必要があります。これは Rule of Zero を破ることを意味します。 clone()関数は階層全体に影響します。すべての派生クラスがそれを実装する必要があるからです。しかし、コンパイラはそれを強制できません。これは、忘れられたオーバーライドがビルドエラーにならないことを意味します。サイレントにスライスされたコピーが生成されます。- インフラストラクチャがインターフェースに漏れ出します。
clone()関数は型について何も語りません。これは、型の実際の定義であるメソッドの隣にパブリック API に置かれた、コピーの回避策です。 constが伝播しません。const unique_ptrを介しても、ポインティは変更可能です(constはポインタを保護するだけで、コンポーネントは保護しないため)。- null が表現可能です。コンテナやメンバーに null ポインタが入るのを防ぐものはありません。そのため、すべての逆参照は、型システムが表現しない暗黙の前提条件を伴います。
C++26 の std::polymorphic 型を使用すると、コンポーネントがポリモーフィックに動作することを許可するために、クローンメカニズムを使用する必要がなくなります。
与えられた例では、Event クラスを次のように簡略化できます。
class Event
{
public:
virtual ~Event() = default;
TimePoint when() const { return ts_; }
virtual std::string describe() const = 0;
virtual void apply(class HomeStatus& home) const = 0;
protected:
TimePoint ts_ = Clock::now();
};
結果として、派生クラスはここで示されるように変更されます。
class TemperatureReadingEvent : public Event
{
std::string room_;
double celsius_;
public:
TemperatureReadingEvent(std::string room, double celsius)
: room_(std::move(room)), celsius_(celsius) {}
double celsius() const { return celsius_; }
std::string describe() const override {
return std::format("[{}] temperature {} C", room_, celsius_);
}
void apply(HomeStatus& home) const override {
home.lastTemperature = celsius_;
}
};
class SmokeDetectedEvent : public Event
{
std::string room_;
double density_;
public:
SmokeDetectedEvent(std::string room, double density)
: room_(std::move(room)), density_(density) {}
std::string describe() const override {
return std::format("[{}] SMOKE detected, density {} %/m", room_, density_);
}
void apply(HomeStatus& home) const override {
home.smokeAlarm = true;
++home.alertCount;
}
};
class CarbonMonoxideDetectedEvent : public Event
{
std::string room_;
int ppm_;
public:
CarbonMonoxideDetectedEvent(std::string room, int ppm)
: room_(std::move(room)), ppm_(ppm) {}
std::string describe() const override {
return std::format("[{}] CARBON MONOXIDE {} ppm", room_, ppm_);
}
void apply(HomeStatus& home) const override {
home.coAlarm = true;
++home.alertCount;
}
};
Event のメンバーである std::vector<std::polymorphic<Event>> events_ を持つ EventLog クラスは、もはや Rule of Five に従い、すべての特殊メンバー関数を実装する必要がなくなります。実際、Rule of Zero に従い、何も実装しないことができます。
class EventLog {
std::vector<std::polymorphic<Event>> events_;
public:
template <typename E, typename... Args>
void record(Args&&... args) {
events_.emplace_back(std::in_place_type<E>, std::forward<Args>(args)...);
}
HomeStatus replay() const {
HomeStatus home;
for (const auto& e : events_)
e->apply(home);
return home;
}
void print() const {
for (const auto& e : events_)
std::cout << " " << e->describe() << "\n";
}
std::size_t size() const { return events_.size(); }
};
クライアントコードは変更されません。前のセクションの main() 関数も変更されません。しかし、コード全体がシンプルになりました。std::polymorphic は一連の問題を解決します。
- 所有者は再びコピー可能になります:
std::polymorphic自体はコピー可能であり、所有オブジェクトの正しいディープコピーを実行します。複合クラスのコピー操作はコンパイラによって生成されるため、Rule of Zero に従います。 clone()プロトコルはありません: コピー操作は型消去され、コンストラクタ時にキャプチャされます。その際、具体的な型は静的に知られています。階層にはclone()関数は不要で、派生クラスが忘れるものもありません。- クリーンなインターフェース: 宣言する
clone()がないため、基底クラスにはドメインメソッドのみが含まれます。 constが伝播します:const std::polymorphicを介して、所有オブジェクトへのアクセスは const になります。ディープな const 性は、規約ではなく型システムによって強制されます。- null にはなりません:
std::polymorphicオブジェクトは常に値を所有します。コンストラクト可能な null 状態はありません。唯一の空の状態はムーブ後の状態であり、valueless_after_move()で明示的に検出可能であるため、それ以外のすべての逆参照は不変条件によって有効です。
要するに、std::polymorphic コンポーネントは、通常のメンバーのように動作します。コピー可能で、ディープに const で、常に存在し、動的型を維持します。
std::polymorphic<Event> をコピーするとき、TemperatureReadingEvent を保持している場合、仮想 clone() なしで TemperatureReadingEvent をコピーする方法をどうやって知るのか、と疑問に思うかもしれません。答えは、コンストラクト時にキャプチャされたコピー操作の型消去です。
概念的には、std::polymorphic<Event> は、小さな内部制御構造の背後にオブジェクトを格納します。std::in_place_type<TemperatureReadingEvent> で構築すると、その時点では型が TemperatureReadingEvent であることを静的に知っているコンストラクタは、次のものと同等の操作をインスタンス化して格納します。
copy = [](const Event& e) { return new TemperatureReadingEvent(static_cast<const TemperatureReadingEvent&>(e)); };
destroy = [](Event* e) { delete static_cast<Event*>(e); };
その後、std::polymorphic のコピーコンストラクタは、格納された操作を呼び出すだけです。これは std::function や std::any が使用するのと同じテクニックであり、コピーコンストラクタに適用されます。これは、std::polymorphic が Event を正しく破棄するために仮想デストラクタを必要としないことも意味します。消去された破棄操作は、すでに具体的な型を知っています。ただし、他のコードがベースポインタ経由で削除する可能性があるため、仮想デストラクタを削除すべきではありません。
インターフェースの残りは std::indirect 型に似ています。
operator*/operator->はEvent&/Event*を提供し、const が伝播します(const std::polymorphicを介してconst Event&を取得します)。- null にはなりません。ムーブ後の状態を除き、
valueless_after_move()でクエリ可能です。 - アロケータ対応であり、
std::indirectとは異なり、実装は小バッファ最適化が許可されており、小さな派生オブジェクトをヒープ割り当てなしでインラインに格納できます。 - 「どの派生型が内部にあるか」を尋ねる方法はありません。
std::anyが持つようなtype()関数はありません。それが必要な場合は、dynamic_castを*pに対して使用します。これは、ベース参照に対して行うのと同様です。設計意図は、ベースインターフェースを介して対話することです。
結論
クラスが実行時に動的な型が変化するコンポーネントを所有し、そのコンポーネントが所有者の値の一部である場合に std::polymorphic を使用します。所有者のコピーはそれをコピーし、const はそれに伝播し、決して欠席してはなりません。典型的なケースは、戦略メンバー、イベントログのような異種コレクション、式ツリーのような再帰構造です。
型が固定されている場合は、std::indirect を使用します。
代替のセットが閉じている場合は、std::variant を使用します。
オブジェクトが値ではなく共有アイデンティティである場合は、スマートポインタを使用し続けてください。
参照
このトピックに関する追加の資料のリストを以下に示します。
- std::polymorphic
- Copying Polymorphic Types: How C++26 simplifies value semantics
- C++26: std::polymorphic