Generics(제네릭스)


컬렉션 프레임워크(List, Set)와 Map을 사용하면서 별 다른 생각 없이 많이 사용은 해왔지만 오픈소스나 다른 소스코드에서 표현되는 등의 확장, 제한된 제네릭스를 보면 지레 겁부터 먹거나 다른 소스코드를 찾아보기가 일쑤였다.

또한 이번에 업무에서 추상화를 하는데에 있어 부딪혔던 이슈와 관련이 깊기 때문에 (다형성의 문제로 직접적인 해결이 되진 않았지만) 이번 기회에 간단하게나마 정리를 하려 한다.


Generics

제네릭스는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일시 타입 체크(compile-time type check) 기능이다. 간단히 말하자면 다룰 객체의 타입을 미리 명시 함으로써 번거로운 형 변환을 줄여준다는 의미이다.

객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 casting의 번거로움을 줄일 수 있다.

  • 타입 안정성 제공
  • 타입 체크와 형변환을 생략할 수 있으므로 코드의 간결성 및 가독성 향상.


제네릭 클래스 선언

제네릭 타입은 클래스, 메서드에 선언이 가능하다. 간단하게 아래 소스코드를 보자

위의 Generic 클래스 Box<T>에서 T를 Type Variable이라 하며 해당 기호는 어떤 기호든 (E, V, …)들어 갈 수 있다. 즉, 기호는 말그대로 본인의 기호 혹은 해당 클래스 사용처에 따라 임의로 지어주면 되겠다. 이들은 기호의 종류만 다를 뿐 임의의 참조형 타입을 의미한다는 것은 모두 같다.

기존에는 다양한 종류의 타입을 다루는 메서드의 매개변수나 리턴타입으로 Object타입의 참조변수를 많이 사용했고, 그로 인해 형변환이 불가피 했지만 이젠 Object타입 대신 원하는 타입을 지정하기만 하면 된다.

이제 제네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자에 타입 T대신에 사용될 실제 타입을 지정해야주어야 한다.

Box<String> box = new Box<String>();    //타입 T대신 실제 타입(String) 지정
box.setItem(new Object());              //Error, String 타입만 가능
box.setItem("ABC");
String item = box.getItem();            //(String) 캐스팅이 필요 없음.

위의 코드에서 타입 T 대신에 String 타입을 지정해 줬으므로 제네릭 클래스 Box<T>는 다음과 같이 정의된것과 같다.

여기서 알아두어야 할 중요한 점은 컴파일 후에 Box<String>은 원시타입인 Box로 바뀐다. 즉, 제네릭 타입이 제거된다.

제네릭스의 제한

제네릭 클래스 Box의 객체 생성시, 객체별로 다른 타입을 지정할 수 있다.(인스턴스별로 다르게 동작하도록 하려고 만든 기능이니까.)

Box<Apple> appleBox = new Box<Apple>();   //Apple 객체만 저장 가능
Box<Grape> grapeBox = new Box<Grape>();   //Grape 객체만 저장 가능

그러나 모든 객체에 대해 동일하게 동작해야 하는 static 멤버에 대해 타입변수 T를 사용할 수는 없다. T는 인스턴스 변수로 간주되기 때문. 이미 알고있듯이 static멤버는 인스턴스 변수를 참조할 수 없다.

또한 new 연산자를 통한 제네릭 클래스 생성(new Box()) 혹은 제네릭 배열(new T[10];)은 불가능 한데, 이는 new 연산자가 컴파일 시점에 타입 T가 무엇인지 정확히 알고 있어야 하기 때문이다. 마찬가지 이유로 instanceof 연산자도 T를 파라미터로 사용할 수 없다.

제네릭 클래스의 객체생성과 사용

다음과 같은 제네릭클래스를 보자.

class Box<T> {
  List<T> list = new ArrayList<T>();

  void add(T item) { list.add(item); }
  T get(int i) { return list.get(i); }
  List<T> getList() { return this.list; }
  int size() { return list.size(); }
  public String toString() { return list.toString(); }

}

위의 제네릭클래스 Box의 객체를 생성할 때는 참조변수와 생성자에 대입된 타입(parameterized type)이 일치해야한다. 일치하지 않으면 에러가 발생한다.

Box<Apple> appleBox = new Box<Apple>();         //OK
Box<Apple> appleBox = new Box<Grape>();         //Error

두 타입이 상속관계에 있어도 마찬가지이다. Apple is-a Fruit라고 가정해보자.

Box<Apple> appleBox = new Box<Apple>();   //Error, 대입된 타입이 다름.

즉 대입된 타입(parameterized Type)는 같아야만 객체생성이 가능하다.

말로 길게 설명했지만 코드를 보면서 이해하면 보다 쉽다.


제한된 제네릭 클래스

다음과 같은 코드를 보자

FruitBox<Toy> fruitBox = new FruitBox<Toy>();
fruitBox.add(new Toy());

위와 같이 타입 매개변수 T에는 모든 종류의 타입을 지정 할 수 있다. 그렇다면 타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한함으로써 과일 상자에 장난감이 들어가는 불상사를 막는 방법은 무엇일까?

아래의 코드를 살펴보자.

//Fruit 자손만 타입 변수로 지정 가능
class FruitBox<T extends Fruit> {
  List<T> list = new ArrayList<T>();
}

위와 같이 타입 변수의 범위를 Fruit 클래스의 자손으로 제한함으로써 장난감이 들어가는 불상사를 막을 수 있다. 또한 만일 클래스가 아니라 인터페이스를 구현해야한다는 제약이 필요하다면 이 때에도 ‘implements’가 아니라 ‘extends’를 사용한다. 클래스 Fruit의 자손이면서 Eatable인터페이스도 구현해야 한다면 ‘&’기호로 연결한다.

