[C#] 30. 제네릭(Generic) 사용법


Study/C#  2021. 9. 16. 16:27

안녕하세요. 명월입니다.


이 글은 C#에서 제네릭(Generic) 사용법에 대한 글입니다.


우리가 리스트(List)나 딕셔너리(Dictionary)를 사용할 때, 어떤 데이터를 리스트 안에서 사용할 지 자료형을 설정합니다.

using System;
using System.Collections.Generic;

namespace Example
{
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // int타입을 다루는 List의 인스턴스를 생성
      var list = new List<int>();
      // list에 데이터를 입력
      list.Add(1);
      list.Add(2);
      // list의 값을 순서대로 출력
      foreach (int node in list)
      {
        // 콘솔 출력
        Console.WriteLine(node);
      }
      // 아무 키나 누르시면 종료합니다.
      Console.WriteLine("Press any key...");
      Console.ReadLine();
    }
  }
}

설정한 데이터를 사용하지 않으면 에러가 발생할 것입니다.

이렇게 어떤 자료형을 사용할 지 설정하는 것을 제네릭(Generic)입니다.

즉, 클래스나 메소드 안에서는 데이터 타입을 정하지 않고, 인스턴스를 생성하는 위치에서 데이터 타입을 설정하여 사용하는 방법의 의미입니다.

using System;
using System.Collections;

namespace Example
{
  // 연결 리스트 알고리즘
  class List
  {
    // 리스트에서 사용할 Node 클래스
    private class Node
    {
      // 데이터
      public int Data { get; set; }
      // 리스트의 다음 포인터
      public Node Next { get; set; }
    }
    // foreach에서 사용될 IEnumerator 인터페이스를 상속 받아 함수 재선언
    private class Pointer : IEnumerator
    {
      // List 인스턴스를 받아 포인터 관리
      private List list;
      // 현재 포인터
      private Node pointer;
      // 생성자
      public Pointer(List list)
      {
        // 인스턴스를 맴버 변수에서 관리
        this.list = list;
        // 포인터를 맨 앞으로 이동
        this.pointer = list.start;
      }
      // 현재 값 출력
      public object Current { get; set; }
      // Current 포인터 값이 있는지 확인 함수
      public bool MoveNext()
      {
        // 없으면 false를 리턴하여 foreach를 중지
        if (this.pointer == null)
        {
          return false;
        }
        // 현재 값을 Current에 넣는다.
        Current = this.pointer.Data;
        // 포인터를 이동한다.
        this.pointer = this.pointer.Next;
        // 현재 포인터 값은 있기에 true
        return true;
      }
      // 포인터 리셋
      public void Reset()
      {
        this.pointer = this.list.start;
      }
    }
    // 리스트의 앞부분
    private Node start = null;
    // 리스트의 뒷부분
    private Node end = null;
    // foreach에서 사용할 pointer 관리 클래스
    private Pointer pointer;
    // 생성자
    public List()
    {
      // pointer 관리 클래스 생성
      pointer = new Pointer(this);
    }
    // 데이터 추가하기
    public void Add(int val)
    {
      // 시작 포인터가 없으면
      if (start == null)
      {
        // 시작 포인터에 인스턴스를 생성한다.
        start = new Node()
        {
          Data = val
        };
        // 마지막 포인터와 시작 포인터를 일치시킨다.
        end = start;
      }
      else
      {
        // 마지막 포인터에 계속 Node를 연결한다.
        end.Next = new Node()
        {
          Data = val
        };
      }
    }
    // foreach에서 사용할 IEnumerator를 리턴한다.
    public IEnumerator GetEnumerator()
    {
      // 포인터 리셋
      pointer.Reset();
      // 포인터 리턴
      return pointer;
    }
  }
  // 실행 함수 클래스
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // 임의로 만든 리스트 인스턴스 생성
      var list = new List();
      // int형 데이터 출력
      list.Add(1);
      list.Add(2);
      // list의 값을 순서대로 출력
      foreach (var item in list)
      {
        // 콘솔 출력
        Console.WriteLine(item);
      }
      // 아무 키나 누르시면 종료합니다.
      Console.WriteLine("Press any key...");
      Console.ReadLine();
    }
  }
}

위 예제는 간단한 연결 리스트의 알고리즘입니다.

사실 연결 리스트의 알고리즘은 굉장히 간단한 알고리즘인데, foreach에 적용하여 데이터를 출력하기 위해서 인라인 클래스 Pointer를 만들었습니다. 덕분에 조금 복잡해 졌네요.


위 예제의 List클래스는 int타입만 사용할 수 있습니다. Add 함수의 파라미터와 Node 클래스의 Data의 타입을 int형으로 설정하였기 때문에 int만 사용할 수 있습니다.

그러나 상황에 따라서 int형 뿐 아니라 String 타입도 넣고 싶네요...

현재 상황에서는 똑같은 클래스에서 Add 함수와 Node 클래스의 데이터 타입만 바꾸고 또 만들어야 하겠네요. 그리고 또 C#.Net framework에서 제공하는 클래스나 원시 데이터가 아니라면, 사용할 때마다 만들어야 겠네요.


그런데 우리는 실제로 리스트(List)클래스를 사용할 때 그렇게 사용하지는 않네요. 괄호(<>)를 사용해서 데이터 타입을 설정하고 사용합니다.

using System;
using System.Collections;

