제네릭스(Generics) - 일반화 (템플릿)
- 제네릭은 다양한 타입에서 재사용 가능한 코드를 작성하는 방법임
- 데이터 타입을 일반화 하는 것이다.
- 타입에 따라 달라진다면 해당 타입으로 바꿔 코드를 재사용할 수 있게 된다.
- C#은 상속과 제네릭 두 가지 방식으로 코드 재사용을 지원함
- 상속: 기본 타입을 통해 재사용성 표현
- 제네릭: "템플릿"과 "플레이스홀더" 타입을 통해 재사용성 표현
- 제네릭을 사용하면 상속에 비해 다음과 같은 장점이 있음:
- 타입 안전성 향상
- 캐스팅과 박싱 연산 감소 - 중요
제네릭 타입
- 제네릭 타입은 타입 매개변수(Type Parameter)를 선언함
- 제네릭 타입 사용자는 타입 인자(Type Argument)를 제공하여 구체적인 타입을 만듦
- <T> : 일반화 매개변수
- <int> : 일반화 인자
기본 예제: Stack<T>
// 제네릭 스택 클래스 정의
public class Stack<T> //일반화 매개변수
{
int position;
T[] data = new T[100];
public void Push(T obj) => data[position++] = obj; //T : 일반화 데이터형
public T Pop() => data[--position]; //일반화 반환형
}
// 제네릭 스택 사용
class Program
{
static void Main()
{
var stack = new Stack<int>(); //<int> : 일반화 인자
stack.Push(5);
stack.Push(10);
int x = stack.Pop(); // x는 10
int y = stack.Pop(); // y는 5
// string을 Push하면 컴파일 오류 발생
// stack.Push("test"); // 오류! -> int 인자를 받는 Stack이 되었기 때문에 string을 인자로 받는 Stack 객체를 따로 만들어야한다.
}
}
제네릭이 필요한 이유
- object 기반으로 범용 스택을 구현하는 경우의 문제점:
public class ObjectStack
{
int position;
object[] data = new object[10];
public void Push(object obj) => data[position++] = obj; //obj가 값형식이면 박싱이 일어난다.
public object Pop() => data[--position];
}
class Program
{
static void Main()
{
ObjectStack stack = new ObjectStack();
stack.Push("s"); // 실수로 string 추가
int i = (int)stack.Pop(); // 런타임 오류 발생! //언박싱이 일어난다.
}
}
- 제네릭을 사용하면:
- 컴파일 시점에 타입 안전성 확보
- 박싱/언박싱 연산 제거
- 명시적 형변환 불필요
제네릭 메서드
- 메서드 시그니처에서 타입 매개변수를 선언함
- 기본적인 알고리즘을 범용적으로 구현할 수 있음
- 타입 인자를 명시하지 않더라도 컴파일러가 타입 인자를 추론할 수 있다.
- 하지만, 일반화 ‘인자’는 명시적으로 작성해야한다.
- 일반화 ‘매서드’만 추론이 가능하다.
// 두 변수의 값을 교환하는 제네릭 메서드
class Program
{
static void Swap<T>(ref T a, ref T b) //ref T a : Call by Reference 매개변수로 작동한다.
{
T temp = a;
a = b;
b = temp;
}
static void Main()
{
int x = 5;
int y = 10;
Swap(ref x, ref y);
Console.WriteLine($"x={x}, y={y}"); // x=10, y=5
// 컴파일러가 타입 인자를 추론할 수 있음
// 명시적으로 타입을 지정할 수도 있음:
// Swap<int>(ref x, ref y);
}
}
타입 매개변수 선언
- 타입 매개변수는 다음에서 선언 가능함:
- 클래스와 구조체
- 인터페이스
- 델리게이트 (Chapter 4에서 다룸)
- 메서드
- 속성, 인덱서, 이벤트, 필드, 생성자, 연산자 등은 타입 매개변수를 도입할 수 없음
- 일반화 인자는 1개 이상 작성할 수 있다.
- 일반화 인자는 T로 시작하는 이름으로 작성하는것이 관행이다.
// 타입 매개변수 도입 예제
class Stack<T> // 클래스에서 도입
{
T[] data; // 필드에서 사용
public T Pop() // 메서드 반환값으로 사용
{
// ...
}
}
// 여러 타입 매개변수 사용
class Dictionary<TKey, TValue> { }
// 사용
Dictionary<int,string> myDict = new Dictionary<int,string>();
// 또는 var 사용
var myDict = new Dictionary<int,string>();
- 타입 이름은 타입 매개변수 개수가 다르면 중복 가능:
- 3개 모두 다르게 동작한다.
class A { }
class A<T> { }
class A<T1,T2> { }
기본 제네릭 값
- default 키워드로 제네릭 타입 매개변수의 기본값을 얻을 수 있음
- int = 0 / string = null
- default(T) : T에 해당하는 기본값이 할당된다.
- 참조 타입의 기본값은 null
- 값 타입의 기본값은 모든 필드가 0인 상태
class Program
{
static void Zap<T>(T[] array)
{
for (int i = 0; i < array.Length; i++)
array[i] = default(T);
}
// C# 7.1부터는 타입 인자 생략 가능
static void ZapSimple<T>(T[] array)
{
for (int i = 0; i < array.Length; i++)
array[i] = default;
}
}
타입 매개변수 제약조건
- where 절을 사용하여 타입 매개변수에 제약조건을 적용할 수 있음
- 가능한 제약조건:
- where T : 기반클래스 // 기반 클래스 제약조건
- T의 인자만 사용할 수 있게된다.
- IComparale<T>를 상속받을때만 사용할 수 있게된다.(맞나? 확인해봐야함)
- where T : 인터페이스 // 인터페이스 제약조건
- where T : class // 참조 타입 제약조건
- where T : struct // 값 타입 제약조건
- where T : new() // 매개변수 없는 생성자 제약조건
- where U : T // naked 타입 제약조건
- 제약을 검으로써 특정 인자를 사용한 상태에서만 사용할 수 있게 된다.
- CompareTo : A가 b보다 크면 양수, 작으면 음수 반환한다.
- 예제에서 IComparable을 상속 안했는데 Int 인자를 바로 쓸 수 있는 이유
- Int32 데이터형은 이미 IComparable이 상속 되어있기 때문이다.
제약조건 예제
// IComparable<T> 인터페이스 제약조건 사용
class Program
{
//IComparable :
static T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
static void Main()
{
int maxInt = Max(5, 10); // 10
string maxStr = Max("ant", "zoo"); // "zoo"
Console.WriteLine($"maxInt={maxInt}, maxStr={maxStr}");
}
}
// 매개변수 없는 생성자(기본생성자) 제약조건 예제
class Program
{
static void Initialize<T>(T[] array) where T : new()
{
for (int i = 0; i < array.Length; i++)
array[i] = new T();
}
static void Main()
{
var arr = new string[2];
// 컴파일 오류 - string은 매개변수 없는 생성자가 없음
// Initialize(arr);
}
}
- CompareTo → 원래 어떤 값이 들어오는지도 모르는데 이렇게 다른 메서드를 호출할 수는 없다.
- 가능한 이유 : where T : IComparable<T>를 상속받은 클래스에서만 호출이 가능하게 되기 때문이다.
제네릭 타입 상속
- 제네릭 클래스도 일반 클래스처럼 상속할 수 있음
- 다음과 같은 방식으로 상속 가능:
- 기반 클래스의 타입 매개변수를 열어둠
- T처럼 정해지지 않은 타입을 ‘열려있다’라고 표현한다.
- 구체적인 타입으로 닫음
- int처럼 타입이 정해진것을 ‘닫혀있다’라고 표현한다.
- 새로운 타입 매개변수 도입
- SpecialStack : Stack<T>가 안되는 이유 : Stack<T>에 어떤 타입이 올지 모르기 때문이다.
- List<T>의 타입에 따라 KeyedList의 T가 결정된다.
// 타입 매개변수를 열어둔 상속
class Stack<T> { }
class SpecialStack<T> : Stack<T> { } //오픈타입 상속 예시
// 구체적인 타입으로 닫은 상속
class IntStack : Stack<int> { }
// 새로운 타입 매개변수 도입
class List<T> { }
class KeyedList<T,TKey> : List<T> { }
자기 참조 제네릭 선언
- 타입은 자신을 닫을 때 구체적인 타입으로 자신을 지정할 수 있음:
public interface IEquatable<T> { bool Equals(T obj); }
public class Balloon : IEquatable<Balloon>
{
public string Color { get; set; }
public int CC { get; set; }
public bool Equals(Balloon b)
{
if (b == null) return false;
return b.Color == Color && b.CC == CC;
}
}
- 다음과 같은 선언도 가능함:
- Bar<T>를 상속받은 자식클래스만 쓸 수 있다는 것이다.
class Foo<T> where T : IComparable<T> { }
class Bar<T> where T : Bar<T> { }
정적 데이터
class Program
{
static void Main()
{
Console.WriteLine(++Bob<int>.Count); // 1
Console.WriteLine(++Bob<int>.Count); // 2
Console.WriteLine(++Bob<string>.Count); // 1
Console.WriteLine(++Bob<object>.Count); // 1
}
}
class Bob<T> { public static int Count; }
타입 매개변수와 형변환
- C#의 캐스트 연산자는 다음 형변환을 수행할 수 있음:
- 숫자 형변환
- 참조 형변환
- 박싱/언박싱 형변환
- 사용자 정의 형변환 (연산자 오버로딩)
- 제네릭 타입 매개변수의 경우 컴파일 시점에 정확한 타입을 알 수 없어 모호성이 발생할 수 있음
- 이를 해결하기 위한 방법:
class Program
{
static StringBuilder Foo<T>(T arg)
{
// 컴파일 오류 - 모호한 형변환
// if (arg is StringBuilder)
// return (StringBuilder)arg; //T가 StringBuilder를 상속한 클래스가 아닐 수 있기 때문이다.
// 해결방법 1: as 연산자 사용
StringBuilder sb = arg as StringBuilder;
if (sb != null) return sb;
// 해결방법 2: object로 먼저 캐스팅
return (StringBuilder)(object)arg;
}
// 언박싱의 경우도 object로 먼저 캐스팅
static int Bar<T>(T x) => (int)(object)x;
}
공변성과 반공변성
- 공변성(Covariance): A가 B로 변환 가능할 때 X\<A>가 X\<B>로 변환 가능한 경우
- 반공변성(Contravariance): A가 B로 변환 가능할 때 X\<B>가 X\<A>로 변환 가능한 경우
공변성 예제
// 공변성을 가진 인터페이스 정의
public interface IPoppable<out T> { T Pop(); }
class Program
{
static void Main()
{
var bears = new Stack<Bear>();
bears.Push(new Bear());
// Bear는 Animal로 변환 가능하므로
// IPoppable<Bear>는 IPoppable<Animal>로 변환 가능
IPoppable<Animal> animals = bears;
Animal a = animals.Pop();
}
}
class Animal { }
class Bear : Animal { }
public class Stack<T> : IPoppable<T>
{
int position;
T[] data = new T[100];
public void Push(T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
반공변성 예제
// 반공변성을 가진 인터페이스 정의
public interface IPushable<in T> { void Push(T obj); }
class Program
{
static void Main()
{
IPushable<Animal> animals = new Stack<Animal>();
IPushable<Bear> bears = animals; // 가능!
bears.Push(new Bear());
}
}
class Animal { }
class Bear : Animal { }
public class Stack<T> : IPushable<T>
{
int position;
T[] data = new T[100];
public void Push(T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
C# 제네릭과 C++ 템플릿의 차이점
- 작동 방식의 주요 차이:
- C# 제네릭: 런타임에 타입이 결정됨
- C++ 템플릿: 컴파일 시점에 타입이 결정됨
- 결과적인 차이:
- C# 제네릭은 라이브러리로 배포 가능
- C++ 템플릿은 소스 코드로만 존재
- C# 제네릭은 동적 타입 검사/생성 가능
- C++ 템플릿은 컴파일 시점에 모든 것이 결정됨