C# Generics(제네릭)과 제약 조건
C# 제네릭(Generic)은 타입 안전성과 코드 재사용성을 극대화하기 위해 도입된 기능입니다.
제네릭을 알아보기 전, 기존 C# 1.0에서는 기본 형식(Primitive types)으로 컬렉션 객체를 사용했습니다.
예를 들어, ArrayList의 경우에는 모든 타입의 컬렉션을 받을 수 있도록 object타입을 사용합니다.
따라서, 모든 기본 타입을 받을 수 있게 설계되었지만, object타입은 참조 타입이기 때문에 값 타입을 할당 할 경우 방식/언박싱이 발생합니다.
박싱(Boxing)/언박싱(Unboxing)은 값 타입(Value Type)과 참조 타입(Reference Type)간의 변환 과정에서 발생하는 일을 말합니다.
박싱은 값 타입을 참조 타입으로 변환하는 과정에서 힙에 새로운 메모리 할당과 값 복사가 발생하므로 추가적인 성능 오버헤드가 생깁니다.
언박싱은 참조 타입을 값 타입으로 변환하는 과정에서 런타임 타입 검사와 값 복사가 필요하므로 추가적인 성능 오버헤드가 있습니다.이러한 변환이 빈번하게 발생하면 가비지 컬렉션 부담이 증가하고, 프로그램의 실행 속도가 느려질 수 있습니다. 따라서 박싱과 언박싱은 필요할 때만 사용하고, 가급적 피하는 것이 성능 최적화에 중요합니다.
이 문제를 해결하려면 ArrayList가 한가지 타입만을 다루도록 해야 하는데, 이렇게 타입을 고정하면 각 타입마다 ArrayList를 구현해야 하는 단점이 생깁니다.
이를 해결하기 위해 도입된 문법이 제네릭(Generic)입니다.
제네릭이란?
제네릭(Generic)은 클래스, 인터페이스, 메서드, 델리게이트 등을 정의할 때 데이터 타입을 미리 지정하지 않고, 이를 사용하는 시점에 구체적인 타입을 지정할 수 있도록 하는 기능입니다.
예를 들어, 제네릭 컬렉션 타입 중 하나인 List<T>타입을 보면, T의 위치에 사용하고자 하는 타입을 지정하면, 컴파일 타임에 해당 타입에 대한 코드가 생성되어 박싱/언박싱 없이 원하는 타입에 대한 컬렉션을 사용할 수 있습니다.
1
2
3
int n = 10;
List<int> list = new List<int>();
list.Add(n);
즉, 제네릭을 사용하면 데이터 타입에 독립적인 코드를 작성할 수 있으며, 이를 통해 보다 유연하고 효율적인 프로그래밍이 가능합니다.
제네릭을 사용하는 이유
- 코드 재사용성: 다양한 데이터 타입에 대해 동일한 코드를 작성할 수 있으므로, 코드 중복을 줄이고 유지 보수성을 향상시킵니다.
- 성능 향상: 제네릭을 사용하면 박싱(Boxing)과 언박싱(Unboxing)을 피할 수 있어 성능이 향상됩니다. 이는 특히 값 타입(Value Type) 데이터를 다룰 때 유용합니다.
- 타입 안전성: 제네릭을 사용하면 컴파일 타임에 데이터 타입을 검증할 수 있습니다. 이는 잘못된 타입 사용으로 인한 런타임 오류를 방지합니다.
제네릭 클래스
제네릭 문법은 간단하게 설명하면, 각괄호(<>)안에 1개 이상의 타입 매개변수(혹은 형식 매개변수)를 지정하는 것이라고 할 수 있습니다.
제네릭 클래스를 정의할 때는 클래스 이름 뒤에 타입 매개변수를 지정합니다.
그리고 해당 타입 매개변수를 평범한 타입처럼 사용하면 됩니다.
1
2
3
4
class 클래스명<타입매개변수[,...]>
{
// 타입 매개변수를 타입처럼 사용
}
예를 들어, T라는 타입 매개변수를 사용하는 제네릭 클래스를 정의해보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class GenericList<T>
{
private T[] items;
private int count;
public GenericList(int capacity)
{
items = new T[capacity];
count = 0;
}
public void Add(T item)
{
if (count < items.Length)
{
items[count] = item;
count++;
}
}
public T GetItem(int index)
{
if (index >= 0 && index < count)
{
return items[index];
}
return default(T);
}
}
위 예에서는 타입 매개변수 1개를 T라는 이름으로 주었고, 이 T를 배열의 타입, 매개 변수, 리턴 타입으로 사용하는 모습을 보여줍니다.
제네릭 메서드
제네릭은 클래스 뿐만 아니라 메서드에도 지정할 수 있습니다.
이를 제네릭 메서드라고 하는데, 제네릭 메서드는 메서드 수준에서 제네릭 타입 매개변수를 사용할 수 있게 합니다.
1
2
3
4
5
6
7
class 일반_클래스
{
반환_타입 메서드명<타입매개변수[,...]>(타입명 매개변수명)
{
// 타입 매개변수를 타입처럼 사용하여 메서드 정의
}
}
예를 들어, T라는 타입 매개변수를 사용하는 제네릭 메서드를 정의해보겠습니다.
1
2
3
4
5
6
7
public static class Utilities
{
public static T DefaultIfNull<T>(T input, T defaultValue)
{
return input == null ? defaultValue : input;
}
}
위 예는 메서드에서 제네릭 타입 T를 사용하여 같은 타입의 매개 변수를 받고, T 타입의 값을 반환하는 예제입니다.
이때, 제네릭 메서드의 타입 매개변수 T는 메서드의 범위 내에서만 유효합니다.
제네릭 인터페이스
제네릭 인터페이스도 제네릭 클래스처럼 인터페이스 내부에서 사용 할 제네릭 타입 매개변수를 인터페이스 이름 뒤에 지정하여 사용 가능합니다.
제네릭 클래스와 사용 방법이 비슷하니 간단하게 예제만 만들어 보겠습니다.
1
2
3
4
5
6
public interface IRepository<T>
{
void Add(T item);
T Get(int id);
void Delete(int id);
}
제네릭 제약 조건
제네릭을 사용할 때, 어떤 사용 환경에서는 제네릭 타입 매개변수가 특정 조건을 만족하도록 만들고 싶을 때가 있습니다.
예를 들어, 다음과 같은 코드를 만든다고 한다면 CompareTo에서 문제가 발생하는 것을 볼 수 있습니다.
1
2
3
4
5
6
7
public static class Utilities
{
public static T Max<T>(T a, T b)
{
return a.CompareTo(b) >= 0 ? a : b;
}
}
즉, T로 대체될 타입이 모두 CompareTo 메서드를 지원하는 것이 아니기에 미리 오류를 발생시켜 잘못된 사용을 막는 것입니다.
이때, where를 사용하여 제네릭 타입 매개변수에 특정 제약 조건을 부여할 수 있습니다.
이를 통해 제네릭 타입이 특정 인터페이스를 구현하거나 특정 기본 클래스를 상속받도록 강제할 수 있습니다.
1
where 타입매개변수 : 제약조건[,...]
where는 제약 조건을 걸고 싶은 타입 매개 변수마다 사용할 수 있으며, 1개 이상의 제약 조건을 사용할 수 있습니다.
예를 들어, 2개의 타입 매개 변수에 제약조건을 걸고 싶다면 다음과 같이 쓸 수 있습니다.
1
2
public class Dict<K, V> where K : ICollection
where V : IComparable
따라서, 처음의 예제는 CompareTo 메서드를 구현하도록 강제하는 IComparable<T> 인터페이스를 구현하도록 타입 매개변수 T에 제약 조건을 추가할 수 있습니다.
1
2
3
4
5
6
7
public static class Utilities
{
public static T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
}
제네릭 타입 매개 변수에 대한 특별 제약 조건
| 제약 조건 | 설명 |
|---|---|
where T : struct | 타입 매개변수 T는 값 타입만 가능합니다. 여기에는 모든 기본 값 타입(int, double 등)과 사용자 정의 구조체(struct)가 포함됩니다. |
where T : class | 타입 매개변수 T는 참조 타입만 가능합니다. 여기에는 모든 클래스, 인터페이스, 델리게이트, 배열 등이 포함됩니다. |
where T : new() | 타입 매개변수 T는 매개변수가 없는 기본 생성자를 가져야 합니다.제네릭 클래스 또는 메서드 내에서 T의 인스턴스를 생성할 때 유용합니다. |
where T : 특정 클래스/인터페이스 | 타입 매개변수 T는 특정 클래스의 파생 클래스이거나, 특정 인터페이스를 구현해야 합니다. 이를 통해 특정 기능이나 멤버를 사용할 수 있게 보장합니다. |
where T : U | 타입 매개변수 T는 다른 타입 매개변수 U의 하위 타입이어야 합니다. 여기서 U는 클래스, 인터페이스 또는 다른 제네릭 타입 매개변수일 수 있습니다. |
여기에서 이해하기 어려울 수도 있는 두 가지 경우에 대해서 설명드리겠습니다.
우선, where T : new()의 경우에는 T 타입의 객체가 기본 생성자를 가져야만 합니다.
1
2
3
4
5
6
7
8
public static T AllocateIfNull<T>(T item) where T : class
{
if (item == null)
{
item = new T();
}
return item;
}
위 코드는 where T : class 제약조건을 통해 참조 타입만 가져오기 때문에 얼핏 보면 new T()를 통해 객체를 생성하고 반환하는 동작에 문제가 없어 보이기도 합니다.
그러나, 과연 모든 타입이 기본 생성자를 가지고 있다고 장담할 수 없습니다.
따라서, new() 제약조건을 통해 기본 생성자를 가진 타입만 정의가 가능하다고 제약 조건을 걸 수 있습니다.
where T : U의 경우에는 조금 어려워 보일 수도 있지만, where T : 특정 클래스/인터페이스와 비슷하다고 생각하시면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class BaseClass
{}
public class DerivedClass : BaseClass
{}
public static class Utilities
{
// where T : U 제약 조건 제네릭 함수
public static B Allocate<B, D>() where D : B, new()
{
return new D();
}
}
internal class Program
{
static void Main(string[] args)
{
BaseClass myClass = Utilities.Allocate<BaseClass, DerivedClass>();
}
}
위의 예제의 경우, 다형성을 이용한 인스턴스 할당 예제입니다.
where D : B의 경우 D가 B의 하위 타입인 경우에만 오게하여 D 객체를 생성하고, 이를 B 타입으로 반환해 줍니다.
간단히 말하면, 하위 타입만 올 수 있도록 제약 조건을 걸 수 있습니다.
정리
C# 제네릭(Generic)은 타입 안전성과 코드 재사용성을 극대화하여, 더욱 강력하고 유연한 프로그래밍을 가능하게 합니다.
제네릭을 통해 타입에 독립적인 코드를 작성할 수 있으며, 컴파일 타임에 타입을 검증함으로써 런타임 오류를 줄일 수 있습니다. 또한, 박싱과 언박싱을 피할 수 있어 성능 면에서도 이점을 제공합니다.
제네릭 클래스, 제네릭 메서드, 제네릭 인터페이스를 적절히 활용하면 코드의 유지 보수성과 가독성을 크게 향상시킬 수 있습니다. 제네릭 제약 조건을 통해 타입 매개변수에 특정 조건을 부여할 수 있어, 더욱 안전하고 효율적인 코드를 작성할 수 있습니다.