ゲームプログラマのための設計シリーズ:原理・原則編の記事です。
前編の続きになります。
概要
- 非メンバ関数を優先しよう
- 純粋関数が望ましいが、C++によるゲームプログラミングではあきらめる必要があることも多い
- 関数の中をブロックで区切るだけでも効果アリ
本文
前回の議論で、「その変数が現在の値に至るまでに確認すべき事象の多さ」 = 「変数の危険度」 を点数化してみました。
静的const変数 | 0 |
constローカル変数/constメンバ変数 | 1 |
ローカル変数 | 10 |
メンバ変数 | 100 |
グローバル変数 | 1000 |
危険な変数と言いましたが、これらは変数の状態を変化させる関数があるからこそ危険なわけです。
- ①危険な変数に触りうる関数を減らしていく
- ②関数内から見える危険な変数の数を減らしていく
ことが重要になります。
①危険な変数に触りうる関数を減らしていく
非const変数を避ける
引数に可変参照やポインタをとる関数は、それを利用する側に非const変数を強いてしまいます。(これを避ける用途でconst_castを使うのはおすすめしないです。やっていた時期もありましたが......)
// 引数を二乗する
void square(int& arg)
{
arg = arg * arg;
}
void func()
{
int value = 2; // constにできない!
square(value);
}
コピーコストがどうしても無視できない場合以外は、引数に可変参照・ポインタはとらず、演算結果は戻り値で戻すようにしましょう。
// 引数を二乗して返す
int square(int arg)
{
return arg * arg;
}
void func()
{
const int value = square(2); // 非const変数を使わずに済んだ!
}
※C++17以降ではRVOを活用して、戻り値によるコピーコストを無視できるようになりました。
メンバ変数を避ける
メンバ変数に触ることを避けるには、非メンバ関数であればよいです。
メンバ関数内ではすべてのメンバ変数に触れるので認知負荷が高くなります。
class Enemy
{
private:
int mHP;
int mMP;
int mStrength;
// などなど多くのメンバがある
public:
// 何やらややこしいダメージ計算
// よく読むとmHPしか触っていないのだが、メンバ関数である限りそれがパッと見わからない
void hogeDamage()
{
mHP = mHP * 4 + 3;
mHP -= 9;
if (mHP > 3)
{
// ...という具合に複雑な手続き
}
}
};
多くのメンバ変数に触る必要のない処理は、積極的に非メンバ関数に逃がしていきましょう。
// ややこしい計算を非メンバ関数に逃がした
int HogeDamage_(int hp)
{
// 本当は仮引数を書き換えるのは避けたほうがいいです。
// 全体的に毎回const変数で受けるか、せめて書き換える用に別の変数に代入することをお勧めします。
hp = hp * 4 + 3;
hp -= 9;
if (hp > 3)
{
// ...という具合に複雑な手続き
}
return hp;
}
class Enemy
{
private:
int mHP;
int mMP;
int mStrength;
// などなど多くのメンバがある
public:
// 何やらややこしいダメージ計算
void hogeDamage()
{
// mHPの更新をしているだけというのがわかりやすくなった!
mHP = HogeDamage_(mHP);
}
};
グローバル変数を避ける
これを避ける言語機能はC++にはないのでプログラマが意識して書く以外にはありません。
純粋関数とゲームプログラミング
「危険な変数」を避けることを徹底していくと、「純粋関数」にたどり着きます。簡単に説明すると、
- 引数の値によって戻り値が一意に定まる(グローバル変数やメンバ変数の状態といった他の要素によって左右されない)
- 戻り値を返す以外にプログラムの状態に影響を一切与えない(引数にわたってきた参照を書き換えたりもしない)
というものです。
プログラムをできるだけ純粋関数で埋めていくことを目指したいのですが、ゲームプログラミングでは
- パフォーマンス:戻り値のコピーコスト
- 動的メモリ確保がしづらい:戻り値を新しくインスタンス化できないことがある
という二点が障害として立ちはだかることがあります。
※戻り値のコピーコストの対策の一つである永続的データ構造も、動的メモリ確保なしには利用しづらい
前述のとおりRVOでコピーコストを回避するか、それも不可能な場合は引数に可変参照をとることだけ許容するのでも十分効果があると思います。
void func(BigData& data)
{
// dataの中身を書き換える以外のことはしない
}
②関数内から見える危険な変数の数を減らしていく
今度は関数内の特定の一行に注目して、そこから見える危険な変数の数を考えてみます。
// 何やらややこしい計算(処理に意味はありません)
int func()
{
int a = 0;
int b = 1;
int c = 2;
int n = 0;
for (int i=0;i<100;++i)
{
if (f(i) > 10)
{
++n;
}
}
if (n >= 20)
{
a = 20;
}
int m = g();
switch (h(m))
{
case 0: m = 3; break;
case 1: m = 6; break;
default: break;
}
b += m * 2;
int l = fuga();
l = (l + 2) * l;
if (l > 5)
{
c += l;
}
if (nyan())
{
c *= piyo();
}
return a + b + c;
}
意味のない処理ということを差し引いても、処理を追うのがつらいのではないでしょうか。それは、関数の各行で多くの危険度の高い変数(この場合非constローカル変数a,b,c,n,m,l)にアクセスできてしまうところにあります。
そこで、変数定義を使用直前に移し、ブロックでスコープを区切ってみます。
int func()
{
int a = 0;
{
int n = 0;
for (int i = 0; i < 100; ++i)
{
if (f(i) > 10)
{
++n;
}
}
if (n >= 20)
{
a = 20;
}
}
int b = 1;
{
int m = g();
switch (h(m))
{
case 0: m = 3; break;
case 1: m = 6; break;
default: break;
}
b += m * 2;
}
int c = 2;
{
int l = fuga();
l = (l + 2) * l;
if (l > 5)
{
c += l;
}
if (nyan())
{
c *= piyo();
}
}
return a + b + c;
}
n,mがスコープにしまわれ、a,b,cにだけ集中すればよくなりました。a,b,cを別個に求めて合算しているんだ、という全体の処理の流れも見えるようになりました。
もう一歩踏み込んでconst化してみると・・・
int func()
{
const int a = []()
{
int a = 0;
int n = 0;
for (int i = 0; i < 100; ++i)
{
if (f(i) > 10)
{
++n;
}
}
if (n >= 20)
{
a = 20;
}
return a;
}();
const int b = []()
{
int b = 1;
int m = g();
switch (h(m))
{
case 0: m = 3; break;
case 1: m = 6; break;
default: break;
}
b += m * 2;
return b;
}();
const int c = []()
{
int c = 2;
int l = fuga();
l = (l + 2) * l;
if (l > 5)
{
c += l;
}
if (nyan())
{
c *= piyo();
}
return c;
}();
return a + b + c;
}
各処理から見える非const変数がさらに減りました。
あとは各ラムダ式を別関数に移せば完璧なのですが、ここまでの変形はほとんど機械的に可能だったのに比べて
- 関数名を考える必要がある
- funcの引数がたくさんあって処理がそれに依存している場合、受け渡しが面倒
とちょっとハードルが上がります。特に幾何演算ではメンドクサイ傾向にある(図と合わせないと関数の命名が困難だったり)ので億劫なことが多いですね・・・
サボりといえばサボりなのですが、ラムダ式で整理するところまででもだいぶ見通しが良くなるのでそこまでは整理しておくと割り切るのもアリかと思っています。
余談
本記事の話題について、Effective C++では
「3項 可能ならいつでもconstを使おう」
「22項 データメンバはprivate宣言しよう」
「23項 メンバ関数より、メンバでもfriendでもない関数を使おう」
「26項 変数の定義は可能な限り先延ばししよう」
などでそれぞれ議論されている内容です。これらを 「その変数が現在の値に至るまでに確認すべき事象の多さ」 で考えると統一的に把握できると思います。