2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[C++] traitsクラスと、enumのコンパイル時文脈への持ち上げ操作を組み合わせて列挙体の拡張を実現する

2
Posted at

はじめに

この記事中では、enumの情報をtraitsクラスに保持し、実行時のenumをコンパイル時文脈のstd::integral_constantへ持ち上げるlift_enum関数と組み合わせることで、柔軟に列挙体を拡張する仕組みを紹介する。

また、この記事ではテンプレートメタプログラミングやその技法(テンプレート特殊化や可変引数テンプレートなど)を使い倒すが、それについては特に解説しない。読者がテンプレートメタプログラミングについて十分理解していることを前提としている。


C++でenum型を書くとき、(一例だが)例えばenumの名前を取得したいことがあるはず。おそらく最も素朴にやるならば次のようなswitch-caseのコードを書くと思う。

enum class hoge {
  foo,
  bar,
  baz,
};

constexpr std::string_view get_name(hoge e) noexcept {
  switch (e) {
  case hoge::foo: return "foo";
  case hoge::bar: return "bar";
  case hoge::baz: return "baz";
  }
}

get_name(hoge::foo); // "foo"

しかしこれは保守性の面で(caseとreturnで同じ名前を書くこと1を除いても)少なくとも3つ困った点がある。

  • caseを書き忘れてもコンパイルエラーにならず、誤った実装となる危険がある
  • 似たような、例えばクラス名を含むenum名を返す関数を作るときなどにも同様のswitchを書かなくてはならない
  • 他のenumについて名前を取得したいときも同様にswitchを書かなくてはならない

enum_traits / lift_enum

この問題の解決のために、まずは新しくenumをテンプレート引数とするようなtraitsクラスを定義し、それに列挙体の情報を持たせるようにする。enum自体に情報を持たせられればよいのだが、C++のenumはそのような機能を持たないので代わりにenum外部で付加情報を保持する。

template<auto e>
struct enum_traits;

 template<>
struct enum_traits<hoge::foo> {
  inline static constexpr char name[] = "foo";
};
template<>
struct enum_traits<hoge::bar> {
  inline static constexpr char name[] = "bar";
};
template<>
struct enum_traits<hoge::baz> {
  inline static constexpr char name[] = "baz";
};

まだこれだけだと解決としては当然不十分で、それぞれのenum名を得るためにはテンプレートを指定しなければならず、not constexprなenum変数を使用してenum名を取得するということができなくなってしまっている。

std::cout << enum_traits<hoge::foo>::name << std::endl; // "foo"

hoge e = hoge::foo;
// コンパイルエラー!
// std::cout << enum_traits<e>::name << std::endl;

これをどうにかするためには、引数で受け取った動的なenumをコンパイル時の静的な値として扱えるような仕組みがあれば良い。これを実現するために、引数のenumを静的なstd::integral_constantへ持ち上げた上で関数オブジェクトへ適用させるlift_enum関数を実装する。

template<typename Func, typename Enum>
constexpr auto lift_enum(Func &&f, Enum e);

hoge e;
e = hoge::foo;
lift_enum(f, e); // f(std::integral_constant<hoge, hoge::foo>{});
e = hoge::bar;
lift_enum(f, e); // f(std::integral_constant<hoge, hoge::bar>{});
e = hoge::baz;
lift_enum(f, e); // f(std::integral_constant<hoge, hoge::baz>{});

この関数があれば実行時指定したenum要素を利用してenum_traitsからnameを得ることができる。

template<typename Enum>
constexpr std::string_view get_name(Enum e) noexcept {
  return lift_enum([] (auto E) noexcept {
    // E = std::integral_constant<Enum, e>{};
    return enum_traits<E()>::name;
  }, e);
}

hoge e = hoge::foo;
get_name(e); // "foo"

lift_enum実装

実行時enumをコンパイル時std::integral_constantへ持ち上げるため、それぞれのenum要素に対応して目的の関数オーバーロード呼び出しをラップする関数ポインタを作り、それを関数ポインタ配列としてまとめるようにする。今までのenum class hogeを例に取ると以下のような形。

