제네릭(Generic)을 알아봅시다

제네릭

재네릭은 코드의 재사용, 알고리즘의 재사용을 촉진하기 위해서 사용하는 기능이다. 제네릭은 기존의 클레스를 디자인 할 때가 아닌 클래스를 사용할 때 타입을 지정해 주는 기술이다. 간단하게 설명하면 하나의 클래스나 인스턴스에서 자신이 원하는 형식으로 선언하여 클래스나 인스턴스를 사용할 수 있다.
제네릭의 핵심은 패턴 구현 코드를 작성하고 패턴이 나타나면 구현 코드를 재사용 하는 것에 있다. 즉, 형식 패턴이 바뀐다고 여러번 코드를 작성할 필요를 줄여준다.

제네릭 사용 예제

  List<Monster> monsterBook;
  monsterBook = new List<Monster>();
  monsterBook.Add(testMonster_1);
  monsterBook.Add(testMonster_2);

  ...

  Monster monster;
  // 형변환이 필요가 없다.
  monster = monsterBook[0];

  List<int> monsterCount;
  monsterCount = new List<int>();
  int currentMonsterCount;
  monsterCount.Add(1);
  monsterCount.Add(!0);

  ...

  // 형변환이 필요 없다.
  currentMonsterCount = monsterCount[0];

위 코드를 보면, List에 Monster라는 형식과 int 형식으로 선언되어 있다. 제네릭은 이처럼 어떠한 형식이라도 객체를 사용 할 수 있도록 지원해 준다. 또한 아래에서 언급하겠지만 Boxing 비용을 절감시켜 준다.

제네릭 클래스 선언 예제

public class Book<T>
{
  private List<T> monsters {get;}

  public MonsterBook(List<T> monster)
  {
    this.monsters = monster;
  }
}

특이점은 T라는 것을 사용했다는 점이다. T는 형식 매개변수로써 단일 형식 인수를 제공한다. T는 어떠한 형식도 가능하다. T라는 형식으로 다양한 형식을 받을 수 있기 때문에 하나의 Method에서 공통 처리 코드를 작성할 수 있다. 대표적으로 double, int, long 등의 숫자 형식을 계산해야 하는 Method에서 유용하게 사용할 수 있다.

제네릭의 장점

  1. 형식 안전성 증대
  2. 클래스 내에서 명시적으로 의도한 것을 제외한 모든 데이터 형식을 막을 수 있다. (ex : List => int 외에는 다른 형식 불가능)
  3. 에러 감소(InvalidCastException)
  4. Object 사용으로 인한 Boxing 변환 차단
  5. 코드 부풀리기 감소
  6. Boxing을 피하고 메모리를 덜 소모한다.
  7. 캐스팅 검사 불필요, 코드의 가독성 증가

제네릭의 활용

제네릭 명명 가이드라인

  1. 형식 이름용으로 의미 있는 이름을 사용하고, 이름에 T를 접두어로 붙인다.
  2. 하나가 아닌 여러 매개변수를 사용한다면 그 다음 알파벳(U,V…)으로 이어서 선언하자
  3. 형식 매개변수의 이름으로 제약 조건을 표시하자.

생성자 초기화 시 주의 사항

생성자를 통해 초기 값을 할당해야 하는 제네릭 구조체를 사용하는 경우, 모든 필드를 초기화 해야 한다. C#에서 구조체를 사용하는 특징을 잘 생각해 본다면 문제 없이 코드를 작성할 수 있을 것이다.
기본값으로 필드 초기화를 하고자 한다면 아래 코드처럼 하면 된다.

struct Generic<T> : IGeneric<T>
{
  public Generic()
  {
    this.One = default(T);
    this.Two = default(T);
  }

  ...
}

다중 형식 매개변수

제네릭은 한 가지 매개변수 뿐만 아니라 두 가지 이상의 형식 매개변수를 선언할 수 있다. 다만 주의 해야 할 점은 제네릭을 사용할 때, 제네릭 형식에서 특정 인자의 수를 맞춰서 사용해야 한다.

중첩 제네릭 형식

중첩 형식을 사용할 때 중첩 형식 이름이 같은 형식 매개변수로 외부 형식 매개변수를 숨기지 않아야 한다. 이유는 제네릭 형식에서 형식 매개변수가 모든 중첩 형식에 자동으로 하위 중첩 되기 때문이다.이 점을 주의하고 사용하면 된다.

class Monster<T,U>
{
  // 위의 T,U 와 동일한 형식매개변수를 사용하면 안된다.
  class EasyMonster<V>
  {
    void SetName(T name, U value, V key)
    {
      ...
    }
  }
}

