【Unity C#】 IReadOnlyListとアロケーション
- 概要
- 環境
- IReadOnlyList の アロケーション発生ポイント
- Unity Test Runnerによるアロケーション確認
- サンプルコード
- 参考
- 雑感
概要
今回はIReadOnlyList
と アロケーションについて深堀りしようと思います。
前回の記事でも述べましたが、IReadOnlyList
はアロケーションを回避したい場合は注意が必要なinterfaceになっていたりします。
そんなIReadOnlyList
のアロケーションについて調べたので共有します。
IReadOnlyList
については前回の記事で少し紹介していますので合わせてどうぞ!
環境
- Unity 2021.2.2f1
- Mono
- .Net Framework (API Comptibility Level)
IReadOnlyList の アロケーション発生ポイント
foreach使用時などのIEnumerable.GetEnumerator
List
をIReadOnlyList
に変換してforeach
に回すと使用するたびにアロケーションが発生します。
List<Foo> list = new List<Foo>(); foreach (var item in list) { } // アロケーションは発生しない IReadOnlyList<Foo> readonlyList = list; foreach (var item in readonlyList) { } // アロケーションが発生する
アロケーションの原因
List
の場合はEnumerable
(値型)のboxingが原因です。
public Enumerator GetEnumerator() { return new Enumerator(this); }
list.cs,569 より
List
のGetEnumerator
が返すEnumerable
は値型のため、List
を直接使用する場合はアロケーションは発生しません。
一方で、IReadOnlyList
に変換した場合のGetEnumerator
はIEnumerable
としてEnumerable
を返します。
IEnumerator<T> IEnumerable<T>.GetEnumerator() { return new Enumerator(this); }
list.cs,574 より
この戻り値のEnumerable
からIEnumerable
は値型から参照型への変換、つまりboxingが発生するためアロケーションが発生します。
アロケーションの回避方法
foreach
ではなく for
を使用すれば回避可能です。
for (int i = 0; i < readonlyList.Count; ++i) { var item = readonlyList[i]; }
IEnumerable
と異なりIReadOnlyList
はlist[i]
が配列の要素に直接アクセスする形となっています。
ListをIEnumerable
(or IReadOnlyCollection
)に変換してしまうとfor
が使用不可でGetEnumerator
によるアロケーションは避けられなくなりますが
IReadOnlyList
への変換であれば読込専用を保ちつつfor
が使用可能なため結果的にアロケーションの回避が可能です。
コーディングの煩わしさはありますが、メモリ的には優しくなります。
IEquatable<T>を実装しない値型(struct)でEquals
IEquatable<T>
を継承していない値型はEquals
の挙動によりアロケーションが発生するパターンが多いです。
IReadOnlyList
とは直接は関係ありませんが、後述するLinq.Enumerable.Contains
に関わってくるため紹介します。
public struct SimpleData { public int value; }
アロケーションの原因
C#の型に定義されているValueType.Equals(Object)
は引数にobject型を受け取ります。
つまり、このEqualsに値型を渡すと値型から参照型への変換でboxingが発生しアロケーションに繋がります。
アロケーションの回避方法
主に2つあります。
- structに
IEquatable<T>
を継承して実装Equals(T value)
で型指定となるためboxingが発生しない
IEqualityComparer<T>
を継承したオブジェクトを実装して使用Equals(T x, T y)
で型指定となるためboxingが発生しない
public struct EquatableData : IEquatable<EquatableData> { public int value; public bool Equals(EquatableData other) { return this.value == other.value; } ... }
IReadOnlyListでLinq.Enumerable.Contains
using System.Linq;
...
readonlyList.Contains(item);
IReadOnlyList
は残念ながらContains
メソッドを持っていません(List.Contains
は定義されている)。
拡張メソッドLinq.Enumerable.Contains
を使用することで同様の機能を使用できます。
しかし、Linq.Enumerable.Contains
はアロケーションが発生するパターンが多いです。
アロケーションの回避方法
Linq.Enumerable.Contains
のアロケーションは細かい話が多いため先に回避方法を紹介します。
これに関してはIReadOnlyList
向けのContains
を実装する必要がありました。
後述する「アロケーションの原因」を アロケーション回避の検証も含めUnitTestも作成しています。
余談ですが、汎用性を保つためwhere T : struct
のような制約は入れていません。
その代わりtypeof(T).IsValueType
による条件分岐が入っています。
アロケーションの原因
共変性変換のIReadOnlyListに対してLinq.Enumerable.Contains
「共変性を使用したIReadOnlyList」とは要するにList<Foo>
-> IReadOnlyList<IFoo>
みたいな変換のことです。
共変性については前回の記事を参照。
ポイントは 共変性を利用して変換したIReadOnlyList
にあります。
実は List<Foo>
-> IReadOnlyList<Foo>
のように不変(TをFoo
のまま変換)の場合はアロケーションは発生しません。
共変性変換の場合にアロケーションが発生する要因はLinq.Enumerable.Contains
の定義にあります。
public static bool Contains<TSource>(this IEnumerable<TSource> source, TSource value) { ICollection<TSource> collection = source as ICollection<TSource>; // ICollectionは不変性のため、IReadOnlyList<IFoo>へのキャストは不可のためnullを返す if (collection != null) return collection.Contains(value); return Contains<TSource>(source, value, null); } public static bool Contains<TSource>(this IEnumerable<TSource> source, TSource value, IEqualityComparer<TSource> comparer) { if (comparer == null) comparer = EqualityComparer<TSource>.Default; if (source == null) throw Error.ArgumentNull("source"); foreach (TSource element in source) // <- GetEnumerator使用によりboxingによるアロケーションが発生 if (comparer.Equals(element, value)) return true; return false; }
ICollection<T>
は共変性(out T
)を持たず不変性のため、List<Foo>
-> ICollection<IFoo>
は不可となります。
よって IReadOnlyList<IFoo>
-> ICollection<IFoo>
の変換も不可のため、上記Contains
のコードでsource as ICollection<TSource>
はnullを返します。
その後foreach
にたどり着き、「foreachなどIEnumerable
を必要とする処理」で述べたアロケーションが発生します。
逆にIReadOnlyList<Foo>
は不変性を満たすためICollection<Foo>
にキャストができforeach
に到達しないためアロケーションが発生しません。
なんともややこしい。。。
IEquatable<T>非継承のstructを型とするListでLinq.Enumerable.Contains
IEquatable<T>
非継承のstructをLinq.Enumerable.Contains
に使用した場合はアロケーションが発生します。
private static EqualityComparer<T> CreateComparer() { ... if (typeof(IEquatable<T>).IsAssignableFrom(t)) { return (EqualityComparer<T>)RuntimeTypeHandle.CreateInstanceForAnotherGenericParameter((RuntimeType)typeof(GenericEqualityComparer<int>), t); } ... return new ObjectEqualityComparer<T>(); }
IEquatable<T>
継承の場合は型指定のEqualityComparer<T>
が生成されますが、
IEquatable<T>
非継承の場合はObjectEqualityComparer<T>
が使用されてboxingが発生するんですね。
IEqualityComparer<T>指定有りでLinq.Enumerable.Contains
Linq.Enumerable.Contains
はオーバーロードで第2引数にIEqualityComparer<T>
指定可能なメソッドがあります。
Equals
のboxingは回避できるんですが、残念ながらその後で使用されるforeach
のboxingは回避できません。。。
「アロケーションの回避方法」にて記載したコードには、IEqualityComparer<T>
指定可能なContains
も実装して記載しています。
Unity Test Runnerによるアロケーション確認
今回のアロケーション有無の検証ですがUnity Test Runnerを利用しました。
実装開始時はIs.AllocatingGCMemory()
とIs.Not.AllocatingGCMemory()
を使い分けていましたが、
再利用性(TestBaseクラスからの派生)と視認性のためにIs.Not.AllocatingGCMemory()
指定で統一しました。
以下の例だと✅でアロケーション発生無し、🚫でアロケーション発生有りという見方になります。
テストコード的にはおかしいですが、一旦視認性を重視しました(別の視認性が確保しやすい方法が思いついたら修正しておきます)。
また、1st, 2ndはインスタンスごとの初回と2回目でアロケーションの変化があるかを確認するために入れています。
List<Foo>
List<Foo> -> IReadOnlyList<Foo>
foreach
のみで発生
List<Foo> -> IReadOnlyList<IFoo>
foreach
および 共変性変換のためLinq.Enumerable.Contains
で発生
List<SimpleData> -> IReadOnlyList<SimpleData>
IEquatable
非実装のstruct
List<EquatableData> -> IReadOnlyList<EquatableData>
IEquatable
実装のstruct
サンプルコード
IReadOnlyList
向けContains
なども含む
追記
Package Manager対応版を用意しました。
参考
- Unity - Scripting API: AllocatingGCMemoryConstraint
- 【Unity】指定のコードがGCを発生させるかどうかをテストする(AllocatingGCMemory) - テラシュールブログ
- テストでのアロケーションの確認方法
雑感
世間ではFacebookからMetaへと社名変更、新しいOculusが発表、メタバースへの注目など、
XR界隈で目まぐるしい変化が来そうなときに、
「なんでXRとは程遠いC#の話してるの?」って言われそうですねw。
アプリケーションの楽しさに直接関連するものでは確かに有りませんが、
一方で快適なXRアプリケーションの実現において、過剰なアロケーションの回避や削減は大事な要素であると考えています。
もちろんUnityにおいて完全にアロケーションを避けることは難しいため、
こだわり過ぎず、避けれるなら避けるぐらいがちょうど良いのではと思います。
マニアックな話では有りましたが、少しでも面白いと思っていただけたなら幸いです。
それでは~