using func_return_type = std::invoke_result_t<Func, std::integral_constant<hoge, hoge::foo>>;

using func_ptr_type = func_return_type (*)(F&&);

constexpr func_ptr_type array[] = {
  [] (Func &&f) { return f(std::integral_constant<hoge, hoge::foo>{}); },
  [] (Func &&f) { return f(std::integral_constant<hoge, hoge::bar>{}); },
  [] (Func &&f) { return f(std::integral_constant<hoge, hoge::baz>{}); },
};

この関数ポインタ配列から適切な要素を選択して関数実行するまでをひとかたまりの関数にできれば、目的のlift_enumが実装できたことになる。

// hoge::fooのとき
array[0](f); // f(std::integral_constant<hoge, hoge::foo>{})
// hoge::barのとき
array[1](f); // f(std::integral_constant<hoge, hoge::bar>{})
// hoge::bazのとき
array[2](f); // f(std::integral_constant<hoge, hoge::baz>{})

ここで、関数ポインタ配列を構築する部分を手書きしてしまうと、はじめに挙げたswitch-caseする例と同じで、個別の列挙型ごとに個別で実装する手間が生じたり、その分実装ミスの可能性や変更に弱くなってしまうなど問題点が多い。

enum class hogeの実装からして、foo, bar, bazの3つはそれぞれ0から始まる連番なので、関数ポインタ配列の作成と使用は次のように書いても全く同じのはず。

constexpr func_ptr_type array[] = {
  [] (Func &&f) { return f(std::integral_constant<hoge, static_cast<hoge>(0)>{}); },
  [] (Func &&f) { return f(std::integral_constant<hoge, static_cast<hoge>(1)>{}); },
  [] (Func &&f) { return f(std::integral_constant<hoge, static_cast<hoge>(2)>{}); },
};
// hoge::fooのとき
array[static_cast<std::size_t>(hoge::foo)](f);
// hoge::barのとき
array[static_cast<std::size_t>(hoge::bar)](f);
// hoge::bazのとき
array[static_cast<std::size_t>(hoge::baz)](f);

逆にenumの各要素が連番でない場合、enumの数値と配列インデックスとの間のマッピングが必要になってしまい面倒なので、簡単のため今回lift_enum関数に使用できるのは各要素が0からの連番として定義されているenumのみということにする。

以上の制限の上では、あとはenumの要素数が分かれば関数ポインタ配列の作るべきサイズも分かり、あとはstd::make_index_sequenceと組み合わせることで手書きしない関数ポインタ配列作成ができるようになるのだが、残念ながらC++ではenumの要素数を取得する方法が用意されていない2ので、enumの要素数だけは人間が手で書くようにする。
(面倒ではあるが、上で言ったenumとインデックスのマッピングよりはかなりマシなのでまあ許容するかという感じ)

具体的にはenumの型をテンプレート引数に持つenum_class_traitsというクラス特殊化を用意してそこにenum要素数を持たせることにする。

template<typename Enum>
struct enum_class_traits;

template<>
struct enum_class_traits<hoge> {
  // hoge::foo, hoge::bar, hoge::baz の3つ
  inline static constexpr std::size_t count = 3;
};

そしてこのenum要素数を基にconstexprなenum要素のリストenum_listを作る。enumの各要素が連番という前提があるため、自動的に作成可能。std::index_sequenceを作って可変長引数のパック展開と合わせればいい。

template<typename Enum, std::size_t ...Idx>
constexpr std::array<Enum, sizeof...(Idx)>
make_enum_list_impl(std::index_sequence<Idx...>) noexcept {
  return std::array<Enum, sizeof...(Idx)>{ static_cast<Enum>(Idx)... };
}

template<typename Enum>
constexpr std::array<Enum, enum_class_traits<Enum>::count>
make_enum_list() noexcept {
  return make_enum_list_impl<Enum>(std::make_index_sequence<enum_class_traits<Enum>::count>{});
}

template<typename Enum>
constexpr auto enum_list = make_enum_list<Enum>();