제네릭 제약 조건

제약 조건이 필요 없는 제네릭에 경우 문제가 없겠지만 명시적인 캐스트가 필요한 경우(ex: get/set에서 기능 구현 할 때 등) 암시적으로 형식을 변환이 불가능하다. 그렇다고 명시적 형식 변환을 작성하면 형식 안전성을 개선하고자 사용하는 제네릭의 핵심 가치를 잃게 된다. 각 형태 별 제약 조건 사용 방법을 알아보자

제약 조건 종류

  1. struct/class 제약 조건 : class 제약 조건은 형식 인수에 클래스, 인터페이스, 대리자, 배열 형식이 올 수 있다. 즉 참조 형식이면 된다. struct는 값 형식이여야 한다. 단 Nullable은 제외한다. Nullable은 값 형식 뿐만 아니라 null 값을 나타낼 수 있기 때문에 값 형식에서 제외된다.
  2. 클래스 형식 제약 조건 : 클래스 형식 제약 조건은 모든 인터페이스 형식 제약 조건 이전에 나와야 한다는 점을 제외한다면 인터페이스 형식 제약 조건과 동일하다. 물론 다중 클래스 제약 조건은 사용 할 수 없다. C#에서는 string과 sytem.Nullable로 제한하는 것을 허용하지 않는다. 이유는 string과 Nullable은 유일하기 때문에 제네릭을 사용하지 말고 해당 형식으로 선언해서 사용하는거랑 다를바가 없다.
  3. 다중 제약 조건 : 인터페이스 형식 제약만 다중 제약 조건을 사용할 수 있다.다중 제약 조건을 지정할 때 AND 관계를 가정한다.
  4. 생성자 제약 조건 : 제네릭 클래스 내부에서 형식 인수의 형식에 대한 인스턴스를 만들어서 제약 조건을 생성한다.
  5. 인터페이스 제약 조건 : 인터페이스에서는 인터페이스 형식 제약 조건을 선언함으로써 명시적 인터페이스 멤버 구현을 호출하기 위한 형변환의 필요성을 없앨 수 있다.

※ 제약 조건 상속 : 제네릭 형식 매개변수는 멤버가 아니기 때문에 파생된 클래스로 상속되지 않는다. 새로운 제네릭 형식을 만들 때에는 다른 제네릭 형식에서 상속하는 것이 일반적이다. 즉, 상속 받는 하위 클래스도 상위 클래스가 가지고 있는 제약 조건을 가지고 있어야 한다.
다만 제네릭 메소드를 사용할 때 해당 메소드를 재정의 한다면 제약 조건을 반복하지는 않아도 된다.

제약 조건의 제한 사항

  1. 연산자 제약 조건은 허용하지 않는다. (T는 형식이 정해지지 않았기 때문에 허용 금지)
  2. OR 조건이 지원되지 않는다. (컴파일러가 지원을 안한다.)
  3. 대리자와 열거형 형식의 제약 조건은 유효하지 않는다. (이벤트 발생의 시그니처는 해당 형식의 데이터 형식을 알 수 없기 때문이다.)
  4. 기본 생성자에 대해서만 생성자 제약 조건이 허용된다.(아래 코드 참조)
public class MonsterBook<TKey>
  where TKey : Monster, new()
{

  private TKey key;

  public void CreateKey()
  {
    TKey key = new TKey();
  }
}

제네릭 메소드

제네릭 형식 매개변수를 사용하는 메소드로, 제네릭 형식은 거의 유사하다.
제네릭 형식의 특징은 아래와 같다.

  1. 제네릭 메소드는 제네릭 형식이나 비제네릭 형식으로 선언이 가능하다.
  2. 제네릭 메소드를 선언하려면 제네릭 형식과 동일한 방식으로 제네릭 형식 매개변수를 지정해야 한다.
  3. 제네릭 형식으로 선언되는 경우 이들의 형식 매개변수는 포함하는 제네릭 형식의 매개변수와는 다르다.
public class MonsterBook
{

  List<Monster> Book;

  public MonsterBook()
  {
    Book = new List<Monster>();
  }

  public void BookSet<T>(T value)
    where T : Monster
  {
    Book.Add(value);
  }
}

제네릭의 공변과 반공변

  • 제네릭의 공변 : I = I 이 성립하면 I는 T의 공변이라 한다.
  • 제네릭의 반공변 : I = I 가 성립하면 I는 T의 반공변이라 한다.

