C#을 공부하다 보면 <T>라는 문법을 자주 볼 수 있다.
예를 들면 이런 코드이다.
List<int> numbers = new List<int>();
또는 직접 제네릭 클래스를 만들 때도 볼 수 있다.
class Box<T>
{
public T value;
}
여기서 <T>가 바로 제네릭과 관련된 문법이다.
제네릭은 쉽게 말해 자료형을 나중에 정할 수 있게 해주는 문법이다.
1. 제네릭이란?
제네릭은 클래스, 메서드, 인터페이스 등을 만들 때 사용할 자료형을 미리 고정하지 않고 나중에 정할 수 있게 하는 기능이다.
예를 들어 정수 값을 저장하는 상자가 있다고 해보자.
class IntBox
{
public int value;
}
문자열을 저장하는 상자도 필요하다면 또 만들어야 한다.
class StringBox
{
public string value;
}
이렇게 하면 자료형이 달라질 때마다 클래스를 계속 새로 만들어야 한다.
이때 제네릭을 사용하면 하나의 클래스로 여러 자료형을 처리할 수 있다.
class Box<T>
{
public T value;
}
이제 T 자리에 원하는 자료형을 넣어서 사용할 수 있다.
Box<int> intBox = new Box<int>();
intBox.value = 10;
Box<string> stringBox = new Box<string>();
stringBox.value = "Hello";
Box<int>에서는 T가 int가 되고,
Box<string>에서는 T가 string이 된다.
2. <T>의 의미
제네릭에서 자주 보이는 <T>는 타입 매개변수라고 부른다.
class Box<T>
{
public T value;
}
여기서 T는 실제 자료형이 아니라, 나중에 들어올 자료형을 대신하는 이름이다.
즉, T는 다음과 같은 의미로 볼 수 있다.
T = 나중에 정해질 자료형
예를 들어 다음 코드에서는 T가 int가 된다.
Box<int> box = new Box<int>();
그래서 클래스 내부의 T value는 실제로는 int value처럼 동작한다.
class Box<int>
{
public int value;
}
물론 위 코드는 설명을 위한 표현이고, 실제로 이렇게 작성하지는 않는다.
3. C++의 template과 C#의 Type 관례
C++에서도 <T>와 비슷한 문법을 볼 수 있다.
template <typename T>
class Box
{
public:
T value;
};
C++에서는 이 기능을 template이라고 부른다.
반면 C#에서는 같은 형태의 <T>를 사용하지만, 이것을 generic, 즉 제네릭이라고 부른다.
class Box<T>
{
public T value;
}
여기서 T는 보통 Type의 앞 글자로 사용된다.
즉, C++에서는 template 문법에서 T를 많이 사용하고,
C#에서는 제네릭 타입 매개변수 이름으로 T를 많이 사용한다.
다만 중요한 점은, C#에서 T라는 이름은 문법적으로 반드시 정해진 것은 아니라는 것이다.
관례적으로 Type의 의미로 T를 많이 쓰는 것뿐이다.
아래 코드는 모두 가능하다.
class Box<T>
{
public T value;
}
class Box<TItem>
{
public TItem value;
}
class Box<DataType>
{
public DataType value;
}
하지만 일반적으로는 짧고 익숙한 T를 많이 사용한다.
4. 제네릭을 사용하는 이유
1) 코드 중복을 줄일 수 있다
제네릭을 사용하지 않으면 자료형마다 클래스를 따로 만들어야 한다.
class IntBox
{
public int value;
}
class StringBox
{
public string value;
}
하지만 제네릭을 사용하면 하나의 클래스로 해결할 수 있다.
class Box<T>
{
public T value;
}
사용할 때만 자료형을 정하면 된다.
Box<int> intBox = new Box<int>();
Box<string> stringBox = new Box<string>();
2) 타입 안정성이 높아진다
제네릭을 사용하면 잘못된 자료형이 들어가는 것을 컴파일 단계에서 막을 수 있다.
예를 들어 List<int>는 int만 저장할 수 있다.
List<int> numbers = new List<int>();
numbers.Add(10);
numbers.Add(20);
하지만 문자열은 넣을 수 없다.
numbers.Add("Hello"); // 오류
이렇게 제네릭은 자료형을 명확하게 정하기 때문에 실수를 줄여준다.
3) 형변환을 줄일 수 있다
제네릭이 없던 방식에서는 object를 많이 사용했다.
class Box
{
public object value;
}
object는 거의 모든 자료형을 담을 수 있다.
Box box = new Box();
box.value = 10;
하지만 값을 꺼낼 때 형변환이 필요하다.
int number = (int)box.value;
만약 잘못된 자료형으로 형변환하면 오류가 발생할 수 있다.
string text = (string)box.value; // 오류 가능
제네릭을 사용하면 이런 문제를 줄일 수 있다.
Box<int> box = new Box<int>();
box.value = 10;
int number = box.value;
형변환 없이 바로 사용할 수 있다.
5. 제네릭 클래스
제네릭 클래스는 클래스 이름 뒤에 <T>를 붙여 만든다.
class Box<T>
{
public T value;
}
사용할 때는 T 자리에 실제 자료형을 넣는다.
Box<int> intBox = new Box<int>();
Box<string> stringBox = new Box<string>();
Box<float> floatBox = new Box<float>();
각각의 객체에서 T는 다르게 해석된다.
Box<int> // T는 int
Box<string> // T는 string
Box<float> // T는 float
이렇게 제네릭 클래스는 여러 자료형에 대응할 수 있다.
6. 제네릭 메서드
제네릭은 클래스뿐만 아니라 메서드에도 사용할 수 있다.
void Print<T>(T value)
{
Console.WriteLine(value);
}
이 메서드는 어떤 자료형이든 출력할 수 있다.
Print<int>(10);
Print<string>("Hello");
Print<float>(3.14f);
보통은 자료형을 생략해도 컴파일러가 알아서 추론한다.
Print(10);
Print("Hello");
Print(3.14f);
이런 것을 타입 추론이라고 한다.
즉, 컴파일러가 전달된 값을 보고 T가 어떤 자료형인지 자동으로 판단하는 것이다.
7. 제네릭 인터페이스
인터페이스에도 제네릭을 사용할 수 있다.
interface IRepository<T>
{
void Add(T item);
T Get(int index);
}
이 인터페이스는 어떤 자료형을 저장할지 나중에 정할 수 있다.
class ItemRepository : IRepository<string>
{
private List<string> items = new List<string>();
public void Add(string item)
{
items.Add(item);
}
public string Get(int index)
{
return items[index];
}
}
여기서는 T가 string이 된다.
따라서 인터페이스 내부의 T는 전부 string처럼 동작한다.
8. 제네릭 컬렉션
C#에서 제네릭을 가장 자주 볼 수 있는 곳은 컬렉션이다.
대표적으로 List<T>가 있다.
List<int> numbers = new List<int>();
여기서 T는 int이다.
numbers.Add(10);
numbers.Add(20);
numbers.Add(30);
문자열 리스트를 만들 수도 있다.
List<string> names = new List<string>();
names.Add("Tom");
names.Add("Jane");
딕셔너리도 제네릭을 사용한다.
Dictionary<string, int> scores = new Dictionary<string, int>();
여기서는 타입 매개변수가 두 개이다.
Dictionary<TKey, TValue>
TKey는 키의 자료형이고, TValue는 값의 자료형이다.
Dictionary<string, int>
이 경우 key는 string, value는 int이다.
9. 타입 매개변수 이름 짓기
제네릭 타입 매개변수는 보통 T로 시작한다.
가장 기본적인 형태는 T이다.
class Box<T>
{
}
타입이 하나이고 의미가 단순하면 T만 써도 충분하다.
하지만 타입의 역할을 더 명확히 하고 싶다면 구체적인 이름을 사용할 수 있다.
class Repository<TEntity>
{
}
class ObjectPool<TObject>
{
}
class Dictionary<TKey, TValue>
{
}
여기서 TEntity, TObject, TKey, TValue는 모두 관례적인 이름이다.
정리하면 다음과 같다.
| T | 일반적인 타입 |
| TItem | 아이템 타입 |
| TKey | 키 타입 |
| TValue | 값 타입 |
| TEntity | 데이터 개체 타입 |
| TObject | 객체 타입 |
10. 제네릭 제약 조건
제네릭은 어떤 자료형이든 받을 수 있다.
class Box<T>
{
public T value;
}
하지만 가끔은 특정 조건을 만족하는 자료형만 받고 싶을 때가 있다.
이럴 때 사용하는 것이 제네릭 제약 조건이다.
제약 조건은 where 키워드를 사용한다.
class Box<T> where T : class
{
}
이 코드는 T가 참조형이어야 한다는 뜻이다.
1) class 제약 조건
class Repository<T> where T : class
{
}
T는 클래스 같은 참조형만 사용할 수 있다.
Repository<string> repo1 = new Repository<string>();
string은 참조형이므로 가능하다.
하지만 int는 값형이므로 사용할 수 없다.
Repository<int> repo2 = new Repository<int>(); // 오류
2) struct 제약 조건
class ValueBox<T> where T : struct
{
}
T는 값형만 사용할 수 있다.
ValueBox<int> box1 = new ValueBox<int>();
ValueBox<float> box2 = new ValueBox<float>();
하지만 클래스는 사용할 수 없다.
ValueBox<string> box3 = new ValueBox<string>(); // 오류
3) new() 제약 조건
class Factory<T> where T : new()
{
public T Create()
{
return new T();
}
}
new() 제약 조건은 T가 매개변수 없는 생성자를 가져야 한다는 뜻이다.
그래야 new T()를 사용할 수 있다.
4) 상속 제약 조건
특정 클래스를 상속받은 타입만 받을 수도 있다.
class Character
{
}
class Player : Character
{
}
class Enemy : Character
{
}
class Spawner<T> where T : Character
{
}
이 경우 T는 Character이거나 Character를 상속받은 클래스여야 한다.
Spawner<Player> playerSpawner = new Spawner<Player>();
Spawner<Enemy> enemySpawner = new Spawner<Enemy>();
5) 인터페이스 제약 조건
특정 인터페이스를 구현한 타입만 받을 수도 있다.
interface IDamageable
{
void TakeDamage(int damage);
}
class DamageSystem<T> where T : IDamageable
{
public void Damage(T target)
{
target.TakeDamage(10);
}
}
이렇게 하면 T가 반드시 TakeDamage()를 가지고 있다는 것을 보장할 수 있다.
그래서 클래스 내부에서 안전하게 메서드를 호출할 수 있다.
11. 제네릭 제약 조건이 필요한 이유
제약 조건이 없으면 T가 어떤 타입인지 알 수 없다.
class DamageSystem<T>
{
public void Damage(T target)
{
target.TakeDamage(10); // 오류
}
}
컴파일러 입장에서는 T가 TakeDamage()를 가지고 있는지 알 수 없다.
그래서 오류가 난다.
하지만 인터페이스 제약 조건을 걸면 가능해진다.
interface IDamageable
{
void TakeDamage(int damage);
}
class DamageSystem<T> where T : IDamageable
{
public void Damage(T target)
{
target.TakeDamage(10);
}
}
이제 컴파일러는 T가 반드시 IDamageable을 구현한다는 것을 안다.
그래서 TakeDamage()를 호출할 수 있다.
12. 타입 매개변수가 여러 개인 제네릭
제네릭은 타입 매개변수를 여러 개 가질 수 있다.
class Pair<TFirst, TSecond>
{
public TFirst first;
public TSecond second;
}
사용할 때는 각각의 타입을 지정한다.
Pair<string, int> pair = new Pair<string, int>();
pair.first = "Score";
pair.second = 100;
여기서 TFirst는 string, TSecond는 int가 된다.
딕셔너리도 같은 방식이다.
Dictionary<string, int> scores = new Dictionary<string, int>();
string은 key 타입, int는 value 타입이다.
13. 제네릭과 object의 차이
제네릭을 잘 이해하려면 object와 비교하면 좋다.
object는 모든 자료형의 부모이기 때문에 여러 자료형을 담을 수 있다.
object value;
value = 10;
value = "Hello";
value = 3.14f;
하지만 꺼내서 사용할 때 형변환이 필요하다.
int number = (int)value;
그리고 잘못 변환하면 오류가 발생할 수 있다.
제네릭은 처음부터 타입을 정해놓고 사용한다.
Box<int> box = new Box<int>();
box.value = 10;
그래서 값을 꺼낼 때 형변환이 필요 없다.
int number = box.value;
또한 잘못된 자료형을 넣으려고 하면 컴파일 단계에서 막아준다.
box.value = "Hello"; // 오류
정리하면 다음과 같다.
| 여러 자료형 처리 | 가능 | 가능 |
| 타입 안정성 | 낮음 | 높음 |
| 형변환 | 필요함 | 거의 필요 없음 |
| 오류 발견 시점 | 실행 중 발생 가능 | 컴파일 중 발견 가능 |
| 코드 가독성 | 낮아질 수 있음 | 좋음 |
14. 제네릭과 박싱, 언박싱
값형을 object에 넣으면 박싱이 일어난다.
object value = 10;
여기서 10은 int 값형인데, object에 들어가면서 박싱된다.
다시 int로 꺼낼 때는 언박싱이 일어난다.
int number = (int)value;
박싱과 언박싱은 불필요한 비용을 만들 수 있다.
하지만 제네릭 컬렉션을 사용하면 이런 문제를 줄일 수 있다.
List<int> numbers = new List<int>();
numbers.Add(10);
List<int>는 int를 그대로 다루기 때문에 object처럼 매번 박싱, 언박싱할 필요가 없다.
그래서 제네릭은 성능 면에서도 유리할 수 있다.
15. Unity에서 제네릭 사용 예시
Unity에서도 제네릭은 많이 사용된다.
대표적으로 GetComponent<T>()가 있다.
Rigidbody rb = GetComponent<Rigidbody>();
여기서 T는 Rigidbody이다.
즉, 다음과 같은 의미로 볼 수 있다.
GetComponent<Rigidbody>()
Rigidbody 타입의 컴포넌트를 가져오겠다는 뜻이다.
다른 컴포넌트도 가져올 수 있다.
Collider col = GetComponent<Collider>();
Animator animator = GetComponent<Animator>();
Unity의 GetComponent<T>()는 제네릭 메서드의 대표적인 예시이다.
16. 제네릭 메서드 직접 만들기
두 값을 바꾸는 Swap 메서드를 만들어보자.
먼저 int 전용으로 만들면 다음과 같다.
void SwapInt(ref int a, ref int b)
{
int temp = a;
a = b;
b = temp;
}
문자열도 바꾸고 싶다면 또 만들어야 한다.
void SwapString(ref string a, ref string b)
{
string temp = a;
a = b;
b = temp;
}
제네릭을 사용하면 하나의 메서드로 만들 수 있다.
void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
사용 예시는 다음과 같다.
int x = 10;
int y = 20;
Swap(ref x, ref y);
string a = "Hello";
string b = "World";
Swap(ref a, ref b);
자료형이 달라도 같은 메서드를 사용할 수 있다.
17. 제네릭 클래스 직접 만들기
간단한 저장소 클래스를 만들어보자.
class Storage<T>
{
private T _data;
public void Save(T data)
{
_data = data;
}
public T Load()
{
return _data;
}
}
사용 예시는 다음과 같다.
Storage<int> intStorage = new Storage<int>();
intStorage.Save(100);
int value = intStorage.Load();
문자열도 저장할 수 있다.
Storage<string> stringStorage = new Storage<string>();
stringStorage.Save("Hello");
string text = stringStorage.Load();
하나의 클래스로 여러 자료형을 처리할 수 있다.
18. 제네릭과 var의 차이
제네릭과 var를 헷갈릴 수 있다.
var는 변수를 선언할 때 컴파일러가 자료형을 추론하게 하는 문법이다.
var number = 10;
이 코드는 컴파일러가 number를 int로 판단한다.
int number = 10;
하지만 제네릭은 클래스나 메서드에서 사용할 타입을 나중에 정하는 문법이다.
class Box<T>
{
public T value;
}
즉, var는 변수 선언을 편하게 해주는 문법이고,
제네릭은 여러 타입을 처리할 수 있게 해주는 문법이다.
19. 제네릭과 배열, 리스트
배열은 특정 자료형만 담을 수 있다.
int[] numbers = new int[3];
numbers에는 int만 들어간다.
numbers[0] = 10;
리스트도 제네릭을 사용해서 특정 자료형만 담게 만든다.
List<int> numbers = new List<int>();
문자열 리스트는 다음과 같이 만든다.
List<string> names = new List<string>();
배열과 리스트 모두 특정 자료형을 담는다는 점은 비슷하다.
하지만 리스트는 크기를 동적으로 늘릴 수 있고, 제네릭을 통해 타입을 안전하게 관리한다.
20. 제네릭을 사용할 때 주의할 점
제네릭은 편리하지만 모든 상황에서 필요한 것은 아니다.
단순히 int만 사용할 클래스라면 굳이 제네릭으로 만들 필요가 없다.
class Score
{
public int value;
}
하지만 여러 타입을 처리해야 한다면 제네릭이 유용하다.
class Storage<T>
{
public T value;
}
또한 제네릭 타입 T는 아무 타입이나 들어올 수 있기 때문에, 특정 기능을 사용하고 싶다면 제약 조건을 걸어야 한다.
class DamageSystem<T> where T : IDamageable
{
}
제약 조건 없이 T에 특정 메서드가 있다고 가정하면 오류가 날 수 있다.
21. 제네릭 사용 기준
제네릭을 사용할지 고민될 때는 다음처럼 생각하면 된다.
여러 자료형에서 같은 구조가 반복되는가?
반복된다면 제네릭을 사용할 수 있다.
Box<int>
Box<string>
Box<float>
이런 구조라면 다음처럼 만들 수 있다.
Box<T>
자료형만 다르고 로직은 같은가?
자료형만 다르고 동작 방식이 같다면 제네릭이 적합하다.
void PrintInt(int value)
void PrintString(string value)
이런 코드는 하나로 줄일 수 있다.
void Print<T>(T value)
특정 기능이 필요한가?
T가 특정 메서드나 속성을 가져야 한다면 제약 조건을 사용해야 한다.
where T : IDamageable
22. 제네릭의 장점
제네릭의 장점은 다음과 같다.
- 코드 중복을 줄일 수 있다.
- 여러 자료형을 하나의 코드로 처리할 수 있다.
- 타입 안정성이 높아진다.
- 불필요한 형변환을 줄일 수 있다.
- 박싱과 언박싱을 줄일 수 있다.
- 컬렉션을 안전하게 사용할 수 있다.
- 재사용성이 높은 코드를 만들 수 있다.
23. 제네릭의 단점
제네릭도 단점이 있다.
첫 번째로, 처음 보면 문법이 어렵게 느껴질 수 있다.
Dictionary<string, List<int>>
이런 코드는 초반에는 복잡해 보일 수 있다.
두 번째로, 너무 과하게 사용하면 코드가 오히려 어려워질 수 있다.
class Manager<TData, TKey, TValue, TResult>
{
}
타입 매개변수가 너무 많아지면 읽기 힘들어진다.
세 번째로, T가 어떤 타입인지 알 수 없기 때문에 아무 메서드나 호출할 수 없다.
target.TakeDamage(10); // 제약 조건 없으면 오류
이럴 때는 where 제약 조건이 필요하다.
24. 제네릭 예제 전체 코드
아래는 제네릭 저장소 예제이다.
using System;
class Storage<T>
{
private T _data;
public void Save(T data)
{
_data = data;
}
public T Load()
{
return _data;
}
}
class Program
{
static void Main()
{
Storage<int> intStorage = new Storage<int>();
intStorage.Save(100);
int number = intStorage.Load();
Console.WriteLine(number);
Storage<string> stringStorage = new Storage<string>();
stringStorage.Save("Hello");
string text = stringStorage.Load();
Console.WriteLine(text);
}
}
출력 결과는 다음과 같다.
100
Hello
하나의 Storage<T> 클래스로 int와 string을 모두 처리할 수 있다.
25. 정리
제네릭은 자료형을 미리 고정하지 않고, 사용할 때 정할 수 있게 해주는 문법이다.
class Box<T>
{
public T value;
}
여기서 T는 타입 매개변수이다.
보통 Type의 앞 글자로 T를 사용하지만, 이것은 문법적으로 강제된 것은 아니고 관례에 가깝다.
C++에서는 비슷한 개념을 template이라고 부른다.
template <typename T>
C#에서는 이를 제네릭이라고 부른다.
class Box<T>
제네릭을 사용하면 하나의 코드로 여러 자료형을 처리할 수 있고, 타입 안정성도 높일 수 있다.
대표적인 예시는 다음과 같다.
List<int>
List<string>
Dictionary<string, int>
GetComponent<Rigidbody>()
제네릭은 처음에는 어렵게 느껴질 수 있지만, 핵심은 간단하다.
제네릭은 자료형을 나중에 정해서 코드의 재사용성을 높이는 문법이다.