enum_list<hoge>; // { hoge::foo, hoge::bar, hoge::baz }

このenum_listをパック展開する形で目的の関数ポインタ配列を作ることができるので、これを利用して目的であったlift_enumの実装に入っていく。

関数ポインタ配列を作る部分のmake_lift_table関数は次のように実装する。(必要な型チェックを省いたりなど少し簡単にしている)

template<typename Func, typename Enum, std::size_t ...Idx>
constexpr auto make_lift_table_impl(std::index_sequence<Idx...>) noexcept {

  using func_return_type = std::invoke_result_t<Func, std::integral_constant<Enum, enum_list<Enum>[0]>>;

  return std::array<func_return_type (*)(Func&&), sizeof...(Idx)>{
    ([] (Func &&f) {
      return std::forward<Func>(f)(std::integral_constant<Enum, enum_list<Enum>[Idx]>{});
    })...
  };
}

template<typename Func, typename Enum>
constexpr auto make_lift_table() noexcept {
  return make_lift_table_impl<Func, Enum>(std::make_index_sequence<std::size(enum_list<Enum>)>{});
}

このmake_lift_tableを使ってlift_enumを実装する。

template<typename Func, typename Enum>
constexpr auto lift_enum(Func &&f, Enum e) {
  constexpr auto table = make_lift_table<Func, Enum>();
  return table[static_cast<std::size_t>(e)](std::forward<Func>(f));
}

書くべきものと、それでできるようになること

ここまでの話で、「enumの要素は0からの連番とすること」など色々条件を付け足したりなどしたので、
一旦ユーザーが書くべきものと、その上で何ができるようになるかをここで整理する。

まずユーザーが書くべきなのは

  1. 要素が0からの連番で定義されたenum型定義
  2. enum要素数をstatic constexprなメンバに持つ、テンプレート引数がenum型のenum_class_traitsクラス特殊化
  3. enum要素の名前など好きなstatic constexprメンバをもたせた、テンプレート引数がenum型要素のenum_traitsクラス特殊化
// 1.
enum class hoge {
  foo = 0,
  bar,
  baz,
};

// 2.
template<>
struct enum_class_traits<hoge> {
  inline static constexpr std::size_t count = 3;
};

// 3.
template<>
struct enum_traits<hoge::foo> {
  inline static constexpr char name[] = "foo";
};
template<>
struct enum_traits<hoge::bar> {
  inline static constexpr char name[] = "bar";
};
template<>
struct enum_traits<hoge::baz> {
  inline static constexpr char name[] = "baz";
};

これによりできることの一つがenum_list<hoge>によるenum一覧の取得。
enum_listを作る処理が、

  • 各要素が連番であり
  • enum_class_traitsでenumの要素数を持つ

ようなenumについて一般化されているために可能となる。

enum_list<hoge>; // { hoge::foo, hoge::bar, hoge::baz }

もう一つがlift_enum関数による実行時enumの、コンパイル時std::integral_constantへの持ち上げ操作と、それを利用したenum_traitsクラスへの実行時アクセス。
ここでも、lift_enum関数は一般化された形で実装されているため、enum型の定義ごとに個別に実装を用意せずとも呼び出すことができる。

template<typename Enum>
constexpr std::string_view get_name(Enum e) noexcept {
  return lift_enum([] (auto E) noexcept {
    return enum_traits<E()>::name;
  }, e);
}

hoge e;
e = hoge::foo;
get_name(e); // "foo"
e = hoge::bar;
get_name(e); // "bar"
e = hoge::baz;
get_name(e); // "baz"

switch-case実装と比較

  • Pros
    • 分岐処理を手で書く必要が無く実装ミスの可能性を大きく減らせる
    • 異なるenum型であっても同一関数を使用できるため個別実装の必要が無い
    • enum要素ごとの分岐内容をenum_traitsに集約することが可能
    • enum_traits内容の定義に不足があったときは実行時エラーでなくコンパイルエラーになる
  • Cons
    • swich-caseと比べると実装が難解でコンパイルも重い
    • 初回のenum_class特殊化定義が面倒
    • enum_class_traitsへenum要素数countを定義する必要性が完全に実装都合
    • countが実際のenum要素数より少ないときもコンパイルが通ってしまう

