빈칸에 아무 종류나 들어오게 두면, 그 빈칸으로 받은 것에 “숫자로 바꿔라” 같은 일을 시킬 수 없다. 숫자가 아닌 것이 들어올 수도 있기 때문이다. 그래서 “숫자 집안만 들어올 수 있다”고 빈칸에 조건을 건다. 조건을 건 만큼 그 집안이 할 줄 아는 일을 마음 놓고 시킬 수 있다. 상자를 받는 쪽에서도 “학생 집안의 상자면 무엇이든” 또는 “직장인과 그 윗대의 상자면 무엇이든”처럼 범위로 받을 수 있다. 조심할 점은, 학생이 사람의 한 종류라고 해서 학생 상자가 사람 상자의 한 종류가 되지는 않는다는 것이다.
예시로 보기
두 값이 같은 크기인지 견주는 메서드를 제네릭 메서드로 만든다. 정수든 실수든 받고 싶어서 타입을 빈칸 T로 두었다.
public static <T> boolean compare(T t1, T t2) {
double v1 = t1.doubleValue();
…
}
error: cannot find symbol
symbol: method doubleValue()
location: variable t1 of type T
번역되지 않는다. T 자리에는 String도, 직접 만든 Tv도 올 수 있다. 그런 객체에는 doubleValue()가 없다. 번역기는 T가 무엇이든 될 수 있다고 보고, 모든 객체가 가진 메서드(Object의 메서드)만 부르게 한다1.
T에 올 수 있는 타입을 Number와 그 자식으로 좁힌다.
public static <T extends Number> boolean compare(T t1, T t2) {
double v1 = t1.doubleValue(); // Number의 doubleValue() 메서드 사용
double v2 = t2.doubleValue();
return (v1 == v2);
}
| 호출 | 결과 | 까닭 |
|---|---|---|
compare(10, 20) |
false |
10.0과 20.0은 다르다 |
compare(10, 10.0) |
true |
Integer 10과 Double 10.0을 둘 다 실수 10.0으로 바꿔 견준다 |
compare(4.5, 4.5) |
true |
|
compare("a", "b") |
번역 오류 | String은 Number의 자식이 아니다 |
Integer와 Double은 모두 Number의 자식이다. Number에 doubleValue()가 있으므로 T가 그 가운데 무엇이 되든 이 메서드를 부를 수 있다1.
정의
제한된 타입 파라미터
제한된 타입 파라미터는 아무 타입으로나 바꿀 수 없고, 어떤 클래스의 자식이거나 어떤 인터페이스를 구현했다는 관계가 있는 타입으로만 바꿀 수 있는 타입 파라미터다.
public <T extends 상위타입> 리턴타입 메서드(매개변수, …) { … }
extends 뒤의 상위 타입은 클래스여도 되고 인터페이스여도 된다. 인터페이스일 때도 implements가 아니라 extends를 쓴다. 상위 타입 자신도 들어올 수 있다1.
와일드카드
와일드카드는 제네릭 타입을 매개변수나 돌려주는 타입으로 쓸 때, 타입 파라미터 자리에 어떤 범위 안의 타입이면 무엇이든 올 수 있게 하는 표시다. ?로 쓴다.
| 적는 법 | 받는 범위 |
|---|---|
제네릭타입<? extends Student> |
Student와 그 자손 |
제네릭타입<? super Worker> |
Worker와 그 조상 |
제네릭타입<?> |
모든 타입 |
다음 클래스들로 범위를 그려 본다. Worker와 Student는 Person의 자식이고, HighStudent와 MiddleStudent는 Student의 자식이다.
flowchart TD
subgraph SUP["? super Worker"]
O["Object"]
P["Person"]
W["Worker"]
end
subgraph EXT["? extends Student"]
S["Student"]
H["HighStudent"]
M["MiddleStudent"]
end
O --> P
P --> W
P --> S
S --> H
S --> M
화살표는 부모에서 자식으로 간다. extends는 기준 타입에서 아래쪽(자손)으로, super는 기준 타입에서 위쪽(조상)으로 범위를 잡는다2.
지원자를 담는 제네릭 클래스와, 지원자를 받아 수강 신청을 하는 메서드 셋이다.
public class Applicant<T> {
public T kind;
public Applicant(T kind) {
this.kind = kind;
}
}
public class Course {
// 누구나 신청할 수 있다
public static void registerCourse1(Applicant<?> applicant) {
System.out.println(applicant.kind.getClass().getSimpleName() + " registered the Course1.");
}
// 학생만 신청할 수 있다
public static void registerCourse2(Applicant<? extends Student> applicant) {
System.out.println(applicant.kind.getClass().getSimpleName() + " registered the Course2.");
}
// Worker와 그 조상(Person)만 신청할 수 있다
public static void registerCourse3(Applicant<? super Worker> applicant) {
System.out.println(applicant.kind.getClass().getSimpleName() + " registered the Course3.");
}
}
new Applicant<Person>(new Person())처럼 지원자를 만들어 세 메서드에 넘긴 결과다.
| 넘긴 것 | registerCourse1 (?) |
registerCourse2 (? extends Student) |
registerCourse3 (? super Worker) |
|---|---|---|---|
Applicant<Person> |
된다 | 번역 오류 | 된다 |
Applicant<Worker> |
된다 | 번역 오류 | 된다 |
Applicant<Student> |
된다 | 된다 | 번역 오류 |
Applicant<HighStudent> |
된다 | 된다 | 번역 오류 |
Applicant<MiddleStudent> |
된다 | 된다 | 번역 오류 |
되는 칸은 실행하면 Person registered the Course1.처럼 한 줄씩 찍힌다. 번역 오류 칸의 문구는 이렇다3.
error: incompatible types: Applicant<Person> cannot be converted to Applicant<? extends Student>
error: incompatible types: Applicant<Student> cannot be converted to Applicant<? super Worker>
compare 표의 세 줄, 세 메서드에 지원자를 넘긴 출력 9줄(registerCourse1 다섯, registerCourse2와 registerCourse3 둘씩), 와일드카드 변수에서 꺼내고 넣는 것, 카드 C4의 답 — 39_java-bounded-wildcard_impl.java. 표의 번역 오류 칸과 카드 C3의 답은 JDK 21 javac로 따로 확인했다.둘은 어떻게 다른가
| 제한된 타입 파라미터 | 와일드카드 | |
|---|---|---|
| 모양 | <T extends Number> |
Applicant<? extends Student> |
| 쓰는 자리 | 타입 파라미터를 선언하는 자리 | 이미 있는 제네릭 타입을 매개변수·돌려주는 타입으로 쓰는 자리 |
| 이름 | T라는 이름이 생긴다. 메서드 안에서 T를 타입으로 쓸 수 있다 |
이름이 없다 |
| 범위 | 위쪽 끝만 정한다(extends) |
위쪽 끝(extends)이나 아래쪽 끝(super)을 정한다 |
compare(T t1, T t2)처럼 “두 인자가 같은 타입 빈칸을 나눠 쓴다”는 것을 적으려면 이름이 있어야 하므로 타입 파라미터를 쓴다. “이 범위의 상자면 된다”만 말하면 될 때는 와일드카드가 짧다2.
스스로 설명해 보기
1. <T extends Number>로 바꾸자 t1.doubleValue()가 번역되는 이유
번역기는 T로 선언한 변수에서, T가 무엇이 되든 반드시 있는 메서드만 부르게 한다. 조건이 없으면 T는 어떤 객체든 될 수 있어 Object의 메서드만 남는다. extends Number를 붙이면 T는 Number이거나 그 자손이고, 자손은 부모의 메서드를 물려받으므로 doubleValue()가 반드시 있다.
2. registerCourse2에 Applicant<Person>을 넘기면 안 되는 이유
? extends Student는 Student와 그 자손만 받는다. Person은 Student의 부모라서 범위 밖이다. Person 객체는 학생이 아닐 수 있다.
3. registerCourse3에 Applicant<Student>를 넘기면 안 되는 이유
? super Worker는 Worker와 그 조상(Person, Object)만 받는다. Student는 Worker의 조상이 아니다. 둘은 부모(Person)가 같은 형제다.
이 문서의 핵심 아이디어는?
빈칸에 들어올 타입의 범위를 좁히면, 좁힌 만큼 그 범위의 타입이 공통으로 가진 것을 믿고 쓸 수 있다. 범위는 상속 관계의 위아래로 잡는다.
이 방법을 쓸 수 있는 다른 상황은?
크기를 견줄 수 있는 것들 가운데 가장 큰 것 고르기. <T extends Comparable<T>> T max(T a, T b)로 선언하면 a.compareTo(b)를 부를 수 있다. 위쪽 끝이 인터페이스인 경우다. max(3, 8)은 8, max("apple", "banana")는 "banana"를 돌려준다.
활용
extends로 범위를 잡은 상자에서는 꺼내 쓰기가 안전하다.Applicant<? extends Student> a의a.kind는 적어도Student이므로Student변수로 받을 수 있다. 반대로a.kind = new Student();는 번역되지 않는다. 실제 상자가Applicant<HighStudent>일 수도 있기 때문이다3.super로 범위를 잡은 상자에는 넣기가 안전하다.Applicant<? super Student> b에는b.kind = new Student();를 쓸 수 있다. 실제 상자가Applicant<Person>이든Applicant<Object>든Student는 들어간다. 꺼낼 때는Object로만 받을 수 있다3.Applicant<?>에서 꺼낸 것은Object로만 받을 수 있다. 위Course의 세 메서드가kind에서getClass()만 부르는 이유다.getClass()는Object의 메서드다.- 오류 문구의
cannot be converted to Applicant<? extends Student>는 “넘긴 상자의 타입이 범위 밖”이라는 뜻이다. 넘긴 쪽의< >안 타입이 기준 타입의 위인지 아래인지를 그림에서 찾는다.
연결
- 선수: 제네릭, 상속(범위는 부모·자식 관계로 잡는다)
extends범위의 상자에서 꺼낸 것을 기준 타입으로 받을 수 있는 것은 자식 객체를 부모 타입 변수에 넣는 자동 타입 변환 덕분이다.- 다형성은 “자식 객체는 부모 타입 자리에 들어간다”이다. 아래 오해 항목은 이 규칙이 상자 타입까지 따라오지는 않는다는 이야기다.
자주 하는 오해
Student가 Person의 자식이니 Applicant<Student>도 Applicant<Person>의 자식이다”틀렸다. Student 객체는 Person 변수에 들어가지만, Applicant<Student> 객체는 Applicant<Person> 변수에 들어가지 않는다. Applicant<Person> p = new Applicant<Student>(new Student());는 incompatible types: Applicant<Student> cannot be converted to Applicant<Person> 오류로 번역되지 않는다. 그럴듯해 보이는 이유는 “학생 지원자도 사람 지원자다”가 말로는 맞기 때문이다. 왜 막는지는 이 줄이 된다고 쳐 보면 보인다. p는 Applicant<Person>이니 p.kind = new Worker();를 쓸 수 있다. 그런데 실제 객체는 학생 상자다. 학생 상자에 직장인이 들어가 버린다. 자식 타입의 상자까지 받고 싶으면 Applicant<? extends Person>으로 받는다. 이 변수로는 kind에 새 객체를 넣을 수 없으므로 위와 같은 사고가 나지 않는다.
확인 문제
C1 <T extends Number>, Applicant<? extends Student>, Applicant<? super Worker>, Applicant<?>가 각각 받는 타입의 범위를 쓰라.
답: 차례로 Number와 그 자손(Integer, Double 등) / Student와 그 자손을 담은 Applicant / Worker와 그 조상을 담은 Applicant / 무엇을 담았든 모든 Applicant.
이유: 기준 타입 자신도 범위에 든다. extends는 아래쪽, super는 위쪽이다. super를 “자식만”으로 뒤집어 외우는 실수가 잦다.
C2 public static <T> boolean compare(T t1, T t2) 안에서 t1.doubleValue()를 부르면 번역되지 않는다. compare(10, 20)처럼 숫자만 넘길 생각인데도 왜 안 되는가?
답: 번역기는 메서드를 부르는 쪽이 무엇을 넘길지 모르는 채로 메서드 몸통을 검사한다. T에 조건이 없으면 String이 올 수도 있고, String에는 doubleValue()가 없다. 그래서 Object의 메서드만 부르게 한다. <T extends Number>로 “숫자 집안만 온다”를 선언에 적어야 한다.
이유: 조건은 머릿속 계획이 아니라 선언에 적힌 것만 통한다. 적어 두면 compare("a", "b") 같은 잘못된 호출도 번역 때 걸러진다.
C3 본문의 클래스와 Course를 쓴다. 번역 오류가 나는 줄을 모두 고르라. ① Course.registerCourse2(new Applicant<MiddleStudent>(new MiddleStudent())); ② Course.registerCourse3(new Applicant<Object>(new Object())); ③ Course.registerCourse3(new Applicant<MiddleStudent>(new MiddleStudent())); ④ Course.registerCourse2(new Applicant<Person>(new Person()));
답: ③과 ④다.
이유: ③: ? super Worker는 Worker, Person, Object만 받는다. MiddleStudent는 Worker의 조상이 아니다. ④: ? extends Student는 Student와 그 자손만 받는다. Person은 부모다. ①은 MiddleStudent가 Student의 자식이라 되고, ②는 Object가 모든 클래스의 조상이라 된다.
C4 static <T extends Number> double half(T t) { return t.doubleValue() / 2; }가 있다. System.out.println(half(7));과 System.out.println(half(2.5));의 출력은? half("7")은 어떻게 되는가?
답: 3.5와 1.25. half("7")은 번역 오류다.
이유: half(7)에서 T는 Integer이고 doubleValue()가 7.0을 돌려주므로 7.0 / 2 = 3.5다. 정수 나눗셈으로 3이 나온다고 답하면 틀린다. "7"은 String이라 Number의 범위 밖이다.
C5 “Applicant<Student>를 Applicant<Person> 변수에 넣을 수 있게 허락해도 문제없다”는 주장이 틀렸음을 보이는 코드 두 줄을 쓰라(허락됐다고 치고).
답: Applicant<Person> p = new Applicant<Student>(new Student()); 다음에 p.kind = new Worker();.
이유: p의 타입으로 보면 kind는 Person이라 Worker를 넣을 수 있다. 하지만 실제 객체는 학생 상자이므로, 이 상자를 Applicant<Student>로 알고 있는 다른 코드가 kind를 Student로 꺼내는 순간 깨진다. 자바는 첫 줄을 번역 단계에서 막아 이 일을 없앤다.