[Unity] 제네릭 박싱 예방

KYO
KYO
∙∙조회 12
[Unity] 제네릭 박싱 예방

들어가며

이 글은 제네릭 제약을 통한 박싱 회피 전략에 대해서 다루지만, 이걸 이해하려면 먼저 GetHashCode나 Equals 등을 이해해야한다. 그래서 글의 구조는 크게 두 꼭지다.

  • GetHashCode / Equals / IEquatable / IEqualityComparer
  • 제네릭 제약

GetHashCode

csharp
public virtual int GetHashCode(); // System.Object

해시 함수의 구체적 구현은 이 글에서 다루지 않는다. 궁금하면 여기서 참고하자.

이 글에서는 이 메소드를 오버라이딩하는 것으로 박싱을 예방하는 전략에 대해서 다룰 것이다.

그래서 주목해야할 점은 둘이다.

  1. GetHashCode는 System.Object 클래스에 정의되어있다.
  2. GetHashCode는 virtual로 정의되어있다.

그리고 몇 가지 더 리마인드한다면

  1. C#의 struct는 System.Object를 ancestor로서 상속받는다. (int, bool 등 모든 타입이 그렇다. interface도 구현하는 측이 object이므로 object의 메소드를 쓸 수 있다.) 사실상 모든 타입에 대해 GetHashCode가 호출 가능하다.

Equals

csharp
public virtual bool Equals(object? obj); // System.Object

Equals 메서드는 virtual로 선언되어, object(참조)와 ValueType(값 타입)에서 각기 다른 구현을 쓴다.

System.Object의 Equals는 두 참조가 같은 Managed 힙 주소를 가리키는지 검사한다. 내부적으로 ReferenceEquals와 동일하다.

System.ValueType의 Equals는 내부 필드를 순회하며 모든 값이 동일한지 검사한다. 이건 내부적으로 최적화되는 분기(모든 필드가 값타입이고 padding이 없을 때 비트 연산)도 있으나, 그렇지 않으면 리플렉션을 쓰기도 한다. 자세히는 모르지만 성능상의 이슈가 발생할 수 있다는 점을 기억하자.

IEquatable

csharp
public readonly struct CellCoord : IEquatable<CellCoord>
{
    public readonly int Floor;
    public readonly int X;
    public readonly int Y;

    public CellCoord(int floor, int x, int y)
    {
        Floor = floor;
        X = x;
        Y = y;
    }

    public bool Equals(CellCoord other)
        => Floor == other.Floor && X == other.X && Y == other.Y;

    public override bool Equals(object obj)
        => obj is CellCoord other && Equals(other);

    public override int GetHashCode()
        => HashCode.Combine(Floor, X, Y);
}

3차원 공간의 좌표계를 표현하는 CellCoord 구조체 예시이다.

이 구조체는 Equals를 두 개 구현하고 있고, GetHashCode도 override해 두었다. 각각이 하는 역할을 짚어보자.

  1. bool Equals(T other)는 CellCoord가 object로 처리되는 것을 피하므로 박싱을 예방한다. EqualityComparer<T>.Default가 이 메서드를 찾아서 쓰므로 Dictionary 등의 Key 조회 및 비교에서도 이는 유효하다.
  2. bool Equals(object obj)를 override한 것은 ValueType.Equals(object)를 대체한 것이다. 혹시나 T.Equals(T)로 호출되지 않더라도, 물론 박싱은 되지만, 내부적으로는 직접 구현한 가벼운 비교를 사용함으로써 ValueType의 무거운 비교는 피하겠다는 뜻이다.
  3. int GetHashCode()를 override한 것은 일종의 규칙인데, Equals를 재정의했다면 GetHashCode도 재정의해야 한다는 점이다. 이때 내부 구현에서는 Equals의 구현에서 쓰는 필드(위 경우 X, Y, Floor)를 똑같이 사용함으로써 A.Equals(B) && A.GetHashCode() != B.GetHashCode() 인 상황을 막을 수 있다. 이를 Equals와 GetHashCode의 일관성이라고 말할 수 있다. 둘 중 하나만 override하는 것은 그 일관성에 대한 기대를 깨뜨리게 된다.

IEqualityComparer, Equals(T)