enum_traits + lift_enumで色々やる

さて、ここまでで用意したenum_traitsクラス特殊化とlift_enumの組み合わせは単にenumの名前を取得する以上に広い箇所で使える汎用性を持っているため、それを紹介していく。

状態遷移の表現

enum名を持たせる以外のシンプルな例。enum_traitsに、次に遷移すべき状態を持たせる。

// enum定義
enum class state {
  a = 0,
  b,
  c,
};

// enum要素数定義
template<>
struct enum_class_traits<state> {
  inline static constexpr std::size_t count = 3;
};

// 状態遷移を定義
template<>
struct enum_traits<state::a> {
  inline static constexpr state next = state::b;
};
template<>
struct enum_traits<state::b> {
  inline static constexpr state next = state::c;
};
template<>
struct enum_traits<state::c> {
  inline static constexpr state next = state::a;
};

// 次の状態の取得
constexpr state next_state(state s) noexcept {
  return lift_enum([] (auto S) noexcept { return enum_traits<S()>::next; }, s);
}

次のように使用できる。

state s = state::a;
s = next_state(s); // s == state::b
s = next_state(s); // s == state::c
s = next_state(s); // s == state::a

コマンドの表現

インタラクティブにコマンドを受取って処理するようなプログラムのコマンド実装に使用できる。下のコードを見てもらえればわかるだろう。

// enum定義
enum class cmd {
  increment,
  decrement,
  print,
  exit,
};

// enum要素数定義
template<>
struct enum_class_traits<cmd> {
  inline static constexpr std::size_t count = 4;
};

// enumのコマンド名と、コマンド実行処理を定義
template<>
struct enum_traits<cmd::increment> {
  inline static constexpr char cmd_name[] = "increment";
  static void exec(int &app_state) {
    ++app_state;
  }
};
template<>
struct enum_traits<cmd::decrement> {
  inline static constexpr char cmd_name[] = "decrement";
  static void exec(int &app_state) {
    --app_state;
  }
};
template<>
struct enum_traits<cmd::print> {
  inline static constexpr char cmd_name[] = "print";
  static void exec(int &app_state) {
    std::cout << app_state << std::endl;
  }
};
template<>
struct enum_traits<cmd::exit> {
  inline static constexpr char cmd_name[] = "exit";
  static void exec(int &app_state) {
    std::exit(0);
  }
};

// コマンド名取得
constexpr std::string_view cmd_name(cmd c) noexcept {
  return lift_enum([] (auto C) noexcept {
    return enum_traits<C()>::cmd_name;
  }, c);
}

// コマンド実行
constexpr void exec_cmd(cmd c, int &app_state) noexcept {
  return lift_enum([&] (auto C) noexcept {
    return enum_traits<C()>::exec(app_state);
  }, c);
}

enum_traitsにて、enum class cmdのコマンド名cmd_nameと、それに合わせ実行するコマンド処理execを定義している。execはメンバ変数でなくメンバ関数として定義しているが、lift_enumはこれも問題なく扱うことが可能。
引数のapp_stateは単純化したアプリケーション状態を表している。

これをインタラクティブにコマンド処理するプログラムとして次のように組み込める。

int main() {
  std::string input;
  int app_state = 0;

  while (true) {
    // コマンド入力待受
    std::cout << "> ";
    std::cin >> input;
    for (cmd c : enum_list<cmd>) {
      // 入力と一致するコマンドを探す
      if (input == cmd_name(c)) {
        // コマンド実行
        exec_cmd(c, app_state);
        break;
      }
    }
  }
}

これをコンパイルしたものは次のように動作する。

> print
0
> increment
> increment
> print
2
> decrement
> print
1
> exit
(終了)

この実装の良いところは、コマンドごと個別の処理が全てenum_traits定義中にあり、main関数には具体的なenum要素やそれに依存した処理が何も現れていないところ。
コマンドの変更や追加削除を行う場合でも、一箇所に固まっているenum定義、enum_class_traits, enum_traitsの3つさえ正しく変更すればプログラム全体でも正しく動作することがすぐ判断できる。

