auto によって束縛される変数の型(解答・解説編)

http://d.hatena.ne.jp/gintenlabo/20110220/1298187803

の解答と解説です。



参考規格: 7.1.6.4 auto specifier [dcl.spec.auto] , N3225

解答および簡単な解説

int   f();
int&  g();
int&& h();

int main()
{
  int x0 = 0; // リテラル 0 は int に推論される

  int i = 1;
  int x1 = i; // i は int 型の lvalue

  int const j = 2;
  int x2 = j; // j は int const 型の lvalue

  int a[3];
  int* x3 = a;  // a は int [3] 型の lvalue

  int const b[] = { 1, 2, 3, 4 };
  int const* x4 = b;  // b は int const [4] 型の lvalue

  int x5 = f(); // f() は int 型の prvalue
  int x6 = g(); // g() は int 型の lvalue
  int x7 = h(); // h() は int 型の xvalue

  int (*x8)() = f;  // f は int () 型の lvalue

  std::initializer_list<int> x9 = { 1, 2, 3 };  // { 1, 2, 3 } は braced-init-list
}

N3225 の 7.1.6.4 auto specifier [dcl.spec.auto] より、

auto x = initializer;

という形で定義される変数 x の型は、

template<class T>
void f( T x );

という関数 f に対し、 f( initializer ); という形で関数呼び出しを行った場合に
T が推論される型になる。
ただし initializerbraced-init-list{ 1, 2, 3 } のような形)ならば、
initializerstd::initializer_list<U> 型として扱われる。


よって、 x0, x5 はそのまま int 型に( 0 は文脈によって NULL ポインタや他の算術型になる可能性があるが、テンプレートによって型推論される場合には int として扱われる)、
x1, x2, x6, x7 は lvalue-to-rvalue conversion により同じく int 型に、
x3, x4 は array-to-pointer conversion によりそれぞれ int*, int const* 型に、
x8 は function-to-pointer conversion により int (*)() 型に、
x9 は各要素が int 型の braced-init-list なので std::initializer_list 型に推論される。

別解

auto x = initializer;

という形の変数定義において、 x の型は、 initializerbraced-init-list でない場合には
typename std::decay<decltype( (initializer) )>::type で得ることができる。


ここで、

  • decltype( (0) )int 、故に x0 の型は std::decay::type つまり int
  • decltype( (i) )int& 、故に x1 の型は std::decay::type つまり int
  • decltype( (j) )int const& 、故に x2 の型は std::decay::type つまり int
  • decltype( (a) )int (&)[3] 、故に x3 の型は std::decay::type つまり int*
  • decltype( (b) )int const(&)[4] 、故に x4 の型は std::decay::type つまり int const*
  • decltype( (f()) )int 、故に x5 の型は std::decay::type つまり int
  • decltype( (g()) )int& 、故に x6 の型は std::decay::type つまり int
  • decltype( (h()) )int&& 、故に x7 の型は std::decay::type つまり int
  • decltype( (f) )int (&)() 、故に x8 の型は std::decay::type つまり int (*)()
  • { 1, 2, 3 } は各要素が int 型の braced-init-list なので x9 の型は std::initializer_list

これを元に auto を書き換えると、上記の解答に一致する。

詳細な解説

まず一番最初に理解しておくべきなのは、

auto x = initializer;

という形で変数 x を定義した場合、 x は原則的に「 initializer のコピーになる」ということです:

#include <iostream>
#include <vector>

int main()
{
  int const n = 5;
  auto x = n; // x はコピーされるので、 const は付かない
  ++x;        // なので変更できる
  
  auto const c = n; // const にしたい場合には、明示的に const を付ける
  // ++c; // c は const なので変更できない

  std::vector<int> v(c);
  v.front() = 0;                        // std::vector の front() は、先頭要素への参照を返す
  std::cout << v.front() << std::endl;  // 0
  
  auto y = v.front(); // が、 auto によって変数に束縛された場合、コピーされるので、
  y = 1;              // 束縛先の変数の値を変更しても、束縛元には関係ない
  std::cout << v.front() << std::endl;  // 0
  
  // 束縛元の変数を変更したい場合には、 auto& を用いて「参照である」旨を明示する
  auto& ref = v.front();
  ref = 1;
  std::cout << v.front() << std::endl;  // 1
}

言い換えるなら、

auto x = initializer;

という形で定義された x は、 initializer が参照だったり const/volatile で修飾されていた場合には、
それらの修飾が全て取り除かれた型の変数になります。((勿論、の型がポインタや std::reference_wrapper 等の「参照を値として保持する」型の場合には、深いコピーではなく浅いコピーが行われ、また参照先への const/volatile 修飾はそのまま残ります。))


これはつまり、例え initializer が参照であった場合でも、 xinitializer の参照するオブジェクトとは独立した変数となる、ということで、
こうすることにより、参照を返す関数の戻り値をうっかり auto で受けた結果、参照先を変更する、といったミスを防ぐことが出来るようになっています。


ただし、「原則的に」という表現を用いたように、これには幾つか例外があります。


