C# 3.1 · 3부. 중급 문법

제네릭

형식을 나중에 정하는 틀을 만듭니다.

예상 학습 시간 16분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 중급
개념 설명

형식만 다르고 나머지가 같은 코드

값 하나를 담아 두는 Box 라는 형식을 만든다고 해봅시다. int 를 담는 것을 만들고 나면 string 을 담는 것이 필요해지고, 그다음에는 DateTime 을 담는 것이 필요해집니다. 담는 형식만 다르고 코드는 글자 하나까지 같습니다.

object 로 담으면 형식은 하나로 줄어들지만 값을 꺼낼 때마다 캐스트를 적어야 하고, 그 캐스트가 맞는지는 실행해 봐야 압니다. 잘못 담은 것이 몇 달 뒤 사용자 화면에서 예외로 발생합니다.

제네릭형식을 나중에 정하는 틀입니다. 형식을 한 번만 만들어 두고 사용하는 쪽이 무엇을 담을지 정합니다. 코드는 하나로 줄어들면서, 잘못 담는 것은 실행 전에 컴파일러가 걸러 줍니다.

  • 같은 코드를 형식마다 되풀이하지 않습니다.
  • 꺼낼 때 캐스트가 필요 없습니다. 담은 형식 그대로 나옵니다.
  • 잘못 담으면 컴파일 오류입니다. 실행 중 예외가 아닙니다.
최소 예제 · 편집 가능

Box 하나로 여러 형식 담기

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
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}";
}
출력
15 5 Box<Int32>: 10 Box<String>: 안녕하세요

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

Box<T>T형식 매개 변수입니다. 메서드의 매개 변수가 값을 나중에 받듯이, T형식을 나중에 받습니다. new Box<int>(10) 이라고 적는 순간 T 자리에 int 가 들어가고, 그 BoxValueint 가 됩니다. 이름이 T 인 것은 관례일 뿐이라 TKey·TItem 처럼 뜻이 드러나게 적어도 됩니다.

상세 사용법

object 로 두는 것과 무엇이 다른가

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

Tint 인지 double 인지는 여전히 모릅니다. 다만 INumber<T> 제약이 "T 는 더할 수 있고 0을 내놓을 수 있는 형식" 임을 보증하므로 그만큼은 부를 수 있습니다. 제약이 하는 일은 이것 하나입니다 — 무엇인지는 몰라도 무엇을 갖추었는지는 알게 하는 것.

제약을 IAdditionOperators<T, T, T> 로 줄이면 + 는 그대로 되지만 T.ZeroCS0704 로 막힙니다. 적어 둔 만큼만 열립니다.

값을 정하지 않은 T

T 의 "비어 있는 값" 이 필요할 때가 있습니다. 그런데 그것이 0 인지 null 인지는 T 가 무엇이냐에 따라 다릅니다. default 가 알아서 정해 줍니다.

static T? Default<T>() => default;

Console.WriteLine(Default<int>());      // 0
Console.WriteLine(Default<string>());   // null(빈 줄)

형식 매개 변수는 여럿이어도 됩니다

이미 매일 사용하고 있습니다 — 1.7Dictionary<TKey, TValue> 가 형식 매개 변수 둘을 받는 제네릭 클래스입니다. List<T>Task<T> 도 마찬가지입니다. 제네릭은 완전히 새로운 개념이 아니라, 이미 사용해 온 것의 내부 구조를 드러내는 방식입니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
2.0
3.0
6.0
7.0
8.0
9.0
11.0
14.0
  • 2.0제네릭 형식 · 제네릭 메서드 · where 제약 · default
  • 11.0static abstract 를 요구하는 제약 · INumber<T>
C# 2.0+

제네릭은 C# 2.0에서 들어왔고, 언어를 통틀어 가장 큰 변화로 꼽힙니다. 그 전까지 ArrayList 처럼 object 로 담던 것들이 전부 List<T> 로 바뀌었고, 뒤에 나온 Nullable<T>·LINQ·Task<T> 가 모두 이것 위에 세워졌습니다. C# 11.0의 static abstract 제약은 그 위에 연산자까지 제약으로 걸 수 있게 한 것입니다.

실습 문제

직접 해보기

1. 두 값을 함께 담는 형식 난이도 하

서로 다른 두 형식을 함께 담는 Pair<TFirst, TSecond> 를 만들어보세요. Pair<string, int> 에 상품명과 가격을 담아 사과 = 1500 처럼 출력해봅니다.

형식 매개 변수는 <TFirst, TSecond> 처럼 쉼표로 나열합니다. 이름은 T 로 시작하는 관례만 지키면 됩니다.
var item = new Pair<string, int>("사과", 1500); Console.WriteLine(item); // 사과 = 1500 // 담는 형식을 바꿔도 클래스는 그대로입니다 var flag = new Pair<int, bool>(1, true); Console.WriteLine(flag); // 1 = True class Pair<TFirst, TSecond>(TFirst first, TSecond second) { public TFirst First { get; } = first; public TSecond Second { get; } = second; public override string ToString() => $"{First} = {Second}"; }
2. 가장 큰 것 찾기 난이도 중

배열에서 가장 큰 값을 돌려주는 Max<T> 를 만들어보세요. int 배열에도 string 배열에도 같은 메서드 하나로 동작해야 합니다. 제약을 무엇으로 걸지 생각해보세요.

> 는 제약을 걸어도 되지 않습니다. 자기끼리 순서를 비교할 수 있다는 약속은 IComparable<T> 이고, 비교는 CompareTo 로 합니다. 빈 배열이 올 때도 생각해보세요.
Console.WriteLine(Max([3, 9, 1])); // 9 Console.WriteLine(Max(["가", "다", "나"])); // 다 static T Max<T>(T[] items) where T : IComparable<T> { if (items.Length == 0) throw new ArgumentException("빈 배열입니다", nameof(items)); T best = items[0]; foreach (var it in items) if (it.CompareTo(best) > 0) best = it; return best; }
요약
  • 제네릭은 형식을 나중에 정하는 틀입니다. T 는 형식을 받는 매개 변수입니다.
  • object 로 담으면 잘못이 실행 중 예외가 발생하지만, 제네릭은 컴파일 오류로 걸립니다.
  • 제네릭 메서드는 넘긴 값에서 형식을 추론하므로 <T> 를 적지 않아도 됩니다.
  • where 제약은 T 가 무엇을 갖추었는지를 적는 것이고, 적어 둔 만큼만 부를 수 있습니다.
  • List<T>·Dictionary<TKey, TValue>·Task<T> 가 전부 이것입니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.