【Unity】【C#】Genericな型の等価判定によるメモリアロケーション及びUnity組み込み構造体のIEquatable実装状況

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
    {
    }
}

結果は以下の通りです。
値型のときのみアロケーションが発生してテストが失敗しています。

f:id:halya_11:20200801214951p:plain
値型のみテスト失敗

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);
    }
}

この状態で先ほどのテストをもう一度回してみると、今度は成功することが確認できます。

f:id:halya_11:20200801215236p:plain
成功、ただし・・

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
    {
    }
}

すると今度はアロケーションが発生して値型のテストが失敗してしまいます。

f:id:halya_11:20200801215602p:plain
失敗

実は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);
    }
}

この状態でもう一度テストを回すと成功することが確認できます。

f:id:halya_11:20200801215917p:plain
成功

補足: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の実装がされていなかったようです。

github.com

これに対してUnity2018.2からは実装が行われています。

github.com

念のため今のバージョン(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());
    }
}

問題なくテストが通ることを確認できました。

f:id:halya_11:20200801222145p:plain
テスト成功

まとめ

ここまでで、結論としては以下の通りとなることが確認できました。

  • 自前の構造体にはIEquatable<T>を実装する
  • Equals(Object)とGetHashCode()もオーバーライドする
  • 等価の判定にはEqualityComparer<T>.Default.Equals()を使う
  • Unity2018.2以降は組み込み構造体にもIEquatable<T>が実装されている

参考

docs.microsoft.com

github.com

github.com