제네릭은 타입 안전성을 잃지 않고 타입이나 메서드를 한 번 작성해 여러 타입에 쓰게 해 줍니다. 캐스트가 필요하고 무엇이든 받아들이는 object의 List 대신, 각각 컴파일러가 검사하는 List<int>와 List<Order>를 얻습니다. 꺾쇠괄호 안의 자리 표시자(관례상 T)가 형식 매개변수이고, 호출하는 쪽이 제공하는 타입이 형식 인수입니다.
왜 제네릭인가: 제네릭이 해결하는 문제
제네릭 이전(C# 1)에는 컬렉션이 object를 저장했습니다. 컴파일은 되었지만 실수는 실행 시점에야 드러났고, 모든 int가 들어갈 때 박싱되었습니다:
var prices = new System.Collections.ArrayList();
prices.Add(9.99m);
prices.Add("12.50"); // compiles: anything is an object
decimal total = 0;
foreach (object p in prices)
total += (decimal)p; // InvalidCastException on the string
List<decimal>이면 두 번째 Add가 컴파일 오류가 되고, 루프에 캐스트가 필요 없으며, decimal은 decimal로 저장됩니다.
제네릭 클래스
클래스 이름 뒤에 형식 매개변수를 선언하고, 클래스 안에서 다른 타입처럼 쓰세요:
출력:
Page 2/3: Chen, Dara, more: True
Page 1/1: 90, 72, 85, more: False
Page<string>과 Page<int>는 하나의 정의에서 만들어진 서로 다른 두 타입입니다. 클래스는 형식 매개변수를 여러 개 가질 수 있습니다(Dictionary<TKey, TValue>). 하나면 T, 여러 개면 TSomething으로 이름을 지으세요.
제네릭 메서드와 형식 유추
메서드는 클래스가 제네릭이든 아니든 자기 형식 매개변수를 가질 수 있습니다. 컴파일러가 인수로부터 유추하므로 호출하는 쪽은 보통 형식 인수를 생략합니다:
출력:
2 1
back front
ho ho ho
4.50 EUR, 12.00 EUR, 0.99 EUR
유추는 인수로부터만 동작하고 반환 타입으로부터는 절대 동작하지 않습니다. Repeat(0, 3)은 표기가 필요 없지만, T Create<T>() 같은 메서드는 유추할 것이 없으므로 호출하는 쪽이 Create<Customer>()라고 써야 합니다. LINQ의 대부분은 이 유추에 기대는 제네릭 메서드입니다. prices.Where(p => p > 1)은 직접 쓰지 않아도 Where<decimal>입니다.
제약 조건: T가 될 수 있는 것
제네릭 메서드 안에서 컴파일러는 모든 가능한 T에 동작하는 것만 허용합니다. 제약 조건이 없으면 object가 지원하는 것과 거의 다를 바 없습니다. CompareTo를 호출하거나, 인스턴스를 만들거나, 속성을 읽으려면 where로 T가 무엇이어야 하는지 컴파일러에게 알려 주세요:
출력:
17
plum
19.99
42 Zoe
쓰게 될 제약 조건:
| 제약 조건 | 의미 | 할 수 있게 되는 것 |
|---|---|---|
where T : class | 참조 형식 | null 대입, as 사용 |
where T : struct | null이 될 수 없는 값 형식 | T?를 Nullable<T>로 사용 |
where T : new() | public 매개변수 없는 생성자 | new T() 호출(마지막에 나열해야 함) |
where T : Entity | Entity에서 파생 | Entity의 멤버 사용 |
where T : IComparable<T> | 인터페이스 구현 | CompareTo 호출 |
where T : U | 다른 형식 매개변수에서 파생 | 두 매개변수를 연관 짓기 |
where T : unmanaged(C# 7.3) | 안에 참조가 없는 구조체 | 포인터, stackalloc |
where T : Enum(C# 7.3) | 열거형 타입 | Enum 메서드에 전달 |
where T : notnull(C# 8) | null이 될 수 없는 타입 | nullable 참조 주석 |
한 매개변수에 여러 제약 조건은 쉼표로 구분하고, 매개변수가 여러 개면 각각 자기 where 절을 가집니다: class Cache<TKey, TValue> where TKey : notnull where TValue : class.
빠진 제약 조건은 멤버를 쓰는 지점에서 컴파일 오류로 나타납니다. 예를 들어 CS1061('T' does not contain a definition for 'CompareTo')이나, new() 없이 new T()를 호출할 때의 CS0304입니다.
default(T)
제네릭 코드는 모르는 타입의 "빈 값"이 필요할 때가 있습니다. default(T)는 숫자는 0, bool은 false, 참조 형식과 nullable 형식은 null, 그 밖에는 0으로 채운 구조체를 줍니다:
출력:
31
0
Porto
True
0001-01-01
두 번째 줄이 이 패턴의 약점을 보여 줍니다. 값 형식에서는 "찾지 못함"과 "0을 찾음"이 똑같아 보입니다. 그래서 프레임워크 메서드는 대신 TryGet 형태(bool TryGetValue(TKey key, out TValue value))를 씁니다. C# 7.1부터는 문맥에서 타입이 분명하면 더 짧은 return default;를 쓸 수 있습니다.
정적 멤버는 구성된 타입마다 따로 있습니다
구성된 각 타입(Counter<int>, Counter<string>)은 클래스의 정적 필드를 자기 복사본으로 가집니다:
출력:
2
1
False
이는 가끔 유용하고(T마다 한 번 계산하는 TypeInfo<T>.Name 같은 타입별 캐시), 공유 카운터 하나를 기대했을 때는 가끔 놀라움을 줍니다. 공유 상태는 제네릭이 아닌 클래스에 두세요.
제네릭 컬렉션
제네릭 컬렉션이 대부분의 요구를 해결합니다: List<T>, Dictionary<TKey, TValue>, HashSet<T>, Queue<T>, Stack<T>, 그리고 이들이 공유하는 인터페이스 IEnumerable<T>, ICollection<T>, IList<T>, IReadOnlyList<T>. 메서드에 필요한 가장 좁은 인터페이스를 받고(순회만 한다면 IEnumerable<T>), 구체 타입이나 읽기 전용 타입을 반환하세요.
공변성과 반공변성
Dog는 Animal이지만 List<Dog>는 List<Animal>이 아닙니다:
List<Dog> dogs = new List<Dog>();
List<Animal> animals = dogs;
// error CS0029: Cannot implicitly convert type 'List<Dog>' to 'List<Animal>'
이것이 허용된다면 animals.Add(new Cat())가 개 리스트에 고양이를 넣게 됩니다. 값을 만들어 내기만 하는 인터페이스는 안전하며, out으로 공변이라고 선언됩니다:
IEnumerable<Animal> readOnly = dogs; // fine: IEnumerable<out T>
IReadOnlyList<Animal> alsoFine = dogs; // fine: IReadOnlyList<out T>
거울상인 반공변성은 in으로 표시하며, 값을 소비하기만 하는 인터페이스와 델리게이트에 적용됩니다. 어떤 동물이든 처리할 수 있는 것은 개도 처리할 수 있으므로, Action<Animal>은 Action<Dog>이 기대되는 곳에 쓸 수 있습니다. IComparer<in T>는 반공변적이고, Func<in T, out TResult>는 두 표시를 모두 씁니다. 가변성은 인터페이스와 델리게이트에만, 그리고 참조 형식 인수에만 적용됩니다. IEnumerable<int>는 IEnumerable<object>로 변환되지 않습니다.
흔한 실수
- 형식 매개변수가 있어야 할 곳에
object쓰기. 컴파일 시점 검사를 포기하고 값 형식을 박싱합니다. - 두
T값을==로 비교하기.class제약 조건이 없으면 컴파일되지 않고(CS0019), 있으면 참조를 비교합니다. 모든T에 동작하고 타입 자신의Equals를 존중하는EqualityComparer<T>.Default.Equals(a, b)를 쓰세요. - "찾지 못함"으로
default(T)반환하기. 값 형식에서는 모호합니다.bool TryX(..., out T value)를 쓰세요. - 너무 많은 제약 조건. 제약 조건 하나하나가 메서드를 쓸 수 있는 쪽을 좁힙니다. 본문에 실제로 필요한 것만 제약하세요.
List<Derived>가List<Base>로 변환되리라 기대하기. 대신IEnumerable<Base>나IReadOnlyList<Base>를 받으세요.
자주 묻는 질문
C#에서 제네릭이란 무엇인가요?
제네릭은 호출하는 쪽이 채우는 자리 표시 타입 T로 클래스, 인터페이스, 메서드를 한 번만 작성하게 해 줍니다: List<int>, List<Customer>. 컴파일러가 모든 사용을 실제 타입에 대해 검사하므로 캐스트 없이 타입 안전성을 얻고, 값 형식은 박싱 없이 저장됩니다. System.Collections.Generic의 컬렉션이 가장 익숙한 예입니다.
C#에서 제네릭 메서드는 어떻게 작성하나요?
메서드 이름 뒤에 형식 매개변수를 둡니다: static T Largest<T>(List<T> items) where T : IComparable<T> { ... }. 컴파일러가 인수로부터 형식 인수를 유추하므로 호출하는 쪽은 보통 이를 생략합니다. prices가 List<decimal>이면 Largest(prices)가 동작합니다. 유추할 것이 없을 때는 Largest<decimal>(...)처럼 명시하세요.
C#에서 where T : class는 무슨 뜻인가요?
제약 조건입니다. T는 참조 형식이어야 하므로, 제네릭 코드가 T에 null을 대입하고 as T를 쓸 수 있습니다. 다른 제약 조건으로 where T : struct(null이 될 수 없는 값 형식), where T : new()(public 매개변수 없는 생성자가 있어서 코드가 new T()를 호출할 수 있음), where T : SomeBaseClass, 그리고 코드가 T에서 그 인터페이스의 멤버를 호출하게 해 주는 where T : ISomeInterface가 있습니다.
C#에서 default(T)란 무엇인가요?
T가 어떤 타입이 되든 그 타입의 기본값입니다. 숫자는 0, bool은 false, 참조 형식과 nullable 형식은 null, 그 밖의 값 형식은 0으로 채운 구조체입니다. 제네릭 코드가 타입을 모르고 "값 없음"이 필요할 때, 예를 들어 조회에 실패했을 때의 반환값으로 씁니다. C# 7.1은 더 짧은 default 리터럴을 추가했습니다.
C#에서 List<Dog>를 List<Animal>에 대입할 수 없는 이유는 무엇인가요?
List<Animal>은 어떤 동물이든 받아들이기 때문입니다. 대입이 허용되면 코드가 실제로는 List<Dog>인 것에 Cat을 추가할 수 있습니다. 컴파일러가 이를 거부합니다(CS0029). 읽기 전용 인터페이스는 공변적이므로 IEnumerable<Animal> animals = dogs;와 IReadOnlyList<Animal> animals = dogs;는 둘 다 컴파일됩니다. 이들을 통해서는 아무것도 추가할 수 없기 때문입니다.