위 CellCoord 구조체가 IEquatable<CellCoord>를 구현하고 있음을 확인할 수 있다.

이 인터페이스는 아래의 메소드를 정의한다.

csharp
public bool Equals(T? other); // IEquatable<T>

이는 인터페이스로 접근 가능한 bool Equals(T) 분기를 만들어준다. 딕셔너리 등의 제네릭 컬렉션에서 key값을 찾을 때나 hash값 받을 때 EqualityComparer<T>를 사용하는데, 이 EqualityComparer<T>가 IEquatable<T>의 T.Equals(T)와 T.GetHashCode()를 쓴다.

예를 들면 우리가 자주 쓰는 IEnumerable의 확장 메서드 Contains를 보자.

csharp
public static bool Contains<TSource>(this System.Collections.Generic.IEnumerable<TSource> source, TSource value, System.Collections.Generic.IEqualityComparer<TSource>? comparer);

IEqualityComparer<TSource>? comparer가 null이면 내부적으로는 EqualityComparer<TSource>.Default를 쓰게 된다. 이때 TSource가 IEquatable<TSource>를 구현했다면 이 comparer는 해당 TSource의 Equals(TSource)를 사용한다.

제네릭 제약

csharp
public sealed class BindableProperty<T>
{
    private T _value;
    public event Action<T> Changed;

    public T Value
    {
        get => _value;
        set
        {
            if (_value.Equals(value)) return;   // 어떤 Equals가 호출되는가?
            _value = value;
            Changed?.Invoke(value);
        }
    }
}

// PlayerHud.cs
public readonly BindableProperty<int> Gold = new();
public readonly BindableProperty<Vector2Int> GridPosition = new();

위 클래스에 주석으로 달아둔 Equals에 대해, 컴파일러는 어떤 Equals를 실행할까?

  1. Object.Equals(object)
  2. ValueType.Equals(object)
  3. T.Equals(T)

정답은 1번이다. 이건 T가 뭔지 모르기 때문에 일어난 일이다. 컴파일러는 T의 타입을 가장 보수적으로 추측하여 object로 여긴다. 아무런 근거가 없기 때문이다. 그럼 박싱이 생겨버리니, 이를 예방하려면 제약을 걸어주어야 한다.

그래서 아래와 같이 고칠 수 있다.

csharp
public sealed class BindableProperty<T> where T : IEquatable<T>

이러면 Object.Equals(object)가 아닌 T.Equals(T)가 호출될 것이다. 컴파일러는 T가IEquatable<T>를 구현했다는 점을 알고 있기 때문이다.

제네릭 제약 정리 표

중요한 것은 컴파일러가 T에 대해 얼마나 아느냐는 점이다. 그에 따라 실제 기능이 달라진다.

제약

컴파일러가 아는 것

실무 효과

없음

object 멤버만 있다

값 타입 T에 Equals(object) 등을 호출하면 박싱

where T : IFoo

IFoo 멤버가 있다

IFoo 멤버 호출 가능.

값 타입 T가 직접 구현했으면 박싱 없음

where T : struct

값 타입이다

null 비교 불필요. IL2CPP 폴백에서 일부 최적화

where T : class

참조 타입이다

null 대입 가능. IL2CPP 폴백 성능 유지.

== 는 참조 비교이므로 Fake Null 주의

where T : Component

Component 하위 타입이다

Unity의 == 오버로드 적용.

GetComponent<T> 계열 API에 전달 가능

마무리

실제 할당 여부는 UTF의 Assert로 확인할 수 있다.

csharp
using NUnit.Framework;
using UnityEngine.TestTools.Constraints;
using Is = UnityEngine.TestTools.Constraints.Is;        

Assert.That(() =>
        {
            // Some Code
        }, Is.Not.AllocatingGCMemory());

Assembly-CSharp.dll을 IL뷰어를 통해 읽고 확인할 수도 있다고 한다.

csharp
// 제약 없음
box          !0                                  ← 인자 박싱
constrained. !0
callvirt     instance bool object::Equals(object)

// where T : IEquatable<T>
constrained. !0
callvirt     instance bool class System.IEquatable`1<!0>::Equals(!0)

 

끗.