
재귀적 타입 한정(Recursive Type Bound)은 제네릭 타입을 정의할 때 사용하는 기법 중 하나로 제네릭 타입이 자기 자신을 타입 매개변수로 사용할 수 있게 하는 방식이다.
예를 들면 다음과 같은 코드가 있다고 하자
✍️ 작성
abstract class MyEnum<T extends MyEnum<T>> implements Comparable<T> {
static int id = 0; // 객체에 붙일 일련번호 (0부터 시작)
int ordinal;
String name = "";
public int ordinal() { return ordinal; }
MyEnum(String name) {
this.name = name;
ordinal = id++;
}
public int compareTo(T t) {
return ordinal - t.ordinal();
}
}
여기서 사용된 MyEnum<T extends MyEnum<T>> 이 부분이 재귀적 타입 한정을 나타내는 구문이다. 이 구문은 T 타입이 MyEnum<T>의 서브타입(subtype) 이어야 한다는 의미를 가지고 있다. 즉, T 타입은 MyEnum 클래스 자체이거나, MyEnum 클래스를 상속받는 클래스여야 한다.
사실 언제 재귀적 타입이 언제 필요한 지 잘 와닿지 않으므로 적절한 예시를 가져왔다.
예를 들어 다음과 같은 문제가 있다고 하자.
Problem
사과와 오렌지를 크기별로 정렬하는 코드를 작성하라. 다만 같은 종류의 과일끼리만 비교할 수 있다.
(예를 들어, 사과와 오렌지를 비교할 수는 없다.)
해당 문제를 풀기 위해 다음과 같이 Solution 1 코드를 작성할 수 있다.
interface Fruit {
Integer getSize();
}
class Apple implements Fruit, Comparable<Apple> {
private final Integer size;
public Apple(Integer size) {
this.size = size;
}
@Override
public Integer getSize() {
return size;
}
@Override
public int compareTo(Apple other) {
return size.compareTo(other.size);
}
}
class Orange implements Fruit, Comparable<Orange> {
private final Integer size;
public Orange(Integer size) {
this.size = size;
}
@Override
public Integer getSize() {
return size;
}
@Override
public int compareTo(Orange other) {
return size.compareTo(other.size);
}
}
class Main {
public static void main(String[] args) {
Apple apple1 = new Apple(3);
Apple apple2 = new Apple(4);
apple1.compareTo(apple2);
Orange orange1 = new Orange(3);
Orange orange2 = new Orange(4);
orange1.compareTo(orange2);
apple1.compareTo(orange1); // Error: different types
}
}
Solution 1 코드 설명
이 코드에서는 같은 종류의 과일끼리만 비교할 수 있다. 즉, 사과는 사과와 비교하고 오렌지는 오렌지와 비교할 수 있으며 사과와 오렌지를 비교하려고 하면 오류가 발생하므로 문제의 조건을 만족한다.
Solution 1 코드의 문제점
여기서 문제는 compareTo() 메서드를 구현하는 코드가 Apple 클래스와 Orange 클래스에서 중복된다는 것이다. 만약 새로운 과일을 추가하기위해 개별 과일 클래스에서 Fruit 인터페이스를 구현할 때마다 이 코드가 더 많이 중복될 것이다. 예제에서는 반복되는 코드의 양이 적지만, 실제 상황에서는 각 클래스마다 수백 줄의 코드가 중복될 수 있다.
이 문제를 Solution 2 에서 해결해보자.
class Fruit implements Comparable<Fruit> {
private final Integer size;
public Fruit(Integer size) {
this.size = size;
}
public Integer getSize() {
return size;
}
@Override
public int compareTo(Fruit other) {
return size.compareTo(other.getSize());
}
}
class Apple extends Fruit {
public Apple(Integer size) {
super(size);
}
}
class Orange extends Fruit {
public Orange(Integer size) {
super(size);
}
}
Solution 2 코드 설명
해당 코드에서는 compareTo() 메서드의 반복된 코드를 부모클래스인 Fruit 클래스로 옮겼다. 이제 Apple과 Orange와 같은 확장된 자식 클래스들은 더 이상 중복된 코드가 존재하지 않는다.
Solution 2 코드의 문제점
하지만 새로운 문제가 생겼는데 해당 코드에서는 서로 다른 종류의 과일들을 비교할 수 있다는 것이다. 즉, 사과와 오렌지를 비교해도 더 이상 오류가 발생하지 않아 문제의 조건을 충족시키지 못한다.
이 문제를 해결하기 위해 Comparator 클래스에 타입 매개변수를 추가하여 코드를 재작성해보자.
class Fruit<T> implements Comparable<T> {
private final Integer size;
public Fruit(Integer size) {
this.size = size;
}
public Integer getSize() {
return size;
}
@Override
public int compareTo(T other) {
return size.compareTo(other.getSize()); // Error: getSize() not available.
}
}
class Apple extends Fruit<Apple> {
public Apple(Integer size) {
super(size);
}
}
class Orange extends Fruit<Orange> {
public Orange(Integer size) {
super(size);
}
}
Solution 3 코드 설명
서로 다른 타입의 객체간 비교를 제한하기 위해 타입 매개변수 T를 도입한다. Apple과 Orange 클래스는 각각 Fruit<Apple>과 Fruit<Orange>를 상속받게 되고 각각 서로 다른 타입변수를 갖는 Comparable을 구현하게 되므로 이 상태에서 서로 다른 타입을 비교하려고 하면 오류가 발생하며, 이는 문제의 조건을 만족시킨다.
Solution 3 코드의 문제점
하지만 이 단계에서 compareTo(T other) 메서드에 추가적인 문제가 발생하게 되는데 바로 Fruit 클래스가 컴파일되지 않는다는 것이다. T의 getSize() 메서드는 컴파일러에게 알려져 있지 않기 때문이다. 이는 Fruit 클래스의 타입 매개변수 T에 아무런 한정이 없기 때문이다. T는 어떤 클래스라도 될 수 있기 때문에, 모든 클래스가 getSize() 메서드를 가질 가능성은 없다.
예를 들어 T에 String이 대입될 수도 있는데 그렇다면 코드 return size.compareTo(other.getSize()); 의 other.getSize() 부분에서 오류가 발생하게 된다. String 클래스에는 getSize()라는 메서드가 없기 때문이다. 그래서 컴파일러가 T의 getSize() 메서드를 인식하지 않는 것이다.
따라서 이 문제를 해결하기 위해서는 T에 어떠한 제한을 걸어주어야 한다. 오로지 getSize() 메서드를 가지고 있는 클래스만 타입 매개변수 T에 지정될 수 있도록 하는 제한이 필요하다. 이 점을 반영하여 코드를 재작성 해보자.
class Fruit<T extends Fruit<T>> implements Comparable<T> {
private final Integer size;
public Fruit(Integer size) {
this.size = size;
}
public Integer getSize() {
return size;
}
@Override
public int compareTo(T other) {
return size.compareTo(other.getSize()); // Now getSize() is available.
}
}
class Apple extends Fruit<Apple> {
public Apple(Integer size) {
super(size);
}
}
class Orange extends Fruit<Orange> {
public Orange(Integer size) {
super(size);
}
}
Solution 4 코드 설명
그래서 우리는 컴파일러에게 T가 Fruit의 서브타입임을 알려준다. 즉, 상한 경계로 T extends Fruit<T>를 지정한다. 이렇게 하면 Fruit의 서브타입만 타입 인수로 허용되도록 보장할 수 있다. 이제 컴파일러는 Fruit 클래스의 서브타입(Apple, Orange 등)에서 getSize() 메서드를 찾을 수 있다는 것을 알게 된다. 왜냐하면 Comparable<T>도 getSize() 메서드를 포함하는 Fruit<T> 타입을 받기 때문이다.
이 방법을 통해 compareTo() 메서드의 중복 코드를 제거할 수 있으며, 같은 종류의 과일들, 즉 사과는 사과끼리, 오렌지는 오렌지끼리 비교할 수 있게 된다.
이렇게 Comparable 인터페이스를 구현하거나 자기 참조(Generic Interface)의 경우 재귀적 타입 한정은 유용하게 사용 될 수 있다.
제네릭 그리고 재귀적 타입 한정
Stack Overflow : What does "Recursive type bound" in Generics mean?
이펙티브 자바 3판 (조슈아 블로크)