EveryDay.DevUp

[PART7.제네릭(3/3)] 공변성과 반공변성 — in/out이 필요한 이유 본문

C# 심화

[PART7.제네릭(3/3)] 공변성과 반공변성 — in/out이 필요한 이유

EveryDay.DevUp 2026. 4. 5. 23:02

공변성과 반공변성 — in/out이 필요한 이유

IEnumerable<Dog>IEnumerable<Animal>로 넘기는 건 직관적으로 당연해 보인다. 그런데 List<Dog>List<Animal>로 넘기면 컴파일 오류가 난다. 왜 같은 상속 관계인데 어떤 제네릭은 되고 어떤 제네릭은 안 될까? 그 답은 outin 키워드가 컴파일러에 알려주는 타입 흐름의 방향에 있다.


1. 문제 제기

Unity에서 적(Enemy) 계층 구조를 설계했다고 하자.

C#
public class Enemy : MonoBehaviour { public virtual int HP => 100; }
public class Zombie : Enemy { public override int HP => 50; }
public class Boss : Enemy { public override int HP => 500; }

모든 적의 HP 합산을 구하는 메서드를 만든다.

C#
public int TotalHP(List<Enemy> enemies)
{
    int total = 0;
    foreach (var e in enemies) total += e.HP;
    return total;
}

문제는 List<Zombie>를 이 메서드에 넘길 수 없다는 것이다.

C#
List<Zombie> zombies = GetActiveZombies();
TotalHP(zombies); // 컴파일 오류! List<Zombie>는 List<Enemy>가 아니다

ZombieEnemy를 상속했으니, List<Zombie>List<Enemy>로 취급할 수 있어야 하지 않을까? 이 질문에 답하려면 C#이 제네릭의 타입 변환을 어떻게 제한하는지, 그리고 outin이 왜 필요한지 이해해야 한다.


2. 개념 정의

분산(Variance)이란 무엇인가

일상의 비유로 시작하자. 택배 상자에 비유하면 이해가 쉽다.

  • 보내는 상자(출력 전용): "이 상자에서 물건을 꺼내기만 한다." 사과를 꺼내는 상자에서 과일을 꺼낸다고 해도 문제없다. 사과는 과일이니까. → 공변성
  • 받는 상자(입력 전용): "이 상자에 물건을 넣기만 한다." 과일을 받는 상자에 사과를 넣어도 된다. 하지만 반대로 사과만 받는 상자에 바나나를 넣으면 안 된다. → 반공변성
  • 보내고 받는 상자(입출력 겸용): 꺼내기도 하고 넣기도 한다. 아무 방향으로도 바꿀 수 없다. → 무공변성
분산(Variance) 제네릭 타입 매개변수 T의 상속 관계가 제네릭 타입 자체의 호환성에 어떻게 영향을 미치는지를 정의하는 규칙이다. C#에서는 인터페이스대리자(Delegate)에만 적용된다.
분산(Variance)의 세 가지 종류

세 가지 분산의 동작을 기본 코드로 확인해 보자.

out — 공변성 한정자 (Covariance modifier) 제네릭 타입 매개변수가 출력 위치(반환 타입)에만 사용됨을 선언한다. 하위 타입에서 상위 타입으로의 변환을 허용한다.
예시: IEnumerable<out T>IEnumerable<Dog>IEnumerable<Animal>에 할당 가능
in — 반공변성 한정자 (Contravariance modifier) 제네릭 타입 매개변수가 입력 위치(매개변수)에만 사용됨을 선언한다. 상위 타입에서 하위 타입으로의 변환을 허용한다.
예시: Action<in T>Action<Animal>Action<Dog>에 할당 가능
C#
using System;
using System.Collections.Generic;

public class Animal { }
public class Dog : Animal { }
public class Cat : Animal { }

public class VarianceDemo
{
    // 공변성: IEnumerable<out T>
    public static void GenericCovariance()
    {
        IEnumerable<Dog> dogs = new List<Dog>();
        IEnumerable<Animal> animals = dogs; // out T 덕분에 안전
    }

