C++ で不可能な状態を表現不可能にする

CppCon 2019 で、Make Impossible State Unrepresentable という名前のライトニング トークを行いました . 5分間のライトニングトークという性質上、手ぶらで、時間に合わせて用意した内容をかなりカットしました。この投稿では、より詳細な説明と例を使用してトピックを深く掘り下げます。

型付き関数型プログラミング コミュニティでの同じ慣行が、このトピックに影響を与えています。しかし、私はこのテーマが「機能的」すぎるとは考えておらず、C++ やその他の型システムを備えたプログラミング言語に確実に適用できます。このトピックは、「強い型付け」とも強い関係があります。

動機

Vulkan Graphics API のチュートリアル Web サイトからコピーした次のコード スニペットを検討してください。どうやら、多くの人がこのチュートリアルを自分のコードベースに直接コピーしています。

スニペットには、さまざまなキューのインデックスの構造体があり、最初にインデックスをクエリし、後でそれらのキューを参照するために使用します。

struct QueueFamilyIndices {
    std::optional<uint32_t> graphics;
    std::optional<uint32_t> present;

    bool isComplete() const {
        return graphics.has_value()
        && present.has_value();
    }
};

QueueFamilyIndices findQueueFamilies(/*...*/) {
  // ...
  QueueFamilyIndices indices;
  for (const auto& queue: queues) {
    if (/* queue i support graphics */) {
        indices.graphics = i;
    }

    if (/* queue i support present */) {
        indices.present = i;
    }

    if (indices.isComplete()) {
        break;
    }
  }
  return indices;
}

この特定のケースでは、関数 01 初期化されていないキュー インデックスを持つことができる唯一の場所です。 構造体:

struct QueueFamilyIndices {
    uint32_t graphics;
    uint32_t present;
};

std::optional<QueueFamilyIndices> findQueueFamilies(/*...*/) {
  // ...
  std::optional<uint32_t> graphicsFamily = std::nullopt;
  std::optional<uint32_t> presentFamily = std::nullopt;

  for (const auto& queue: queues) {
    if (/* queue i support graphics */) {
        graphicsFamily = i;
    }

    if (/* queue i support present */) {
        presentFamily = i;
    }

    if (graphicsFamily && presentFamily) {
        return QueueFamilyIndices{*graphicsFamily, *presentFamily};
    }
  }

  return std::nullopt;
}

20 のメモリ使用量 16 バイトから 8 バイトに削減されます。その理由の 1 つは、不要な情報を保存しなくなったことと、複数の 38 の非効率的なアラインメントのためです。 最初の 48 から .

struct A {
  optional<uint32_t> i;
  optional<uint32_t> j;
};

struct B {
  bool has_i;
  bool has_j;
  uint32_t i;
  uint32_t j;
};

上記のスニペットでは、53 61 は 16 バイトです。 はわずか 12 バイトです。

また、アサーションや実行時チェックの必要性を減らしました。 71 に注意してください このロジックを複数回呼び出す必要がないため、関数は 2 番目のケースではなくなります。最初のケースでは、89 を残したバグがある可能性があるため、それほど自信はありません。 初期化されていません。

代数データ型

上記の例は、代数和型 (93 または 102 )、最初は非効率的な方法ですが。これらの型は、C++17 で新たに追加された「語彙型」に属しますが、他のプログラミング言語やサードパーティの C++ ライブラリでは長い歴史があります。 「合計タイプ」という名前は、それらのタイプの可能な状態のセットのカーディナリティに由来します。同様に、より馴染みのある 117 またはタプルは、それらのカーディナリティがすべてのフィールドのカーディナリティの積であるため、「製品タイプ」と呼ばれます。 Sum 型は、「タグ付きユニオン」または「バリアント型」と呼ばれることもあります。

代数和型は、ステート マシンの構築に有利です。このようなユースケースの教科書的な例は、ネットワーク接続です:

struct Connection {
  struct Closed {};
  struct Connecting {
      IP ip;
  };
  struct Connected {
      IP ip;
      Id id;
  };

  std::variant<Closed, Connecting, Connected> state;
};

この実装は、各州で使用されるデータを忠実に表しています。たとえば、121 の IP アドレスを保存しても意味がありません。 136 ですか .

継承階層と合計型

合計型と継承の両方がランタイム ポリモーフィズムに使用されます .つまり、ランタイム ポリモーフィズムが必要な場合にのみ使用してください。 Sum 型は、継承に対して 1 つの主要な制約を追加します。仮想継承は拡張可能ですが、合計型は閉じています。制約は必ずしも悪いことではありません。たとえば、コンパイラは最大サイズ情報を静的に認識しているため、147 全体を配置できます。 スタック上のオブジェクト。

ここで「継承階層」について話すとき、唯一の焦点は、仮想ディスパッチ対応の継承です。特に、CRTP や、サブタイピング ポリモーフィズムを有効にする代わりにコードを再利用することを目的とした仮想関数のない継承の他の使用法は含めません。

