📌

テンプレートクラスのメンバを型引数に応じて増減させるには

2022/07/26に公開

端的に回答するなら「テンプレートの特殊化と、SFINAEを駆使すればできるよ」ということになるんですが、これは完全に「わかる人限定」の説明であって、これらのパーツを繋ぎ合わせて目的にたどり着くのはちょっと難しいです。

なので、似たような解説記事は色々あると思うんですが、テンプレートは「STLやboostのクラスは使えますけど……」というレベルの方でも理解でき、C++のテンプレートメタプログラミングにドはまりする人を増やしたいなぁという思いで、この記事を書くことにします。

最後に、現時点で一番冴えてるやり方(コンセプト利用)も載せましたので最後までご覧ください。

状況設定

型Tをテンプレート引数に取るクラスFooがあって、次のようなメンバを持つとします。

template<typename T>
class Foo {
 public:
  void SomeProcess() {}
  T GetValue() { return m_value; }
  void SetValue(const T& value) { m_value = value; }
  // and more...
 private:
  T m_value;
  // and more...
};

で、こいつは他にも色々メンバを持っていて、何かしら大事な役割をどこかで担っているものとします。

ある時、このFooに対して「特定の型の値をメンバに持つ必要はないが、今まで通りの役割を果たして欲しい。そのためにはm_valueというメンバ変数を持つのがメモリ的にもったいないし、m_valueに関わるメンバ関数も要らない」という要求があったとします。なんじゃそりゃ、と思うかもしれませんが、C++を使っているとこういうことは稀に良くあります。

妥協案:特殊化ですっぱり分岐する

一番分かりやすいのは、Fooという型自体を特殊化して実装をわけてしまうことです。Tにvoidが渡されたら値を持たないFooクラス、ということにしましょう。

template<>
class Foo<void> {
 public:
  // and more...
 private:
  // and more...
};

これはこれでいいんですが、and moreな実装部分を二重に持つことになるのはうまくありません。実装の二重持ちを避けつつ、メンバの減少をするにはどうすればいいかを考えていきます。

妥協しないために色々調べて考える

メンバ関数を定義する型を限定する

https://t.co/2r5W5HOoW0

上記の素晴らしい解説記事を見てください、としか言いようがないのですが、それでも理解が難しかった人のために、自分なりの解説を加えさせていただきます。

まず、妥協案としてあげたFooのvoid特殊化をしなかった場合に、Foo<void>を利用すると何が起きるかというと、 void GetValue()void SetValue(const void&) が定義されてしまい、コンパイルエラーになります。

これを回避する手段としてよく用いられるのがSFINAEです。

https://cpprefjp.github.io/lang/cpp11/sfinae_expressions.html

「テンプレートの置き換えに失敗っていうなら、void GetValue() だって失敗では?」と思うかもしれませんが、これはテンプレートの置き換えには成功した結果、関数として定義できない状態になった、というケースなのでSFINAEは働きません。

そのため、テンプレートの置き換え失敗を起こすには、ある条件下でのみ定義される型を利用することで「利用するつもりだった型がない!置き換え失敗だ!」という状況に持ち込む必要があります。これを実現してくれるのが 'std::enable_if' です。

https://cpprefjp.github.io/reference/type_traits/enable_if.html

じゃあこの無敵のenable_if先生を使えば何とかなるのかというと、前述の素晴らしい解説記事にあるように「テンプレートクラスであってもメンバ関数自体がテンプレート化されていないとSFINAEは働かない」というトラップに引っ掛かってしまいます。

これを理解するには Two-phase name look up という仕様を知る必要があります。これについて私がざっくり理解するのに参考になった記事がこちら。

https://mimosa-pudica.net/cpp-specialization.html

今回のケースに照らし合わせるならば「クラス全体のテンプレートパラメータを置き換える時点で通常の(非テンプレート)メンバ関数は定義されてしまうため、そこで生じるコンパイルエラーはSFINAEでは回避できない」と言えます。

メンバ関数を、実用上の意味がなくてもとりあえずテンプレート関数化することで、テンプレートの置き換えタイミングを関数単位のタイミングまで遅延させることにより、特定の型が指定された時の定義を抑制する、といったことが可能になります。

メンバ変数を削減する

メンバ変数はメモリレイアウトに直結するので、SFINAEのようなテクニックに頼ることができません。よって「メンバ変数をラップする型を作り、特殊化で分岐する」くらしか手段がないと思われます。これは妥協案で示した特殊化とやることは大差ないので、改めての解説は省きます。

実際にやってみた

では上記の知識や方針を元に、メンバ変数とメンバ関数を増減するクラスを作ってみます。
#include <type_traits> をお忘れなく。

template<typename T>
class Foo {
  // (1)分岐条件は何回も参照するのでコンパイル時定数として定義しておく
  static constexpr bool HasValue = !std::is_void_v<T>;

 public:
  void SomeProcess() {}

  // (2)テンプレート化のための引数としてstd::nullptr_tを利用
  template<std::nullptr_t D = nullptr>
  auto GetValue() -> std::enable_if_t<HasValue && D == nullptr, T> { return m_value.value; }

  // (3)Tを引数に含んでいるとテンプレート化が遅延できないのでいったん別の型引数で受ける
  template<typename U, std::enable_if_t<std::is_same_v<U, T> && HasValue, std::nullptr_t> = nullptr>
  void SetValue(const U& value) { m_value.value = value; }

  // (3')返り値型にSFINAEを適用してstd::nullptr_tを使わない場合
  template<typename U>
  auto SetValue2(const U& value) -> std::enable_if_t<std::is_same_v<U, T> && HasValue, void> { m_value.value = value; }

  // (3')TをUに振り替えつつenablerイディオムで書くならGetValueもこんな感じに書ける
  template<typename U = T, std::enable_if_t<std::is_same_v<U, T> && HasValue, std::nullptr_t> = nullptr>
  U GetValue2() { return m_value.value; }

// and more...
 private:
  // (4)ホルダー型の定義と特殊化、およびメンバ変数としての定義 
  template<typename V>
  struct ValueHolder { V value; };
  template<>
  struct ValueHolder<void> {};

  ValueHolder<T> m_value;
  // and more...
};

SFINAEの式はゴチャゴチャしがちなので、必要に応じてコンパイル時定数としてエイリアスを作ると良いです。コネコネして導出した型をusingするのも有効です。ユーザーにとっても有用ならば、publicで公開しても良いでしょう。std::is_void_v<T>std::is_void<T>::value の代わりに使えるバージョンです。C++17以降で追加されています。同様に std::enable_if_t<cond, T>typename std::enable_if<cond, T>::type の代わりに使えます。だいぶ記述量が減らせるので、C++14以降が使えるなら積極的に使いましょう。この後出てくる std::is_same_v<T, U>std::is_same<T, U>::value も同じような関係です。

GetValue()は std::nullptr_t をダミー引数としてテンプレート化しました。前述のブログでお勧めされていたパターンです。返り値型を後置せずに std::enable_if_t<HasValue && D == nullptr, T> GetValue() と定義してもいいんですが、行頭にいきなりSFINAE式が来ると関数であることが分かりづらくなります。なので返り値型に対してSFINAEを適用したい場合は、autoで書き始めて、返り値型は->で後置した方が分かりやすいです。

次はSetValue()ですが、コメントにある通り引数にT型が含まれるとテンプレート化の遅延ができません。そこでテンプレート関数の型引数Uで受けて、enable_ifの条件式でTと等しい型であることを定義の条件に加えます。GetValue()の時のように返り値型へのSFINAEでもいいのですが、実装方法のバリエーションを提示するため、俗にいうenablerイディオムで書いてみました。関数の動作に影響しないテンプレート引数を末尾に追加し、条件を満たす場合はstd::nullptr_tを返すenable_ifを書くやり方です。このようにSFINAEを利用するにはいくつかやり方がありますので、ある程度の範囲内では方法を統一した方が、読み手(将来の書き手も含めて)が混乱しなくて済むでしょう。

最後は値のホルダー型を定義して、voidの場合は空っぽの型として特殊化し、T型を渡してメンバ変数として定義すれば完成です。ホルダー型を利用する関数は、SFINAEでT == voidの時は定義されないようになっているため、コンパイルエラーにはなりません。

一番新しく一番冴えてるやり方

ここまで読んで「これってバッドノウハウじゃん?」と思われた方もいらっしゃるかもしれません。私もそう思ってます。もっと、もっと何とかならないのか……こんなことで頭を悩ませる必要のある言語なんて……と絶望しかけましたが、C++20から導入されたコンセプトを使えば、もうちっと何とかなるんじゃない?と思い立ち、それを利用した書き方を提示して本稿を締めます。

#include <concepts>

template<typename T>
class Foo {
  template<typename U>
  static constexpr bool IsEnable() { return std::is_same_v<U, T> && !std::is_void_v<T>; }

 public:
  void SomeProcess() {}

  T GetValue() requires (IsEnable<T>()) { return m_value.value; }

  template<typename U = T>
  void SetValue(const U& value) requires (IsEnable<U>()) { m_value.value = value; }

  // and more...
 private:
  template<typename V>
  struct ValueHolder { V value; };
  template<>
  struct ValueHolder<void> {};

  ValueHolder<T> m_value;
  // and more...
};

すげ~~~~~~~~~~~~~~~~
超スッキリ~~~~~~~~~~~~~~~~
最高~~~~~~~~~~~~~~~~~~~~~~~

SetValue()のところは型引数を1段挟む必要がありましたが、それにしたって超スッキリです。最高!もうコンセプト無しではC++書きたくないです!!!!!!

でも、今やってるプロジェクト、まだC++17なんだよね……(完)

Discussion