제네릭에서는 공변에 대해서 제한을 두고 있다. 공변에 대한 제한이 없으면 형식 안전성에 문제가 발생할 수 있기 때문이다.

이러한 제약에서 자유롭기 위해 공변 변환에는 아래 세 가지의 제약 사항이 있다.

  1. 제네릭 인터페이스와 제네릭 대리자만 공변 가능
  2. 원본과 대상 제네릭 형식은 참조 형식만 가능하다. (ex : string -> object)
  3. 인터페이스와 대리자는 공변성을 지원하게 선언되어야 한다. 또한 실제 출력 위치에서만 사용 되는지 확인 가능해야 한다.

C# 4.0부터는 in 형식 매개 변수 한정자를 통해 반공변성을 사용할 수 있다. 사용 방법은 아래와 같다.

public interface IBookList<in T>
{
    void AddBookData(T data);

    void CountIsMore(T t1, T t2);
}

class MonsterBook : IBookList<Monster>
{
    private List<Monster> monsterBook;

    public void AddBookData(Monster data)
    {
        monsterBook.Add(data);
        return;
    }

    ...
}

class Monster {}

class MonsterDragon : Monster {}

class MonsterOrc : Monster {}

IBookList<Monster> msBook = new MonsterBook();
MonsterDragon dragon = new MonsterDragon("dragon", 1);
MonsterOrc orc = new MonsterOrc("orc", 2);
msBook.AddBookData(dragon);
msBook.AddBookData(orc);

// 아래 코드가 정상 작동한다. IBookList에서 in이 빠지면 오류가 난다.
IBookList<MonsterDragon> mdBook = msBook;

반공변 변환도 공변 변환과 유사한 제한을 가진다.

  1. 제네릭 인터페이스, 대리자 형식에만 존재
  2. 형식 인수는 참조 인수만 가능
  3. 컴파일러가 반공변 변환에 안전한지를 검증할 수 있어야 함

SCRIPT

//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js

SCRIPT

(adsbygoogle = window.adsbygoogle || []).push({});

제네릭 공변, 반공변은 어디에 써야 할까?

공변과 반공변은 정리하자면 아래와 같다.

  • 공변 : 자신과 자식 객체를 허용한다.
  • 반공변 : 자신과 부모 객체를 허용한다.

이러한 결론을 통해 공변과 반공변은 상속과 관련하여 유연하게 사용할 수 있다. 위 코드에서 처럼 Monster 중에서 Dragon Monster를 Monster에 잔뜩 추가하고 MonsterDragon 클래스로 다시 BookList를 옮길 때 반공변을 통해 해당 객체의 데이터를 받아 갈 수 있다. 공변은 반대로 자식 객체에 자신의 데이터를 넘겨주면 된다.
이렇게 제네릭을 사용할 때 좀 더 유연하게 객체를 활용할 때 공변과 반공변을 사용하면 된다. 단 공변성을 사용할 때 주의해야 할 사항이 있다. 위 코드도 위험한 사례인데 배열을 사용하면 형식 안정성이 보장되지 않기 때문에 사용에 조심해야 한다. 읽기 전용으로 배열을 사용한다면 공변성을 사용해도 된다. (예시 : IEnumerable)

※ Java와 C# 제네릭 비교

Java와 C# 제네릭의 가장 큰 차이점은 중간언어(JVM, .NET)에 제네릭 타입 정보의 존재 유무의 차이가 있다. 그래서 .NET의 제네릭은 타입 안정성이 있지만 JVM에서는 타입 안정성이 없다.
JVM에서는 타입 정보를 저장하지 않기 때문에 .NET에서 가지고 있는 Boxing, UnBoxing에 대한 성능 이득을 챙기지 못한다.

제네릭 결론

제네릭은 위에서 언급한 다양한 장점을 가지고 있다. 또한 다양한 활용 방법을 가지고 있다. 위에 방법 말고도 더 많은 정보는 MSDN을 참고하면 된다.
우리가 제네릭을 사용할 경우는 똑같은 기능의 객체에 대해서 형식만 다른 코드를 작성할 때 가장 높은 효율을 보일 수 있다. C# 제네릭을 통해 형식 안정성과 메모리 효율성을 높여보자. 나아가 좀 더 추상화 된 어플리케이션 설계를 할 수 있도록 하자.

함께보면 좋은 글

람다식과 LINQ 기초

참고

https://learn.microsoft.com/ko-kr/dotnet/csharp/programming-guide/generics/generic-methods

Leave a Comment