제네릭

Copyright (c) 2015-, All Rights Reserved by Kwanghoon Choi
(아래 자바 프로그래밍 강의 교재의 내용을 자유롭게 이용하되 다른 웹 사이트 등을 통한 배포를 금합니다.)

 

  1. 제네릭(Generic)

제네릭은 클래스, 인터페이스, 메소드를 정의할 때 타입을 일종의 인자로 지정하는 방법이다. 제네릭을 사용하는 대표적인 예는 여러 원소를 담을 수 있는 리스트나 집합 등과 같은 컬렉션(collection) 클래스이다.

ArrayList<String> strArrList = new ArrayList<String>();
ArrayList<Integer> children = new ArrayList<Integer>();

ArrayList는 길이를 자유롭게 변경할 수 있는 배열을 표현하는 클래스이다. 이 ArrayList에 필요에 따라 문자열(String), 정수(Integer) 등 임의의 타입의 원소들을 담을 수 있다. 사실 ArrayList를 사용할 때 타입 인자를 지정하지 않고도 사용 가능하다. 이 경우 원소의 타입을 Object 타입으로 간주한다.

ArrayList objArrList = new ArrayList();

objArrList.add("Hello"); // add 메소드는 Object 타입 인자를 요구, 
objArrList.add("World"); // "Hello"와 "World"는 String 타입이지만 Object로 간주할 수 있음

String s1 = (String)objArrList.get(0); // (String) 필요. get의 반환 타입이 Object이므로.
String s2 = (String)objArrList.get(1); // (String) 필요. get의 반환 타입이 Object이므로.

objArrList는 문자열을 담는 목적으로 사용하지만 objArrList로 부터 원소를 꺼내면 Object 타입이 되고 String 변수에 저장하려면 타입변환 (String)이 필요하다. 참고로 제네릭 클래스에서 타입 인자 없는 이름을 가공하지 않은 타입(raw type)이라 부른다. 제네릭은 J2SE 5에서 처음 도입되었고, 그 이전 버전에 이미 작성해놓은 프로그램들의 호환성을 유지하기 위해 가공하지 않은 타입도 사용가능하다. J2SE 5 플랫폼에서 ArrayList<E>로 선언하고 있는 반면 그 이하 버전의 플랫폼에서는 타입 인자 없이 ArrayList로 선언하고 있다. 여러 문자열을 담는 ArrayList 객체를 사용할 때 제네릭을 사용하면 다음과 같은 장점이 있다.

ArrayList objArrList = new ArrayList();

objArrList.add(1); // int 타입의 1을 오토박싱으로 Integer 타입으로 변환. 
                   // Integer 타입도 Object 타입으로 간주되므로 OK.

String s1 = (String)objArrList.get(0); // (String) 필요. 컴파일 시점에는 get의 반환 타입이 Object이므로
                                       // String 타입으로 변환할 수도 있으므로 컴파일 에러가 발생하지는 않음.
                                       // 
                                       // 그럼에도 불구하고 실제 반환되는 객체는 Integer 타입이고
                                       // Integer를 String으로 변환할 수 없으므로 실행 시간에 에러 발생
Exception in thread "main" java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String

위의 예와 같이 타입을 지정하지 않은 경우(혹은 Object로 느슨하게 지정하면) 런타임에 에러가 발생하는 코드가 컴파일 과정에서 타입 검사를 통과할 수 있다. 타입을 지정하면 이러한 문제를 컴파일 과정에서 사전에 파악할 수 있다.

ArrayList<String> strArrList = new ArrayList<String>();

strArrList.add("Hello");
strArrList.add(1);  // 컴파일 에러. 1은 Integer 타입으로 String 타입과 무관.

String s1 = strArrList.get(0); // (String) 타입 변환이 필요 없음. get의 반환 타입이 Object가 아니라 String.

이와 같이 제네릭을 사용하면서 프로그래머가 제공한 타입 인자 정보를 활용해 컴파일러로 하여금 타입변환을 자동으로 추가하거나 오류를 컴파일 시점에 찾아낼 수 있다. 컴파일 과정에서 타입 오류를 발견하는 다른 예를 살펴보자.

Integer i = strArrList.get(0);

strArrList 객체는 ArrayList<String> 타입이므로 get메소드를 통해 원소를 꺼내면 반드시 String 객체이다. 그런데 이 String 객체를 전혀 관련없는 Integer 타입의 변수에 저장할 수 없으므로 컴파일 에러가 발생한다.

Integer i = (Integer)objArrList.get(0);

만일 objArrList 객체에서 원소를 꺼내 Integer 타입의 변수에 저장한다고 하면 이때 get 메소드의 리턴 타입은 String이 아닌 Object이고 Object는 모든 클래스의 조상 클래스이므로 컴파일러는 호환 가능하다고 판단해서 컴파일 에러를 내지는 않는다. 하지만 이 대입문을 실행할 때 String 객체를 호환 관계가 없는 Integer 타입의 변수에 저장할 때 타입 변환 런타임 에러가 발생한다. 프로그램의 오류는 최대한 미리 알아내는 것이 바람직하다.

Exception in thread "main" java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer

제네릭을 이용하면 타입에 무관하게 사용할 수 있는 알고리즘을 구현할 수 있다. 예를 들어, List, Set, Map 등과 같은 컬렉션(collection) 클래스가 있다.

[참고] http://docs.oracle.com/javase/tutorial/collections/index.html

 

원소 한 개를 담을 수 있는 제네릭 클래스 Box<E>를 정의해보자. 비교를 위해 먼저 제네릭을 사용하지 않는 버전을 정의한다.

public class Box {
      private Object element;
      
      public void set(Object element) {
             this.element = element;
      }
      public Object get() {
             return this.element;
      }
}

