자주 헷갈리던 제네릭의 심화인 와일드카드 문법에 대해 정리하려 합니다.
제네릭에 대한 기본적인 내용은 다루지 않습니다.
class Box<T> {
private T ob;
public void set(T o) { ob = o; }
public T get() { return ob; }
@Override
public String toString() {
return ob.toString();
}
}
class Unboxer {
public static <T> void peekBox(Box<T> box) {
System.out.println(box);
}
}
위와 같이 데이터를 넣을 수 있는 클래스 Box
와 데이터를 꺼낼 수 있는 기능의 제네릭 메서드를 가진 Unboxer
클래스가 있습니다.
여기서 Unboxer
의 메서드가 제네릭으로 정의된 이유는 Box<Integer>
, Box<String>
등 Box<T>
의 다양한 타입의 인스턴스를 인자로 전달받도록 하기 위함인데요. 그러면 다음과 같이 정의해도 되지 않을까요?
public static void peekBox(Box<Object> box) {
System.out.println(box);
}
안됩니다. 그 이유는 제네릭에 대해서 공부해보셨으면 아실텐데,
Box<Object>
<- Box<String>
, Box<Object>
<- Box<Integer>
등의 상속 관계는 형성되지 않기 때문입니다. 대신 '와일드카드'를 이용하면 다음과 같이 가능합니다.
public static void peekBox(Box<?> box) {
System.out.println(box);
}
위와 같은 메서드는 Box<T>
기반으로 생성된, Box<Integer>
, Box<String>
등의 인스턴스를 인자로 받을 수 있습니다.
// 제네릭 메서드
public static <T> void peekBox(Box<T> box) {
System.out.println(box);
}
// 와일드카드 기반 메서드
public static void peekBox(Box<?> box) {
System.out.println(box);
}
기능 적인 측면에서 두 메서드는 완전 동일합니다. 즉 제네릭 메서드와 와일드카드 기반 메서드는 상호 대체 가능한 측면이 있습니다. 그러나 코드가 조금 더 간경하다는 이유로 와일드카드 기반 메서드의 정의가 선호된다고 합니다.
public static void peekBox(Box<? extends Number> box) {
System.out.println(box);
}
위와 같이 선언하면 Box<T>
에서 T
가 Number
또는 Number
의 하위 클래스인 제네릭 타입의 인스턴스만 전달되도록 제한할 수 있습니다.
Box<? extends Number> box
box
는 Box<T>
인스턴스를 참조하는 참조변수Box<T>
인스턴스의 T
는 Number
또는 이를 상속하는 하위 클래스public static void peekBox(Box<? super Integer> box) {
System.out.println(box);
}
위와 같이 선언하면 Box<T>
에서 T
가 Integer
또는 Integer
의 상위 클래스인 제네릭 타입의 인스턴스만 전달되도록 제한할 수 있습니다.
Box<? super Integer> box
box
는 Box<T>
인스턴스를 참조하는 참조변수Box<T>
인스턴스의 T
는 Integer
또는 Integer
가 상속하는 클래스와일드 카드에 대해 "Box<T>의 T를 Number 또는 Number를 직간접적으로 상속하는 클래스로 제한하기 위한 것", "Box<T>의 T를 Integer 또는 Integer가 직간접적으로 상속하는 클래스로 제한하기 위한 것" 등의 설명 이외에 추가적인 이해가 필요합니다.
설명을 위해 아래 예제를 사용합니다.
class Box<T> {
private T ob;
public void set(T o) { ob = o; }
public T get() { return ob; }
}
class Toy {
@Override
public String toString() {
return "I am a Toy";
}
}
class BoxHandler {
public static void outBox(Box<Toy> box) {
Toy toy = box.get(); // 상자에서 꺼내기
System.out.println(toy);
}
public static void inBox(Box<Toy> box, Toy toy) {
box.set(toy); // 상자에 넣기
}
}
해당 예제의 코드는 잘 정의되었고, 잘 동작하지만 잘 만들어진 코드는 다음의 조건을 추가로 만족해야 하는데, 위 BoxHandler
의 메서드는 이 조건까지는 만족하지 못 합니다.
"필요한 만큼의 기능을 허용하여, 코드의 오류가 컴파일 과정에서 최대한 발견되도록 한다."
먼저 다음 메서드를 봅시다.
class BoxHandler {
// 매개변수 box가 참조하는 상자에서 인스턴스를 꺼내는 기능
public static void outBox(Box<Toy> box) {
Toy toy = box.get(); // 상자에서 꺼내기
System.out.println(toy);
}
}
이 메서드를 정의할 당시 프로그래머의 생각은 다음과 같을 것 입니다.
'상자에서 내용물을 꺼내는 기능의 메서드를 정의하자'
그러나 현재는 매개변수 box를 대상으로 get은 물론 set의 호출도 가능합니다.
public static void outBox(Box<Toy> box) {
Toy toy = box.get(); // 꺼내는 것! OK!
box.set(new Toy()); // 넣는 것! 이것도 OK!
}
위와 같은 실수는 누구나 할 수 있지만 이러한 오류는 컴파일 과정에서 발견되지 않습니다.
다음과 같이 매개변수를 선언하면 상자에서 꺼내는 것은 가능하지만 넣는 것은 불가능하게 됩니다. 넣으려고 하면 컴파일 오류가 발생합니다.
public static void outBox(Box<? extends Toy> box) {
Toy toy = box.get(); // 꺼내는 것! OK!
box.set(new Toy()); // 넣는 것! ERROR!
}
위의 상황에서 set 메서드의 호출이 불가능한 이유는 위 메서드의 매개변수로 Toy
인스턴스를 저장할 수 있는 상자만(Box<T>
인스턴스만) 전달된다는 사실을 보장할 수 없기 때문입니다.
Toy 클래스는 다음과 같이 다른 클래스들에 의해 얼마든지 상속이 될 수 있습니다.
class Car extends Toy { ... }
class Robot extends Toy { ... }
그리고 이렇게 상속 관계를 맺으면 위의 outBox
aptjemdp Box<Car>
또는 Box<Robot>
인스턴스가 인자로 전달될 수 있습니다. 이러한 상황에서 다음과 같이 Toy
인스턴스를 상자에 담을 수 있을까요?
public static void outBox(Box<? extends Toy> box) {
// box로 Box<Car> 또는 Box<Robot> 인스턴스가 전달된다면?
box.set(new Toy()); // 넣는 것! ERROR!
}
바로 이러한 문제점 때문에 다음과 같이 선언된 매개변수를 대상으로는 저장하는(전달하는) 메서드의 호출이 불가능 합니다.
Box<? extends Toy> box
정리하자면 다음과 같은 매개변수 선언을 보았을 때,
public static void outBox(Box<? extends Toy> box) {
/*
이 안에서는 box가 참조하는 인스턴스에
Toy 인스턴스를 저장하는(전달하는) 메서드 호출은 불가능하다.
*/
}
"box가 참조하는 인스턴스를 대상으로 저장하는 기능의 메서드 호출은 불가능하다." 라는 판단을 할 수 있어야 합니다.
class Box<T> {
private T ob;
public void set(T o) { ob = o; }
public T get() { return ob; }
}
class Toy {
@Override
public String toString() {
return "I am a Toy";
}
}
class BoxHandler {
public static void outBox(Box<? extends Toy> box) {
Toy toy = box.get(); // 상자에서 꺼내기
System.out.println(toy);
}
public static void inBox(Box<Toy> box, Toy toy) {
box.set(toy); // 상자에 넣기
}
}
이번에는 다음 메서드를 봅시다.
class BoxHandler {
public static void inBox(Box<Toy> box, Toy toy) {
box.set(toy); // 상자에 넣기
}
}
위의 inBox 메서드도 좋은 코드가 되기 위한 다음 조건을 만족하지 못 합니다.
"필요한 만큼의 기능을 허용하여, 코드의 오류가 컴파일 과정에서 최대한 발견되도록 한다."
이 메서드는 상자에 인스턴스를 저장하는 것이 목적이니, 다음과 같이 get 메서들르 호출하는 코드가 삽입된다면 이는 분명 프로그래머의 실수입니다.
public static void inBox(Box<Toy> box, Toy toy) {
box.set(toy); // 넣는 것! OK!
Toy myToy = box.get(); // 꺼내는 것! 이것도 OK!
}
그러나 이러한 실수는 컴파일 과정에서 발견되지 않습니다. 따라서 이러한 실수가 컴파일 과정에서 발견될 수 있도록 매개변수를 다음과 같이 선언해야 합니다.
public static void inBox(Box<? super Toy> box, Toy toy) {
box.set(toy); // 넣는 것! OK!
Toy myToy = box.get(); // 꺼내는 것! ERROR!
}
위와 같이 매개변수를 선언하면 get 메서드의 호출문에서 컴파일 오류가 발생합니다. 이유는 반환형을 Toy
로 결정할 수 없기 때문입니다. 즉 get 메서드 호출 자체는 문제되지 않으나, 반환되는 값을 저장하기 위해 선언한 참조변수 형을 Toy
로 결정했다는 사실에서 문제가 발생합니다.
Toy
클래스의 상속관계가 다음과 같다고 가정합시다.
class Plastic { ... }
class Toy extends Plastic { ... }
그러면 inBox 메서드의 첫 번째 인자로 전달 가능한 두 가지 유형의 Box<T>
인스턴스는 Box<Toy>
, Box<Plastic>
입니다.
inBox 메서드에 인자로 Box<Toy>
의 인스턴스가 전달되면 메서드 내에서 다음 문장을 실행하는데 문제가 없지만,
// get이 반환하는 것이 Toy 인스턴스이므로 문제가 없음
Toy myToy = box.get();
Box<Plastic>
의 인스턴스가 전달되면 메서드 내에서 다음 문장을 실행하는데 있어서 문제가 됩니다. 그래서 컴파일러는 이 문장 자체를 허용하지 않습니다.
// get이 반환하는 것이 Plastic 인스턴스이므로 문제가 됨
Toy myToy = box.get();
정리하자면 다음과 같은 매개변수 선언을 보았을 때,
public static void inBox(Box<? super Toy> box, Toy toy) {
/*
이 안에서는 box가 참조하는 인스턴스에서
Toy 인스턴스를 꺼내는(반환하는) 메서드 호출은 불가능하다.
*/
}
"box가 참조하는 인스턴스를 대상으로 꺼내는 기능의 메서드 호출은 불가능하다" 라는 판단을 할 수 있어야 합니다.
class Box<T> {
private T ob;
public void set(T o) { ob = o; }
public T get() { return ob; }
}
class Toy {
@Override
public String toString() {
return "I am a Toy";
}
}
class BoxHandler {
public static void outBox(Box<? extends Toy> box) {
Toy toy = box.get(); // 상자에서 꺼내기
System.out.println(toy);
}
public static void inBox(Box<? super Toy> box, Toy toy) {
box.set(toy); // 상자에 넣기
}
}
Object
형으로 선언한다면public static void inBox(Box<? super Toy> box, Toy toy) {
Object myToy = box.get();
}
위의 메서드 정의는 컴파일이 됩니다. 그러나 자바는 Object
형 참조변수의 선언이나 Object
형으로의 형 변환이 불필요하도록 문법을 개선시켜 봤습니다. Object
라는 이름이 코드에 직접 등장하는 것은 컴파일러를 통한 오류의 발견 가능성을 낮추는 행위이기 때문입니다. 그러므로 지금 설명하는 부분에서 참조변수를 Object
형으로 선언하는 것은 논외로 해야하며, 동시에 당연히 피해야할 일입니다.
앞서 Toy
클래스를 담은 상자를 기준으로 inBox, outBox 메서드를 정의하였습니다.
class BoxHandler {
public static void outBox(Box<? extends Toy> box) {
Toy toy = box.get(); // 상자에서 꺼내기
System.out.println(toy);
}
public static void inBox(Box<? super Toy> box, Toy toy) {
box.set(toy); // 상자에 넣기
}
}
위의 두 메서드는 Box<Toy>
인스턴스를 대상으로 정의된 메서드 입니다. 이 상황에서 다음 클래스를 정의했다고 가정해봅시다.
class Robot { ... }
그리고 Box<Robot>
의 인스턴스를 대상으로 outBox, inBox 메서드를 호출하고 싶다면, 오버로딩을 하여 메서드를 정의하는 방법을 고려할 수 있습니다.
그런데 다음 두 메서드 정의는 오버로딩이 성립하지 않습니다.
public static void outBox(Box<? extends Toy> box) { ... }
public static void outBox(Box<? extends Robot> box) { ... }
그 이유는 자바는 제네릭 등장 이전에 정의된 클래스들과의 상호 호환성 유지를 위해 컴파일 시 제네릭과 와일드카드 관련 정보를 지우는 과정을 거치는데, 그로 인해 위의 두 매개변수 선언은 컴파일 과정에서 다음과 같이 수정되고 이로 인해 메소드의 오버로딩이 성립 불가능한 상태가 됩니다.
Box<? extends Toy> box -> Box box
Box<? extends Robot> box -> Box box
반면 다음 두 메서드는 제네릭과 관련없는 두 번째 매개변수의 자료형이 다르기 때문에 오버로딩이 인정됩니다.
public static void inBox(Box<? super Toy> box, Toy n) { ... }
public static void inBox(Box<? super Robot> box, Robot n) { ... }
위와 같은 상황을 해결하는 답은 '제네릭 메서드'에 있습니다.
class BoxHandler {
public static <T> void outBox(Box<? extends T> box) {
T t = box.get();
System.out.println(t);
}
public static <T> void inBox(Box<? super T> box, T n) {
box.set(n);
}
}