T 는 여기에 타입이 들어온다는 빈칸이다
Esc

    T 는 여기에 타입이 들어온다는 빈칸이다

    –C#기초

    List<int> 를 처음 쓸 때 꺾쇠 안이 “담을 타입을 적는 자리” 라고만 하고 넘어갔습니다. 이제 그게 무엇인지 봅니다.

    정수만 담는 목록을 직접 만든다고 해 봅시다.

    class IntBox
    {
        private int value;
        public void Set(int v) { value = v; }
        public int Get() { return value; }
    }

    문자열도 담고 싶으면 StringBox 를 또 만듭니다. double 도, Dog 도요. 하는 일이 완전히 같은데 타입만 다른 클래스가 계속 늘어납니다.

    object 로 받으면 되지 않나

    모든 타입은 object 를 물려받는다고 했습니다. 그럼 object 로 받으면 다 되겠네요.

    class Box
    {
        private object value;
        public void Set(object v) { value = v; }
        public object Get() { return value; }
    }

    됩니다. 그런데 두 가지가 망가집니다.

    첫째, 타입 검사가 사라집니다.

    Box b = new Box();
    b.Set(42);
    
    string s = (string)b.Get();      // 컴파일은 됩니다. 실행하면 터집니다

    정수를 넣고 문자열로 꺼냈습니다. object 로 받았으니 컴파일러는 무엇이 들었는지 모릅니다. 꺼낼 때마다 캐스팅해야 하고, 그 캐스팅이 맞는지는 실행해 봐야 압니다.

    앞에서 컴파일 에러가 실행 중 에러보다 낫다고 여러 번 이야기했습니다. object 로 받는 것은 그 안전장치를 스스로 끄는 일입니다.

    둘째, 값 타입이면 박싱이 일어납니다.

    b.Set(42);      // int 를 object 자리에 넣음 → 힙에 상자를 만들어 담는다

    앞에서 본 박싱입니다. 넣을 때마다 힙에 자리가 생기고 나중에 GC 가 치웁니다. 목록에 만 개를 담으면 만 번 일어납니다.

    타입을 나중에 정하되, 정해지고 나면 컴파일러가 확인하게 하면 됩니다.

    제네릭은 이렇게 생겼다

    class Box<T>
    {
        private T value;
        public void Set(T v) { value = v; }
        public T Get() { return value; }
    }

    object 였던 자리가 전부 T 가 됐습니다. T 는 타입 이름이 아니라 “여기에 타입이 들어온다” 는 빈칸입니다.

    쓸 때 빈칸을 채웁니다.

    Box<int> a = new Box<int>();
    a.Set(42);
    int n = a.Get();          // 캐스팅이 필요 없습니다
    
    Box<string> b = new Box<string>();
    b.Set("안녕");
    b.Set(42);                // 컴파일 에러

    Box<int> 를 쓰는 순간 그 안의 T 가 전부 int 가 됩니다. Set 은 int 만 받고 Get 은 int 를 돌려줍니다.

    틀린 타입을 넣으면 컴파일 에러입니다. 실행해 볼 필요가 없습니다. 꺼낼 때 캐스팅도 없습니다. 박싱도 없습니다. object 로 받았을 때 망가진 두 가지가 다 돌아왔습니다.

    Box<T> T value; Box<int> int value; Box<string> string value; 정의는 한 벌 쓸 때 빈칸을 채우면 그 타입 전용이 된다
    한 번 적고 타입마다 쓴다. 대신 타입 검사는 그대로 남는다.

    T 라는 이름은 관습입니다. Type 의 첫 글자입니다. 두 개면 <TKey, TValue> 처럼 뜻이 드러나게 적습니다. Dictionary<string, int> 의 꺾쇠 두 칸이 그것이었습니다.

    메서드에도 붙는다

    클래스 전체가 아니라 메서드 하나만 제네릭으로 만들 수도 있습니다.

    void Swap<T>(ref T a, ref T b)
    {
        T temp = a;
        a = b;
        b = temp;
    }
    
    int x = 1, y = 2;
    Swap(ref x, ref y);

    int 든 string 이든 Dog 든 같은 코드로 바꿔치기합니다. 넘긴 값을 보고 T 가 무엇인지 컴파일러가 알아내므로 Swap<int>(...) 라고 안 적어도 됩니다.

    아무 타입이나 들어오면 할 수 있는 게 없다

    T 는 아무 타입이나 될 수 있습니다. 그래서 안에서 할 수 있는 일이 별로 없습니다.

    T Max<T>(T a, T b)
    {
        if (a > b) return a;      // 컴파일 에러
        return b;
    }

    T 에 Dog 가 들어올 수도 있는데 개끼리 크기를 비교할 수는 없습니다. 컴파일러는 > 를 쓸 수 있다는 보장이 없으니 막습니다.

    이럴 때 조건을 붙입니다.

    T Max<T>(T a, T b) where T : IComparable<T>
    {
        if (a.CompareTo(b) > 0) return a;
        return b;
    }

    where T : IComparable<T> 는 “T 는 비교할 수 있는 것이어야 한다” 는 뜻입니다. 앞에서 인터페이스는 약속 목록이라고 했습니다. 그 약속을 조건으로 건 것입니다.

    이제 안에서 CompareTo 를 쓸 수 있습니다. 그 약속을 지킨 타입만 들어오니까요. 약속을 안 지킨 타입을 넣으면 컴파일 에러입니다.

    자주 쓰는 조건들입니다.

    조건 뜻
    where T : class 참조 타입만
    where T : struct 값 타입만
    where T : new() 매개변수 없는 생성자가 있어야
    where T : 인터페이스 그 약속을 지킨 것만

    우리가 쓰던 것들이 전부 이것이었다

    List<int>
    Dictionary<string, int>
    HashSet<string>
    Stack<int>

    전부 제네릭입니다. List 를 만든 사람이 IntList, StringList 를 따로 만든 것이 아닙니다. List<T> 한 벌을 만들어 뒀고, 우리가 쓸 때 빈칸을 채우는 것입니다.

    그래서 List<int> 에 문자열을 넣으면 컴파일 에러가 났고, 꺼낼 때 캐스팅이 필요 없었습니다. 그리고 int 를 담아도 박싱이 안 일어났습니다.

    TryGetValue 의 out 타입이 딱 맞게 나오던 것도 같은 이유입니다. Dictionary<string, int> 의 TValue 가 int 로 정해져 있으니까요.

    정리

    • object 로 다 받으면 타입 검사가 사라지고 값 타입은 박싱이 일어납니다.
    • 제네릭은 타입을 빈칸으로 두고 쓸 때 채웁니다. T 가 그 빈칸입니다.
    • 채우고 나면 그 타입 전용이 됩니다. 틀린 타입은 컴파일 에러입니다.
    • 캐스팅이 필요 없고 박싱도 안 일어납니다.
    • 클래스뿐 아니라 메서드 하나만 제네릭으로 만들 수도 있습니다.
    • 아무 타입이나 들어오면 안에서 할 수 있는 일이 없습니다. where 로 조건을 붙입니다.
    • List, Dictionary, HashSet 이 전부 제네릭이었습니다.