まず些細な例として、束縛元の式の型が

int f();
auto x8 = f;

のような関数(この例では、関数型 int() の lvalue )の場合には、
そもそも関数というのはオブジェクトではないので、コピーして変数に束縛することが出来ません。
よって、そのような場合には、渡された関数(への参照)から関数ポインタへの暗黙変換が行われ、

int (*x8)() = f;

のように定義されます。(( int (*)() x8 = f; と間違えた人、そう書きたくなる気持ちは痛いほど分かりますが、 C++ の文法的にはアウトなので、その辺りの文法を確認しなおすことを勧めます。))
これは極めて真っ当な動作で、そう言われてみなければ気づかないレベルのものです。


問題は、 initializer の型が

int a[3];
auto x3 = a;

のような配列(この例では、配列型 int [3] の lvalue )の場合で、その場合、
配列の中身はコピーされず、先頭要素を指すポインタへと暗黙変換されて

int* x3 = a;

のように束縛されます。((なお、 std::arraystd::vector 等のクラスは配列ではないので、きちんとコピーされます。))


これの何が問題なのかというと、この場合、束縛元の変数はコピーされないため、

int a[] = { 1, 2, 3 };
auto x = a; // 配列の場合、ポインタに変換され、中身はコピーされないので
x[0] = 0;   // auto によって束縛された変数を介して、束縛元を変更できてしまう
std::cout << a[0] << std::endl; // 0

auto const y = a; // なお悪いことに、 const を付けても
y[1] = 0;         // 束縛元の配列を変更できてしまう
std::cout << a[1] << std::endl; // 0

// この動作を避けるには、
// auto const& z = a;
// z[2] = 0;  // コンパイルエラー
// または
// auto const* z = a;
// z[2] = 0;  // コンパイルエラー
// のように書く必要がある

上記のコードに示すように、うっかり配列内部を書き換えてしまう問題を引き起こす可能性があるのです。


じゃあ何故、配列に限ってはコピーが行われずポインタに変換されるか、というと、この記事の冒頭にも書いたように、

auto x = initializer;

という表現によって推論される変数 x の型は、文法を単純化するため、
initializer{ } で包まれたリストの形式でない限り、

template<class T> void f( T x );

という関数 f を呼び出す際に行われる型推論と同じプロセスで決定されるからです。

なお、これは auto const& や auto* という書き方をした場合でも同じで、順に

template<class U> void g( U const& y );
template<class V> void h( V* z );

という関数テンプレートによって推論される y, z と同じように初期化されます。


また、変数 x が

auto x = { initializer-list };

のように { } で包まれたリストの形で定義されていた場合、
{ initializer-list } は、一旦 std::initializer_list に推論された上で、
後は関数テンプレートの型推論と同じプロセスで決定されます(ここでは auto 単独なので、そのまま std::initializer_list になります)。
なお、関数テンプレート template void f( T x ); に対し、直接
f( {1, 2, 3} ); のように braced-init-list を渡した場合、
コンパイラは型 T を推論することが出来ません。(( gcc では関数テンプレートに直接渡した場合でも std::initializer_list に推論されるのですが、現行の規格ではこのような動作は行われないことになっています(それを受け、 gcc でもコンパイラオプションによって禁止することができるようになっています)。))


C++ の規格では、

template<class T> void f( T x );

という関数テンプレートに対し、実引数として式 expr を与えた場合の型推論は、以下のように行われます:

  • expr の型を U と置く。ただし expr が参照の場合には、 U はその参照を取り除いた型とする
  • U が配列の場合、 TU を array-to-pointer conversion によりポインタに変換した型とする
  • U が関数の場合、 TU を function-to-pointer conversion により、ポインタに変換した型とする
  • U が関数でも配列でもない場合には、 TUconst/volatile 修飾を取り除いた型とする

比較的複雑な型変換ですが、この型変換は標準ライブラリの std::decay で行う事ができ、
T の型を得るには、 decltype と組み合わせて

typename std::decay<decltype((expr))>::type

とすることで得ることができます。


ここで注目すべきは、配列(や関数)が与えられた場合には、それらはポインタに変換される、ということで、
何故かというと、 C++ では、 C との互換性という名の歴史的経緯により、

void f( int x[N] );

という関数 f の宣言は、

void f( int* x );

という関数 f の宣言と全く同じ物であるとして扱われる以上、
テンプレートの型推論においても、その辺りに対する整合性を取る必要があるからで、
このような複雑な事情により、単独の auto を使った型推論では、
配列を渡した場合にはポインタに変換されるようになっているのです。((なお、 auto &auto const& を使った場合には、配列や関数を渡した場合でも、ちゃんと「配列や関数に対する参照」として扱ってくれます。))


このように、 C++0x の言語仕様は、 auto 一つとっても非常にややこしい奥の深いものとなっています。
勿論、実際に auto を使う場合には、こういうルールは殆ど気にする必要は無いわけですが、
興味が湧いたのならば、いろいろと調べてみると楽しいんじゃないでしょうか。