この記事はUnityゲーム開発者ギルド Advent Calendar 2022 その2 の18日目の記事です。
あらすじ
デジタルカードゲームを作っていると、カードの効果処理が膨大になるため、クラスで分けたかったりします。
例↓
public class Model
{
Dictionary<int, ICardEffectHandler> cardEffectHandlerDict = new Dictionary<int, ICardEffectHandler>()
{
// { id, 効果処理クラス }
{ 1, new CardEffectHandler1() },
{ 2, new CardEffectHandler2() },
{ 3, new CardEffectHandler3(10) },
{ 4, new CardEffectHandler3(20) }, // ほぼ同じ効果で数値だけ違うバージョン
};
/// <summary>
/// カードの効果を処理する
/// </summary>
/// <param name="id">カードID</param>
public void ExecuteCardEffect(int id)
{
// idに対応する処理クラスを持ってきて処理
cardEffectHandlerDict[id].Execute();
}
}
でも、カードの種類が数百〜となった場合、1試合のあいだに使われないクラスも多くなることが想定されます。
(デッキを組むカードゲームの場合、デッキに入ってないカードの効果は使われません)
使われないクラスも全部初期化時に生成するのはもったいないですよね?
そこで以下のような処理を考えました。
public class Model2
{
Dictionary<int, Func<ICardEffectHandler>> cardEffectHandlerDict = new Dictionary<int, Func<ICardEffectHandler>>()
{
// { id, 効果処理クラスを返すデリゲート }
{ 1, () => new CardEffectHandler1() },
{ 2, () => new CardEffectHandler2() },
{ 3, () => new CardEffectHandler3(10) },
{ 4, () => new CardEffectHandler3(20) }, // ほぼ同じ効果で数値だけ違うバージョン
};
Dictionary<int, ICardEffectHandler> cardEffectHandlerCache = new Dictionary<int, ICardEffectHandler>();
/// <summary>
/// カードの効果を処理する
/// </summary>
/// <param name="id">カードID</param>
public void ExecuteCardEffect(int id)
{
// キャッシュをチェック
if (cardEffectHandlerCache.TryGetValue(id, out var handler))
{
// キャッシュがあればそれを実行
handler.Execute();
}
else
{
// キャッシュがなければインスタンスを生成
handler = cardEffectHandlerDict[id].Invoke();
handler.Execute();
// キャッシュ
cardEffectHandlerCache.Add(id, handler);
}
}
}
実行時に必要なものだけインスタンスを生成して、同じクラスを何度も生成しないようにキャッシュに入れておくという方法ですね。
これでめでたし、めでたし〜と思ったのですが、
よくよく考えると クラス のインスタンスを生成しない代わりに デリゲート(ラムダ式) のインスタンスを生成しちゃってるじゃないか!1
あれ、どっちのほうが軽いの?
ということでパフォーマンス検証です。
パフォーマンス検証
環境:M1 Mac(ver12.6 Monterey), Unity2021.3.15f1
クラスインスタンスorラムダ式を返すDictionaryに要素を100万個追加するのにかかる時間を比較します。
それぞれ10回ずつデータをとり、最大値と最小値を除いた8個のデータを平均して比較しました。
実行環境はUnityで、MonoBehaviourのStartで実行します。
void Measure10Times()
{
var res = new List<long>();
var sw = new System.Diagnostics.Stopwatch();
for (int i = 0; i < 10; i++)
{
// 計測
sw.Start();
for (int j = 0; j < 1000000; j++)
{
int index = j;
// 実装(1)
}
sw.Stop();
var ms = sw.ElapsedMilliseconds;
Debug.Log($"{i}: {ms} ms");
res.Add(ms);
// リセット処理
sw.Reset();
// 実装(2)
}
// 最大値と最小値を除いた8個のデータの平均を取る
res.Sort();
var avg = res.Skip(1).SkipLast(1).Average();
Debug.Log($"avg: {avg} ms");
}
Dictionary<int, SomeClass> classDictionary = new Dictionary<int, SomeClass>();
実装(1):
classDictionary.Add(index, new SomeClass(index));
実装(2):
classDictionary.Clear();
Dictionary<int, Func<SomeClass>> lambdaDictionary = new Dictionary<int, Func<SomeClass>>();
実装(1):
lambdaDictionary.Add(index, () => new SomeClass(index));
実装(2):
lambdaDictionary.Clear();
public class SomeClass
{
int param;
public SomeClass(int param)
{
this.param = param;
}
}
結果はこちら
クラス | ラムダ式 |
---|---|
86 ms | 246 ms |
えぇぇ!?クラス生成の方が速い!?
いやいやいや、これはきっとクラスの中身がほぼ空っぽだったからだ。
そうに違いない!(焦)
ということで、クラスをもっと重くしましょう↓
public class SomeClass
{
int param;
List<List<int>> list2d = new List<List<int>>()
{
new List<int>(),
new List<int>(),
new List<int>(),
new List<int>(),
new List<int>(),
new List<int>(),
new List<int>(),
new List<int>(),
new List<int>(),
new List<int>(), // やりすぎ?
};
public SomeClass(int param)
{
this.param = param;
}
}
クラスを重くした結果がこちら
クラス | ラムダ式 |
---|---|
1672 ms | 247 ms |
ラムダ式の方はほぼ変わらず。
クラスの方はめちゃめちゃ重くなりました。
ということなので、カード効果処理クラスの生成がめちゃめちゃ重い場合などは効果がありそうですね。
でも処理を見やすくするためにクラスに分けてるんですよね。
そもそもそんなにデカくならないのでは?
結論
勘のいい方はお気づきかと思いますが、クラスが重い場合でも、100万個要素を追加してやっと1秒程度の差が出る結果となりました。
つまりカードの種類が数百、数千程度では1ミリ秒変わるか変わらないかくらいの結果です。
素直にクラスのインスタンスを返すDictionaryを使用しましょう!
そっちの方が圧倒的に実装が楽です!
ラムダ式は上手ぶりでした!!すいませんでしたぁ!!!
-
ラムダ式の展開のされ方 → https://takap-tech.com/entry/2022/01/26/221919 ↩