理論的には、156 以上のディスパッチ 168 の現在の実装はどれもありませんが、仮想ディスパッチよりも高速になる可能性があります 仮想よりも高速です。ただし、言語バリアントとパターン マッチングを備えた将来の潜在的な C++ バージョンでは、証拠 1 があります。 そのバリアントは利点を提供します。

Mach7:C++ のパターン マッチング

ただし、継承の「拡張可能」プロパティが役立つ場合があります。たとえば、コンパイラに取り組んでいるとします。式を次のような従来のオブジェクト指向の方法で表すことができます。

struct Expr { ... };

struct ConstExpr : Expr { ... };
struct LambdaExpr : Expr { ... };
struct ApplyExpr : Expr { ... };

コンパイル エラーの追加は、179 のような派生クラスを追加するのと同じくらい簡単です。 と 182 これらのエラーはステージ間で完全に隠されます。対照的に、合計タイプでは、1 つのオプションは次のような混乱を作成することです:

using Expr = std::variant<ConstExpr, LambdaExpr, ApplyExpr,
                          SyntaxErrorExpr, TypeErrorExpr>;

このアプローチでは、196 を処理する必要があります。 パーサーで .もう 1 つのオプションは、追加のオーバーヘッドを支払い、206 ごとにラップすることです。 予想に。どちらの選択肢も理想的とは言えず、抽象構文ツリーがより複雑になり、階層が含まれる場合、問題はさらに大きくなります。

もう 1 つのタイプのポリモーフィズムは行ポリモーフィズムです。行ポリモーフィズムでは、型の機能と構造のみが考慮されます。継承と同様に、行ポリモーフィズムも拡張可能であるため、継承と同様に多くの利点があります。行ポリモーフィズムは、間違いなく仮想継承の優れた代替手段です 2 3 4 5 6 .行ポリモーフィズムはまさに C++ の概念が実現するものですが、C++ にはランタイム ポリモーフィズムに対するサポートが組み込まれていません。 Go および Typescript インターフェースと Rust トレイトは、そのような言語機能の例です。 C++ では、型消去を手動で行うことにより、実行時の行ポリモーフィズムを実装できます。

より良いコード:ランタイム ポリモーフィズム - Sean Parent3 :Simon Brand:"Rust が正しいポリモーフィズムを得る方法"4 :CppCon 2017:Louis Dionne 「ランタイム ポリモーフィズム:基本に戻る」5 :Mathieu Ropert:多形性アヒル6 :CppCon 2018:ボリスラフ・スタニミロフ「DynaMix:ポリモーフィズムへの新たな取り組み」

データ モデリング

上記のすべての議論は、データ モデリング (データ要件を定義および分析し、それに応じてデータ モデルを定義する) につながります。データ指向設計と関数型プログラミングの両方の人々は、データ モデリングについて話すのが好きです。

オブジェクト指向開発者の観点からは、データ モデリングはクラス設計に似ています。クラスは、多くの場合、自分自身で操作を行う方法を知っている自己完結型のエンティティとして機能します。ただし、このようなアプローチでは、すべての「論理的に関連する」データを 1 つの構造にまとめる必要があり、多くの場合、意味がありません。たとえば、以下は pbrt-v3 が三角形を実装する方法です:

struct TriangleMesh
{
  std::vector<int> vertexIndices;
  std::unique_ptr<Point3f[]> p;
  // other data
};

class Triangle
{
public:
  // Triangle operations

private:
  std::shared_ptr<TriangleMesh> mesh; // back pointer
  const int *v; // A pointer to vertex indices
};

218 自分自身を操作するには、バック ポインターを格納する必要があります。また、ポインタ 220 が ぶら下がっていません。この特定の例では、プログラマーは 239 を確認します。 常に 245 によって管理されるメモリを指します .

共有所有権に関する有効な使用例は別として、258 「あいまいな所有権」を表すために誤用されることがよくあります。

三角形が自分自身を操作する方法を知っている必要があるという考えを放棄すると、三角形は頂点への単なるインデックスになります:

struct Triangle {
  std::uint32_t first;
  std::uint32_t second;
  std::uint32_t third;
};

struct TriangleMesh
{
  // Triangle operations

  std::vector<Triangle> triangles;
  std::unique_ptr<Point3f[]> p;
  // other data
};

インデックスのぶら下がりを心配する必要がなくなったので、参照カウントはもう必要ありません。

API を変換する

より良いデータ モデリングのガイドラインに従うことは、API の変更を意味する場合があります。このような変更により、API が使いやすくなり、誤用が難しくなります。そのため、後で開始するよりも早い段階で開始することをお勧めします。

以下は、GPU に送信するコマンドがあるグラフィックス プログラミングの別の例です。データを GPU に直接プッシュするのではなく、261 にキャッシュします。 後でバッチ送信できるオブジェクト。

