출처: http://www.ibm.com/developerworks/kr/library/j-jtp04298.html
와일드카드 캡처 이해하기
Brian Goetz, 선임 스태프 엔지니어, Sun Microsystems
옮긴이: 오국환 dwkorea@kr.ibm.com
2008 년 9 월 16 일
자바(Java™) 언어의 제네릭(generic)에서 가장 복잡한 측면 중 하나가 바로 와일드카드입니다. 와일드카드의 사용법도 복잡할 뿐 아니라 와일드카드 캡처(wildcard capture)와 관련된 에러 메시지 역시 혼란스럽기 그지 없습니다. 이번 자바 이론과 실습, 연재에서는 베테랑 자바 개발자인 Brian Goetz가 javac에서 생성하는 괴상한 에러 메시지를 해독하고 제네릭을 쉽게 쓸 수 있는 몇 가지 비법과 우회 기법을 제시합니다.
JDK 5에 제네릭이 추가되면서, 제네릭은 모순된 논제 중 하나가 되었다. 혹자는 타입 시스템의 범위를
확장하고 타입 안정성 검사에서 컴파일러의 활용도를 높여, 프로그래밍을 단순화한다고 말한다.
반면 다른 부류에서는 필요 이상으로 언어가 복잡해졌다고 말한다. 제네릭의 사용법은 어렵지 않으므로,
머리 몇 번 긁적이면 그만이다. 그러나 제네릭에서 훨씬 더 난해한 부분이 있으니,
바로 와일드카드(wildcard)다.
제네릭은 알려지지 않은 타입에 대해 클래스와 메서드의 동작에 대한 타입의 제약을 기술하기 위한 수단이다. 예를 들면, "이 메서드에 임의 타입의 매개변수 x
와 y
가 있는데, 이 둘은 서로 같은 타입이어야 한다"든지,
두 메서드에 동일한 타입의 매개변수를 전달해야 한다", 또는 " foo()
의 리턴 타입은 bar()
의 매개변수 타입과
같아야 한다"는 식의 제약을 말한다.
와일드카드(타입 매개변수가 있을 자리를 대신 차지하는 웃기는 물음표)는 알려지지 않은 타입의 각도에서
타입 제약을 표현하는 수단이다. (제네릭 자바(Generic Java, GJ) 프로젝트에서 파생된)
원래 제네릭 디자인에 이것은 포함되지도 않았다. JSR 14의 형성과 최종 릴리스까지 5년에 걸쳐 수행된
디자인 과정에서 이것이 포함되었다.
와일드카드는 타입 시스템에서 중요한 역할을 한다. 이는 제네릭 클래스가 명시하는 타입 군(群)에 대한
유용한 타입 제한 조건을 제공한다. 제네릭 클래스 ArrayList
에서, ArrayList<?>
타입은 임의의 (참조)
타입 T
에 대해 ArrayList<T>
의 슈퍼타입(supertype)을 의미한다. (즉, 루트 타입 Object
를 지원하는, ArrayList
본래 타입을 말한다. 그러나 이러한 슈퍼타입은 타입 추론에 있어 그다지 유용하지 않다.)
와일드카드 타입 List<?>
는 원본 타입 List
를 비롯하여 구체적인 타입 List<Object>
와도 다르다.
변수 x
가 List<?>
타입이라는 말은 x
가 List<T>
타입인 임의의 타입 T
가 있다는 의미를 내포한다.
여기서 x
의 엘리먼트가 어떤 구체적인 타입을 갖는지 모르더라도 어쨌든 x는 동질적(homogeneous)이다.
물론 그렇다고 내용이 무엇이든 상관없다는 뜻은 아니다. 단지 내용에 대한 타입 제약 조건을 모른다는 것을 의미할 뿐이다. 아무튼 무언가 제약 조건이 있다는 것은 안다. 반면, 원본 타입 List
는 이질적(heterogeneous)이므로, 엘리먼트에 임의의 타입 조건을 부여할 수 없다. 우리가 명시적으로 알고 있듯이, 확정된 타입 List<Object>
에는 어떠한 오브젝트든 담을 수 있다. (물론, 제네릭 타입 시스템은 "목록의 내용물"에 대한
개념은 없다. 하지만, 이러한 개념은 List
와 같은 컬렉션 타입에서 제네릭을 쉽게 이해하는 방법이다.)
타입 시스템에서 와일드카드의 활용도는 부분적으로 제네릭 타입 간에 서로 연관성(covariance)이 없다는
사실에 있다. 배열은 서로 간에 연관성을 지닌다. Integer
는 Number
의 서브타입이고, 배열 타입인 Integer[]
는 Number[]
의 서브타입이며, 따라서 Number[]
값을 요구하는 곳이면 Integer[]
값을 사용할 수 있다.
반면, 제네릭은 이러한 특성이 없다. List<Integer>
는 List<Number>
의 서브타입이 아니다. List<Number>
를
요구하는 곳에 List<Integer>
를 제공하면 타입 에러가 발생한다. 이는 단순 사고 또는 누구나 생각할 수 있는
그런 필요에 의해 발생하는 에러가 아니다. 이렇게 제네릭과 배열의 동작상의 차이점으로 인해 중대한 혼란이 발생한다.
이제 와일드카드는 다루었다. — 그래서 어쩌자는 것인가?
Listing 1은 간단한 컨테이너 타입 Box
를 소개한다. 이 타입에는 put
과 get
동작을 지원한다. Box
는 제네릭
타입으로 타입 매개변수 T
를 선언했다. 여기서 T
는 Box 내용물의 타입을 의미한다.
Box<String>
이란 String
타입의 엘리먼트만 포함할 수 있다.
public interface Box<T> {
public T get();
public void put(T element);
}
|
와일드카드의 이점 중 한 가지는 정확한 타입 제약을 모르는 상태에서도 제네릭 타입 변수에 대한 코드를 작성할 수 있다는 점이다. 한 가지 예로, Listing 2의 unbox()
메서드의 box()
매개변수와 같이, Box<?>
타입 변수가 있다고 하자. 넘겨 받은 box로 unbox()
가 무엇을 할 수 있겠는가?
Listing 2. 와일드카드 매개변수를 사용한 unbox 메서드
public void unbox(Box<?> box) {
System.out.println(box.get());
}
|
get()
메서드를 호출할 수 있고, (hashCode()
처럼) Object
에서 상속받은 어떠한 메서드이든 호출할 수 있다. 이것이 할 수 없는 일이란 put()
메서드를 호출하는 것이다. 이는 타입 매개변수 T
를 모르는 상태에서는 타입 안정성을 검증할 수 없기 때문이다. box
는 Box<?>
이지, 원본 Box
도 아니다. 컴파일러는 box
의 타입 매개변수로 쓰이는 T
라는 타입이 있다는 점을 알고 있다. 그런데 그 T
가 도대체 무엇인지 모르고, 따라서 컴파일러는 put()
호출이 Box
의 타입 안정성 제약 조건을 위배하는지 판단할 수 없다. 따라서 put()
호출을 허용하지 않는다. (실제 한 가지 특별한 경우로 null
을 넘기는 경우에 한하여 put()
을 호출할 수 있다. 타입 T
가 무엇을 가리키든 간에 이미 null
은 모든 참조 타입에 유효한 값이기 때문이다.)
box.get()
의 리턴 타입에 대해 unbox()
가 아는 것은 무엇인가? T
가 무엇인지는 모르지만 어쨌든 T
가 있다는 것은 안다. 따라서 최소한 그 알려지지 않은 타입 T
를 get()
의 리턴 타입에서 지울 수는 있다. 이 경우 와일드카드에 대한 알려지지 않은 타입 T
는 바로 Object
다. 따라서 Listing 2의 box.get()
표현은 Object
타입을
갖는다.
Listing 3은 마치 동작할 것 같지만 동작하지 않는 코드를 보여 준다. 이는 제네릭 Box
를 취하여, 값을 꺼내고 같은 Box
로 그 값을 다시 돌려 넣고자 한다.
Listing 3. box에서 한 번 값을 꺼내면, 다시 넣을 수 없다.
public void rebox(Box<?> box) {
box.put(box.get());
}
Rebox.java:8: put(capture#337 of ?) in Box<capture#337 of ?> cannot be applied
to (java.lang.Object)
box.put(box.get());
^
1 error
|
이 코드는 바로 꺼낸 값을 바로 돌려 넣기 때문에 동작할 것처럼 보인다.
그러나 컴파일러는 Object
와 호환하지 않는다며 "capture#337 of ?"라는 (매우 혼란스러운) 에러 메시지를
생성한다.
"capture#337 of ?"란 도대체 무슨 뜻인가? 컴파일러는 rebox()
의 box
매개변수와 같이 타입에 와일드카드를 가진 변수를 만나면, box
가 Box<T>
인 T
가 어딘가 있을 것을 안다. T
가 무슨 타입인지는 모르나,
T
의 구체적인 타입을 지칭하기 위해 그 타입의 지시자(placeholder)는 생성할 수 있다. 그 지시자를 특정
와일드카드의 캡처라고 부른다. 이 경우, 컴파일러는 box
타입의 와일드카드에 "capture#337 of ?"라는 이름을
할당했다. 각 변수 선언에서 와일드카드를 만나면 서로 다른 캡처를 할당하게 된다. 따라서 제네릭 선언 foo(Pair<?,?> x, Pair<?,?> y)
에서 컴파일러는 네 개의 와일드카드에 대해 서로 다른 캡처를 할당한다.
이는 알려지지 않은 타입 사이에 서로 연관성이 없기 때문이다.
이 에러 메시지가 말하고자 하는 것은 put()
의 실제 매개변수 타입이 형식상 매개변수 타입과 호환되는지
검증할 수 없다는 점이다. 형식상 매개변수는 여기에서 알려지지 않았다. 이 경우 '?
'는 "? extends Object"를
의미하므로 컴파일러는 box.get()
이 "capture#337 of ?"가 아닌 Object
라고 이미 결론 내렸다.
이제 컴파일러는 이 Object
를 "capture#337 of ?" 지시자가 가리키는 타입에 사용할 수 있는지 정적으로
검증할 수 없다.
컴파일러가 일부 유용한 정보를 버리는 것처럼 보이나, 그러한 정보를 재구성할 수 있는 한 가지 비법이 있다. 이는 알려지지 않은 와일드카드 타입에 이름을 주는 것이다. Listing 4는 그러한 비법으로 제네릭 도우미 메서드를 사용한 rebox()
의 구현을 보여 준다.
public void rebox(Box<?> box) {
reboxHelper(box);
}
private<V> void reboxHelper(Box<V> box) {
box.put(box.get());
}
|
도우미 메서드인 reboxHelper()
는 제네릭 메서드다. 제네릭 메서드는 (리턴 타입 앞 각괄호 내에 위치한)
새로운 타입 매개변수를 추가로 소개한다. 이는 그 메서드의 매개변수와 리턴 값 사이의 타입 제약 조건을
공식화하기 위해 사용한다. 그러나 reboxHelper()
의 경우 제네릭 메서드는 타입 매개변수를 타입 제약을
명시하기 위한 것이 아닌, 컴파일러가 (타입 추론을 통하여) box 타입에 이름을 부여할 수 있도록 하기 위해
사용한다.
캡처 도우미 비법은 와일드카드를 다루는 컴파일러의 한계를 우회하기 위한 것이다. rebox()
가 reboxHelper()
를 호출할 때, box
매개변수는 알려지지 않은 T
에 대한 Box<T>
임이 분명하므로 그 메서드 호출이 안전하다는 것을 알고 있다. 메서드 시그너처(signature)에 소개된 타입 매개변수 V
는 다른 타입 매개변수에 종속되지 않으므로, 이는 어떠한 알려지지 않은 타입을 가리킬 수 있다. 따라서 알려지지 않은 T
에 대한 Box<T>
는
또한 알려지지 않은 V
에 대한 Box<V>
일 수도 있다(이는 람다 미적분(lambda calculus)에서 알파 환원(alpha reduction)과 비슷하다. 알파 환원의 경우 바운드 변수의 이름을 변경할 수 있다).
이제 reboxHelper()
내 box.get()
의 표현은 더 이상 Object
타입을 갖는 것이 아니라 타입 V
를 가진다.
이로써 V
를 Box<V>.put()
으로 넘길 수 있다.
처음부터 rebox()
를 reboxHelper()
와 같이 제네릭 메서드로 선언할 수도 있었다. 그러나 이는 그리 좋은 API 디자인 스타일이 아니다. 여기에서 준수하는 주된 디자인 원칙은 "이름으로 참조하지 않을 곳에는 이름을 부여하지 말라"는 것이다. 제네릭 메서드에서 타입 매개변수가 메서드 시그너처에 딱 한 번만 나온다면, 이름을 부여한 타입 매개변수보다는 와일드카드 쪽이 바람직하다. 일반적으로 와일드카드를 사용하는 API는 제네릭 메서드를 사용하는 API보다는 단순하다. 복잡한 메서드 선언에서 타입 이름이 넘쳐나면, 그만큼 그 선언에 대한 가독성이 떨어지게 마련이다. 필요하면 내부적으로 캡처 도우미를 통하여 이름을 다시 살릴 수 있으므로, 이러한 접근법이 유용한 정보를 버리지 않으면서 API를 깔끔하게 유지하는 길이다.
캡처 도우미 비법은 타입 추론과 캡처 변환(capture conversion)이라는 몇 가지 요소에 의존한다.
자바 컴파일러는 그다지 많지 않는 곳에서만 타입 추론을 수행한다. 제네릭 메서드에서는 한 곳에서만
타입 매개변수를 추론한다. (다른 언어는 타입 추론에 훨씬 더 깊이 의존한다. 미래에는 자바 언어에도
추가적인 타입 추론을 볼 수 있을지 모르겠다.) 원하면 타입 매개변수에는 이름을 붙여 그 값을 명시할 수
있지만, 캡처 타입에는 이름을 명시할 수 없다. 따라서 가능한 유일한 비법은 컴파일러가 프로그래머를
대신하여 타입을 추론하도록 하는 것이다. 캡처 변환은 누구로 인해 컴파일러가 캡처한 와일드카드를 위한
지시자 타입 이름을 생성하느냐에 대한 것이므로, 타입 추론은 와일드카드가 바로 그 지시자 타입이라는 것을 추론할 수 있다.
컴파일러는 한 제네릭 메서드 호출을 판단할 때 그 타입 매개변수에 해당하는 가장 구체적인 타입을 추론하고자 한다. 예를 들어 다음 제네릭 메서드를 보자.
public static<T> T identity(T arg) { return arg };
|
Integer i = 3;
System.out.println(identity(i)); |
T
가 Integer
또는 Number
, Serializable, Object
라는 것을 추론할 수 있다. 그러나 컴파일러는 제약 조건을 꼭 만족하는 가장 구체적인 타입으로 Integer
를 선택한다.
제네릭 인스턴스를 구성할 때, 군더더기를 줄이기 위해 타입 추론을 사용할 수 있다. 예를 들면, Box
클래스를 사용할 때 Box<String>
을 생성하려면 타입 매개변수 String
을 두 번 사용해야 한다.
Box<String> box = new BoxImpl<String>();
|
BoxImpl
클래스 구현은 (좋은 아이디어로) Listing 5와 같이 제네릭 팩토리 메서드(factory method)를 제공한다. 이로써 클라이언트 코드에서 군더더기를 줄일 수 있다.
Listing 5. 타입 매개변수를 반복 명시하는 군더더기를 피하는 제네릭 팩토리 메서드
public class BoxImpl<T> implements Box<T> {
public static<V> Box<V> make() {
return new BoxImpl<V>();
}
...
}
|
BoxImpl.make()
팩토리 메서드를 사용하여 BoxImpl
인스턴스를 생성할 때, 타입 매개변수는 한 번만 기술하면 된다.
Box<String> myBox = BoxImpl.make();
|
make()
메서드는 임의의 타입 V
에 대한 Box<V>
를 돌려 주며, Box<String>
을 사용하는 문맥에서 그 리턴 값이 사용된다. 컴파일러는 String
이 타입 제약을 만족시키는 V
의 가장 구체적인 타입이라고 판단한다. 여기에서는 V
가 String
이라고 추론한다. 물론 다음과 같이 선택적으로 V
의 값을 명시할 수도 있다.
Box<String> myBox = BoxImpl.<String>make();
|
여기 언급한 팩토리 메서드 기법은 키 입력을 절약하는 것 외에도 생성자 대비 장점이 몇 가지 더 있다. 이름을 좀 더 명시적으로 기술할 수 있고, 정해진 이름에 해당하는 리턴 타입 대신 그 서브타입을 돌려 줄 수도 있으며, 매번 메서드를 부를 때마다 변하지 않는 인스턴스는 공유하도록 하여 새로운 인스턴스를 생성하지 않아도 된다(정적인 팩토리 메서드의 장점에 대해서는 참고자료의 Effective Java의 1강을 참고하라).
와일드카드는 분명 까다로울 수 있다. 자바 컴파일러가 생성하는 가장 혼란스러운 에러 메시지의 일부는 와일드카드와 관련이 있다. 또한 자바 언어 명세의 가장 복잡한 부분 중 일부가 와일드카드와 연관이 있다. 그러나 적절히 사용하면 와일드카드는 아주 강력하다. 여기에 소개한 두 가지 비법, 즉 캡처 도우미 비법과 제네릭 팩토리 비법은 제네릭 메서드와 타입 추론을 활용한다. 이 비법들을 적절히 사용하면 이러한 복잡한 측면의 상당 부분을 숨길 수 있다.
교육
- (Brian Goetz, developerWorks): 전체 연재를 읽어 보라.
- "Generics gotchas"(Brian Goetz, developerWorks, 2005년 1월): 제네릭 학습에 있어 몇 가지 함정을 인지하고 피하는 법을 배우라.
- Introduction to generic types in JDK 5(Brian Goetz, developerWorks, 2004년 12월): developerWorks에 자주 기고하는 필자이면서 자바 프로그래밍 전문가인 Brian Goetz의 발자취를 따라가 보라. Brian Goetz는 자바 언어에 제네릭을 추가한 동기와 상세 문법 및 제네릭 타입의 의미를 설명하고 클래스에 제네릭을 사용하기 위한 도입 설명을 제공한다.
- JSR 14: 자바 프로그래밍 언어에 제네릭을 추가하였다. 초기 규격은 GJ에서 파생되었다. Wildcards는 나중에 추가되었다.
- Java Generics and Collections : 제네릭을 다루는 심도 있는 방법을 제시한다.
- Effective Java: 1강에서는 정적 팩토리 메서드의 장점을 열거한다(역주: 한국어 번역본도 있다).
- Generics FAQ: Angelika Langer가 제네릭에 대한 각종 FAQ를 심도 있게 정리했다.
- Java Concurrency in Practice : 스레드에 안전한 클래스와 프로그램을 생성하고 구성하는 법, 런타임에서 위험 요소를 피하는 법, 성능을 관리하는 법, 병행 애플리케이션을 테스트하는 법 등 자바 코드로 병행 프로그램을 개발하는 매뉴얼이다.
- 기술 서점: • 이 주제 또는 다른 기술적인 주제를 다루는 책을 찾아보라.
- 자바 기술 존: 자바 프로그래밍의 여러 관점에 대한 수백 가지 자료가 있다.
'IT_Programming > Java' 카테고리의 다른 글
[펌] JDBC 별 드라이버 사용법 (0) | 2009.01.30 |
---|---|
[펌] 제네릭스 해부, Part 2 (0) | 2009.01.22 |
[펌] 고급 PreparedStatement를 사용하여 JDBC 코드에 로깅 추가하기 (0) | 2009.01.02 |
[펌] [Executable JAR 파일] SWT/JFace 프로그램 배포판 만들기 (0) | 2009.01.02 |
[펌] Apache Commons Lang에 관한 내용2 (0) | 2009.01.02 |