와일드 카드와 제네릭의 활용
타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장하도록 제한할 수 있지만, 여전히 모든 종류의 타입을 지정할 수 있다. 아예 지정할 수 있는 타입에 제한을 두고 싶다면 타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한하면 된다.
예를 들어 카페에서 식재료를 보관하는 보관함들이 있다. 하지만 따로 제한을 두지 않을 경우 엉뚱한 물건을 담게 될 수도 있다.
StorageBox<Tool> storageBox = new StorageBox<Tool>();
storageBox.add(new Tool()); // 식재료 대신 공구를 저장하게 됨
다음과 같이 제네릭 타입에 extends
를 사용하면 특정 타입의 자식들만 대입할 수 있게 제한할 수 있다. 여전히 한 종류의 타입만 담을 수 있지만 담을 수 있는 타입의 종류에 제한이 추가된 것이다.
public class StorageBox<T extends Ingredient> { // Ingredient의 자식 타입만 지정 가능
ArrayList<T> list = new ArrayList<T>();
...
}
public class CoffeeBeans extends Ingredient{
////////////////////////////////////////////////////////////////////////
StorageBox<CoffeeBeans> storageBox1 = new StorageBox<CoffeeBeans>();
StorageBox<Tool> storageBox2 = new Serving<Tool>(); // 에러
만일 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요한 경우 역시 implements
대신 extends
를 사용한다. 자식 클래스이면서 특정 인터페이스도 구현해야 한다면 기호 &
로 연결한다.
public interface Brewable { ... }
public class CoffeeBeans extends Ingredient implements Brewable {
public class StorageBox<T extends Ingredient & Brewable> { ... }
제네릭 클래스를 생성할 때, 참조 변수에 지정된 제네릭 타입과 생성자에 지정된 타입은 일치해야 하고 상속 관계에 있더라도 일치하지 않는다면 컴파일 에러가 발생한다. 이런 제네릭 타입에 다형성을 적용하기 위해 도입된 것이 와일드카드이다. 와일드카드는 기호 ?
를 사용하며 extends
와 super
로 상한과 하한을 제한할 수 있다.
<? extends T>
: 와일드카드의 상한(upper bound) 제한. T와 그 자식들만 가능.<? super T>
: 와일드카드의 하한(lower bound) 제한. T와 그 부모들만 가능.<?>
: 제한 없음. 모든 타입이 가능(<? extends Object>
와 동일).제네릭 타입으로 와일드카드를 사용하면 다음과 같이 하나의 참조 변수로 다른 제네릭 타입이 지정된 객체를 다룰 수 있다.
public class FreshIngredient extends Ingredient{ ... }
public class Fruit extends FreshIngredient{ ... }
public class Vegetable extends FreshIngredient{ ... }
////////////////////////////////////////////////////////////////////////
StorageBox<? extends FreshIngredient> fruitBox = new StorageBox<Fruit>();
StorageBox<? extends FreshIngredient> vegeBox = new StorageBox<Vegetable>();
와일드카드를 메서드의 매개변수에 적용하면 제네릭 타입이 다른 여러 객체를 매개변수로 지정할 수 있다.
public class FreshIngredient extends Ingredient{ String state; }
public class Freezer {
static void freeze(StorageBox<? extends FreshIngredient> storageBox) {
for(FreshIngredient i : storageBox.list) {
i.state = "frozen";
}
}
}
////////////////////////////////////////////////////////////////////////
Freezer.freeze(new StorageBox<Fruit>());
Freezer.freeze(new StorageBox<Vegetable>());
컴파일러는 제네릭 타입을 이용해서 소스 파일을 체크하고 필요한 곳에 형변환을 추가한 후 제네릭 타입을 제거한다. 즉 컴파일된 파일(*.class)에는 제네릭 타입과 관련된 정보가 없어지는 것이다. 이렇게 하는 이유는 제네릭이 도입되기 이전의 소스 코드와의 호환성을 유지하고 제네릭 타입을 호출할 때마다 대입된 타입의 새로운 클래스가 생성되는 것을 방지하여 런타임 오버헤드를 줄이기 위함이다.
제네릭 타입의 경계(bound) 제거
public class StorageBox<T extends Ingredient> {
ArrayList<T> list = new ArrayList<T>();
void add(T item) { list.add(item); }
T get(int i) { return list.get(i); }
}
////////////////////////////////////////////////////////////////////////
public class StorageBox {
ArrayList list = new ArrayList();
void add(Ingredient item) { list.add(item); }
Ingredient get(int i) { return list.get(i); }
}
제네릭 타입을 제거한 후 타입이 일치하지 않으면 형변환을 추가
public class StorageBox {
...
Ingredient get(int i) { return (Ingredient) list.get(i); }
}
모든 객체가 공유하는 static 멤버에는 타입 변수 T를 사용할 수 없다. T는 인스턴스를 생성할 때마다 지정되어 인스턴스 변수로 간주되기 때문이다.
public class StorageBox<T extends Ingredient> {
static T item; // 에러
...
}
static T item;
에서 발생하는 에러는 static 멤버가 인스턴스 멤버를 참조하려는 경우에 발생하는 에러이다('StorageBox.this' cannot be referenced from a static context
). 인스턴스 생성 시 각 인스턴스마다 다른 타입을 지정할 수 있도록 제네릭스가 도입된 것인데 모든 인스턴스가 공유하는 static 멤버에 타입 변수를 적용할 경우 동일한 값을 유지할 수 없게 된다.
StorageBox<CoffeeBeans> coffeeBeansBox = new StorageBox<>();
StorageBox<Fruit> fruitBox = new StorageBox<>();
StorageBox<Vegetable> vegeBox = new StorageBox<>();
만약 static 멤버에 타입 변수가 허용된다면 위 코드의 static 변수 item은 StorageBox에서 CoffeeBeans가 되고 StorageBox에서 Fruit이 되고, StorageBox에서는 Vegetable이 된다. 모든 인스턴스가 더이상 공유할 수 없게 되는 것이다.
또한 제네릭 타입의 배열을 생성할 수 없다. 이는 new
연산자가 컴파일 시점에 타입 변수가 어떤 타입이 될지 알 수 없기 때문이다. instanceof
연산자도 같은 이유로 타입 변수를 피연산자로 쓸 수 없다. 만약 배열을 생성할 수 있다면 아래와 같이 될 것이다.
ArrayList<String>[] stringLists = new ArrayList<String>[1]; // 실제론 컴파일 에러
////////////////////////////////////////////////////////////////////////
// 타입 변수는 컴파일 시 제거되므로 컴파일 후 다음과 같이 됨.
ArrayList[] stringLists = new ArrayList[1];
결국 문자열 대신 다른 타입이 저장되는 일을 방지할 수 없게 된다.
메서드 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라고 하며 리턴 타입과 매개변수 타입으로 타입 파라미터를 가질 수 있다. 제네릭 타입의 선언 위치는 반환 타입 바로 앞이다. 대표적으로 Collections.sort()가 있다. 제네릭 메서드는 제네릭 클래스가 아닌 클래스에도 정의될 수 있다.
public class Collections {
...
public static <T> void sort(List<T> list, Comparator<? super T> c) {
list.sort(c);
}
...
}
제네릭 클래스에 정의된 타입 매개변수 T와 제네릭 메서드에 정의된 타입 매개변수 T는 별개이다.
public class StorageBox<T extends Ingredient> {
static <T> void sort(List<T> list, Comparator<? super T> c) {
...
}
}
제네릭 클래스 StorageBox에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 전혀 다른 변수이다. 제네릭 클래스에 선언된 타입 매개변수가 인스턴스 변수로 취급되는 것처럼 메서드에 선언된 제네릭 타입은 지역 변수와 같다. 해당 타입 매개변수는 메서드 내에서 지역적으로 사용될 것이므로 메서드가 static
이더라도 제네릭 타입을 선언하고 사용하는 것이 가능하다. 앞서 나왔던 freeze 메서드를 제네릭 메서드로 바꾸면 다음과 같다.
public class Freezer {
static <T extends FreshIngredient> void freeze(StorageBox<T> storageBox) {
for(FreshIngredient i : storageBox.list) {
i.state = "frozen";
}
}
}
제네릭 메서드를 호출할 때는 타입 변수에 타입을 대입해야 하지만 컴파일러가 대입된 타입을 추정할 수 있는 경우 생략 가능하다.
StorageBox<Fruit> fruit = new StorageBox<Fruit>();
StorageBox<Vegetable> vege = new StorageBox<Vegetable>();
Freezer.freeze(fruit); // Freezer.<Fruit>freeze(fruit);
Freezer.freeze(vege); // Freezer.<Vegetable>freeze(vege);