コンパイラによって動作が変わるコード
C++ の規格・仕様というのはちゃんと決められていて各コンパイラはそれにそって実装されています。・・・と言い切りたいのですが、実装してくれていないコンパイラも中にはいます。
今時のコンシューマゲーム機用コンパイラやPC&スマートデバイス用のコンパイラはだいたい C++11 に対応し、コンパイラによって大きく動作が変わるものも減りつつあります。ですが、マルチプラットフォーム対応を考えたとき、少しおかしなコンパイラの存在を意識し、そういったコンパイラでも意図した動作をするようなコードを書きたくなることがあります。
今日はそんなコードの一例を紹介したいと思います。
Pod なメンバ変数の初期化
Pod(Plain Old Data)はC言語でも表現できるようなシンプルな構造体のことです。
参考:http://en.cppreference.com/w/cpp/concept/PODType
例えば、こんなコードがあったとします。
struct Vec2
{
float x;
float y;
};
class Weapon
{
public:
int power;
Vec2 velocity;
Weapon()
: power(0)
, velocity()
{
}
};
void func()
{
Weapon obj;
std::printf("vel: %f¥n", obj.velocity.x);
}
ここで func() を呼び出したとき、今時のコンパイラなら vel: 0
が出力されます。
ですがコンパイラによっては未初期化になることがあります。C++ の仕様としてはNGの動作という認識です。昔の C++ の規格では動作が決まっていなかったんですかね?
ということで、Pod のデータを初期化するときはこういうコードを私は書くことが多いです。
template<typename TPodType>
struct PodInitData
{
/// テンプレートで指定した型をゼロ初期化したものを作成して返す。
static TPodType Create()
{
TPodType tmp = {};
return tmp;
}
};
class Weapon2
{
public:
int power;
Vec2 velocity;
Weapon()
: power(0)
, velocity(PodInitData<Vec2>::Create()) // 絶対ゼロで初期化される。
{
}
};
}
コピー渡しをするのでちょっと無駄な処理が走ってしまいますが、この書き方による処理負荷増がそんなに問題になることがないのと、コードの意味を第3者が比較的想像しやすいのでこういう書き方をしています。
引数の評価順
関数の引数の評価順はコンパイラによって異なります。
引数の評価というのは何か。例えば関数の引数に、別の関数の戻り値だったり、コンストラクタの呼び出しだったり、何かしらの処理の結果を渡す場合がありますよね。それを処理することを評価と言います。
つまり評価順というのは、どの関数の引数から処理をしていくか、ということです。だいたい後ろから評価されるものが多数派ですが、ときどき第1引数から評価していくものもあります。
int getInt()
{
static int var = 0;
int result = var;
++var;
return result;
}
void printInt2(int aA, int aB)
{
std::printf("A:%d B:%d¥n", aA, aB);
}
void func()
{
printInt2(getInt(), getInt());
}
}
このコードを実行すると、 A:0 B:1
と表示されるコンパイラもあれば A:1 B:0
と表示されるコンパイラもあります。これは C++ の仕様で評価順について「こうしなさい!」と決められていないため、コンパイラによって変わってしまうことはしょうがないことだそうです。
その現実を踏まえまして。どんなコンパイラでも A:0 B:1
と表示されるような結果を求めるのであれば次のような書き方をすると確実です。
void func()
{
// 複数の文(セミコロン)で分けられているものは
// 書かれた順番に評価されるというC++の仕様があるため
// 一度変数として保持しておくと大丈夫。
const int argA = getInt();
const int argB = getInt();
printInt2(argA, argB);
}
これが問題になる場合は主にマルチプラットフォーム対応です。1つのプラットフォームでしか対応しなくていいのであればコンパイラの動作が一定になるので問題ありません。複数のプラットフォームの場合で、かつ評価順が変わることで副作用となるコードが書かれていると「プラットフォームAでは大丈夫なのにプラットフォームBではバグが出た!」といったことが起こります。
更にマルチプラットフォーム間で通信するゲームの場合は更に注意が必要です。通信しないスタンドアローンなゲームよりも問題が出る場合が多く、問題が起きたときの調査も難しくなります。
コンパイラの動作に振り回されにくくする方法
今回は2つの例を紹介しましたが、こんな C++ の癖をチームのプログラマ全員が把握し常に注意し続けることは正直生産性が良いとは言えません。
この問題を完全には解決できるわけではないのですが、軽減する1つの方法について話をします。
例えば、たいがいのゲームプログラムはシステム層とゲームロジック層というものに分けられます。
ここでいうゲームロジック層は3Dアクションゲームでしたらキャラクタやギミックの挙動・AIの実装といった直接的なゲーム体験を実現するコードの部分を指します。そして、システム層はゲームロジック層を動かすための共通コードだったり土台となるコードの部分を指します。
ゲームプロジェクトにおいて、システム層よりもゲームロジック層を作るプログラマ(スクリプタ)のほうが人数が多かったり、書かれるコードの量も多くなりがちです。そしてその人たちの生産性を上げることはとても重要になります。
そのゲームロジック層を C++ ではなくスクリプトで実装できるようにする、というのが1つの対策方法です。
スクリプトを書く人は C++ の細かい仕様を熟知する必要も無く、今回紹介した事例や未初期化が原因のよく分からない不具合も発生しない優しい世界に閉じられます。そのため C++ コンパイラの仕様に振り回されることも激減しますし、ゲームロジック層を量産する人たちは作るモノのほうにより意識を注力できることになるでしょう。
余談:テンプレート構文について
コンパイラによってテンプレート型やテンプレート関数で動作が変わることはほとんど経験したことはありません。
その代わり、コンパイルが通る・通らないといった違いはよく遭遇します。Aというコンパイラでは大丈夫だがBというコンパイラではNG、といった感じです。
おわり
今回は2つの例を紹介しましたが他にもある気がしています。普段から当たり前のようにコンパイラ依存しないコードを書いてしまっているため思い出すことが逆に難しい、そんな筆者でございました。
リンク:ゲームプログラマの小話-目次