struct CommandBuffer {
  CommandBuffer& push_draw_command(uint32_t count, uint32_t vertex_offset,
                                   uint32_t instance_count);
  CommandBuffer& push_draw_indirect_command(void* indirect);
  CommandBuffer& push_bind_graphics_pipeline_command(GraphicsPipelineHandle pipeline);

  // ...
};

このグラフィックス API は、Vulkan や DirectX12 などの下位レベルの API に直接マッピングされ、非常に柔軟です。ただし、大きな欠点が 1 つあります。グラフィックス パイプライン オブジェクトは、GPU に送信したデータを解釈する方法など、描画のすべてのロジックをカプセル化します。ただし、現在の API では、グラフィック パイプラインにバインドせずにオブジェクトの描画を自由に開始できます。

CommandBuffer buffer;
buffer.push_draw_command(count, 0, 1);
queue.submit(buffer);

単純な前方修正の 1 つは、各コマンド内にグラフィックス パイプラインの参照を配置することです。それにもかかわらず、グラフィックス パイプラインが同じままかどうかを確認する必要があるため、ここでは追加のオーバーヘッドを支払っています。そうであれば、グラフィックス パイプラインの再バインドは GPU でコストのかかる操作になる可能性があるため、パイプラインを再度バインドする必要はありません。このモデルのもう 1 つの最適化は、各コマンドの上にグラフィックス パイプラインに対する並べ替えを追加することです。ただし、このような最適化では追加のオーバーヘッドも発生します。

struct CommandBuffer {
  CommandBuffer& push_draw_command(GraphicsPipelineHandle pipeline, uint32_t count,
                                   uint32_t vertex_offset, uint32_t instance_count);
  CommandBuffer& push_draw_indirect_command(GraphicsPipelineHandle pipeline,
                                            void* indirect);

  // ...
};

より良い修正は、別の構造 275 を導入することです これには、グラフィックス パイプラインと描画コマンドが含まれています。このモデルでは、チェックやソートの必要がなく、すぐに 289 を構築できます。

struct DrawingCommandbuffer {
  DrawingCommandbuffer(GraphicsPipelineHandle pipeline);

  DrawingCommandbuffer& push_draw_command(uint32_t count, uint32_t vertex_offset,
                                   uint32_t instance_count);
  DrawingCommandbuffer& push_draw_indirect_command(void* indirect);
};

struct CommandBuffer {
  void push_drawing_commands(DrawingCommandBuffer buffer);
};

298 を実装できることに注意してください Vulkan のセカンダリ コマンド バッファーに関しては、実装方法に制限はありません。したがって、異なる下位レベルのグラフィックス API の実装では、まったく異なるアプローチを使用できます。

制限事項

コンパイル時にすべての不変条件をチェックできるわけではありません。そのため、多くのプログラミング言語がコントラクトまたは少なくともランタイム アサーションをサポートしています。ただし、「コンパイル時の既知の状態」をすべて数えても、C++ で「不可能な状態を表現できないようにする」には制限があります。 C++ 型システムの設計によるものもあれば、C++ アプリケーションのパフォーマンス要件によるものもあります。

Move セマンティクスの奇妙なケース

私は C++11 の移動セマンティクスが大好きです。しかし、ムーブ セマンティクスは多くの問題を解決しますが、C++ 型システムに穴を開けます。基盤となる C スタイル API でリソースをラップするクラスを考えてみましょう。 C++98 領域では、リソースの有効期間がオブジェクトの有効期間と結び付いているため、完全な RAII を達成しました。

class Window {
  // ...

private:
  // Would never be nullptr
  GLFWwindow* window;

  Window(const Window& other);
  Window& operator=(const Window& other);
}

ムーブ セマンティクスを導入して移動可能にしました。ただし、リソース ハンドルの移動セマンティクスを有効にするために、ポインターのようなオブジェクトを作成しました。その理由は、移動後の状態が有効でなければならないからです。移動後の状態を有効にするために、クラスで空の状態を表現する必要があります。それが 306 がある理由です しかし 312 はありません C++ 標準ライブラリにあります。また、人々が破壊的な動きを繰り返し提案する理由の一部でもあります .

破壊的な動きのもう 1 つの理由 パフォーマンスです。 move のパフォーマンスの向上は、Arthur O'Dwyer の素晴らしいが野心的ではない 自明な再配置可能 によって達成できます。 [P1144]提案。

class Window {
  // ...

  Window(Window&& other) noexcept : window{other.window} {
    other.window = nullptr;
  }

private:
  GLFWwindow* window;
}

結論

静的型システムをうまく活用することで、一連のケースで実行時不変違反の可能性を根絶することができます。このアプローチにより、非常識なデバッグ セッションの可能性と積極的なアサーションの必要性が減少します。また、静的型システムが保証するものをテストする必要がないため、テストにも役立ちます。さらに、データをより注意深くモデル化する方法を検討することで、パフォーマンスが向上することもあります。

<オール>