    // 반공변성: Action<in T>
    public static void GenericContravariance()
    {
        Action<Animal> actAnimal = a => Console.WriteLine(a);
        Action<Dog> actDog = actAnimal; // in T 덕분에 안전
        actDog(new Dog());
    }
}
IL
// GenericCovariance — 공변 변환
.locals init (
    [0] class IEnumerable`1<class Dog>,
    [1] class IEnumerable`1<class Animal>  // 타입이 다르지만 같은 참조
)
IL_0001: newobj instance void class List`1<class Dog>::.ctor()
IL_0006: stloc.0                           // dogs에 저장
IL_0007: ldloc.0                           // dogs 참조를 스택에
IL_0008: stloc.1                           // animals에 그대로 저장 — 참조 복사만!

// GenericContravariance — 반공변 변환
.locals init (
    [0] class Action`1<class Animal>,
    [1] class Action`1<class Dog>           // 타입이 다르지만 같은 참조
)
IL_0020: stloc.0                           // actAnimal에 저장
IL_0021: ldloc.0                           // actAnimal 참조를 스택에
IL_0022: stloc.1                           // actDog에 그대로 저장 — 참조 복사만!
IL_0024: newobj instance void Dog::.ctor()
IL_0029: callvirt instance void class Action`1<class Dog>::Invoke(!0)  // Dog로 호출

IL에서 핵심은 stloc로 참조를 그대로 복사하는 부분이다. box, newobj, castclass 같은 변환 명령어가 전혀 없다. 공변/반공변 변환은 새 객체를 만들거나 데이터를 복사하지 않는 참조 재해석이다. 즉, 런타임 오버헤드가 0이다.


3. 내부 동작

컴파일러가 in/out을 검증하는 방법

out T로 선언하면 컴파일러는 T출력 위치에만 등장하는지 검사한다. 입력 위치에 쓰면 컴파일 오류가 발생한다. in T는 정반대다.

C#
// 공변 인터페이스 — T는 출력 전용
public interface ISpawner<out T> where T : class
{
    T Spawn();                     // OK — 반환 타입(출력 위치)
    // void Accept(T target);     // 컴파일 오류! — 매개변수(입력 위치)에 out T 사용 불가
}

// 반공변 인터페이스 — T는 입력 전용
public interface IDamageProcessor<in T>
{
    void Process(T target);        // OK — 매개변수(입력 위치)
    // T GetTarget();              // 컴파일 오류! — 반환 타입(출력 위치)에 in T 사용 불가
}

CLR의 IL 메타데이터 — +- 플래그

컴파일러가 outin을 검증한 뒤, IL(Intermediate Language, .NET 중간 언어) 메타데이터에는 +(공변)와 -(반공변) 플래그로 기록된다.

C# 키워드 → IL 메타데이터 매핑

실제 IL을 확인해 보자. 위에서 작성한 ISpawner<out T>IDamageProcessor<in T>를 컴파일하면:

C#
// 공변 인터페이스 — 생산자
public interface ISpawner<out T> where T : class
{
    T Spawn();
    IEnumerable<T> SpawnMultiple(int count);
}

// 반공변 인터페이스 — 소비자
public interface IDamageProcessor<in T>
{
    void Process(T target);
}

public class Enemy { public int HP = 100; }
public class Zombie : Enemy { }

public class ZombieSpawner : ISpawner<Zombie>
{
    public Zombie Spawn() => new Zombie();
    public IEnumerable<Zombie> SpawnMultiple(int count)
    {
        var list = new List<Zombie>();
        for (int i = 0; i < count; i++) list.Add(new Zombie());
        return list;
    }
}

public class EnemyDamageProcessor : IDamageProcessor<Enemy>
{
    public void Process(Enemy target) => target.HP -= 10;
}
IL
// ISpawner — 공변 인터페이스
.class interface public abstract beforefieldinit ISpawner`1<class +T>  // +T = 공변(out)
{
    .method public abstract virtual instance !T Spawn ()               // T가 반환 위치(출력)
    .method public abstract virtual instance class IEnumerable`1<!T> SpawnMultiple (int32)
}