class FruitBox<T extends Fruit & Eatable> { ... }

이제 FruitBox에는 Fruit의 자손이면서 Eatable을 구현한 클래스만 타입 매개변수 T에 대입될 수 있다.

전체적인 코드를 보면 보다 이해가 쉽다.


와일드 카드(객체 생성, 매개변수 대입)

매개변수에 과일박스를 대입하면 주스를 만들어서 반환하는 Juicer라는 클래스가 있고, 이 클래스에는 과일을 주스로 만들어서 반환하는 makeJuice()라는 static 메서드가 다음과 같이 정의되어있다고 가정하자.

class Juicer {
  //<Fruit>으로 지정
  static Juice makeJuice(FruitBox<Fruit> box) {
    String tmp = "";
    for(Fruit f : box.getList()) {
      tmp = tmp + f + " ";
    }

    return new Juice(tmp);
  }
}

위의 Juicer 클래스는 제네릭 클래스가 아닌데다, 제네릭 클래스라고 해도 static메서드에는 타입 매개변수 T를 사용할 수 없으므로 (static void method(T item) 불가) 위와같이 타입 매개변수 (FruitBox<T>) 대신 특정 타입을 지정(FruitBox<Fruit>) 해주어야 한다. 그렇다면 아래 코드에서의 문제점을 확인해보자.

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.makeJuice(fruitBox)); //OK. FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); //Error. FruitBox<Apple>

이렇게 제네릭 타입을 FruitBox으로 고정해 놓으면 위의 코드에서 알 수 있듯 FruitBox타입의 객체는 makeJuice()의 매개변수가 될 수 없으므로 다음과 같이 여러 가지 타입의 매개변수를 갖는 makeJuice()를 만들 수 밖에 없다.

그러나 위와 같이 오버로딩 하면 컴파일 에러가 발생한다. 제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다. 제네릭 타입은 컴파일러가 컴파일 할 때만 사용하고 제거해버린다. 그래서 위의 두 메서드는 오버로딩이 아니라 메서드의 중복이다.

이럴때 사용하는것이 와일드카드이다. 와일드 카드는 기호 ‘?’‘로 표시현하는데, 와일드 카드는 어떠한 타입도 될 수 있다.’?’만으로는 Object 타입과 다를게 없으므로 다음과 같이 ‘extends’와 ‘super’로 상한과 하한을 제한 가능하다.

  • <? extends T> 와일드 카드의 상한 제한, T와 그 자손들만 가능
  • <? super T > 와일드 카드의 하한 제한, T와 그 조상들만 가능
  • <?> 제한 없음, 모든 타입이 가능. <? extends Object>와 동일.

와일드 카드를 사용해서 makeJuice()의 매개변수 타입을 FruitBox<Fruit>에서 FruitBox<? extends Fruit>으로 바꾸면 다음과 같이 된다.

class Juicer {
  //와일드카드로 지정
  static Juice makeJuice(FruitBox<? extends Fruit> box) {
    String tmp = "";
    for(Fruit f : box.getList()) {
      tmp = tmp + f + " ";
    }

    return new Juice(tmp);
  }
}

이제 이 메서드의 매개변수로 FruitBox<Fruit>뿐 아니라 FruitBox<Apple>과 FruitBox<Grape>도 가능하게 된다.

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.makeJuice(fruitBox)); //OK. FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); //OK. FruitBox<Apple>

주의! 매개변수의 타입을 FruitBox<? extends Object>로 하면 모든 종류의 FruitBox가 이 메서드의 매개변수로 가능해진다. 대신 전과 달리 box의 요소가 Fruit의 자손이라는 보장이 없으므로 아래의 for문에서 box에 저장된 요소를 Fruit타입의 참조 변수로 못 받는다.

class Juicer {
  //<와일드카드> 지정
  static Juice makeJuice(FruitBox<? extends Object> box) {
    String tmp = "";
    for(Fruit f : box.getList()) {      //Error! Fruit(자손)이 아닐 수 있음
      tmp = tmp + f + " ";
    }

    return new Juice(tmp);
  }
}

그러나 실제로는 문제없이 컴파일 되는데, 그 이유는 바로 지네릭 클래스 FruitBox를 제한했기 때문

class FruitBox<T extends Fruit> extends Box<T> {...}

즉, 컴파일러는 위 문장으로부터 모든 FruitBox의 요소들이 Fruit의 자손이라는 것을 알고 있으므로 문제삼지 않는것.

아래의 코드를 보면 보다 쉽게 이해할 수 있다.


이제 ‘super’로 와일드 카드의 하한을 제한하는 경우에 대하여 살펴보자. 먼저 아래의 코드를 확인한다.

위 예제는 Collections.sort()를 이용해서 appleBox와 grapeBox에 담긴 과일을 무게별로 정렬한다. 이 메서드의 선언부는 다음과 같다.

static <T> void sort(List<T> list, Comparator<? super T> c)

‘static’앞에 ‘'는 메서드에 선언된 제네릭 타입이다. 첫 번째 매개변수는 **정렬할 대상**이고 두 번째 매개변수는 **정렬할 방법이 정의된 Comparator**이다 Comparator의 제네릭 타입에 하한 제한이 걸려있는 와일드 카드가 사용되었다.

appleComp와 grapeComp는 타입만 다를 뿐 완전히 같은 코드이다. 코드의 중복도 문제이지만 새로운 Fruit의 자손이 생길 때 마다 위와 같은 코드를 반복해서 만들어야 한다는 것이 더 문제다. 이 문제를 해결하기 위해서는 타입 매개변수에 하한 제한의 와일드 카드를 적용해야 한다. 앞서 살펴본바와 같이 sort()는 원래 그렇게 정의되어 있다.




###참고 및 출처

  • 자바의 정석