1. サイトトップ
  2. ブログ
  3. Unity
  4. 【Unity】IReadOnlyListを使用する際の注意点

【Unity】IReadOnlyListを使用する際の注意点

はじめに

こんにちは、情熱開発部プログラム2課の辻野です。

今回で技術ブログを書くのは5回目になりますが、前回書いたのが一年前だと思うと時の流れを感じます。
新卒の時と比べると、設計や負荷について考えることも増えてきました。

本記事では、「IReadOnlyListでforeachを利用する際に発生する問題」を取り上げます。
Listを読み取り専用にする際に使用しますが、知らずに使うとメモリ効率を下げる可能性があるので説明していきます。

※Unity 6000.0.28f1を使用

IReadOnlyListについて

IReadOnlyListはListを読み取り専用にするためのInterfaceです。

ListはIReadOnlyListを継承しており、IReadOnlyListにはインデクサによる参照のみが宣言されています。
外部にコレクションを公開する際に、キャストしておけば参照しかできないため、値の追加を防ぐことができる仕組みです。

public class Unit
{
    public readonly int Id;
    public readonly string Name;
    public readonly int Health;
    public readonly int Attack;

    public Unit(int id, string name, int health, int attack)
    {
        Id = id;
        Name = name;
        Health = health;
        Attack = attack;
    }
}

public class UnitCollection
{
    private List<Unit> collection = new List<Unit>();
    public IReadOnlyList<Unit> Collection => collection;

    public UnitCollection()
    {
        // ユニット情報を10000個作成する
        for (var i = 0; i < 10000; ++i)
        {
            collection.Add(new Unit(i, $"unit_{i}", 10, 10));
        }
    }
}

public class CollectionTest : MonoBehaviour
{
    private UnitCollection unitCollection = new UnitCollection();

    void Update()
    {
        // 要素の参照はできる
        var item = unitCollection.Items[0];

        // Addメソッドが存在していないのでコンパイルエラー
        unitCollection.Add(new Unit(100, "", 10, 10));
    }
}

コメントにもありますが、ListそのものではなくInterfaceを共有することで要素の追加を防げます。
UnityだとScriptableObjectを利用する際に、よく見るかなと思います。

注意点

概要を説明したところで本題に入りますが、IReadOnlyListを使用する際は注意が必要です。

要素数10,000のコレクションを用意して、同じインスタンスをListとIReadOnlyListそれぞれをforeachで走査してみます。

List<Unit> list = new List<Unit>(10000);
IReadOnlyList<Unit> readOnlyList = list;
...10,000のUnitを追加しているが省略...

// ++++Listのforeach++++
// Profiler用のタグ
Profiler.BeginSample("List foreach");
foreach (var item in list)
{
}
Profiler.EndSample();

// ++++IReadOnlyListのforeach++++
// Profiler用のタグ
Profiler.BeginSample("IReadOnlyList foreach");
foreach (var item in readOnlyList)
{
}
Profiler.EndSample();

Updateでの実行をProfilerで確認してみます。

比較すると処理時間はほとんど同じですが、GC Allocの項目が目立って異なります。

GC Alloc

前提として、C#ではメモリの種類が大まかに2種類あり、それぞれ以下の特徴があります。

種類StackHeap
用途ローカル値型変数等
一時的な値を格納する領域
参照型のオブジェクト等
寿命がスコープに縛られないものを格納する領域
管理方法スコープを抜けると解放されるGC(ガベージコレクション)によって
どこにも参照されていないと判断されたものが開放される
速度解放タイミングが一定なので、メモリの断片化が発生しない。割り当てが高速解放タイミングがオブジェクト毎に異なるのでメモリの断片化が発生する。割り当て時に領域を探索するため、スタックと比べると遅い
GCの動作自体にコストが発生する

GC Alloc 40Bというのは、Heap領域に40Byte値を確保したということになります。
一見すると大したサイズではないですが、60fpsだと一秒で40×60の2.4KB、それが複数あるとさらに倍になっていきます。