// IDamageProcessor — 반공변 인터페이스
.class interface public abstract beforefieldinit IDamageProcessor`1<-T>  // -T = 반공변(in)
{
    .method public abstract virtual instance void Process (!T target)    // T가 매개변수 위치(입력)
}

// 사용부 — Main 메서드
.locals init (
    [0] class ISpawner`1<class Zombie>,
    [1] class ISpawner`1<class Enemy>,     // Zombie → Enemy 공변 변환
    [2] class Enemy,
    [3] class IDamageProcessor`1<class Enemy>,
    [4] class IDamageProcessor`1<class Zombie>  // Enemy → Zombie 반공변 변환
)
IL_0001: newobj instance void ZombieSpawner::.ctor()
IL_0006: stloc.0                            // zombieSpawner 저장
IL_0007: ldloc.0
IL_0008: stloc.1                            // enemySpawner에 참조 복사만 — 변환 비용 0
IL_000a: callvirt instance !0 class ISpawner`1<class Enemy>::Spawn()  // Enemy로 호출해도 실제로는 Zombie 반환
IL_0010: newobj instance void EnemyDamageProcessor::.ctor()
IL_0015: stloc.3                            // enemyProcessor 저장
IL_0016: ldloc.3
IL_0017: stloc.s 4                          // zombieProcessor에 참조 복사만 — 변환 비용 0
IL_001b: newobj instance void Zombie::.ctor()
IL_0020: callvirt instance void class IDamageProcessor`1<class Zombie>::Process(!0)  // Zombie로 호출

IL 분석 포인트:

  1. +T-T 플래그: ISpawner1<class +T>에서 +는 공변, IDamageProcessor1<-T>에서 -는 반공변을 뜻한다. CLR(Common Language Runtime, .NET 실행 엔진)은 이 플래그를 보고 타입 변환 허용 여부를 판단한다.
  2. 변환 시 stloc만 사용: IL_0007 → IL_0008(공변)과 IL_0016 → IL_0017(반공변) 모두 ldloc + stloc로 참조를 그대로 복사한다. castclass, isinst, box 같은 변환 명령어가 없다. Unity의 Update 루프에서 매 프레임 이 변환이 일어나더라도 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 부담이 전혀 없다.

배열의 암시적 공변성 — in/out이 탄생한 역사적 배경

C# 제네릭에 in/out이 필요한 이유를 이해하려면, 배열의 설계 실수부터 알아야 한다. C#의 배열은 Java의 영향을 받아 암시적 공변성을 허용한다.

C#
public class Animal { }
public class Dog : Animal { }
public class Cat : Animal { }

public class ArrayCovarianceDemo
{
    public static void Main()
    {
        Dog[] dogs = new Dog[3];
        Animal[] animals = dogs; // 배열 공변성 — 컴파일 OK!

        animals[0] = new Dog();  // 실제 Dog 배열에 Dog을 넣으니 OK
        animals[1] = new Cat();  // Cat을 넣으려 하면 런타임 오류!
    }
}
IL
// ArrayCovariance 메서드
.locals init (
    [0] class Dog[],
    [1] class Animal[],
    [2] class Animal[]
)
IL_0002: newarr Dog                        // Dog 배열 생성
IL_0007: stloc.0
IL_0008: ldloc.0
IL_0009: stloc.2
IL_000a: ldloc.2
IL_000b: stloc.1                           // Animal[]에 Dog[] 참조 저장 — 컴파일러가 허용

IL_000d: ldc.i4.0
IL_000e: newobj instance void Dog::.ctor()
IL_0013: stelem.ref                        // 배열 요소 저장 — CLR이 런타임 타입 검사 수행!

IL_0015: ldc.i4.1
IL_0016: newobj instance void Cat::.ctor()
IL_001b: stelem.ref                        // Cat 저장 시도 — ArrayTypeMismatchException 발생!

핵심 — stelem.ref의 런타임 타입 검사:

stelem.ref는 배열에 참조 타입 요소를 저장하는 IL 명령어다. 이 명령어는 매번 실행될 때마다 저장하려는 객체의 실제 타입이 배열의 원래 요소 타입과 호환되는지 검사한다. Dog[]Cat을 넣으려 하면 ArrayTypeMismatchException이 발생한다.

이것이 바로 배열 공변성의 문제다:

  • 컴파일 타임에는 오류를 잡지 못한다
  • stelem.ref가 매번 런타임 타입 검사를 수행하므로 성능 비용이 발생한다
  • Unity의 핫패스(매 프레임 실행되는 코드)에서 배열에 요소를 저장할 때마다 이 검사가 일어난다

C# 4.0의 in/out은 이 문제를 해결한다. 컴파일러가 타입 흐름 방향을 미리 검증하므로, 런타임 오류도 없고 런타임 타입 검사 비용도 없다.


4. 실전 적용

매개변수에 IReadOnlyList<T> 대신 List<T>를 쓰는 실수

Unity 프로젝트에서 가장 흔하게 마주치는 분산 관련 문제는 [문제 제기]에서 본 것처럼 List<T>를 매개변수로 쓰는 것이다.

C#
// Before: List<T>는 무공변 — 하위 타입 리스트를 전달 불가
public class Enemy { public virtual int HP => 100; }
public class Zombie : Enemy { public override int HP => 50; }
public class Boss : Enemy { public override int HP => 500; }

public static int TotalHP_Invariant(List<Enemy> enemies)
{
    int total = 0;
    foreach (var e in enemies) total += e.HP;
    return total;
}

// After: IReadOnlyList<out T>는 공변 — 하위 타입 리스트 전달 가능
public static int TotalHP_Covariant(IReadOnlyList<Enemy> enemies)
{
    int total = 0;
    for (int i = 0; i < enemies.Count; i++) total += enemies[i].HP;
    return total;
}
IL
// Before — TotalHP_Invariant: List<Enemy>만 받음
.method public hidebysig static int32 TotalHP_Invariant (
    class List`1<class Enemy> enemies     // 무공변 — 정확히 List<Enemy>만
) cil managed
{
    IL_0005: callvirt instance valuetype List`1/Enumerator<!0> class List`1<class Enemy>::GetEnumerator()
    IL_000f: call instance !0 valuetype List`1/Enumerator<class Enemy>::get_Current()
    IL_0017: callvirt instance int32 Enemy::get_HP()
}

// After — TotalHP_Covariant: IReadOnlyList<Enemy>를 받으므로 List<Zombie>도 전달 가능
.method public hidebysig static int32 TotalHP_Covariant (
    class IReadOnlyList`1<class Enemy> enemies   // 공변(+T) — List<Zombie>도 OK
) cil managed
{
    IL_000a: callvirt instance !0 class IReadOnlyList`1<class Enemy>::get_Item(int32)
    IL_000f: callvirt instance int32 Enemy::get_HP()
}

// Main에서 사용
IL_001f: ldloc.0                          // List<Zombie>를 로드
IL_0020: call int32 TotalHP_Covariant(class IReadOnlyList`1<class Enemy>)  // 공변 변환으로 전달!

