[Unity] 제네릭과 가변성(variance)
![[Unity] 제네릭과 가변성(variance)](/_next/image?url=https%3A%2F%2Fdata.develog.develrocket.com%2Fupload%2Fdevelog%2Fgmumdev%2F1791297148427-udtfz5%2F_____.png&w=3840&q=75)
개요
in은 안이라는 뜻이고 out은 바깥이라는 뜻이다.
그럼 아래 코드들은 무슨 뜻..?
List<T>와IReadOnlyList<out T>IEnumerator<out T>Action<in T>와Func<in T, out TResult>
정의
가변성(variance)이란 제네릭 G<A>와 G<B>의 변환 가능 여부를 말하는 표현이다.
가변성을 선언하는 방법은 형식 매개변수 T 앞에 out이나 in을 붙이는 것으로 한다.
out을 붙였다면 공변이라고 하고, in을 붙였다면 반공변이라 한다.
아무것도 안 붙인 T는 불공변이라고 한다.
대표적으로는 이런 것들이 있다.
List<T>IComparer<in T>,Action<in T>IEnumerable<out T>,IReadOnlyList<out T>
제네릭
List<T>와 같은 타입을 제네릭이라 부르고, 그 안의 T를 형식 매개변수라 한다.
'아무거나 넣을 수 있음!' 정도로 기억했던 녀석이다.
근데 종종 이 친구 앞에 이상한 게 따라 붙는데, 바로 in과 out이다. 얘들은 함수 매개변수 앞에 붙던 녀석들이다. 근데 걔들은 매개변수 한정자라는 이름을 가진 다른 애들이다. 우리가 다루는 건 형식 매개변수다. 다르다.
형식 매개변수(타입 매개변수)
개념은 간단한데, in은 사용자(호출부)가 해당 T를 넘기는 데에만 사용한다는 뜻이고, out은 받는 데에만 사용된다는 뜻이다.
Action<in T>는.Invoke(T 인스턴스)이렇게 넘기는데 쓴다는 거고.IEnumerator<out T>는T Current일케 받는데 쓰겠다는 뜻.
in/out은 interface와 delegate 형식매개변수에서만 사용이 가능하다.
여기서 도출되는 성질이 있다.
성질
Enemy 클래스가 Unit을 상속받는다고 하자.
I<out T>라면I<Enemy>를I<Unit>으로 바꿔도 안전하다.
이는 일반적인 상속의 방향이다(Unit unit = new Enemy). 공변성(covariance)이라고 한다.
I<in T>라면I<Unit>을I<Enemy>로 바꿔도 안전하다.
이는 상속의 반대 방향이다. 반공변성(contravariance)이라고 한다.
예시 - 공변성
예를 들면, IReadOnlyList<out T>에는 Add 함수가 없다. out이 붙었으니 사용자는 T를 받기만 해야 하는데, Add는 인자로 T를 받으니 T를 넘기는 메소드이기 때문이다. 이처럼 만약 해당 인터페이스에 인자로 T를 받는 메소드가 있다면, 사용자가 T를 넘기게 되므로 컴파일러가 막는다(CS1961).
공변성 심화
Q. 어 근데 확장메서드로 만들면요?
이 질문을 던졌더니 AI가 아래와 같은 예제를 주었다.
public static void AddUnsafe<T>(this IReadOnlyList<T> list, T item)
=> ((List<T>)list).Add(item);
IReadOnlyList<Unit> units = enemies; // List<Enemy> enemies
units.AddUnsafe(someEnemy); // InvalidCastException
units에 enemies를 넣었는데, 이 부분을 자세히 뜯어보면 이렇다.
List<Enemy>는IReadOnlyList<Enemy>를 구현한다. 따라서IReadOnlyList<Unit> units = (IReadOnlyList<Enemy>)enemies;이다.IReadOnlyList<Unit>에IReadOnlyList<Enemy>를 넣을 수 있다!IReadOnlyList<out T>이므로 공변성을 갖기 때문이다.
그래서 units를 선언한 부분은 문제가 없다.
문제는 AddUnsafe 메소드에 units를 전달할 때 발생한다.
- 먼저 units가
IReadOnlyList<T>로 전달되어, 컴파일러가 T를 Unit으로 추론한다. (List<T>)list로 다운 캐스팅되고 있다! 정상 동작할까?
먼저, 컴파일러는 이 구현에 대해 아무런 에러도 뱉지 않는다. 그러나 정상 동작하는지는 별개다.
컴파일러는 매개변수로 받은 list의 출처를 모르기에, 그것이 List<T> 형식으로 캐스팅되는지 판단할 수 없다. 만약 List<Unit> = List<Unit>이라면 가능할 것이다. 그러나 List<Unit> = List<Enemy>라면 InvalidCastException이 터져야 한다. 이 판단은 typeof(T)로 알 수 없고, list.GetType()과 같이 실제 힙 객체에 붙어있는 타입 헤더를 가져와야 한다. 컴파일러는 정적 검사만 하므로, 실제 헤더를 읽는 작업은 런타임에 맡길 수밖에 없는 구조다. 따라서, 컴파일러는 이 판단을 유보하고 일단 컴파일 시킨다. 빌드도 잘 된다. 문제가 터지는 건 실행 시점이다.
"런타임에 넘긴다", 구체적으로는 IL에
castclass명령이 들어가 있다고 한다. 컴파일이 모두 완료되고 실제로 해당 라인이 실행될 때가 되어서, 즉(List<Unit>)list가 실행될 때, InvalidCastException이 발생할 가능성이 있다.
다시 말해 units가 new List<Unit>라면 문제가 없지만, new List<Enemy>라면 문제가 된다.
후자는 (List<Unit>)list를 시도하면 list의 타입 헤더가 List<Enemy>를 말할 것이다. 즉 이 코드는 List<Unit> = List<Enemy>를 시도하는 것이 된다. List<T>의 T는 불공변성(invariance)이므로, 이 대입은 Invalid하다.
예시 - 반공변성
예를 들면, IComparer<in T>에는 T를 반환하는 프로퍼티나 함수가 없다. in이 붙었으니 사용자는 T를 넘기기만 해야 한다. 이처럼 만약 해당 인터페이스에 T를 뱉는 프로퍼티나 메소드가 있다면, 사용자가 T를 받게 되므로, 컴파일러가 막는다(CS1961).
반공변성 심화
Q. 어 그럼 TryGet처럼 매개변수 한정자 out T로 뱉으면요?
이 질문에 대해서는 AI가 아래와 같은 코드를 주었다.
public interface IHitHandler<in T> { bool TryGetLast(out T target); } // 여기서 이미 CS1961 뜬다.
class UnitHitHandler : IHitHandler<Unit>
{
private Ally lastHitAlly; // class Ally : Unit
public bool TryGetLast(out Unit target) { target = lastHitAlly; return true; }
}
IHitHandler<Enemy> h = new UnitHitHandler(); // in T이므로 허용된다고 가정하면
h.TryGetLast(out Enemy e); // Enemy 변수 e에 lastHitAlly가 써진다. NO WAY!!!
그래서 어림도 없다.
그리고 아래와 같은 코드도 막힌다
public interface IHitHandler<in T> { void Subscribe(Action<T> callback); } // CS1961
Action<in T>뭐 T를 반환한 것도 아니니 문제 없어 보이지만, 사실 callback이라는 것이 구독자에게 T에 값을 넣어서 돌려주는 방식이라 이것도 막혀야 맞다. 예시로는 아래 코드를 참고할 수 있다.
public interface IHitHandler<in T> { void Subscribe(Action<T> callback); } // CS1961로 터짐
class UnitHitHandler : IHitHandler<Unit>
{
private Action<Unit> _callback;
public void Subscribe(Action<Unit> callback) => _callback = callback;
private void OnUnitHit(Unit unit) => _callback(unit);
public void ReportHit(Unit unit) => OnUnitHit(unit);
}
IHitHandler<Enemy> h = new UnitHitHandler(); // in T이므로 허용된다고 가정하자
h.Subscribe((Enemy e) => e.DropLoot()); // Enemy만 처리하는 콜백 등록이 가능하다.
((UnitHitHandler)h).ReportHit(healer); // 근데 이러면 어쩔건데
// → OnUnitHit(healer) → _callback(healer) → DropLoot 람다에 Ally가 들어감. NO WAY!!!
예시에서는 class Ally : Unit으로 선언된 Ally의 인스턴스 healer가 ReportHit에 들어갔다. 문제는 없어 보인다.
아니 사실 문제 있다.
이게 콜백의 매개변수로 들어가서, 람다의 Enemy e까지 들어간다. 즉, T가 사용자에게 전달된 셈이다.
in T였는데 이러면 안 되지. 그래서 CS1961이 터뜨린다.
정리
결국 다음과 같이 정리해볼 수 있다.
out T는 사용자(호출부)가 T를 받기만 하겠다는 뜻.I<Child> -> I<Parent>가 허용된다.in T는 사용자가 T를 넘기기만 하겠다는 뜻.I<Parent> -> I<Child>가 허용된다.
값 타입은 다름
근데 값타입 T는 좀 이야기가 다르다. IReadOnlyList<object> list = new List<int>(); 는 아래처럼 변환을 시도할 텐데...
List<int>-->IReadOnlyList<int>-->IReadOnlyList<object>
가변 변환은 정적 타입만 바꾸고, 객체의 타입 헤더를 바꾸지 않는다.
만약 위의 가변 변환이 성립한다면, 런타임엔 IReadOnlyList<object>를 순회하려는 코드가 그 요소에 접근했을 때 헤더를 찾을 수 없어서 문제가 생긴다. (타입 헤더를 바꾸지 않는다는 건, 타입 헤더를 만들지도 않는다 = 박싱 안한다는 말)
따라서 이는 컴파일러 수준에서 막히고, CS0266이 터진다.
근데 CS0266은 이렇게 말한다.
암시적으로 'type1' 형식을 'type2' 형식으로 변환할 수 없습니다. 명시적 변환이 있습니다. 캐스트가 있는지 확인하세요.
음, IReadOnlyList<object> list = (IReadOnlyList<object>)new List<int>(); 가 되기는 한다.
이건 컴파일러가 터뜨리진 않는데, 실행 단계에서 InvalidCastException으로 막힌다.
결국, 값타입 형식매개변수 T에는 가변 변환이 적용되지 않는다.
참고로
참조 타입 배열은 일단 공변이다. 컴파일러가 안 막는다는 뜻이지, 이상한 거 대입하면 런타임에 에러가 터진다.
Enemy[] spawned = new Enemy[8];
Unit[] units = spawned; // 컴파일된다 (배열 공변성)
units[0] = someAlly; // ArrayTypeMismatchException
복잡해;
끗.

![[Unity] PC 세이브 데이터 Android로 옮기기](https://data.develog.develrocket.com/upload/develog/muchan/1792206352286-hd4xz4/_____2026-10-17_114953.png)
![[Unity] 제네릭 박싱 예방](https://data.develog.develrocket.com/upload/develog/gmumdev/1792123312021-bhxx2i/_____.png)

천천히 읽어보는데도 처음 본 개념이라 그런지 너무 어렵네요.. 좋은 글 감사합니다