もっと言うとこれは、enum_traitsにhelpの説明文を持たせたり、コマンドにオプションを渡せるようにしてexecの引数でstd::vector<std::string_view>などのような形で受け取れるようにするなど、より複雑な場合にも拡張対応可能な設計となっている。

enumで要素アクセス可能な名前付きtuple

最後はもう少し複雑な例で、enumを使用して要素にアクセスできるようなtuple型を作る。
次のように使えるenum_tuple型を目指す。

enum class hoge {
  foo = 0,
  bar,
  baz,
};

template<>
struct enum_class_traits<hoge> {
  inline static constexpr std::size_t count = 3;
};

// tupleアクセス時に得られるデータ型を定義
template<>
struct enum_traits<hoge::foo> {
  using data_type = int;
};
template<>
struct enum_traits<hoge::bar> {
  using data_type = float;
};
template<>
struct enum_traits<hoge::baz> {
  using data_type = std::string;
};

int main() {
  enum_tuple<hoge> t; // tuple型変数定義

  enum_get<hoge::foo>(t) = 42; // int
  enum_get<hoge::bar>(t) = 3.14; // float
  enum_get<hoge::baz>(t) = "baz"; // std::string
}

これを実現するためのものを色々定義していく。

実装

enumでアクセス可能なタプルを表すenum_tupleクラスだが、std::tupleのような高機能なものを一から用意するのは大変なので、単に適切なテンプレート引数を指定したstd::tupleのエイリアス宣言として用意することにする。今回で言うと

static_assert(std::is_same_v<enum_tuple<hoge>, std::tuple<int, float, std::string>>);

となるようにenum_tupleを定義したい。
これは、std::tupleのテンプレート引数にenum_traits<enum要素>::data_typeを並べたものを作れれば良い。ということで次のように定義できる。

// 宣言だけ、定義はいらない
template<typename Enum, std::size_t ...Idx>
std::tuple<typename enum_traits<enum_list<Enum>[Idx]>::data_type...>
enum_tuple_impl(std::index_sequence<Idx...>);

template<typename Enum>
using enum_tuple =
  decltype(enum_tuple_impl<Enum>(
    std::make_index_sequence<std::size(enum_list<Enum>)>{}
  ));

static_assert(std::is_same_v<enum_tuple<hoge>, std::tuple<int, float, std::string>>);

enum_tupleの型を定義することは出来たので、次はこのタプル要素にenumでアクセスするenum_getを作る。
とはいってもenum_tupleの中身は単なるstd::tupleなのでstd::getをラップするだけで良い。左辺値参照、const左辺値参照、右辺値参照の三種類のオーバーロードを作る。

template<auto e>
constexpr auto &enum_get(enum_tuple<decltype(e)> &t) noexcept {
  return std::get<static_cast<std::size_t>(e)>(t);
}

template<auto e>
constexpr const auto &enum_get(const enum_tuple<decltype(e)> &t) noexcept {
  return std::get<static_cast<std::size_t>(e)>(t);
}

template<auto e>
constexpr auto &&enum_get(enum_tuple<decltype(e)> &&t) noexcept {
  return std::get<static_cast<std::size_t>(e)>(std::move(t));
}

これで、enumでアクセスできる名前付きタプルとして必要な最低限のものは実装できたので、とりあえずOK。

enum_tupleへの動的アクセス

このenum_tupleだが、今までlift_enumでやっていたように、実行時指定したenumを使用してenum_tupleの要素を取得するということもできる。例として今回はenum_tupleの要素に実行時アクセスして文字列化したものを返すようにしてみる。せっかくなのでC++20のformatを使って、コンパイル時に指定した書式で文字列化する。

enum class hoge {
  foo = 0,
  bar,
  baz,
};

template<>
struct enum_class_traits<hoge> {
  inline static constexpr std::size_t count = 3;
};