Before에서는 List<Enemy>만 받기 때문에 List<Zombie>를 넘기면 컴파일 오류가 발생한다. After에서 IReadOnlyList<out T>로 바꾸면, List<Zombie>를 변환 비용 없이 그대로 전달할 수 있다. IReadOnlyList<T>out T로 선언되어 있어 읽기 전용이 보장되기 때문이다.

콜백/이벤트에서의 반공변성 활용

Unity에서 적이 파괴될 때 이펙트를 재생하는 콜백을 설계한다고 하자.

C#
// 모든 Enemy에 적용 가능한 콜백
Action<Enemy> onEnemyDestroyed = enemy =>
{
    // 폭발 이펙트 재생
    ParticleSystem.Play();
    ScoreManager.Add(enemy.ScoreValue);
};

// Action<in T>의 반공변성 — Enemy를 처리할 수 있으면 Zombie도 처리 가능
Action<Zombie> onZombieDestroyed = onEnemyDestroyed;  // in T 덕분에 안전!
Action<Boss> onBossDestroyed = onEnemyDestroyed;      // Boss도 마찬가지!

Action<in T>에서 T는 입력 전용이다. Enemy를 받아서 처리할 수 있는 메서드는 Zombie(Enemy의 하위 타입)도 당연히 처리할 수 있다. ZombieEnemy의 모든 멤버를 갖고 있으니까. 각 적 타입마다 별도의 콜백을 만들 필요 없이, 하나의 상위 타입 핸들러로 모든 하위 타입 이벤트를 처리할 수 있다.

커스텀 인터페이스 설계 — 읽기/쓰기 분리 패턴

실전에서 공변/반공변을 직접 설계하는 가장 좋은 패턴은 읽기와 쓰기를 별도 인터페이스로 분리하는 것이다.

C#
// 읽기 전용 — 공변(out)
public interface IObjectPool_Reader<out T>
{
    T Peek();
    int Count { get; }
}

// 쓰기 전용 — 반공변(in)
public interface IObjectPool_Writer<in T>
{
    void Return(T item);
}

// 구현 클래스 — 양쪽 모두 구현
public class ObjectPool<T> : IObjectPool_Reader<T>, IObjectPool_Writer<T>
{
    private readonly Queue<T> _pool = new Queue<T>();

