출처: http://www.ibm.com/developerworks/kr/library/j-jtp07018.html
get-put 원칙
Brian Goetz, 책임 엔지니어, Sun Microsystems
옮긴이: 오국환 dwkorea@kr.ibm.com
2008 년 10 월 21 일
자바(Java™) 언어에 제네릭이 포함되었는데, 그 중 와일드카드는 혼란스럽기 그지 없는 부분입니다. 경계 지정 와일드카드(bounded wildcard) 두 가지("
? super T
"와"? extends T
") 중하나를 잘못 선택하는 실수는 흔하게 저지르기 마련입니다. 독자들도 이런 실수를 범한 적이
있습니까? 전문가들조차 실수하게 마련이니 걱정하지 마십시오. 이 달에는 Brian Goetz가
실수를 피하는 법을 알려 줄 것입니다.
자바 언어에서 배열은 타입 호환성을 유지한다(역주: 원문은 'arrays are covariant'이며, 엘리먼트 타입 간
호환성 여부가 배열 타입 간에도 적용됨을 의미한다). 즉, Integer
는 Number
이기도 하므로 Integer
의 배열은 Number
의 배열이기도 하다. 그러나 제네릭은 그렇지 않다(List<Integer>
는 List<Number>
가 아니다).
어떤 선택이 옳고 그른지는 논쟁의 여지가 있다. 양쪽이 모두 장단점이 있기 때문이다. 어쨌든, 미묘하게 다른 의미의 파생 타입을 생성하기 위한 목적으로 비슷한 두 메커니즘이 존재한다는 점이 혼란과 실수 유발의 근본 원인인 것은 의문의 여지가 없다.
경계 지정 와일드카드("? extends T
" 식의 웃기는 제네릭 타입 지시자)는 타입 호환성 부족을 해소하는 수단 중 하나다. 이로써 클래스에서 메서드 인자 또는 리턴 값의 타입 호환성(또는 반 호환성)을 선언할 수 있다.
그러나 문제는 경계 지정 와일드카드 사용 시점을 파악하는 것이 무척 복잡하다는 것이다. 주로 라이브러리
사용자보다는 라이브러리 작성자에게 제네릭 사용의 부담이 주어진다. 경계 지정 와일드카드에서 가장 흔하게 저지르는 실수는 주로 다음 세 가지다. 첫째, 이것을 사용해야 할 때 사용하지 않는다. 둘째, 클래스의 활용을 제약한다. 셋째, 기존 클래스를 직접 재사용할 수 없어 반드시 보조 클래스를 통해 우회적으로 사용하도록
강요한다.
값 하나를 담는 Box
라는 이름의 단순한 제네릭 클래스에서 시작해 보자. 이 클래스는 단지 알려진 타입의
값을 담는 기능만 있다.
public interface Box<T> {
public T get();
public void put(T element);
}
|
Integer
가 Number
라고 해도 Box<Integer>
가 Box<Number>
는 아니다. 물론, 이와 같더라도 Box
같이 단순한 제네릭 클래스에서는 그다지 문제가 되지 않는다. (T 타입에서 확장된 타입이 아닌) 전적으로 T 타입의 변수 관점에서만 Box<T>
인터페이스를 지정하였으므로, 문제가 발생하리라고 인식하지도 못할 수 있다. 게다가, 이와 같이 특정 타입 변수를 명시적으로 지정하는 경우에도, 컴파일러가 기본으로 제공하는 다형성 정도는 허용한다. Listing 1은 이러한 부류의 다형성의 두 가지 예, 즉 Box<Integer>
의 내용물을 Number
로 꺼내고 Box<Number>
로 Integer
를 넣는 예를 보여 준다.
Box<Integer> iBox = new BoxImpl<Integer>(3);
Number num = iBox.get();
Box<Number> nBox = new BoxImpl<Number>(3.2);
Integer i = 3;
nBox.put(i);
|
Box
클래스에서 타입 호환성은 필요치 않다. 다형성이 필요한 위치에서 데이터가 이미 컴파일러가 적절한 서브타입 규칙을 적용할 수 있는 형태를 띠기 때문이다.
그러나 타입 T 변수뿐 아니라 T에서 파생된 타입 변수를 함께 다루는 것은 훨씬 더 복잡하다. Listing 2에서 보는 바와 같이 한 Box
에서 내용물을 꺼내고 다른 Box
로 이 값을 넣는 메서드를 새로 추가한다고 가정해 보자.
Listing 2. Box 인터페이스의 확장, 이건 보기처럼 유연하지 않다.
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<T> box);
}
|
Box
관련 문제는 Box
의 타입 매개변수가 수신하는 box와 정확히 똑같은 경우에만 내용물을 넣을 수 있다는 것이다. 따라서 예를 들면 Listing 3의 코드는 컴파일할 수 없다.
Box<Number> nBox = new BoxImpl<Number>();
Box<Integer> iBox = new BoxImpl<Integer>();
nBox.put(iBox); // ERROR
|
Box<Number>
에 put(Box<Integer>)
메서드를 찾을 수 없다는 에러 메시지를 얻는다. 제네릭이 타입 호환성이 없다는 점만 알면, 이 에러 메시지는 당연한 것이다. Integer
가 Number
라 할지라도, Box<Integer>
는 Box<Number>
가 아니기 때문이다. 그러나 어쨌든 이러한 속성 때문에 제네릭이 기대하는 만큼 유연하지 않다고 느낄 수 있다. 제네릭 코드의 활용도를 높이려면, 한 가지 정해진 제네릭 타입 매개변수를 명시하는 대신, 타입 매개변수의 상한 경계(upper-bound) 또는 하한 경계(lower-bound)를 명시할 수 있어야 한다. 즉, 경계 지정 와일드카드를 사용해야 하는 것이다. 이 와일드카드는 "? extends T
" 또는 "? super T
"의 형식을 띤다. (경계 지정 와일드카드는 타입 매개변수로만 사용할 수 있으며, 그 자신을 타입으로 사용할 수는 없다. 이 때문에 경계가 지정된 타입 변수가 필요하다.) Listing 4에서는 put()
의 시그너처를 "Box<? extends T>
"와 같이 상한 경계 와일드카드를 사용하도록 변경하였다. 이로써 Box
의 타입 매개변수로 T
또는 T
의 어떠한 서브클래스이든 사용할 수 있다.
Listing 4. 타입 호환성을 고려한, Listing 3의 Box 클래스의 개선된 버전
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<? extends T> box);
}
|
put()
의 매개변수는 타입 매개변수가 T 또는 그 서브타입인 Box를 모두 허용하므로, 이제 Listing 3의 코드를 우리가 원하는 대로 컴파일할 수 있다. Integer
는 Number
의 서브타입이고 Box<Integer>
는 경계 지정 와일드카드인 Box<? extends Number>
와 부합하므로, 컴파일러는 비로소 put(Box<Integer>)
메서드 참조를 허용한다.
이전 버전의 Box
에서 예시한 실수는 흔히 볼 수 있는 실수 중 하나인지라, 전문가라 할지라도 이러한 실수를 간혹 저지르곤 한다. 플랫폼 클래스 라이브러리의 여러 곳에서 Collection<? extends T>
대신 Collection<T>
가 쓰이는 것을 볼 수 있다. 예를 들어, java.util.concurrent 패키지의 AbstractExecutorService
에서 invokeAll()
의 인자는 원래 Collection<Callable<T>>
였다. 이 때문에 invokeAll()
사용이 매우 번거로웠다. 정확히 Callable<T>
를 매개변수로 지정하는 컬렉션을 써서 작업 집합을 구성해야 했기 때문이다. 자바 6에서는 시그너처가 Collection<? extends Callable<T>>
로 변경되었다. 도대체 얼마나 이런 실수를 저지르기 쉬운가 보라. invokeAll()
메서드를 더 정확히 수정한다면, Collection<? extends Callable<? extends T>>
인자를 수용하도록 해야 한다. 후자의 코드가 흉하게 보일지는 몰라도, 사용자 코드에서 고정된 타입으로 굳이 감싸지 않아도 되는 장점이 있다.
경계 지정 와일드카드는 상한 경계를 지정하는 경우가 대부분이다. "? extends T
" 표기가 그 타입의 상한 경계를 명시한다. 드물기는 하지만, "? super T
" 표기를 써서 T 또는 T의 슈퍼클래스를 의미하는 하한 경계를 명기할 수도 있다. 하한 경계 와일드카드는 Comparator와 같은 콜백 객체나 값을 하나 보관하는 자료 구조를 명기할 때 등장한다.
다른 box와 내용물을 비교할 수 있는 기능을 추가하여 Box
클래스를 확장하기를 원한다고 가정해 보자. Listing 5에서 보듯이 containsSame()
메서드와 Comparator
콜백 객체를 정의하여 Box
를 확장할 수 있다.
Listing 5. Box에 비교 메서드를 추가하였으나 메서드 사용에는 제약이 많은 접근법
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<? extends T> box);
boolean containsSame(Box<? extends T> other,
EqualityComparator<T> comparator);
public interface EqualityComparator<T> {
public boolean compare(T first, T second);
}
}
|
앞서 나는 containsSame()
에 와일드카드를 사용하여 다른 box의 타입을 정의했다.
이렇게 함으로써 먼저 보았던 문제점을 피할 수 있었다. 그러나 여전히 비슷한 문제가 발생한다.
Comparator 매개변수는 정확히 EqualityComparator<T>
이어야 하고, 따라서 Listing 6의 코드를
작성할 수 없다.
Listing 6. Listing 5에서 정의한 비교 메서드 사용의 실패 사례
public static EqualityComparator<Object> sameObject
= new EqualityComparator<Object>() {
public boolean compare(Object o1, Object o2) {
return o1 == o2;
}
};
...
BoxImpl<Integer> iBox = ...;
BoxImpl<Number> nBox = ...;
boolean b = nBox.containsSame(iBox, sameObject);
|
EqualityComparator<Object>
사용은 매우 합리적인 선택으로 보인다. 어째서 일반적인 방법으로 명시할 수 있을 때조차 굳이 매번 Box
타입의 경우마다 별도의 비교 클래스를 작성해야만 할까? 이 상황에서 해법은 "? super T
"로 표기하는 하한 경계 와일드카드를 사용하는 것이다. 올바른 버전의 Box
클래스는 Listing 7에서 보는 것처럼 compareTo()
메서드로 확장한 버전이다.
Listing 7. 경계 지정 와일드카드를 사용하여 Listing 5의 경우보다 유연한 비교 작업을 지원하는
버전
public interface Box<T> {
public T get();
public void put(T element);
public void put(Box<? extends T> box);
boolean containsSame(Box<? extends T> other,
EqualityComparator<? super T> comparator);
public interface EqualityComparator<T> {
public boolean compare(T first, T second);
}
}
|
하한 경계 와일드카드를 써서, containsSame()
메서드는 T 또는 그 슈퍼타입과 비교할 수 있는 무언가가
필요하다는 점을 드러낸다. 이로써 comparator를 굳이 EqualityComparator<Number>
로 감싸지 않더라도, Object만을 비교할 수 있는 comparator를 제공할 수 있다.
"시계 하나를 가진 사람은 현재 시각을 언제든 알 수 있지만, 시계를 둘 가진 사람은 현재 시각을 확신할 수
없다"라는 오래된 농담이 있다. 프로그래밍 언어에서 상한과 하한 경계 와일드카드를 모두 지원하면,
어떤 것을 언제 사용해야 할지 어찌 알겠는가?
여기 get-put 원칙이라고 하여 상황에 따라 어떤 와일드카드를 사용할지를 알려 주는 간단한 규칙이 있다.
제네릭에 관한 Naftalin과 Wadler의 훌륭한 저서, Java Generics and Collections(참고자료 참조)에서
get-put 원칙을 다음과 같이 설명하였다.
구조체에서 값을 읽기만 하는 경우에는
extends
와일드카드를 사용하라.반대로 구조체에서 값을 쓰기만 하는 경우에는
super
와일드카드를 사용하라.두 경우 모두 필요한 경우에는 와일드카드를 사용하지 말라.
get-put 원칙은 Box
와 같은 컨테이너 클래스 혹은 Collection 클래스에 가장 쉽게 이해하여 적용할 수 있는
원칙이다. 값을 읽고 쓰는 동작 자체와 무언가를 저장하는 이러한 클래스의 역할 간에 자연스러운 연관성이
있기 때문이다. 하나의 Box
를 다른 Box
로 복사하는 메서드를 생성할 때 get-put 원칙을 적용하고자 한다면, 가장 일반적인 형식이 Listing 8에서 보는 바와 같이 될 것이다. 여기에서는 상한 경계 와일드카드가 소스 인자에 쓰였고, 하한 경계 와일드카드가 목적 인자에 사용되었다.
Listing 8. 상한과 하한 경계 와일드카드를 모두 사용하여 Box를 복사하는 메서드
public static<T> void copy(Box<? extends T> from, Box<? super T> to) {
to.put(from.get());
}
|
앞에서 예시된 containsSame()
메서드의 경우에 get-put 원칙을 어떻게 적용할 수 있을까?
여기서는 box에 대해서는 상한 경계 와일드카드를 사용했고, comparator에 대해서는
하한 경계 와일드카드를 사용하였다. 첫 번째 부분은 다른 box에서 값을 얻는 것이므로 쉽게 판단할 수 있다.
즉, extends
와일드카드를 사용하면 된다. 두 번째 부분은 comparator가 컨테이너가 아니라서 다소 모호하다. comparator는 자료 구조에서 값을 얻지도 넣지도 않는 것처럼 보인다.
데이터 타입이 collection처럼 명백한 컨테이너 클래스가 아닌 경우에 get-put 원칙을 고려하기 위한 방법은 다음과 같다. EqualityComparator
의 메서드에 어떤 값을 전달한다는 관점에서 보면, 이 인터페이스가
자료 구조는 아니더라도, 여전히 어떤 값을 넣을 수 있는 무엇임에는 분명하다. containsSame()
메서드에서,
Box
는 (Box
에서 어떤 값을 꺼냈으므로) 값의 생산자로 사용하였다. 반면, comparator는 (comparator로 값을 전달하므로) 값의 소비자로 사용하였다. 따라서 Box
에는 extends
와일드카드를 사용하고 comparator에는 super
와일드카드를 사용하는 것이 합리적이다.
Listing 9에서 Collections.sort()
선언에서 get-put 원칙이 어떻게 접목되는지 볼 수 있다.
Listing 9. 하한 경계 와일드카드 사용의 또 다른 예
public static <T extends Comparable<? super T>> void sort(List<T>list) { ... }
|
위 선언에서는 Comparable
을 구현하는 어떤 타입이든 List
의 타입 매개변수로 사용하여 그 List를 정렬할 수 있다는 의미를 담고 있다. 그러나 sort()
를 엘리먼트끼리만 서로 비교 가능한 리스트로 그 사용 영역을 제안하기보다, 엘리먼트를 그 슈퍼타입과도 비교하는 리스트의 정렬 목적으로도 사용할 수 있다. 두 엘리먼트 간에 상대 순서를 결정하기 위한 목적으로 comparator에 값을 넣으려는 것이므로, 여기에서는 get-put 원칙에 따라 super 와일드카드를 쓰면 된다.
"T
extends 'T
를 매개변수로 사용하는 무엇'"이라고 하면 마치 순환 참조인 것처럼 보인다. 그러나 이는 전혀 순환 관계가 아니다. 이는 단지 List<T>
를 정렬할 수 있는 제약 조건을 표현한 것인데, 이 제약 조건은 X
가
T
또는 그의 슈퍼타입인 경우 T
는 Comparable<X>
를 구현해야 한다는 것이다.
본 규칙의 마지막 부분은 값을 읽고 쓰는 것이 모두 필요한 경우에 와일드카드를 쓰지 말라는 것인데, 이는
앞서 두 가지 규칙에서 파생된 것이다. T
또는 그 서브타입의 어떤 값을 넣을 수 있고, T
또는 그 슈퍼타입의 어떤 값을 얻을 수 있다면, 값을 얻으면서 동시에 넣을 수도 있는 유일한 타입은 T
자신뿐이기 때문이다.
메서드 리턴 타입에 경계 지정 와일드카드를 사용하고 싶은 충동을 느낄 수 있다. 그러나 리턴 타입에서의
경계 지정 와일드카드는 대개 사용자 코드를 "오염"시키기 마련인지라, 이러한 충동은 피하는 것이 최선이다. 만일 한 메서드의 리턴 타입이 Box<? extends T>
라면, 리턴 값으로 받은 변수의 타입은 Box<? extends T>
이어야 한다. 이는 이 메서드를 호출하는 쪽으로 경계 지정 와일드카드를 다루는 부담을 전가하는 꼴이 된다. 경계 지정 와일드카드는 사용자 코드가 아닌 API 내부에서만 사용하는 것이 최선이다.
제네릭 API가 더 유연해진다는 점에서, 경계 지정 와일드카드는 매우 유용하다. 정확한 경계 지정 와일드카드 사용의 최대 장애물은 이런 건 사용할 필요가 없다는 인식이다. 어떤 상황에서는 하한 경계 와일드카드가 필요하고, 어떤 경우에는 상한 경계 와일드카드가 필요하다. 상황에 따라 어떤 것을 선택할지를 결정하기 위한 수단으로 get-put 원칙을 사용할 수 있다.
교육
- 자바 이론과 실습(Brian Goetz, developerWorks): 전체 연재를 읽어 보라.
- JSR 14: 자바 프로그래밍 언어에 제네릭을 추가했다. 초기 규격은 GJ에서 파생되었다. 와일드카드는 후에 추가되었다.
- Java Generics and Collections: 제네릭을 다루는 심도있는 방법을 제시한다.
- 제네릭 FAQ: Angelika Langer가 제네릭에 대한 각종 FAQ를 심도있게 정리했다.
- "자바 이론과 실습: 제네릭스 해부"(Brian Goetz, 한국 developerWorks, 2008년 9월): 와일드카드의 또 다른 함정인 와일드카드 캡쳐(wildcard capture)를 다룬다.
- "Java theory and practice: Generics gotchas"(Brian Goetz, developerWorks, 2005년 1월): 제네릭 학습에 있어 몇 가지 함정을 인지하고 피하는 법을 배우라.
- "Introduction to generic types in JDK 5.0(Brian Goetz, developerWorks, 2004년 12월): developerWorks에 자주 공헌하는 필자이면서 자바 프로그래밍 전문가인 Brian Goetz의 발자취를 따라가 보라. 그는 자바 언어에 제네릭을 추가한 동기와 상세 문법, 제네릭 타입의 의미를 설명하고 클래스에 제네릭을 사용하기 위한 도입 설명을 제공한다.
- Java Concurrency in Practice: 스레드에 안전한 클래스와 프로그램을 생성하고 구성하는 법, 런타임에서의 위험 요소를 피하는 법, 성능을 관리하는 법, 병행 애플리케이션을 테스트하는 법 등 자바 코드로 병행 프로그램을 개발하는 매뉴얼이다.
- 이 주제 또는 여타 기술적인 주제를 다루는 책을 서점에서 찾아 보라.
- developerWorks의 자바 코너: 자바 프로그래밍의 여러 관점에 대한 수백 가지 자료를 제공한다.
'IT_Programming > Java' 카테고리의 다른 글
웹 브라우저 실행하기 (0) | 2009.02.03 |
---|---|
[펌] JDBC 별 드라이버 사용법 (0) | 2009.01.30 |
[펌] 제네릭스 해부, Part 1 (0) | 2009.01.22 |
[펌] 고급 PreparedStatement를 사용하여 JDBC 코드에 로깅 추가하기 (0) | 2009.01.02 |
[펌] [Executable JAR 파일] SWT/JFace 프로그램 배포판 만들기 (0) | 2009.01.02 |