&size(30){&color(darkblue,#c0c0ff){''C++11がゲームプログラミングにもたらすもの''&br;};}; #counter *はじめに [#uab6e7b1] ゲームプログラミングの開発環境が変化してきました。~ かつてアセンブラやCで書かれていたゲームプログラムは、C++での開発が主流になったといえるでしょう。~ C#やJavaでのゲームプログラミングも現実的になってきましたが、本格的なゲーム開発はC++が多数派だと思います。~ なぜならば、ゲームは処理速度、メモリ効率、レスポンス、いずれも高い次元で動作することが要求されるからです。 C++11の登場で、C++でのゲームプログラミングも大幅に進化しました。~ それは、アセンブラからCへ、CからC++へと変遷していったときと同じぐらい、大きなインパクトがあります。 C++11により、C++のウイークポイントが解消され、実行速度、メモリ効率、開発効率、ともに大幅な改善がされたためです。 実際にゲーム開発でC++11を本格的に導入して2年ほど経過しましたので、 C++をゲームプログラミングで効率的に使う方法をまとめてみました。 ----- * もくじ [#o69f245d] - [[Chap.1 moveの活用>#se0884ad]] -- [[1-1 moveのおさらい>#w470c8e3]] -- [[1-2 moveコンストラクタとmove operator = を実装>#e79b2ab6]] -- [[1-3 moveの落とし穴(地雷)>#d5297a36]] --- [[1-3-1 std::swapの違い>#u367b8ee]] --- [[1-3-2 move後のクラスオブジェクトへのアクセス>#z2f97230]] --- [[1-3-3 継承クラスのmoveコンストラクタ>#k34d6cf5]] --- [[1-3-4 VisualStudioはmoveコンストラクタを自動生成してくれない>#v2b4d8f6]] - Chap.2 ラムダ式の活用 -- 2-1 コールバックの活用 -- 2-1 キャプチャの注意点 - Chap.3 コピーコンストラクタの廃止 -- 3-1 コピーコンストラクタをmoveコンストラクタに置き換える -- 3-2 shared_ptrをunique_ptrに置き換える - Chap.4 応用編 描画ループの処理 -- 4-1 1/60秒の間隔で呼ばれるメインループ -- 4-2 Threadもco-routineも使わないマルチタスク的処理 -- 4-3 タスクマネージャの紹介 ----- * CHAPTER-1 moveの活用 [#se0884ad] moveの活用による恩恵は計り知れません。 とくに、速度とメモリ効率、そしてデバッグ効率を重視するゲームプログラミングにおいて、 moveセマンティクスの導入は大きな効果がありました。 なお、moveに関して十分な知識のある方ば、Chap.1は読み飛ばしてください。 ----- ** 1-1 moveのおさらい [#w470c8e3] moveを使う前に、右辺値参照について知っておく必要があります。 このプログラムを見てください。 #sh(cpp){{ string a = "1"; string b = "2"; string c = a + b; // ~~~~~ // 右辺値 }} この"a + b"の部分が右辺値です。~ 右辺値とは、名前のない一時的に生成されるオブジェクトのことです。~ この場合、string型で値が"12"の一時オブジェクトが生成されます。 わかりやすくC++03の書式で置き換えると、 #sh(cpp){{ string a = "1"; string b = "2"; string tmp(a + b); string c = tmp; }} このような動作になります。~ tmpは式の外では不要になる、一時オブジェクトです。 さて、最後の"c = tmp"で行われるコピーが無駄な動作ということは明白ですね。~ C++03では、右辺値を変数に代入する時点で、コピーが発生してメモリと処理速度の無駄が発生していました。 コピーの無駄を省くにはtmpをcにエイリアスしてしまえはば解決しますが、 #sh(cpp){{ string& c = a + b; }} これはエラーになります。"a + b"は右辺値なので、参照型として使うことができません。 C++03では、メモリ上の何処かに生成された一時オブジェクトを、式の外へ持ち出す手段がありませんでした。 C++11では、右辺値参照という新しい機能が追加されました。 #sh(cpp){{ string&& c = a + b; }} これで、C++03で記述する以下の動作とほぼ等しくなります。 #sh(cpp){{ string tmp = a + b; string& c = tmp; }} C++03では、右辺値として生成されたオブジェクトを使う場合、いったんコピーする必要がありました。 では、moveはどこでつかうかというと、 #sh(cpp){{ string&& c = a + b; }} とするかわりに、 #sh(cpp){{ string c = move(a+b); }} とすることで、"a + b"の一時オブジェクトをcに移動することが可能になります。 最初の例と大きな違いがないように見えますが、前者は(右辺値)参照、後者は移動(move)という違いがあります。 一般的には、moveのコストはcopyよりもずっと小さく、stringならばバッファのポインタとサイズをコピーするだけで終わります。 メモリ上にアロケートされた実体はコピーされずにそのまま使われます。 なお、上記の例はわかりやすくするために move(a+b)と書きましたが、a+bは明らかに右辺値なのでmoveは省略できます。 moveを明示的に使うのは、左辺値を右辺値に変換するときに使用します。 #sh(cpp){{ string a = "1"; string c = move(a); // cにaのインスタンスが移動する。 // これ以降はaにアクセスしてはならない。 // aは、ヌケガラ、デガラシ、捨てられたバナナの皮のようなもの。 // アクセスすると、未定義動作の洗礼を受けることになる。 }} この例だと、aをcに移動させているだけで、なんのメリットもないコードです。 しかし、moveは後述するコンストラクタや代入演算子で必要になります。 ----- *** 1-2 moveコンストラクタとmove operator = を実装 [#e79b2ab6] 下記のプログラムは、C++03とC++11では動作が大きく異なります。 #sh(cpp){{ string c = a + b; }} 先ほど解説したとおり、C++11では、"a + b"の一時オブジェクトはcにmoveされます。 なぜmoveされるのか? それは、stringにmove代入演算子とmoveコンストラクタがあるからです。 もし、自前のクラスで、moveコンストラクタやmove代入演算子が定義されていなかったら、moveされません。 #sh(cpp){{ Hoge a = 1; Hoge b = 2; Hoge c = a + b; }} この、3行目の"c = a + b"の動作は、Hogeクラスにmoveコンストラクタが実装されているか否かできまります。 http://melpon.org/wandbox/permlink/7v7e0TFsorASaOzQ Hogeにmove代入演算子がなくても、ビルドはとおります。プログラムは何事もなかったかのように、COPYを行ってゆっくりと動作するでしょう。 目に見えにくいので注意が必要です。 moveを使うには、moveコンストラクタとmove代入演算子を定義する必要があります。 #sh(cpp){{ struct Hoge { string str_; Hoge(const Hoge& hoge) : str_(hoge) {} // コピーコンストラクタ Hoge(Hoge&& hoge) : str_(move(hoge)) {} // moveコンストラクタ Hoge& operator = (const Hoge& hoge) { str_ = hoge.str_; return *this; } // copy代入演算子 Hoge& operator = (Hoge&& hoge) { str_ = move(hoge.str_); return *this; } // move代入演算子 }; }} これでOKです。~ インスタンスに対する操作が標準的な実装ならば、以下のように省略することができます。 #sh(cpp){{ struct Hoge { string str_; Hoge(const Hoge& hoge) = default; Hoge(Hoge&& hoge) = default; Hoge& operator = (const Hoge& hoge) = default; Hoge& operator = (Hoge&& hoge) = default; }; }} http://melpon.org/wandbox/permlink/OHFJkkmV7XZllqL7 ----- ** 1-3 moveの落とし穴(地雷) [#d5297a36] moveを活用することで速度とメモリ効率を同時に向上できますが、いくつか落とし穴があります。 実際に私が踏んでしまた地雷について解説します。 ----- *** 1-3-1 std::swapの違い [#u367b8ee] C++03でのstd::swapは、以下のような実装でした。 #sh(cpp){{ template<typename T> void swap(T& a, T& b) { T tmp = a; a = b; b = tmp; } }} このように、代入を3回行うことでaとbを入れ替えています。~ これが、C++11になると以下のような実装に変わりました。 #sh(cpp){{ template<typename T> void swap(T& a, T& b) { T tmp = move(a); a = move(b); b = move(tmp); } }} コピー3回から、move3回に変わっています。~ 通常のクラスなら気にすることはないのですが、バッファやリソースの管理を行っているクラスで、コピーやswapに特殊な操作が必要な場合は要注意です。 std::swapは、コピーコンストラクタの有無、moveコンストラクタの有無、代入演算子の有無などで、以下のような動作になります。 | |copy constructor|move constructor|operator= const&|operator= &&|copyされる回数|moveされる回数| |C++03 | なし | なし | なし | なし |CENTER: 3回 |CENTER: 0回 | |C++11 | なし | なし | なし | なし |CENTER: 0回 |CENTER: 3回 | |C++11(VS2013)| なし | なし | なし | なし |CENTER: 3回 |CENTER: 0回 | |C++11 | both | なし | both | なし |CENTER: 3回 |CENTER: 0回 | |C++11 | both | あり | both | あり |CENTER: 0回 |CENTER: 3回 | |C++11 | あり | あり | both | なし |CENTER: 2回 |CENTER: 1回 | |C++11 | あり | なし | both | あり |CENTER: 1回 |CENTER: 2回 | C++03で書かれたソースをビルドすると、コピーコンストラクタが無いクラスは、moveされ、copyコンストラクタが定義されているクラスはコピーされます。~ (VisualStudioではいずれもコピーになります) C++11では、moveコンストラクタとmove代入演算子をセットで定義しておかないと、std::swapが期待通りの動作をしてくれないようです。 ----- *** 1-3-2 move後のクラスオブジェクトへのアクセス [#z2f97230] 以下のプログラムは、何の問題もなく動くはずでした。すくなくともC++03までは。 #sh(cpp){{ void foo(string&); void func() { string a = "hoge"; foo(a); cout << a << endl; } }} しかし、moveセマンティクスが導入されたC++11では、foo()の実装次第では安全でなくなります。 #sh(cpp) {{ void foo(string& a) { string b = move(a); // ここでaはヌケガラとなる } void func() { string a = "hoge"; foo(a); // fooの中でaが破壊される cout << a << endl; // 未定義動作 } }} これを回避するには、関数のAPIをしっかりと設計するしかありません。 参照で受け取った引数を破壊するような関数は設計しないほうが無難です。 この問題のやっかいなところは、ヌケガラとなった変数にアクセスしても、「それなりに」動作してしまうことです。 上記のプログラムも、何事もなかったかのように動作してしまいます。 標準ライブラリのmoveされたオブジェクトへのアクセスは、ランタイムエラーにしてほしいところです。 ----- *** 1-3-3 継承クラスのmoveコンストラクタ [#k34d6cf5] 以下のプログラムは、潜在的な問題を含んでいます。 #sh(cpp){{ struct BaseClass { string baseStr_; BaseClass(BaseClass&& bc) : baseStr_(move(bc.baseStr_)) {} }; struct SubClass : BaseClass { string subStr_; SubClass(SubClass&& sc) : BaseClass(move(sc)) , subStr_(move(sc.subStr_)) {} }; }} 一見して問題のなさそうなプログラムですが、SubClassのmoveコンストラクタが正しく動作しないことがあります。~ 問題はココ #sh(cpp){{ SubClass(SubClass&& sc) : BaseClass(move(sc)) }} BaseClassに、SubClassの引数であるscをmoveし手渡しているところです。~ このmoveにより、scのインスタンスはBaseClassの引数に「移動」します。 baseStr_は良いのですが、subStr_も一緒に移動してしまい、受け取り手がいないので闇に葬られてしまいます。 SubClassの次の行で、 #sh(cpp){{ , subStr_(move(sc.subStr_)) {} }} としていますが、このときすでにscはヌケガラなので、subStr_も空っぽです。 したがって、このプログラムは意図した動作をしない可能性があります。 「可能性があります」と書いたのは、実はこのケースはほとんどの場合、意図した動作をします。 BaseClassのmoveコンストラクタの引数の評価が遅延されると、subStr_のmove動作は行われません。 引数の評価のタイミングに依存したコードとなっています。 実際に試したところ、単純なプログラムでは正常に動作しましたが、比較的大きなクラス&ライブラリ化などのいくつかのステップを踏むことで、 BaseClassのmoveコンストラクタに渡すmoveでSubClassのsubStr_が消失してしまう現象を確認しています。 同じプログラムが環境によって動作したりしなかったりは、デバッグを困難にする困った問題です。 予防的な意味でも、下記のようにベースクラスのmoveコンストラクタを呼ぶ場合はキャストしたほうが良いでしょう。 #sh(cpp){{ struct SubClass : BaseClass { string subStr_; SubClass(SubClass&& sc) : BaseClass(move(static_cast<BaseCLass&&>(sc))) , subStr_(move(sc.subStr_)) {} }; }} ----- *** 1-3-4 VisualStudioはmoveコンストラクタを自動生成してくれない [#v2b4d8f6] 以下のようなクラスがあります。 #sh(cpp){{ struct Hoge { string name_; }; }} このHogeというクラスには、コンストラクタ、コピーコストラクタ、代入オペレーターなどが自動生成されます。 C++11になり、moveによる代入オペレーターも自動生成されるようになったのですが、VisualStudio 2013は現時点でmoveコンストラクタおよび代入オペレータを生成してくれません。 以下のプログラムを実行した場合、 #sh(cpp){{ Hoge foo() { Hoge hoge; return hoge; } Hoge hoge; hoge = foo(); }} GCCやClangでは、moveコンストラクタと代入演算子が自動生成され、Hogeのインスタンスであるstring name_はmoveされます。~ ところが、VisualStudio 2013で同じプログラムを実行すると、string name_は関数foo()のreturn時にコピーコンストラクタによりコピーされ、 hoge = foo(); でコピー代入演算子によりコピーされます。つまり、stringが2回もコピーされる結果となります。 VisualStudioでは、下記のように明示的にmoveコンストラクタとmove代入演算子を定義する必要があります。 #sh(cpp){{ struct Hoge { string name_; Hoge(); Hoge(Hoge&& h) : name_(move(h.name_)) {} Hoge& operator = (Hoge&& h) { name_ = move(h.name_); return *this; } }; }} http://melpon.org/wandbox/permlink/jBdMQWZLwuwOAzqP ----- * Chap.2 ラムダ式の活用 [#b1ba22c5] ラムダ式の導入は、C++11での最大のトピックといっても過言ではありません。~ 特に、処理が複雑になりがちなゲームプログラミングにおいて、ラムダ式の活用はコード量を削減し、 パフォーマンスを犠牲にすることなくバグの出にくいプログラムが可能になりました。 本章ではゲームプログラミングにおけるラムダ式の活用例を紹介いたします。 ----- ** 2-1 コールバックの活用 [#b7b82b67] C++03でも、boost::lambdaを活用することで、効率的なプログラミングが可能でした。~ しかし、C++11でのラムダ式の導入は、従来のアプローチをはるかに超える便利さがあります。 たとえば、ボタンを押されたら指定された関数を呼び出す処理を考えてみましょう。 ボタンが押されたかの判定は、bool buttonClicked(); という関数を使用します。 #sh(cpp){{ template<typename Func> void buttonCheck(Func f) { if (buttonClicked()) f(); } }} これでOKです。たとえば、ボタンが押された時に、MyClass::playSound(123); という関数を呼び出す処理を行いたい場合、 #sh(cpp){{ // 呼び出し時 buttonCheck(bind(&MyClass::playSound, this, 123)); }} と書けばOKです。ちょっとわかり辛いですが、なんとか1行でかけました。~ (C++03のときは、boost::bindのお世話になりました) では、playSound(123)のあとにつづけて、playSound(124)を実行して、音を2回鳴らすように改造するにはどうすれば良いでしょう? buttonCheckに渡すのはあくまでも「関数のポインタ」なので、手続きを関数のポインタに変換することはできません。 仕方がないので、以下のように書きます。 #sh(cpp){{ class MyClass { ... void playSound2(int a, int b) { playSound(a); playSound(b); } ... buttonCheck(bind(&MyClass::playSound2, this, 123, 124)); }} MyClassにメソッドを追加することになります。 単独の関数でも構いませんが、いずれにしてもbuttonCheckを呼び出す場所と離れたところに処理を記述する必要がありました。 ラムダ式を使うと、以下のようにシンプルにかけます。 #sh(cpp){{ buttonCheck([this]{ playSound(123); playSound(124); }); }} とても簡単に書けるようになりました。この、「簡単でわかりやすい」というのがラムダ式を使う大きなメリットなのです。 従来は、コールバックを要求するAPIに対して関数を追加して対応していましたが、ラムダ式を使うとコードサイズをぐっと減らすことができます。 ----- ** 2-2 遅延評価 [#wcae6356] ラムダ式をうまく使うことで、値の評価を実際に使うときまで引き延ばすことが簡単にできるようになりました。 ここに、引数を元になんらかの計算を行う tryCalculationという関数があるとします。 ただし、毎回計算を行うわけではなく、needCalculation()がtrueのときだけ計算する関数です。 この関数を、乱数を計算するrand()というメンバ関数を使って呼び出してみます。 #sh(cpp){{ // needCalculation()がtrueのときに、引数をもとに処理を行う関数 void tryCalculation(int value) { if (needCalcucation()) { doCalculatoin(value); } } struct MyClass { int rand(); ... void func() { tryCalculation(rand()); // ここでrand()が呼ばれて乱数が生成される } }; }} 特に問題のないプログラムですが、計算を実行する必要がないときにもrand()による乱数生成の処理を行ってしまうのが欠点です。 必要なときだけ乱数の計算を行うように修正すると、 #sh(cpp){{ // needCalculation()がtrueのときに、引数をもとに処理を行う関数 void tryCalculation(function<int()> func) { if (needCalcucation()) { doCalculatoin(func()); } } struct MyClass { int rand(); ... void func() { tryCalculation([this]{return rand()}); // rand()の実行は、doCalculationまで遅延される } }; }} このように、ほんの少しの修正で遅延実行の処理を書くことができます。 パフォーマンスを重視するゲームにおいて、遅延実行は大きな効果を発揮します。 ----- ** 2-3 ラムダ式の注意点 [#y96adffe] ----- *** 2-3-1 キャプチャのパフォーマンス [#ycae0541] ラムダ式には、任意の変数を渡すことができますが、渡し方によりオーバーヘッドが発生します。 &による参照渡しの場合はポインタとして4バイトないし8バイト、実体渡しの場合は変数やクラスのサイズ分のメモリが確保され、コピーされます。 大きなクラスオブジェクトやコピーにコストがかかるものは、コピー渡しにしないほうがよいでしょう。 "="の記述のみによるキャプチャは、思わぬオーバーヘッドを見落としてしまうので、できるかぎり自動にしないで変数を列挙するようにしています。 C++11では、コピー不可のオブジェクトを実体として渡すことはできません。参照として渡すことになるので、生存期間の管理が必要です。 ※C++14からはmoveによるキャプチャがサポートされる予定です。 ----- *** 2-3-2 shared_ptrのキャプチャ問題 [#kba68e9e] 参照によるキャプチャは寿命の問題があるため、解決方法としてshared_ptrを使うことが考えられます。 ただし、shared_ptrをそのままキャプチャすると、参照カウントの問題が発生します。 下の例では、MyClassの参照カウントはゼロにならず、ゾンビオブジェクト化してメモリリークを引き起こします。 #sh(cpp){{ struct MyClass { function<void()> callback_; ... void hoge(); }; ... // shared_ptrのインスタンスを作成 auto myptr = make_shared<MyClass>(); // callback_関数に、自分のshared_ptrをキャプチャしてラムダ式を登録 myptr->callback_ = [myptr] { myptr->hoge(); } // ~~~~~ // このmyptrは、callback_にコピーして保存されるため、MyClassは自身の参照を保持することになりゾンビとなる。 }} 単独の循環参照なら比較的気がつきやすいですが、多くのクラスを介した三つ巴の循環参照を引き起こす場合は発見が困難です。 しかも、プログラムはメモリを食いつぶしながらも正常に動作してしまうので、発見が遅れる困ったバグとなります。 この問題は、weak_ptrを利用することで解決できます。 #sh(cpp){{ ... // shared_ptrのインスタンスを作成 auto myptr = make_shared<MyClass>(); // callback_関数に、自分のweak_ptrをキャプチャしてラムダ式を登録 weak_ptr<MyClass> wptr = myptr; myptr->callback_ = [wptr] { if (auto p = wptr.lock()) p->hoge(); } // ~~~~~ // werk_ptrは自身で保持しても循環参照とならない }} 一行増えてしまいますが、shared_ptrをキャプチャする場合は必ずweak_ptrを利用することをお勧めします。 C++14の場合は、以下のようにかけるので便利です。 #sh(cpp){{ ... // shared_ptrのインスタンスを作成 auto myptr = make_shared<MyClass>(); // callback_関数に、自分のweak_ptrをキャプチャしてラムダ式を登録 myptr->callback_ = [wptr = weak_ptr<MyClass>(myptr)] { if (auto p = wptr.lock()) p->hoge(); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // c++14ではこのように書ける }} ----- * Chap.3 コピーコンストラクタの廃止によるパフォーマンスアップ [#k512d924] ----- ** 3-1 コピーコンストラクタをmoveコンストラクタに置き換える [#sd4fcc08] ----- ** 3-2 shared_ptrをunique_ptrに置き換える [#j6d280b8] ----- * Chap.4 応用編 描画ループの処理 [#t0585e51] ----- ** 4-1 1/60秒の間隔で呼ばれるメインループ [#k6a2857f] たとえば、a(), b(), c() という処理に比較的時間のかかる3つの関数を順番に実行する手順を考えてみましょう。 #sh(cpp){{ void func() { a(); b(); c(); } }} このようにプログラムが書ければベストです。 しかし、ゲーププログラミングは、通常ですと60分の1秒の間隔で入力やレンダリングの処理が必要です。 #sh(cpp) {{ void func() { update(); // 入力やゲーム処理 render(); // 描画処理 a(); b(); c(); } }} これだと、a(),b(),c()すべての処理が終わるまで描画が止まってしまい、ゲームになりません。 つまり、入力や描画の処理と、a(),b(),c()の処理を非同期に行う必要があります。 非同期処理には、スレッドを使う方法と、処理をフレーム単位に分割して行う方法があります。 ** 4-2 Threadもco-routineも使わないマルチタスク的処理 [#n33073b3] ** 4-3 タスクマネージャの紹介 [#e5377937]