제네릭 클래스 Box는 다음과 같다. Box 클래스의 Object 타입을 타입 인자 E로 바꾼 형태이다. 타입 인자에 박스에 넣을 원소 객체의 타입으로 지정하여 사용한다.

public class Box<E> {
      private E element;
      
      public void set(E element) {
             this.element = element;
      }
      public E get() {
             return this.element;
      }
}

Box<String> stringBox = new Box<String> ();
stringBox.set("Generics");
String s = stringBox.get();

Box<Integer> integerBox = new Box<Integer> ();
integerBox.set(1);
int i = integerBox.get();

이번에는 제네릭 메소드를 정의해보자. 앞에서 정의한 Box<E> 클래스의 객체를 받아 이 박스가 비어있는지 여부를 검사하는 메소드를 작성하자. 이 경우 박스의 원소가 String 클래스 객체인지 Integer 클래스 객체인지 중요하지 않고 단지 박스의 상태에만 관심이 있다.

public class BoxTest {
  public static void main(String[] args) {
    Box<String> strBox = new Box<String>();
    Box<Integer> intBox = new Box<Integer>();

    strBox.set("abc");  // strBox는 채워져 있고
                        // intBox는 비워져 있는 상태

    isEmpty(strBox); // 리턴값: false
    isEmpty(intBox); // 리턴값: true 
  }
  
  public static <E> boolean isEmpty(Box<E> box) {
    E elem = box.get();
    if (elem == null) return true;
    else return false;
  }

제네릭 메소드 isEmpty에서 static과 boolean 사이에 있는 <E>는 타입 인자이다. isEmpty(strBox)와 같이 호출할 때 E는 String을 받고, isEmpty(intBox)와 같이 호출하면 E에 Integer를 받는다. 사실 프로그래머가 isEmpty<String>(strBox)와 isEmpty<Integer>(intBox)와 같이 쓸 수는 없지만, String 타입 실인자와 Integer 타입 실인자를 Java 컴파일러가 자동으로 유추해서 지정해 준다. (개인 의견: Java 1.6부터 제네릭을 지원. Java 언어가 처음 나왔을 때 부터 제네릭을 지원했었더라면 제네릭 메소드 호출에서 타입 실인자를 개발자가 지정하는 구문을 허용하지 않았을까...)

제네릭 클래스와 제네릭 메소드는 별개의 특징으로 독립적으로 각각 사용할 수 있다. 즉, 제네릭 클래스 안에서 제네릭 메소드를 사용해야 하는 것은 아니다. 하지만 제네릭 클래스 안에서 제네릭 메소드를 사용할 때 타입 인자 이름이 겹치는 경우 혼동하지 않고 프로그램을 이해할 수 있어야 한다. 제네릭 메소드 isEmpty를 제네릭 클래스 Box<E> 안에 포함시켜 코드를 작성해보자.

public class Box<E> {
      private E element;
      
      public void set(E element) {
             this.element = element;
      }
      public E get() {
             return this.element;
      }

      public static <E> boolean isEmpty(Box<E> box) {
             E elem = box.get();
             if( elem ==  null ) return true;
             else return false;
      }
}

제네릭 클래스 Box<E>의 타입 인자 E와 제네릭 메소드 <E> boolean isEmpty에서 타입 인자 E는 우연히 이름이 같을 뿐 전혀 다른 것이다. 마치 아래 예제로 두 개의 함수 f와 g의 인자가 모두 동일한 이름 x의 인자를 받지만 두 x는 전혀 다르다는 것에 비유할 수 있다.

void f(int x) { ... x + 1 ... }
void g(double x) { ... x + 1.0 ... }

제네릭 메소드 isEmpty의 타입 인자 E를  T로 이름을 바꾸어 작성하면 두 타입 인자가 다르다는 사실을 더 쉽게 이해할 수 있을 것이다.

public class Box<E> {
      private E element;
      
      public void set(E element) {
             this.element = element;
      }
      public E get() {
             return this.element;
      }

      public static <T> boolean isEmpty(Box<T> box) {
             T elem = box.get();
             if( elem ==  null ) return true;
             else return false;
      }
}

제네릭 클래스가 타입 인자를 받아 클래스를 정의하는 것 처럼 제네릭 인터페이스도 타입 인자를 받아 인터페이스를 정의할 수 있다. 하나 이상의 타입인자를 받아 제네릭을 선언할 수 있다.

class ClassName <T1,T2,...,Tn> {
  ...
}


public interface Pair<K, V> {
       public K getKey();
       public V getValue();
}

public class PairClass<K, V> implements Pair<K, V> {
       private K key;
       private V value;

       public PairClass(K key, V value) {
              this.key = key;
          this.value = value;
       }

       public K getKey() { 
              return key; 
       }

       public V getValue() { 
              return value; 
       }
}


Pair<String, Integer> p1 = new PairClass<String, Integer> ("year", 2013);
Pair<String, Integer> p2 = new PairClass<String, Integer> ("month", 9);
Pair<String, Integer> p3 = new PairClass<String, Integer> ("day", 1);

Pair<String, String> dic1 = new PairClass<String, String> ("dog", "개");
Pair<String, String> dic2 = new PairClass<String, String> ("cat", "고양이");

참고로, Java 제네릭은 C++에서 템플릿과 유사하다. 타입 매개변수를 갖는 코드를 한번 정의해서 타입 인자를 달리 지정해서 여러 방법으로 사용할 수 있어 편리하다. 제네릭과 템플릿의 차이점은 제네릭은 여러 서로 다른 타입 인자를 사용하더라도 실제 코드는 한 세트지만 (타입 erasure 방식), 템플릿은 다른 타입 인자를 지정할 때마다 특화된 코드가 여러 세트로 (타입 expansion 방식) 별도로 준비된다.

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *