[C++]std::decayの役割。

std::decayは放射性のstd::atomicを非放射性へと変換するための機能である(嘘)1

std::decayはよくわからない機能である。標準ライブラリの実装などを見ていると結構な頻度で見かけたりするのだが、一体何を目的に使用されているのかはすぐには理解できない。ただ関数型と配列型について考えてみるとなんとなくその用途が理解できるような気がするので、自分なりの理解をちょっと書き留めておく。
本稿の内容はC++規格書を大して読んだことのない人間の勝手な推測である。誤りがある可能性を十分考慮してもらいたい。また誤りに気がついた場合はコメントにて指摘してもらえると嬉しい。

std::decayの機能

std::decayは以下の機能をまとめたものである。

  • const、volatile、参照を取り除く。
  • 関数型を関数ポインタ型に変換する。
  • 配列型をポインタに変換する。

そもそも何故これらの変換が一纏めにされているのだろう? その理由は、たぶん次のことで説明できる。

関数テンプレートの型推論

次のようなコードがあったとき、Typeがどのように推定されるのかを考えてみる。

template <class Type>
void PrintType(Type v)
{
    printf("Type = ", typeid(v).name());
}
int func(int x) { return x; }

int main()
{
    int a = 0;
    const int& b = a;
    int c[4];
    print(a);//Type = int
    print(b);//Type = int
    print(c);//Type = int * __ptr64
    print(func);//Type = int (__cdecl*)(int)
    return 0;
}

上から順に、Typeはint、int、int*、int(*)(int)である。分かるだろうか、これらのTypeはstd::decayを通した型と全く同じなのだ。

using Type1 = typename std::decay<int>::type;//int
using Type2 = typename std::decay<const int&>::type;//int
using Type3 = typename std::decay<int(&)[4]>::type;//int*
using Type4 = typename std::decay<int(int)>::type;//int(*)(int)

std::decayの本質は、上のようなテンプレート型推論と同等な動作をさせることである。ってエラい人が言ってた。

特に面白いのは配列型と関数型の取り扱いだ。次のようなクラスを考えてみよう。

まず配列型について。配列型という型そのものは存在するが、一般に配列は代入演算子によるコピー、ムーブはできない。初心者以外にとっては常識だろう。古めかしいC言語のクソ仕様である。

int a[4] = { 1, 2, 3, 4 };
int b[4] = a;//error

同様に、もしテンプレート型推論で配列型がポインタへと変換されなかったら、上の"print(c)"という関数呼び出しは値渡しに失敗してエラーになるだろう。尤もC言語仕様により配列型引数は強制的にポインタとして扱われるので、型推論の仕組みに関わらず動作はするかもしれないが。

では関数型はどうか。"関数ポインタ型"はよく知られているが、C++には"関数型"も存在する。例えば上の例では、"func"は"int(int)"という関数型で、"&func"は"int(*)(int)"という関数ポインタ型だ。
ただし、関数ポインタ型の変数は存在するが、関数型の変数は存在しないと思われる2

using Func = int(int);//型としての"関数型"は存在する。
Func f;//関数fの宣言と解釈されるらしい。
int f(int x) { return x * 2; }//こんな感じに宣言に対応する定義を書くこともできるようだ。
using FuncPtr = int(*)(int);//"関数ポインタ型"ももちろん存在する。
int(*f)(int) = &func;//OK
int f(int) = func;//error fは単なる関数と認識されるので、funcを代入することはできない。
int(&f)(int) = func;//OK 関数型への参照は許される。

関数型とはつまり関数そのもののことなので、変数にはできないということだろう。 ということは、もしテンプレート型推論で関数型から関数ポインタ型への変換が行われない場合、配列のときと同様にprint(func)はコンパイルエラーとなるだろう。まあprint(&func)として関数ポインタ型で渡せばいい話ではあるのだが、参照渡しが一般化しているC++でいちいち&記号を要求されるのは変な話だ。

さて、総括すると、テンプレート型推論の規則はconstでもvolatileでもreferenceでもない、代入可能な変数型に推定する目的で定義されているのだと考えられる。

std::decayはコピー可能な変数型への変換機能

std::decayもこれと同じ役割を持たせたいのではなかろうか。つまり、テンプレート型推論の働かない状況下で、型推論と同様に格納可能な変数型を判定してくれるような機能がほしいのだ。次のような例を考えてみる。

template <class Type>
struct Test
{
    Test(Type v) : v(v) {}
    Type v;
};
int func(int x) { return x; }
int main()
{
    int a = 1;
    int b[4] = { 1, 2, 3, 4 };
    Test<int> t1(a);//OK
    Test<int[4]> t2(b);//error
    Test<int(int)> t3(func);//error
}

このクラスは、通常のプリミティブ型やクラスを与えることはできるが、関数型や配列を与えてしまうとコンパイルエラーになる。上で見たように、関数型の変数などというものは存在しないし、配列の場合はコンストラクタの初期化で弾かれてしまう。
ただし、std::decayを通せば別である。

template <class Type>
struct Test2
{
    using DecayedType = typename std::decay<Type>::type;
    Test2(DecayedType v) : v(v) {}
    DecayedType v;
};
int func(int x) { return x; }
int main()
{
    int a = 1;
    int b[4] = { 1, 2, 3, 4 };
    Test2<int> t1(a);//OK
    Test2<int[4]> t2(b);//OK
    Test2<int(int)> t3(func);//OK
}

std::decayを通せば普通の値でも配列でも関数でも全てメンバ変数として格納可能な形になるのだ。もちろんこれはメンバ変数として使うときばかりではなくて、引数を受け取るときでもローカル変数を宣言するときでも同様だ。

配列型を引数に渡す機会なんて早々ない、と思われるかもしれないが、案外そうでもない。例えば誰しもよく使うものとしては文字列リテラルがある。

template <class Type>
Test<Type> MakeTest(Type& v) { return Test<Type>(v); }
template <class Type>
Test2<Type> MakeTest2(Type& v) { return Test2<Type>(v); }
int main()
{
    auto t = MakeTest("abc");//error
    auto t2 = MakeTest2("abc");//OK
}

文字列リテラルはchar型配列なので、Test型を使うMakeTest関数ではエラーになる。しかし、std::decayを内蔵するTest2型であれば動作する。つい先日もPoco::Anyに文字列リテラルを渡してコンパイルエラーになるという問題に遭遇したばかりだ。このような機会は案外多い。

以上のように、std::decayは値渡し可能な変数型への変換が目的であり、通常の変数、関数、配列などの型を問わず、何らかの変数として実体化する際にエラーを起こさない目的で使われているのだ、と私は解釈した。


  1. std::decayについて調べているうちに某所で見つけたジョーク。ちょっと面白かった。

  2. 本当に存在しないのかどうかは規格書を読んでいない私にはわからないので注意されたい。