제네릭
형식을 나중에 정하는 틀을 만듭니다.
형식만 다르고 나머지가 같은 코드
값 하나를 담아 두는 Box 라는 형식을 만든다고 해봅시다.
int 를 담는 것을 만들고 나면
string 을 담는 것이 필요해지고, 그다음에는
DateTime 을 담는 것이 필요해집니다.
담는 형식만 다르고 코드는 글자 하나까지 같습니다.
object 로 담으면 형식은 하나로 줄어들지만 값을 꺼낼 때마다
캐스트를 적어야 하고, 그 캐스트가 맞는지는 실행해 봐야 압니다.
잘못 담은 것이 몇 달 뒤 사용자 화면에서 예외로 발생합니다.
제네릭은 형식을 나중에 정하는 틀입니다. 형식을 한 번만 만들어 두고 사용하는 쪽이 무엇을 담을지 정합니다. 코드는 하나로 줄어들면서, 잘못 담는 것은 실행 전에 컴파일러가 걸러 줍니다.
- 같은 코드를 형식마다 되풀이하지 않습니다.
- 꺼낼 때 캐스트가 필요 없습니다. 담은 형식 그대로 나옵니다.
- 잘못 담으면 컴파일 오류입니다. 실행 중 예외가 아닙니다.
Box 하나로 여러 형식 담기
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
var number = new Box<int>(10); var text = new Box<string>("안녕하세요"); // 꺼낸 값이 담은 형식 그대로입니다 — 캐스트가 없습니다 Console.WriteLine(number.Value + 5); // int 라서 더할 수 있습니다 Console.WriteLine(text.Value.Length); // string 이라서 Length 가 있습니다 Console.WriteLine(number); Console.WriteLine(text); // T 는 "나중에 정할 형식" 의 이름입니다 class Box<T>(T value) { public T Value { get; } = value; public override string ToString() => $"Box<{typeof(T).Name}>: {Value}"; }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
Box<T> 의 T 는
형식 매개 변수입니다. 메서드의 매개 변수가 값을 나중에 받듯이,
T 는 형식을 나중에 받습니다.
new Box<int>(10) 이라고 적는 순간
T 자리에 int 가 들어가고,
그 Box 의 Value 는 int 가
됩니다. 이름이 T 인 것은 관례일 뿐이라
TKey·TItem 처럼 뜻이 드러나게
적어도 됩니다.
object 로 두는 것과 무엇이 다른가
class ObjectBox(object value) { public object Value { get; } = value; } var b = new ObjectBox(10); Console.WriteLine((int)b.Value + 5); // 15 // 문자열을 담아도 막지 않습니다 var w = new ObjectBox("열"); Console.WriteLine((int)w.Value + 5); // 컴파일됩니다 // 실행할 때 InvalidCastException
class Box<T>(T value) { public T Value { get; } = value; } var b = new Box<int>(10); Console.WriteLine(b.Value + 5); // 15 // 애초에 담기지 않습니다 var w = new Box<int>("열"); // 컴파일 오류 // 잘못이 실행 전에 드러납니다
오류가 드러나는 시점이 다릅니다. 왼쪽은 값을 담을 때가 아니라 꺼내 사용할 때 예외가 발생하므로, 잘못 담은 자리와 예외가 발생하는 자리가 멀리 떨어져 있습니다. 제네릭이 들어오기 전(C# 1.0)에는 목록 하나 담는 것도 이 방식이었습니다.
메서드에도 붙일 수 있습니다
형식뿐 아니라 메서드 하나에만 형식 매개 변수를 둘 수도 있습니다.
이름 뒤에 <T> 를 적으면 됩니다.
static T First<T>(T[] items) => items[0]; // 형식을 적지 않아도 됩니다 — 넘긴 값에서 알아냅니다(형식 추론) Console.WriteLine(First([3, 1, 2])); // 3 Console.WriteLine(First(["가", "나"])); // 가 // 드러내고 싶거나 추론이 어긋날 때는 직접 적습니다 Console.WriteLine(First<double>([1.5, 2.5])); // 1.5
제약 — 이 단원의 핵심
여기서 한 가지 문제가 생깁니다. T 가 무엇이든 될 수
있다면, 그것으로 할 수 있는 일도 거의 없습니다. 아래는 전부 컴파일되지
않습니다.
static T Bigger<T>(T a, T b) => a > b ? a : b; // CS0019: '>' 연산자는 'T' 및 'T' 형식의 피연산자에 적용할 수 없습니다 static T Make<T>() => new T(); // CS0304: 'T' 변수 형식에 new() 제약 조건이 없으므로 인스턴스를 만들 수 없습니다 static int Len<T>(T x) => x.Length; // CS1061: 'T'에는 'Length'에 대한 정의가 포함되어 있지 않습니다
당연합니다. T 자리에 무엇이 올지 모르는데
> 로 비교할 수 있다고 장담할 수 없습니다.
where 제약은 "T 가 아무거나는 아니다" 를 적어
두는 것입니다. 적어 두면 그만큼을 컴파일러가 허락합니다.
// "T 는 자기끼리 비교할 수 있는 형식이다" static T Bigger<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b) >= 0 ? a : b; // "T 는 매개 변수 없는 생성자가 있는 형식이다" static T Make<T>() where T : new() => new T(); Console.WriteLine(Bigger(3, 7)); // 7 Console.WriteLine(Bigger("가", "나")); // 나
제약을 만족하지 않는 형식을 넣으면 넣는 자리에서 걸립니다 —
CS0310·CS0311 입니다. 어느 쪽이든
실행 전입니다.
where T : class— 참조 형식만.null을 담을 수 있습니다.where T : struct— 값 형식만. 2.7의 구조체입니다.where T : new()— 매개 변수 없는 생성자가 있는 형식만.where T : SomeClass— 그 클래스이거나 그것을 물려받은 형식만.where T : ISomething— 그 인터페이스를 구현한 형식만.
여럿을 한꺼번에 걸 수도 있고(where T : class, new()),
형식 매개 변수가 둘이면 각각에 따로 겁니다
(where TKey : notnull where TValue : new()).
2.9 의 T.Zero 가 여기에 놓입니다
2.9에서 형식이 무엇인지 모르는 채로
+ 와 T.Zero 를 부르는 코드를
보았습니다. 그것이 바로 인터페이스 제약이었습니다.
using System.Numerics; static T Sum<T>(T[] items) where T : INumber<T> { T total = T.Zero; foreach (var it in items) total += it; return total; } Console.WriteLine(Sum([1, 2, 3])); // 6 Console.WriteLine(Sum([1.5, 2.5])); // 4
T 가 int 인지
double 인지는 여전히 모릅니다. 다만
INumber<T> 제약이 "T 는 더할 수 있고 0을
내놓을 수 있는 형식" 임을 보증하므로 그만큼은 부를 수 있습니다.
제약이 하는 일은 이것 하나입니다 — 무엇인지는 몰라도 무엇을
갖추었는지는 알게 하는 것.
제약을 IAdditionOperators<T, T, T> 로 줄이면
+ 는 그대로 되지만 T.Zero 는
CS0704 로 막힙니다. 적어 둔 만큼만
열립니다.
값을 정하지 않은 T
T 의 "비어 있는 값" 이 필요할 때가 있습니다. 그런데 그것이
0 인지 null 인지는
T 가 무엇이냐에 따라 다릅니다.
default 가 알아서 정해 줍니다.
static T? Default<T>() => default; Console.WriteLine(Default<int>()); // 0 Console.WriteLine(Default<string>()); // null(빈 줄)
형식 매개 변수는 여럿이어도 됩니다
이미 매일 사용하고 있습니다 — 1.7의
Dictionary<TKey, TValue> 가 형식 매개 변수 둘을 받는
제네릭 클래스입니다. List<T> 도
Task<T> 도 마찬가지입니다.
제네릭은 완전히 새로운 개념이 아니라, 이미 사용해 온 것의 내부 구조를
드러내는 방식입니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 2.0제네릭 형식 · 제네릭 메서드 · where 제약 · default
- 11.0static abstract 를 요구하는 제약 · INumber<T>
제네릭은 C# 2.0에서 들어왔고, 언어를 통틀어 가장 큰 변화로
꼽힙니다. 그 전까지 ArrayList 처럼
object 로 담던 것들이 전부
List<T> 로 바뀌었고, 뒤에 나온
Nullable<T>·LINQ·Task<T>
가 모두 이것 위에 세워졌습니다. C# 11.0의
static abstract 제약은 그 위에 연산자까지
제약으로 걸 수 있게 한 것입니다.
직접 해보기
서로 다른 두 형식을 함께 담는 Pair<TFirst, TSecond> 를
만들어보세요. Pair<string, int> 에 상품명과 가격을
담아 사과 = 1500 처럼 출력해봅니다.
배열에서 가장 큰 값을 돌려주는 Max<T> 를
만들어보세요. int 배열에도
string 배열에도 같은 메서드 하나로
동작해야 합니다. 제약을 무엇으로 걸지 생각해보세요.
- 제네릭은 형식을 나중에 정하는 틀입니다.
T는 형식을 받는 매개 변수입니다. object로 담으면 잘못이 실행 중 예외가 발생하지만, 제네릭은 컴파일 오류로 걸립니다.- 제네릭 메서드는 넘긴 값에서 형식을 추론하므로
<T>를 적지 않아도 됩니다. where제약은 T 가 무엇을 갖추었는지를 적는 것이고, 적어 둔 만큼만 부를 수 있습니다.List<T>·Dictionary<TKey, TValue>·Task<T>가 전부 이것입니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.