template<>
struct enum_traits<hoge::foo> {
  using data_type = int;
  // 4桁右寄せ
  inline static constexpr char format[] = "{:0>4}";
};
template<>
struct enum_traits<hoge::bar> {
  using data_type = float;
  // 指数表記しない
  inline static constexpr char format[] = "{:f}";
};
template<>
struct enum_traits<hoge::baz> {
  using data_type = std::string;
  // ダブルクオートで囲む
  inline static constexpr char format[] = "\"{}\"";
};

// enum_tuple要素の書式化出力関数
template<typename Enum>
std::string format_at(const enum_tuple<Enum> &t, Enum e) {
  return lift_enum([&t] (auto E) {
    return std::format(enum_traits<E()>::format, enum_get<E()>(t));
  }, e);
}

int main() {
  enum_tuple<hoge> t; // tuple型変数定義

  enum_get<hoge::foo>(t) = 42;
  enum_get<hoge::bar>(t) = 3.14;
  enum_get<hoge::baz>(t) = "baz";

  format_at(t, hoge::foo); // "0042"
  format_at(t, hoge::bar); // "3.140000"
  format_at(t, hoge::baz); // "\"baz\""
}

これを使えば、enum_tupleのCSV行出力ができたりする。

template<typename Enum>
void print_csv_line(std::ostream &os, const enum_tuple<Enum> &t) {
  bool first = true;
  for (Enum e : enum_list<Enum>) {
    if (!first) {
      os << ",";
    }
    os << format_at(t, e);
    first = false;
  }
  os << "\n";
}

print_csv_line(std::cout, t); // "0042,3.140000,\"baz\"\n"

補足

lift_enumの明示的な型チェックなどを含めた完全な実装は以下の通り。

template<typename Func, typename Enum, std::size_t ...Idx>
constexpr auto make_lift_table(std::index_sequence<Idx...>) noexcept
  -> std::array<
    std::invoke_result_t<Func, std::integral_constant<Enum, enum_list<Enum>[0]>>
    (*)(Func&&) noexcept(
      std::is_nothrow_invocable_v<Func, std::integral_constant<Enum, enum_list<Enum>[0]>>
    ),
    sizeof...(Idx)
  >
{
  using return_type = std::invoke_result_t<Func, std::integral_constant<Enum, enum_list<Enum>[0]>>;
  constexpr bool noexceptness = std::is_nothrow_invocable_v<Func, std::integral_constant<Enum, enum_list<Enum>[0]>>;

  static_assert(
    (std::is_same_v<
      return_type,
      std::invoke_result_t<Func, std::integral_constant<Enum, enum_list<Enum>[Idx]>>
    > && ...),
    "all overloads must have the same return type."
  );

  static_assert(
    ((noexceptness == std::is_nothrow_invocable_v<Func, std::integral_constant<Enum, enum_list<Enum>[Idx]>>
    ) && ...),
    "all overloads must have the same noexceptness."
  );

  return std::array<return_type (*)(Func&&) noexcept(noexceptness), sizeof...(Idx)> {
    ([] (Func &&f) noexcept(noexceptness) {
      return std::invoke(
        std::forward<Func>(f),
        std::integral_constant<Enum, enum_list<Enum>[Idx]>{}
      );
    })...
  };
}

template<typename Func, typename Enum>
constexpr auto lift_enum(Func &&f, Enum e)
  noexcept(std::is_nothrow_invocable_v<Func, std::integral_constant<Enum, enum_list<Enum>[0]>>)
  -> std::invoke_result_t<Func, std::integral_constant<Enum, enum_list<Enum>[0]>>
{
  constexpr auto table = make_lift_table<Func, Enum>(std::make_index_sequence<std::size(enum_list<Enum>)>{});
  return table[static_cast<std::size_t>(e)](std::forward<Func>(f));
}
  1. enumの文字列化については、実はすでにコンパイラ拡張を使用した黒魔術的な解決策が存在する。詳細についてはこの辺りを参照すること。

  2. C++26では静的リフレクションが追加されるので、実はこの部分もなんとかなってしまう。cpprefjp/std::meta::enumerators_of

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?