namespace Example
{
  // 연결 리스트 알고리즘 (데이터 타입을 제네릭으로 설정한다.)
  class List <T>
  {
    // 리스트에서 사용할 Node 클래스
    private class Node
    {
      // 데이터 - 데이터 타입을 정하지 않고 List의 제네릭에서 설정한 데이터 타입으로 설정
      public T Data { get; set; }
      // 리스트의 다음 포인터
      public Node Next { get; set; }

    }
    // foreach에서 사용될 IEnumerator 인터페이스를 상속 받아 함수 재선언
    private class Pointer : IEnumerator
    {
      // List 인스턴스를 받아 포인터 관리
      private List<T> list;
      // 현재 포인터
      private Node pointer;
      // 생성자
      public Pointer(List<T> list)
      {
        // 인스턴스를 맴버 변수에서 관리
        this.list = list;
        // 포인터를 맨 앞으로 이동
        this.pointer = list.start;
      }
      // 현재 값 출력
      public object Current { get; set; }
      // Current 포인터 값이 있는지 확인 함수
      public bool MoveNext()
      {
        // 없으면 false를 리턴하여 foreach를 중지
        if (this.pointer == null)
        {
          return false;
        }
        // 현재 값을 Current에 넣는다.
        Current = this.pointer.Data;
        // 포인터를 이동한다.
        this.pointer = this.pointer.Next;
        // 현재 포인터 값은 있기에 true
        return true;
      }
      // 포인터 리셋
      public void Reset()
      {
        this.pointer = this.list.start;
      }
    }
    // 리스트의 앞부분
    private Node start = null;
    // 리스트의 뒷부분
    private Node end = null;
    // foreach에서 사용할 pointer 관리 클래스
    private Pointer pointer;
    // 생성자
    public List()
    {
      // pointer 관리 클래스 생성
      pointer = new Pointer(this);
    }
    // 데이터 추가하기 (파라미터 데이터 타입을 제네릭 타입으로 한다.)
    public void Add(T val)
    {
      // 시작 포인터가 없으면
      if (start == null)
      {
        // 시작 포인터에 인스턴스를 생성한다.
        start = new Node()
        {
          Data = val
        };
        // 마지막 포인터와 시작 포인터를 일치시킨다.
        end = start;
      }
      else
      {
        // 마지막 포인터에 계속 Node를 연결한다.
        end.Next = new Node()
        {
          Data = val
        };
      }
    }
    // foreach에서 사용할 IEnumerator를 리턴한다.
    public IEnumerator GetEnumerator()
    {
      // 포인터 리셋
      pointer.Reset();
      // 포인터 리턴
      return pointer;
    }
  }
  // 실행 함수 클래스
  class Program
  {
    // 실행 함수
    static void Main(string[] args)
    {
      // 임의로 만든 리스트 인스턴스 생성 (List 안에서 사용할 데이터 타입은 string으로 설정)
      var list = new List<string>();
      // string형 데이터 출력
      list.Add("Node 1");
      list.Add("Node 2");
      // list의 값을 순서대로 출력
      foreach (var item in list)
      {
        // 콘솔 출력
        Console.WriteLine(item);
      }
      // 아무 키나 누르시면 종료합니다.
      Console.WriteLine("Press any key...");
      Console.ReadLine();
    }
  }
}

제네릭은 데이터 타입이 설정되지 않았기 때문에 T라는 임의의 문자로 치환을 합니다.

즉, 인스턴스를 생성하는 입장에서 int나 String으로 설정하면 T라는 문자가 전부 int나 String으로 변한다고 생각하면 됩니다.

그러니깐 데이터 타입에 따른 리스트를 계속 만들 필요없이 제네릭을 사용하면 어떠한 타입으로도 대응이 된다는 뜻입니다.


제네릭은 기본적으로 클래스에서 선언하는 방법이 있고 메소드만 사용하는 방법이 있습니다.

using System;
using System.Collections.Generic;

namespace Example
{
  class Program
  {
    // 리스트를 배열로 바꾸는 함수
    static T[] ConvertToArrayFromList<T>(List<T> list)
    {
      // 배열의 인덱스
      int i = 0;
      // 제네릭 타입을 이용해 배열을 생성
      T[] ret = new T[list.Count];
      // List를 반복문을 통해 추출
      foreach(var item in list)
      {
        // 배열에 값을 입력
        ret[i++] = item;
      }
      return ret;
    }
    // 실행 함수
    static void Main(string[] args)
    {
      // string 타입을 다루는 List의 인스턴스를 생성
      var list = new List<string>();
      // 데이터를 추가
      list.Add("Node 1");
      list.Add("Node 2");
      // List를 배열로 변환한다.
      var array = ConvertToArrayFromList<string>(list);
      // 배열 2번째의 값을 출력한다.
      Console.WriteLine(array[1]);
      // 아무 키나 누르시면 종료합니다.
      Console.WriteLine("Press any key...");
      Console.ReadLine();
    }
  }
}

매소드 제네릭은 함수명 옆에다 설정하게 됩니다.


위 예제에서는 사용할 때 호출될 떄 괄호(<>)를 써서 사용합니다만, 실제에서는 파라미터에 string값을 넣으면 자동으로 제네릭이 설정됩니다.

그래서 함수 제네릭를 사용할 때는 굳이 제네릭을 설정하지 않아도 프로그램에서 에러가 발생하지 않습니다.

여기까지 C#에서 제네릭(Generic) 사용법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.