공변성이란
공변성(Covariance)은 어려운 용어처럼 들리지만,
'따라서 같이 변할 수 있는 성질'이라는 간단한 의미이다.
예를 들어 A라는 타입이 B라는 타입으로 바뀔 수 있을 때,
여기에서 파생되는 C<A> 타입이 C<B> 타입으로 따라서 바뀔 수 있을 때 이를 공변성이라고 한다.
C#에서 대표적인 공변성 예시로는 IEnumerable<T>이 있다
IEnumerable<string> strings = new List<string>();
// string -> object로 바뀔 수 있을 때,
// IEnumerable<string>도 따라서 IEnumerable<object>로 변경 가능하다
IEnumerable<Object> objects = strings;
공변성에 의한 런타임 에러
C#에서 또 공변성이 있는 예는 바로 배열이다.
string[] strArray = new string[5];
// string[] -> object[]
object[] objArray = strArray;
그런데 object 배열에 string이 아닌 다른 클래스의 인스턴스를 넣으면 어떻게 될까?
.NET은 런타임에 배열에 안전하지 않은 타입이 들어가는지 검사해서 ArrayTypeMismatchException 예외가 발생하게 된다.
object[] objArray = new string[5];
// System.ArrayTypeMismatchException:
// 'Attempted to access an element as a type incompatible with the array.'
objArray[0] = 1234;
타입 체크 비용 (Stelem_Ref) 회피
.NET이 배열에 요소를 저장할 때마다 안전한지 검사한다는 말은 추가적인 비용이 발생함을 의미한다.
일반적인 로직의 경우 이 오버헤드는 무시할만큼 작고,
저장하려는 값이 null이거나 동일 배열의 값이라면 이미 검증된 값이기 때문에 스킵하도록 하는 최적화 등을 하고 있지만
(JIT: avoid store covariance check for ref-type ldelem from same array · Issue #9159 · dotnet/runtime)
핫패스에 있어서 매우 자주 실행되는 코드인 경우 신경을 써야할 수도 있다.
만약 프로파일링을 통해 이 오버헤드가 잡혔다면 Stelem_Ref라는 이름으로 나타나게 된다.
1안 - sealed 키워드 사용
.NET Sealed 클래스 성능 고려사항 글에서 이야기한 것처럼,
컴파일러는 sealed된 클래스는 더 이상 상속이 일어나지 않는 것을 확신할 수 있다.
따라서 배열의 대상이 되는 클래스에 sealed를 붙이게 된다면,
저장하려는 타입이 실제로는 다른 타입(하위 클래스)가 아니라는 것을 확신할 수 있게 되어
타입 체크를 하지 않는 최적화를 진행할 수 있게 된다
sealed class MyClass { }
// 배열에 저장시 배열 타입 검사하지 않음
MyClass[] myArray = new MyClass[5];
myArray[0] = new MyClass();
2안 - 메모리 직접 쓰기
unsafe한 코드가 허용되는 상황이고, 주어진 변수의 타입을 확신할 수 있는 상황이라면
타입 검사를 우회하여 메모리에 직접 쓸 수도 있다.
fixed되는 메모리가 많으면 GC 성능에 영향을 주기도 하고
잘못된 타입이 들어간 경우 다른 곳에서 런타임 에러가 날 수 있기 때문에 배열에 저장된 값은 조심해서 사용해야 한다.
public static void SetArrayValue<T>(T[] array, int index, T value)
{
unsafe
{
fixed (T* _ = array) // GC에 의해 이동되지 않도록 고정
{
ref T start = ref MemoryMarshal.GetArrayDataReference(array);
Unsafe.Add(ref start, index) = value;
}
}
}
object[] objArray = new string[5];
SetArrayValue(objArray, 0, 4); // 런타임 에러 나지 않음
SetArrayValue(objArray, 1, "test");
3안 - struct 레퍼 사용
1안은 배열의 대상을 직접 정의해서 사용할 때만 사용 가능하고, 확실하지 않은 컴파일러 최적화에 의지하게 된다.
2안은 unsafe 코드를 사용하고 GC에 영향을 끼친다.
더 나은 방법으로 struct로 한번 래핑을 해서 배열을 만들게 되면 struct는 값 타입이기 때문에 공변성 타입 검사를 진행하지 않게 된다.
2안과 마찬가지로 역시 타입 검사를 진행하지 않기 때문에 잘못된 타입이 들어갈 수 있는 상태가 되었으므로 배열에 저장된 값은 조심해서 사용해야 한다.
class MyClass { }
class MyChildClass : MyClass { }
struct Element
{
public MyClass Value;
}
Element[] elementArray = new Element[5];
elementArray[0] = new Element { Value = new MyClass() };
elementArray[1] = new Element { Value = new MyChildClass() }; // 런타임 에러 나지 않음
특히 오브젝트 풀은 저장하는 대상의 클래스를 사용자가 정의하기 때문에 struct 래퍼 방식을 사용하는 경우가 많다.
대표적인 예시로 Roslyn의 ObjectPool 구현이 있다