.NET 배열 공변성으로 인한 성능 고려사항

공변성이란

공변성(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 구현이 있다