T 는 여기에 타입이 들어온다는 빈칸이다
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 로 받았을 때 망가진 두 가지가 다 돌아왔습니다.
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이 전부 제네릭이었습니다.