공부/C#

제네릭, 제네릭 메서드, 타입 매개변수 제약조건

월러비 2025. 8. 18. 19:37

제네릭스(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개 모두 다르게 동작한다.
    • 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++ 템플릿은 컴파일 시점에 모든 것이 결정됨