    public T Peek() => _pool.Peek();
    public int Count => _pool.Count;
    public void Return(T item) => _pool.Enqueue(item);
}

이 패턴을 쓰면:

  • IObjectPool_Reader<Zombie>IObjectPool_Reader<Enemy>로 넘길 수 있다 (공변)
  • IObjectPool_Writer<Enemy>IObjectPool_Writer<Zombie>로 받을 수 있다 (반공변)
  • 읽기만 필요한 메서드에 쓰기 권한을 노출하지 않으므로 API가 더 안전해진다

5. 함정과 주의사항

함정 1: 값 타입에 분산을 시도하면 박싱이 발생한다

분산은 참조 변환에만 적용되므로, int, float, struct 같은 값 타입에는 적용되지 않는다.

C#
// ❌ Before: 값 타입에 공변 변환을 시도 — Cast<object>로 박싱 발생
public static void WithBoxing()
{
    IEnumerable<int> numbers = new List<int> { 1, 2, 3 };
    // IEnumerable<object> boxed = numbers; // 컴파일 오류! int는 참조 타입이 아님
    IEnumerable<object> boxed = numbers.Cast<object>(); // 매 요소마다 박싱!
    foreach (object o in boxed) { }
}

// ✅ After: 제네릭 메서드로 박싱 회피
public static void ProcessAll<T>(IEnumerable<T> items)
{
    foreach (T item in items) { }
}

public static void WithoutBoxing()
{
    IEnumerable<int> numbers = new List<int> { 1, 2, 3 };
    ProcessAll(numbers); // 박싱 없음 — T가 int로 특수화됨
}
IL
// ❌ Before — WithBoxing: Cast<object> 사용
IL_0020: call class IEnumerable`1<!!0> Enumerable::Cast<object>(class IEnumerable)
// Cast<object> 내부에서 매 요소마다 box int32가 실행됨!
// Unity Update 루프에서 수천 개 int를 처리하면 GC 스파이크의 원인이 된다

// ✅ After — WithoutBoxing: 제네릭 메서드 사용
IL_0020: call void ValueTypeVarianceTrap::ProcessAll<int32>(class IEnumerable`1<!!0>)
// T가 int32로 특수화되어 박싱 없이 직접 처리

Cast<object>IEnumerable(비제네릭)을 거쳐 각 요소를 object로 박싱(Boxing, 값 타입을 힙에 참조 타입으로 복사하는 과정)한다. Unity 모바일에서 매 프레임 이런 코드가 실행되면 Boehm GC(Unity의 가비지 컬렉터)가 빈번하게 수집을 수행해 프레임 드랍의 원인이 된다.

함정 2: List<T>가 무공변인 이유를 이해하지 못하고 캐스팅을 시도한다

C#
// ❌ 잘못된 시도: List<T>를 강제 캐스팅
List<Zombie> zombies = new List<Zombie>();
List<Enemy> enemies = (List<Enemy>)(object)zombies; // 컴파일은 되지만 런타임 오류!

List<T>Add(입력)와 인덱서(출력)를 모두 갖고 있으므로, T가 입력과 출력 양쪽에 모두 사용된다. 따라서 outin도 붙일 수 없고, 무공변이 되어야 한다. [문제 제기]에서 List<Zombie>List<Enemy>로 넘길 수 없었던 근본 원인이 바로 이것이다.

C#
// ✅ 올바른 해결: 읽기만 필요하면 IReadOnlyList<T>로 받는다
void ProcessEnemies(IReadOnlyList<Enemy> enemies) { /* ... */ }

List<Zombie> zombies = new List<Zombie>();
ProcessEnemies(zombies); // IReadOnlyList<out T> 덕분에 안전하게 전달

함정 3: Unity IL2CPP에서 제네릭 분산과 리플렉션 조합

Unity의 IL2CPP(Intermediate Language to C++, Unity의 AOT 컴파일 백엔드) 환경에서는 제네릭 타입이 AOT(Ahead-of-Time, 미리 컴파일) 컴파일된다. 분산 자체는 정상 동작하지만, 리플렉션으로 제네릭 인터페이스를 동적 생성할 때는 해당 타입 조합이 AOT 컴파일 시점에 알려져야 한다.

