| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- Tween
- 가이드
- ui
- 오공완
- AES
- 2D Camera
- Job 시스템
- 최적화
- sha
- base64
- Framework
- 직장인자기계발
- 패스트캠퍼스
- 패스트캠퍼스후기
- 프레임워크
- TextMeshPro
- job
- 환급챌린지
- DotsTween
- 직장인공부
- 암호화
- RSA
- Unity Editor
- 샘플
- Dots
- unity
- adfit
- Custom Package
- 게임개발
- C#
- Today
- Total
EveryDay.DevUp
[PART7.제네릭(3/3)] 공변성과 반공변성 — in/out이 필요한 이유 본문
공변성과 반공변성 — in/out이 필요한 이유
IEnumerable<Dog>를 IEnumerable<Animal>로 넘기는 건 직관적으로 당연해 보인다. 그런데 List<Dog>를 List<Animal>로 넘기면 컴파일 오류가 난다. 왜 같은 상속 관계인데 어떤 제네릭은 되고 어떤 제네릭은 안 될까? 그 답은 out과 in 키워드가 컴파일러에 알려주는 타입 흐름의 방향에 있다.
1. 문제 제기
Unity에서 적(Enemy) 계층 구조를 설계했다고 하자.
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 합산을 구하는 메서드를 만든다.
public int TotalHP(List<Enemy> enemies)
{
int total = 0;
foreach (var e in enemies) total += e.HP;
return total;
}
문제는 List<Zombie>를 이 메서드에 넘길 수 없다는 것이다.
List<Zombie> zombies = GetActiveZombies();
TotalHP(zombies); // 컴파일 오류! List<Zombie>는 List<Enemy>가 아니다
Zombie는 Enemy를 상속했으니, List<Zombie>도 List<Enemy>로 취급할 수 있어야 하지 않을까? 이 질문에 답하려면 C#이 제네릭의 타입 변환을 어떻게 제한하는지, 그리고 out과 in이 왜 필요한지 이해해야 한다.
2. 개념 정의
분산(Variance)이란 무엇인가
일상의 비유로 시작하자. 택배 상자에 비유하면 이해가 쉽다.
- 보내는 상자(출력 전용): "이 상자에서 물건을 꺼내기만 한다." 사과를 꺼내는 상자에서 과일을 꺼낸다고 해도 문제없다. 사과는 과일이니까. → 공변성
- 받는 상자(입력 전용): "이 상자에 물건을 넣기만 한다." 과일을 받는 상자에 사과를 넣어도 된다. 하지만 반대로 사과만 받는 상자에 바나나를 넣으면 안 된다. → 반공변성
- 보내고 받는 상자(입출력 겸용): 꺼내기도 하고 넣기도 한다. 아무 방향으로도 바꿀 수 없다. → 무공변성
분산(Variance) 제네릭 타입 매개변수 T의 상속 관계가 제네릭 타입 자체의 호환성에 어떻게 영향을 미치는지를 정의하는 규칙이다. C#에서는 인터페이스와 대리자(Delegate)에만 적용된다.

세 가지 분산의 동작을 기본 코드로 확인해 보자.
out— 공변성 한정자 (Covariance modifier) 제네릭 타입 매개변수가 출력 위치(반환 타입)에만 사용됨을 선언한다. 하위 타입에서 상위 타입으로의 변환을 허용한다.
예시:IEnumerable<out T>—IEnumerable<Dog>를IEnumerable<Animal>에 할당 가능
in— 반공변성 한정자 (Contravariance modifier) 제네릭 타입 매개변수가 입력 위치(매개변수)에만 사용됨을 선언한다. 상위 타입에서 하위 타입으로의 변환을 허용한다.
예시:Action<in T>—Action<Animal>을Action<Dog>에 할당 가능
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());
}
}
// 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는 정반대다.
// 공변 인터페이스 — 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 메타데이터 — +와 - 플래그
컴파일러가 out과 in을 검증한 뒤, IL(Intermediate Language, .NET 중간 언어) 메타데이터에는 +(공변)와 -(반공변) 플래그로 기록된다.

실제 IL을 확인해 보자. 위에서 작성한 ISpawner<out T>와 IDamageProcessor<in T>를 컴파일하면:
// 공변 인터페이스 — 생산자
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;
}
// 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 분석 포인트:
+T와-T플래그:ISpawner1<class +T>에서+는 공변,IDamageProcessor1<-T>에서-는 반공변을 뜻한다. CLR(Common Language Runtime, .NET 실행 엔진)은 이 플래그를 보고 타입 변환 허용 여부를 판단한다.- 변환 시
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의 영향을 받아 암시적 공변성을 허용한다.
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을 넣으려 하면 런타임 오류!
}
}
// 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>를 매개변수로 쓰는 것이다.
// 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;
}
// 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에서 적이 파괴될 때 이펙트를 재생하는 콜백을 설계한다고 하자.
// 모든 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의 하위 타입)도 당연히 처리할 수 있다. Zombie는 Enemy의 모든 멤버를 갖고 있으니까. 각 적 타입마다 별도의 콜백을 만들 필요 없이, 하나의 상위 타입 핸들러로 모든 하위 타입 이벤트를 처리할 수 있다.
커스텀 인터페이스 설계 — 읽기/쓰기 분리 패턴
실전에서 공변/반공변을 직접 설계하는 가장 좋은 패턴은 읽기와 쓰기를 별도 인터페이스로 분리하는 것이다.
// 읽기 전용 — 공변(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 같은 값 타입에는 적용되지 않는다.
// ❌ 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로 특수화됨
}
// ❌ 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>가 무공변인 이유를 이해하지 못하고 캐스팅을 시도한다
// ❌ 잘못된 시도: List<T>를 강제 캐스팅
List<Zombie> zombies = new List<Zombie>();
List<Enemy> enemies = (List<Enemy>)(object)zombies; // 컴파일은 되지만 런타임 오류!
List<T>는 Add(입력)와 인덱서(출력)를 모두 갖고 있으므로, T가 입력과 출력 양쪽에 모두 사용된다. 따라서 out도 in도 붙일 수 없고, 무공변이 되어야 한다. [문제 제기]에서 List<Zombie>를 List<Enemy>로 넘길 수 없었던 근본 원인이 바로 이것이다.
// ✅ 올바른 해결: 읽기만 필요하면 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 컴파일 시점에 알려져야 한다.
// ❌ 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에서 제네릭이 도입되었지만, 모든 제네릭 타입 매개변수는 무공변이었다. 대리자에 한해서만 메서드 그룹 변환에서 암시적 분산이 지원되었다.
// 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 기본 클래스 라이브러리)의 주요 인터페이스에 분산이 적용되었다.
// 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의 런타임 타입 검사 비용이 있고, 런타임 오류 위험이 있다
'C# 심화' 카테고리의 다른 글
| [PART8.컬렉션과 LINQ(1/6)] List<T> vs Array — 언제 무엇을 선택하는가 (1) | 2026.04.05 |
|---|---|
| [PART7.제네릭(2/3)] 제네릭 제약 조건 — where T : 의 의미 (0) | 2026.04.05 |
| [PART7.제네릭(1/3)] 제네릭 — <T>가 해결하는 문제 (0) | 2026.04.05 |
| [PART6.문자열(3/3)] 문자열 포맷팅 — String.Format·보간·Span 기반 포맷 (0) | 2026.04.05 |
| [PART6.문자열(2/3)] string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 (0) | 2026.04.05 |
