以前、range-based for loopに非配列変数の一覧を与えるためのHoldRefArray関数を実装していた時、std::common_typeを使うことはできないかと考えたことがある。のだが、これがどうも“共通して変換可能な型”という説明から連想される結果と今ひとつ合致しないような気がして、その挙動を詳しく調べてみた。
std::common_type_tの挙動
std::common_type<Ts...>は、与えられたTs全てに共通する最大公約数的な型を取り出すものである(と私は思っていた)。例えば次のような結果になる。
class Base {}; class Derived : public Base {}; std::common_type_t<int, char>;//int std::common_type_t<int, const int>;//int std::common_type_t<std::string, const char*>;//std::string std::common_type_t<void*, int*>;//void* std::common_type_t<int[]>;//int* std::common_type_t<int*, const int*>;//const int* //std::common_type_t<std::vector<double>, double>;エラー。両者が共通してキャストできる型はない std::common_type_t<Base*, Derived*>;//Base*
だいたいどんな理屈で成り立っているかは分かるだろう。確かに、両者に共通する型を返しているようだ。
さて、上の例で私は、敢えて参照型を一つも書かなかった。それが今回の肝なのだ。そして、何故このような仕様になっているのかと不思議に感じている。以下の例を見てもらいたい。
std::common_type_t<int&>;//int std::common_type_t<int&, int&>;//int std::common_type_t<int&, const int&>;//int std::common_type_t<Base&, Derived&>;//Base
色々とテストをして調べていくうちにこの挙動に行き着き、私の中で疑問符が噴出した。何故かstd::common_type_tを通すと参照やconstが引っ剥がされるのである。もし変換先が最大公約数的な型であるのなら、int&の最大公約数はint&であるべきだし、int&とconst int&の共通型はconst int&になるのではないかと思ったのだ。実際は、そうではなかった。
原因
何故このような挙動になっているのかを詳しく説明しよう。
std::common_typeの実態は、三項演算子とstd::decayである。std::common_type<Ts...>のTsについて、三項演算子によって共通型を取り出す。その後、その共通型はstd::decayを通される。
std::decayとは簡単に言えば、
- CV修飾と参照を引っ剥がす。
- 配列型はポインタ型に変換する。
- 関数型は関数ポインタ型に変換する。
の三つの動作を複合したものである。この時点で上記の参照型が消失する。冒頭の例でconst int*のconstが剥がれていないじゃないかと反論されるかもしれないが、これは別に間違っていない。ここで引っ剥がされるconst修飾とは、アスタリスク(*)の右側に付与されたものに限定されるのだ。もしint* constという型を与えたのなら、int*が返ってくる1。
さて、上述の参照型の消失の正体はわかった。std::decayの影響だ。何故そんな方法を取るのかはまだ理解できていないが、とにかくconstも参照もかき消されてしまうことは分かった。
とても細かいことを言えば、原因はstd::decayだけではない。三項演算子にも実は参照型を引っ剥がす場合がある。例えば次の例だ。
const int& a = 1; const int& b = 2; const float& c = 3.; decltype(true ? a : b);//const int& decltype(true ? a : c);//float
一般にintとfloatは相互にキャストできるが、これはC++が暗黙的なキャストを行うように作られているからで、例えばconst int&型の変数にfloat型変数を代入することは出来ない。このように、ある共通の参照型にキャストできない場合は、当たり前ではあるが参照型は消えてしまう。とはいえこれは特殊な状況ではあるし、真っ当なプログラマーなら5行目で参照型を受け取ろうなどと期待しないだろう。
この振る舞いに至った経緯
stackoverflowで見つけた議論によれば、このような定義になってしまった原因はstd::declvalにあるそうだ。
template <class A, class B> struct common_type<A, B> { using type = decltype(true ? declval<A>() : declval<B>()); };
ここで、declvalはどちらもrvalue reference(A&&、B&&)を返す。となると、上の実装ではcommon_type<int, int>::typeがint&&型になってしまう。そこでその対処としてstd::decayを付け加えることが提案されたらしい。
template <class A, class B> struct common_type<A, B> { using type = decay_t<decltype(true ? declval<A>() : declval<B>())>; };
……何だその怠慢な理由は、と思わずにいられない。もっとちゃんと実装すれば直感のとおりに動作するものが出来上がりそうなものだが。