Genericな型の等価判定をすることによるメモリアロケーションの問題とその解決策、
及びUnity組み込み構造体のIEquatable実装状況についてまとめました。
Unity2020.1
Genericな等価判定とメモリアロケーション
Generic + Equalsはメモリアロケーションが発生する
いま、以下のように二つのオブジェクトの等価性を判定するGenericなクラスを考えます。
Equals()
の引数はobject型なので、Tが値型だった場合にはボックス化が発生してメモリが割り当てられてしまいます。
public class Example<T> { public bool IsEqual(T a, T b) { // Tが値型だとメモリアロケーションが発生 return Equals(a, b); } }
これを確認するために簡単なテストを記述します。
using NUnit.Framework; using UnityEngine.TestTools.Constraints; using Is = UnityEngine.TestTools.Constraints.Is; public class AllocationTests { [Test] public void CheckValueTypeAllocation() { var comparer = new Example<int>(); Assert.That(() => { var isEqual = comparer.IsEqual(1, 2); }, Is.Not.AllocatingGCMemory()); } [Test] public void CheckReferenceTypeAllocation() { var comparer = new Example<DummyClass>(); var obj1 = new DummyClass(); var obj2 = new DummyClass(); Assert.That(() => { var isEqual = comparer.IsEqual(obj1, obj2); }, Is.Not.AllocatingGCMemory()); } public class DummyClass { } }
結果は以下の通りです。
値型のときのみアロケーションが発生してテストが失敗しています。
EqualityComparer.Default
このようなメモリアロケーションを防ぐため、以下のようにEqualityComparer<T>.Default
を使用します。
public class Example<T> { private readonly EqualityComparer<T> _comparer = EqualityComparer<T>.Default; public bool IsEqual(T a, T b) { // メモリアロケーションが防げる(T型にIEquatableが実装されている場合のみ) return _comparer.Equals(a, b); } }
この状態で先ほどのテストをもう一度回してみると、今度は成功することが確認できます。
IEquatableでアロケーションを防ぐ
しかしここで一点注意が必要で、この方法でアロケーションが防げるのは型TにIEquatableが実装されている場合のみです。
プリミティブ型は問題ありませんが、例えば以下のように独自の構造体を作ることを考えます。
public struct ExampleStruct { public int x; public int y; }
これを使って値型のテストを書き換えます。
using NUnit.Framework; using UnityEngine.TestTools.Constraints; using Is = UnityEngine.TestTools.Constraints.Is; public class AllocationTests { [Test] public void CheckValueTypeAllocation() { var comparer = new Example<ExampleStruct>(); var obj1 = new ExampleStruct(); var obj2 = new ExampleStruct(); Assert.That(() => { var isEqual = comparer.IsEqual(obj1, obj2); }, Is.Not.AllocatingGCMemory()); } [Test] public void CheckReferenceTypeAllocation() { var comparer = new Example<DummyClass>(); var obj1 = new DummyClass(); var obj2 = new DummyClass(); Assert.That(() => { var isEqual = comparer.IsEqual(obj1, obj2); }, Is.Not.AllocatingGCMemory()); } public class DummyClass { } }
すると今度はアロケーションが発生して値型のテストが失敗してしまいます。
実はEqualityComparer<T>.Default
を使ってアロケーションが防げるのは型TにIEquatableの実装がされている場合のみです。
したがって、自前の構造体にはIEquatableを実装する必要があります。
public struct ExampleStruct : IEquatable<ExampleStruct> { public int x; public int y; public bool Equals(ExampleStruct other) { return x.Equals(other.x) && y.Equals(other.y); } }
この状態でもう一度テストを回すと成功することが確認できます。
補足:Equals(Object)とGetHashCode()もオーバーライド
本筋と外れるので補足として扱いますが、IEquatable<T>
を実装する際にはEquals(Object)
とGetHashCode()
もオーバーライドして、
IEquatable<T>.Equals()
とオーバーライドしたEquals(Object)
が同じ挙動をするようにする必要があります。
ここまでのまとめ
少し複雑ですが、まとめると以下の二点を守ればGenericな型の等価判定がゼロアロケーションで実現できます。
- 自前の構造体には
IEquatable<T>
を実装する - Equals(Object)とGetHashCode()もオーバーライドする
- 等価の判定には
EqualityComparer<T>.Default.Equals()
を使う
Unity組み込み構造体とIEquatable
さてここまでで、自前の構造体にはIEquatable<T>
を実装するべきということがわかりました。
ここで気になるのが、以下のようなUnityの組み込み構造体にはIEquatableが実装されているのかという点です。
- Vector3
- Color
- Rect
- Quaternion
- etc..
実は古いバージョンのUnityの構造体にはIEquatableは実装されていませんでした。
しかし最近のUnityのバージョンでは実装されています。
いつから実装されたのかを知るためにUnity Cs ReferenceのVector3構造体を追ってみます。
すると、Unity2018.1まではIEquatableの実装がされていなかったようです。
これに対してUnity2018.2からは実装が行われています。
念のため今のバージョン(Unity2020.1)でVector3がアロケーションしないかどうかをテストしてみます。
using System.Numerics; using NUnit.Framework; using UnityEngine.TestTools.Constraints; using Is = UnityEngine.TestTools.Constraints.Is; public class AllocationTests { [Test] public void CheckValueTypeAllocation() { var comparer = new Example<Vector3>(); var obj1 = Vector3.Zero; var obj2 = Vector3.One; Assert.That(() => { var isEqual = comparer.IsEqual(obj1, obj2); }, Is.Not.AllocatingGCMemory()); } }
問題なくテストが通ることを確認できました。
まとめ
ここまでで、結論としては以下の通りとなることが確認できました。
- 自前の構造体には
IEquatable<T>
を実装する - Equals(Object)とGetHashCode()もオーバーライドする
- 等価の判定には
EqualityComparer<T>.Default.Equals()
を使う - Unity2018.2以降は組み込み構造体にも
IEquatable<T>
が実装されている