uchan note

プログラミングや電子工作の話題を書きます

C++ autoの使いどころ・使わない方が良い場面

この記事では C++11 から導入された型推論 auto の使いどころ,使うべきでない場面を紹介します. 可読性や保守性の高いプログラムにするために記事が役立てば幸いです. (筆者の考え方が偏っているかもしれません.是非,ご意見ご感想をお寄せください)

結論をまとめると次の通りです.

  • auto を使った方が良いのは
    • 右辺の式から型がはっきりわかる
    • 具体的な型があまり重要ではない
    • ラムダ式を受け取る
  • auto を使うべきでないのは
    • その場所でその型になっていることを保証する
    • 親クラスの参照として受け取る
    • その変数を後でオーバーロードされた関数に渡す

型推論 auto

C++11 から型推論のための auto キーワードが導入されました. auto を使うと具体的な型を書かずにコンパイラに型の決定を任せることができます. 詳しい仕様・使い方は auto - cpprefjp C++日本語リファレンス を参照してください.

簡単な使い方を紹介します.

auto p = std::make_unique<int>(42);
std::cout << *p << std::endl;

このプログラムを実行すると,標準出力に「42」と出力されます.

ここでは std::make_unique の戻り値を格納する変数の型を推論させるために auto キーワードを用いています. 実際に変数 p の型は std::unique_ptr<int, std::default_delete<int>> に推論されます.

推論された型名を見る方法

どんな型に推論されたかを見る簡単な方法は,コンパイル時にエラーを起こしてみることです.

auto p = std::make_unique<int>(42);
decltype(p)::hogehoge;

調べたい型 decltype(p) が関係するエラーをわざと起こします.上記のコードをコンパイルしようとすると,次のようなエラーが出るでしょう.

prog.cc: In function 'int main()':
prog.cc:6:14: error: 'hogehoge' is not a member of 'std::unique_ptr<int, std::default_delete<int> >'
    6 | decltype(p)::hogehoge;
      |              ^~~~~~~~

エラーメッセージを読むと p がどんな型に推論されたのかを正確に知ることができます.便利!

auto の使いどころ

auto を使った方が良いのは次のような場面でしょう.

  • 右辺の式から型がはっきりわかる
  • 具体的な型があまり重要ではない
  • ラムダ式を受け取る

注意)可読性や保守性というものは 0 と 1 の世界ではなく,主観も入り混じった人間臭い世界です. そのため,絶対というルールはなく,その場その場で適切と思われるやり方を模索していくことが大切です. 以下で紹介する話は目安であり,そのまま適用すればどんな場合も上手くいくわけではありません.

右辺の式から型がはっきりわかる

先の例にも出てきた std::make_unique や,イテレータを得る .begin() などは戻り値の型がはっきり分かります. std::make_unique は unique を make すると言っているのだから,そりゃもう unique_ptr 以外が返ってきたら関数の実装がおかしいです. .begin()イテレータが戻ってくることは(C++ プログラマなら)誰でも知っているわけです.

型がはっきりわかるのであれば,長ったらしい型名を書くより auto で推論させた方がソースコードは短くなり,可読性が上がります. これは明示的なキャストを行う場合も同様です.

uint64_t addr = ...;
auto reg = reinterpret_cast<SetupStageTRB*>(addr);

ハードウェアのドライバなどを書いていると,整数とポインタのキャストは日常的に出てきます. 整数とポインタは暗黙的なキャストは行われませんから,上記のように明示的にキャストを行う必要があります. この場合,右辺の型はこれ以上ないほどはっきりしていますし,型名が長いので,auto の絶好の使用ポイントとなります.

具体的な型があまり重要ではない

その値の意味さえ分かれば具体的な型が必要ないような場合もよくあります. 例えば,配列などの「サイズ」は,サイズを表す何らかの値であることが分かりさえすれば,具体的な型(符号の有無,ビット数など)は不要な場合があります.

auto size = obj.size();

また,上記のプログラムで下手に int size などとしてしまうことで,obj.size() が予想以上の大きさだった場合にオーバーフローを引き起こします. しかもそのバグはテストでは隠れていて,大量のデータが流れる本番環境で顕在化するのです. size が負値になってしまった場合の挙動はとても不可解になること請け合いです.

型推論には,関数を定義した人,すなわち実装をよく知っている人が指定した「そのデータを扱うのに最も適する型」に自動的に従う,という効果もあることが分かります.

ラムダ式を受け取る

ラムダ式を受け取る変数の型には auto を使った方が良いというより,auto が必須です. なぜならラムダ式の型をプログラマが手書きすることはできないからです.

int i = 42;
auto add = [&](int a){ i += a; };
add(3);

auto の代わりに std::function<void (int)> と書くことは可能ですが,ここで言いたいのは「ラムダ式の型を書き下すことはできない」ということです.

auto を使うべきでない場面

逆に,auto を使わず,具体的な型を書き下した方が良い場面もあります.

  • その場所でその型になっていることを保証する
  • 親クラスの参照として受け取る
  • その変数を後でオーバーロードされた関数に渡す

その場所でその型になっていることを保証する

これは,型を不変条件(invariant)として使うということです. 具体的な型を書いておくと,その地点では必ずその型になっていることを保証できます.

int a = foo(bar(), baz());

型を書き下したので,変数 a は必ず int 型になります. 将来,関数 foo の実装が変更され,なぜか数値ではない値(std::string など)が返されるようになると,上記の部分でコンパイルエラーが発生します. 型によって,プログラムのある種の不変条件を記述できているということですね.

静的型付き言語における型は,コンパイラソースコードを静的に解析することで正しさを検証するためのものという側面があります. 悲しいことに C++コンパイルできたとしても多くの「未定義動作」が残ってしまう言語であり,「型が保証する正しさ」は一部分だけです. それでもせっかく備わっている型ですから,効果的に使っていきたいですね.

親クラスの参照として受け取る

継承関係のある子クラスのインスタンスを,親クラスの参照を介して操作したい場合を考えます. この場合,親クラスの型を書き下すことで無駄なキャストをしなくて済みます.

SubClass sub_obj;
BaseClass& obj = sub_obj;

親クラスの参照(またはポインタ)を介して操作することで,親クラスの公開インターフェースしか使っていないことを保証しやすくなります. 後で子クラスを入れ替えてもそのまま動くようにしたい,という場合は子クラス固有のインターフェースに依存しないことが重要です.

auto& obj = sub_obj; のように書いてしまうと obj の型は SubClass& に推論されてしまいます.

その変数を後でオーバーロードされた関数に渡す

型推論はとても便利ですが,右辺の型が変わると推論結果が変わるという特徴が危険をもたらすことがあります. 型推論オーバーロードを組みわせた場合です.

f(int);
f(double);

auto hoge = ...;
f(hoge)

関数 f が引数の型が違うだけの複数のオーバーロードを持つ場合,変数 hoge型推論の結果によって複数の実装が呼び分けられることになります. オーバーロードされた関数にバグが無く,どのオーバーロードが呼ばれてもプログラマが驚かない結果になるのであれば問題はありません. (我々は普段から std::cout << などと書いて,operator <<オーバーロードに頼りまくっていますよね.)

しかし,その関数にバグが残っているとデバッグが辛いものになります. 知らないうちに呼び出されるオーバーロードが切り替わってしまった場合を想像してください. プログラマが「こっちが呼ばれるはずだ」と思っている関数は,今やもう呼ばれず,隣の関数が呼び出されている状況. これほど時間をつぶすのにちょうどよい遊びもないでしょう.