C#
// ❌ IL2CPP에서 위험: 런타임에 제네릭 타입을 동적으로 구성
Type spawnerType = typeof(ISpawner<>).MakeGenericType(enemyType);
// IL2CPP가 이 조합을 미리 컴파일하지 않았으면 ExecutionEngineException 발생

// ✅ 안전한 방법: 사용할 타입 조합을 명시적으로 선언
[Preserve]
static void EnsureGenericTypes()
{
    // IL2CPP가 이 타입 조합을 AOT 컴파일하도록 힌트
    ISpawner<Zombie> _ = null;
    ISpawner<Boss> __ = null;
}

직접 바꿔보면 어떻게 될까? ISpawner<out T>에서 out을 제거하고 T를 매개변수 위치에도 사용해 보자. 컴파일러가 어떤 오류를 내는지 확인하면, out이 왜 "출력 전용"을 강제하는지 체감할 수 있다.


6. C# 버전별 변화

C# 2.0 — 제네릭 도입, 모든 것이 무공변

C# 2.0에서 제네릭이 도입되었지만, 모든 제네릭 타입 매개변수는 무공변이었다. 대리자에 한해서만 메서드 그룹 변환에서 암시적 분산이 지원되었다.

C#
// Before (C# 2.0): 대리자 메서드 그룹 변환에서만 암시적 분산
delegate Animal AnimalFactory();
delegate void AnimalHandler(Dog dog);

static Dog CreateDog() => new Dog();
static void HandleAnimal(Animal animal) { }

// C# 2.0에서 이미 허용 (대리자 메서드 그룹 변환)
AnimalFactory factory = CreateDog;      // 반환 타입 공변성
AnimalHandler handler = HandleAnimal;   // 매개변수 반공변성

// 하지만 제네릭 인터페이스는 완전히 무공변
IEnumerable<Dog> dogs = new List<Dog>();
// IEnumerable<Animal> animals = dogs; // C# 2.0/3.0에서는 컴파일 오류!

C# 4.0 — in/out 키워드 도입

C# 4.0에서 제네릭 인터페이스와 대리자에 in/out을 명시할 수 있게 되었다. BCL(Base Class Library, .NET 기본 클래스 라이브러리)의 주요 인터페이스에 분산이 적용되었다.

C#
// After (C# 4.0): BCL 인터페이스에 분산 적용
IEnumerable<Dog> dogs = new List<Dog>();
IEnumerable<Animal> animals = dogs; // C# 4.0부터 OK! (IEnumerable<out T>)

Func<Dog> dogFactory = () => new Dog();
Func<Animal> animalFactory = dogFactory; // Func<out TResult>

Action<Animal> animalAction = a => { };
Action<Dog> dogAction = animalAction;     // Action<in T>

C# 4.0에서 분산이 적용된 주요 BCL 타입:

공변 (out) 반공변 (in)
IEnumerable<out T> Action<in T>
IEnumerator<out T> IComparer<in T>
IReadOnlyList<out T> IEqualityComparer<in T>
IReadOnlyCollection<out T> Comparison<in T>
Func<out TResult> Predicate<in T> (내부적)

C# 4.0 이후 분산의 규칙 자체에는 큰 변화가 없다. 적용 대상이 인터페이스와 대리자로 한정되는 점도 동일하다.


7. 정리

공변성과 반공변성은 제네릭의 타입 변환을 컴파일 타임에 안전하게 허용하는 메커니즘이다.

  • [ ] out T(공변): T를 출력(반환)으로만 쓸 때 선언 — IEnumerable<Dog>IEnumerable<Animal> 허용
  • [ ] in T(반공변): T를 입력(매개변수)으로만 쓸 때 선언 — Action<Animal>Action<Dog> 허용
  • [ ] 무공변: T를 입출력 모두 사용하면 변환 불가 — List<T>, IList<T>가 대표적
  • [ ] 런타임 오버헤드 0: 분산 변환은 참조 재해석일 뿐, 박싱이나 객체 생성이 없다
  • [ ] 값 타입 불가: int, struct 등은 분산 적용 불가 — Cast<object> 쓰면 박싱 발생
  • [ ] 매개변수는 약하게: List<T> 대신 IReadOnlyList<T>IEnumerable<T>를 쓰면 공변성을 활용할 수 있다
  • [ ] 배열 공변성은 피하라: Dog[]Animal[]은 허용되지만 stelem.ref의 런타임 타입 검사 비용이 있고, 런타임 오류 위험이 있다