アロケーション発生の原因

なぜ同じインスタンスを使用しているのに、ListとIReadOnlyListで違いが発生するのか。
原因はGetEnumeratorで返す型の違いにあります。

GetEnumeratorなんてどこにも存在してないように見えますが、foreachは実行時に下記の様に展開されています。
※ 分かりやすくするため一部省略しています

// Listのforeach
List<Unit>.Enumerator enumerator = list.GetEnumerator();
while (enumerator.MoveNext())
{
    Unit current = enumerator.Current;
}

// IReadOnlyListのforeach
IReadOnlyList<Unit> readOnlyList = list;
IEnumerator<Unit> enumerator2 = readOnlyList.GetEnumerator();
while (enumerator2.MoveNext())
{
    Unit current2 = enumerator2.Current;
}

GetEnumeratorで受け取っている型に注目です。
ListはEnumerator、IReadOnlyListはIEnumeratorを返しています。

public struct Enumerator : IEnumerator<T>, IEnumerator, IDisposable
{
    public T Current { get; }

    public void Dispose();
    public bool MoveNext();
}

public interface IEnumerator<out T> : IEnumerator, IDisposable
{
    T Current { get; }
}

GC Allocが発生している理由はIEnumeratorがInterface、つまり参照型でHeapに確保されるからです。

より実装を細かく見ると、structからInterfaceのキャストが発生しているのが原因です。

public Enumerator GetEnumerator()
{
    return new Enumerator(this);
}

IEnumerator<T> IEnumerable<T>.GetEnumerator() {
    return new Enumerator(this);
}

上記はListの実装コードです。
このような値型から参照型への変換をboxingと呼びます。

要素数によってGC Allocが増えるわけでもないことが分かります。

解決方法

IReadOnlyListをforeachで列挙するのにデメリットがある事が分かりました。
GC Allocを発生させないようにするには、以下のような方法があります。

for文を使う

単純ですが、個人的にはこれで良いと思っています。

// IReadOnlyListのfor
Profiler.BeginSample("IReadOnlyList for");
for (var i = 0; i < readOnlyList.Count; ++i)
{
    var x = readOnlyList[i];
}
Profiler.EndSample();

以下は展開されたコードです。
IEnumeratorを使用しないので、GC Allocが発生しません。

int num = 0;
while (num < readOnlyList.Count)
{
    Unit unit = readOnlyList[num];
    num++;
}

ReadOnlyArrayを利用する

後から要素が追加されない場合に限りますが、読み取り専用のReadOnlyArrayを使用するパターン。
UnityEngine.InputSystem.Utilitiesで定義されている。

Profiler.BeginSample("ReadOnlyArray foreach");
foreach (var item in readOnlyArray)
{
    var x = item;
}
Profiler.EndSample();

ReadOnlyArrayはIEnumerableを継承しているのでforeachが可能。
foreachでは一番上の関数が呼び出されるため、GC Allocが発生しない。

public Enumerator GetEnumerator()
{
    return new Enumerator(m_Array, m_StartIndex, m_Length);
}

IEnumerator<TValue> IEnumerable<TValue>.GetEnumerator()
{
    return GetEnumerator();
}

IEnumerator IEnumerable.GetEnumerator()
{
    return GetEnumerator();
}

IReadOnlyListより若干早い。

まとめ

IReadOnlyListでforeachを利用する際の注意点について解説させていただきました。

致命的な問題ではないですが、メモリ領域の種類やboxingについて把握ができるとより深いレベルで、プロファイルができるようになります。
コードの可読性とパフォーマンス、どちらを優先するかで対応も変わりますが、本記事が判断の一助になれば幸いです。

参考文献に記載させていたページから、色々調べてみると面白いかもしれません。

参考文献

記事を書く上で参考にさせていただいたサイト。


【免責事項】

本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。