[Unity] 제네릭과 함정

KYO
KYO
∙∙조회 5

들어가며

이전 글로부터 기억해야할 점은 아래와 같다.

  1. 정적 타입과 런타임 타입은 구분된다.
  2. 힙 객체의 런타임 타입은 헤더에 있다.
  3. 가변변환이 헤더를 바꾸지 않는다.

Foreach, IEnumerator 함정

csharp
public static class TargetFinder
{
    public static Unit FindNearestFor(IReadOnlyList<Unit> candidates, Vector3 origin)
    {
        Unit nearest = null;

        for (int i = 0; i < candidates.Count; i++)
        {
            Unit unit = candidates[i];
            //...
        }
        return nearest;
    }

public static Unit FindNearestForeach(IReadOnlyList<Unit> candidates, Vector3 origin)
    {
        Unit nearest = null;

        foreach (var unit in candidates)
        {
            //...
        }
        return nearest;
    }
    

}

코드를 짠 사람은 Unit을 상속받은 애들도 FindNearest를 쓸 수 있게 하고 싶어서 IReadOnlyList를 썼다고 한다. List는 불공변이고 IReadOnlyList는 공변이니 합리적인 판단같다.

근데 함수 두 개가 정의되어있다. 하나는 for버전, 다른 하나는 foreach버전인데 하는 일은 완전히 같다. 그러나 둘 중 하나는 박싱이 생기고 다른 하나는 박싱이 없다. 이건 다음 글로 정리할 예정.

in T와 비교자 함정

csharp
public sealed class DistanceComparer : IComparer<Unit>
{
    public Vector3 Origin;

    public int Compare(Unit a, Unit b)
    {
        float da = (a.transform.position - Origin).sqrMagnitude;
        float db = (b.transform.position - Origin).sqrMagnitude;
        return da.CompareTo(db);
    }
}

public class SkillCaster : MonoBehaviour
{
    private readonly List<Enemy> _enemiesInRange = new();
    private readonly List<Ally> _alliesInRange = new();
    private readonly DistanceComparer _byDistance = new();

    private void SortTargets()
    {
        _byDistance.Origin = transform.position;
        _enemiesInRange.Sort(_byDistance);  // Sort(IComparer<Enemy>)에 IComparer<Unit> 전달
        _alliesInRange.Sort(_byDistance);   // Sort(IComparer<Ally>)에 IComparer<Unit> 전달
    }
}

DistanceComparer를 구조체가 아닌 클래스로 만들어서 캐싱해서 쓰고 있다. 만약 구조체였다면, IComparer로 박싱되어서 Sort에 전달되므로 Sort호출마다 박싱이 생겼을 것이다.

Action의 += 함정

csharp
public class AllyManager : MonoBehaviour
{
    public event Action<Ally> AllyDied; // class Ally: Unit
}

public class EnemyManager : MonoBehaviour
{
    public event Action<Enemy> EnemyDied; // class Enemy: Unit
}

public class CombatLogPanel : MonoBehaviour
{
    [SerializeField] private AllyManager _allyManager;
    [SerializeField] private EnemyManager _enemyManager;

    private Action<Unit> _onUnitDied;

    private void Awake()
    {
        _onUnitDied = unit => Debug.Log(unit + " died.");
    }

    private void OnEnable()
    {
        _allyManager.AllyDied += _onUnitDied;
        _enemyManager.EnemyDied += _onUnitDied;
    }

    private void OnDisable()
    {
        _allyManager.AllyDied -= _onUnitDied;
        _enemyManager.EnemyDied -= _onUnitDied;
    }
}

먼저, 위 코드는 컴파일 될까? 된다! OnEnable과 OnDisable에서 반공변변환을 통해 매니저 클래스의 이벤트에 동일한 인스턴스를 넣었다. 덕분에 onUnitDied를 타입 각각에 대해 구현하지 않고 Unit에 대해서만 구현할 수 있다.

그런데, 위 코드는 런타임에 안전할까? 안전하지 않다!

누군가 같은 프로젝트에서 아래와 같은 코드를 작성했다고 생각해보자...

csharp
_enemyManager.EnemyDied += (e) => Debug.Log(e);
  1. 이벤트 EnemyDied는 Action<Enemy> 타입이고, 인스턴스로는 onUnitDied를 아까 가변변환해서 넣었다.
  2. 누군가 람다로 이벤트를 구독했다. 둘 다 Action<Enemy> 이므로 아무런 문제가 없어보인다.
  3. 컴파일 된다. 빌드도 된다. 근데 런타임에 구독이 늦은 쪽에서 ArgumentException (Incompatible Delegate Types)이 터진다.

원인은 +=의 내부 동작에 있다. 이 연산자는 컴파일러에 의해 Delegate.Combine(a, b) 메소드로 치환되는데, 이 메소드는 두 인자의 헤더가 완전히 동일해야한다.

가변변환이 헤더를 바꾸지 않는다는 점을 기억하자. 이 경우에 하나는 헤더가 Action<Unit>이고, 다른 하나는 Action<Enemy>이므로 터진 것이다.

그럼 어떻게 할까? 아래와 같은 방법도 있다.

csharp
private void OnEnable()
{
    _allyManager.AllyDied += HandleUnitDied;
    _enemyManager.EnemyDied += HandleUnitDied;
}

private void OnDisable()
{
    _allyManager.AllyDied -= HandleUnitDied;
    _enemyManager.EnemyDied -= HandleUnitDied;
}
private void HandleUnitDied(Unit unit) => Debug.Log(unit + " died.");

메서드 이름(메서드 그룹)으로 넣으면, 대상 이벤트의 타입에 맞는 대리자 인스턴스를 컴파일러가 생성해서 넣어준다.

다만 그 때문에, 위 코드는 구독과 해제시에 매번 할당이 생긴다. 그래서 아래와 같은 처리도 가능하다.

csharp
private Action<Ally> _onAllyDied;
private Action<Enemy> _onEnemyDied;

private void Awake()
{
    _onAllyDied = HandleUnitDied;   
    _onEnemyDied = HandleUnitDied;
}

private void OnEnable()
{
    _allyManager.AllyDied += _onAllyDied;
    _enemyManager.EnemyDied += _onEnemyDied;
}

private void OnDisable()
{
    _allyManager.AllyDied -= _onAllyDied;
    _enemyManager.EnemyDied -= _onEnemyDied;
}

위의 코드는 enemy와 ally 각각의 매니저 이벤트에 그 타입이 맞는 대리자 인스턴스를 +=, -=하고 있다.

대리자 인스턴스를 미리 캐싱해두었으므로, 구독과 해제시에는 새로운 대리자 인스턴스를 만들지 않아 그만큼 할당이 없다!

 

참고

  1. 람다를 이벤트에 넣으면, 컴파일러는 대상 대리자 타입과 동일한 타입의 대리자 인스턴스를 생성한다. Roslyn은 캡처가 없는 람다를 정적 필드에 캐싱한다.
  2. Delegate.Combine은 새 인스턴스를 반환하는 방식이라 할당이 있다. -=연산에 해당하는 Delegate.Remove도 마찬가지. 다만 Combine은 두 인자 중 하나가 null이면 할당이 없고, Remove는 반환값이 null이면 할당이 없다. 즉, 최초 +=와 최후 -=시점에 할당이 없다.

끗.