타입 상한과 메서드 추론
제한 없는 T에서 Animal 메서드를 호출할 수 없는 컴파일 실패를 재현하고 extends 상한, 제네릭 타입·메서드의 범위, 타입 추론을 구분합니다.
제네릭 선언의 T에는 기본적으로 어떤 참조 타입이든 들어올 수 있습니다.
따라서 컴파일러는 T를 Object 수준으로만 다룰 수 있고 size()나 sound() 같은 Animal 기능을 허용하지 않습니다.
타입 매개변수 상한은 허용 입력을 줄이는 동시에 구현이 사용할 수 있는 최소 계약을 늘립니다.
제한 없는 타입 매개변수
개발자는 Hospital에 동물만 넣을 생각이지만 선언에는 그 의도가 없습니다.
Hospital<Integer>도 가능한 상태이므로 javac는 T에 size가 있다고 가정할 수 없습니다.
public final class UnboundedAnimalMethodFailure {
public static void main(String[] args) {
Hospital<Dog> hospital = new Hospital<>(new Dog("dog", 40));
System.out.println(hospital.size());
}
private static final class Hospital<T> {
private final T animal;
Hospital(T animal) {
this.animal = animal;
}
int size() {
return animal.size();
}
}
private interface Animal {
int size();
}
private record Dog(String name, int size) implements Animal {}
}error: cannot find symbol
symbol: method size()
location: variable animal of type T주석, 변수 이름, main의 실제 Dog 사용은 제네릭 클래스 전체 계약을 바꾸지 않습니다.
클래스는 다른 호출자도 사용할 수 있으므로 선언만 보고 안전해야 합니다.
타입 상한과 허용 동작
<T extends Animal>은 Animal 또는 그 하위 타입만 타입 인자로 허용합니다.
구현 안에서는 T를 Animal로 사용할 수 있고 반환할 때는 실제 T를 유지합니다.
클래스 상속과 인터페이스 구현 모두 제네릭 상한 문법에서는 extends를 씁니다.
public final class BoundedAnimalHospital {
public static void main(String[] args) {
Hospital<Dog> dogs = new Hospital<>(new Dog("d1", 40));
Dog bigger = dogs.bigger(new Dog("d2", 75));
dogs.checkup();
System.out.println("bigger=" + bigger.name());
}
private static final class Hospital<T extends Animal> {
private final T animal;
Hospital(T animal) {
this.animal = animal;
}
void checkup() {
System.out.println(animal.name() + ":" + animal.size());
animal.sound();
}
T bigger(T other) {
return animal.size() >= other.size() ? animal : other;
}
}
private interface Animal {
String name();
int size();
void sound();
}
private record Dog(String name, int size) implements Animal {
public void sound() {
System.out.println("woof");
}
}
}d1:40
woof
bigger=d2상한은 T를 Animal로 바꾸는 것이 아닙니다.
구현이 Animal 기능을 안전하게 쓰면서 호출 측의 Dog 타입도 보존합니다.
여러 상한이 필요하면 <T extends BaseClass & FirstRole & SecondRole>처럼 클래스 하나를 먼저, 인터페이스들을 뒤에 둡니다.
제한 없는 Hospital의 animal.size 호출은 컴파일 실패한다. Animal 상한이 있는 예제는 저장한 Dog d1을 checkup하고, bigger는 별도로 Dog d2를 반환한다.
| 검사·관찰 항목 | 선언이 보장하는 범위 | 원문 결과 |
|---|---|---|
제한 없는 타입 Hospital<T> | T에 size() 계약이 없음main의 Dog 사용만으로 선언의 상한이 바뀌지 않음 | animal.size()컴파일 실패 |
상한 안의 검진 checkup() | T extends Animal저장된 d1의 이름·크기·울음 호출 | d1:40woof |
상한 안의 반환 bigger(other) | 40 >= 75는 거짓other인 Dog d2 반환 | Dog biggerbigger=d2 |
- 제한 없는 타입
Hospital<T> - 선언이 보장하는 범위:
T에size()계약이 없음main의 Dog 사용만으로 선언의 상한이 바뀌지 않음원문 결과:animal.size()컴파일 실패 - 상한 안의 검진
checkup() - 선언이 보장하는 범위:
T extends Animal저장된d1의 이름·크기·울음 호출원문 결과:d1:40woof - 상한 안의 반환
bigger(other) - 선언이 보장하는 범위:
40 >= 75는 거짓other인 Dogd2반환원문 결과:Dog biggerbigger=d2
main은 bigger의 반환값을 받은 뒤 checkup()을 호출합니다. 저장된 d1이 d2로 바뀌는 것은 아닙니다.
제네릭 메서드의 범위
제네릭 타입의 타입 매개변수는 인스턴스를 만드는 시점에 정해져 필드와 여러 메서드가 공유합니다.
제네릭 메서드의 타입 매개변수는 호출할 때마다 독립적으로 정해집니다.
반환 타입 왼쪽에 <T>를 선언해야 하며 static <T> T identity(T value) 순서로 씁니다.
public final class GenericMethodScope {
public static void main(String[] args) {
Integer viewCount = identity(50);
String title = identity("제네릭 메서드 질문");
Double larger = largerNumber(10.5, 8.0);
System.out.println(title + "=" + viewCount);
System.out.println("larger=" + larger);
}
private static <T> T identity(T value) {
return value;
}
private static <T extends Number & Comparable<T>> T largerNumber(T left, T right) {
return left.compareTo(right) >= 0 ? left : right;
}
}제네릭 메서드 질문=50
larger=10.5identity의 첫 호출 T는 Integer, 둘째 호출 T는 String입니다.
largerNumber는 Number 기능과 같은 T끼리의 Comparable 계약을 모두 요구합니다.
<Integer>를 호출부에 직접 적을 수도 있지만 인수와 대입 문맥이 충분하면 추론에 맡기는 편이 읽기 쉽습니다.
타입 추론의 입력 문맥
컴파일러는 전달된 인수 타입에서 후보를 모으고 메서드 상한과 목표 타입을 만족하는 타입을 찾습니다.
인수가 서로 다른 하위 타입이면 공통 부모가 추론될 수 있어 반환이 기대보다 넓어질 수 있습니다.
추론 결과가 의도를 숨기면 타입 인자를 명시하거나 메서드 시그니처를 좁힙니다.
public final class GenericAnimalMethodInference {
public static void main(String[] args) {
Dog first = new Dog("d1", 40, "A");
Dog second = new Dog("d2", 60, "B");
Dog bigger = bigger(first, second);
Animal mixed = bigger(first, new Cat("c1", 80));
System.out.println("dog-code=" + bigger.code());
System.out.println("mixed=" + mixed.name());
}
private static <T extends Animal> T bigger(T left, T right) {
return left.size() >= right.size() ? left : right;
}
private interface Animal {
String name();
int size();
}
private record Dog(String name, int size, String code) implements Animal {}
private record Cat(String name, int size) implements Animal {}
}dog-code=B
mixed=c1Dog 두 개에서는 T가 Dog라 고유 code를 쓸 수 있습니다.
Dog와 Cat을 함께 넘긴 이 예제에서는 결과를 공통 Animal 타입의 변수로 받습니다.
“같은 종만 비교”가 요구라면 이런 혼합 호출을 허용하는 메서드 하나로는 충분하지 않습니다.
종을 표현하는 더 정교한 타입 계층이나 각 타입의 인스턴스 메서드가 필요합니다.
identity의 Integer와 String 호출은 독립적이다. Dog 두 개를 비교한 결과는 Dog로, Dog와 Cat을 비교한 결과는 Animal 변수로 받으며 실제로 선택된 값은 Cat c1이다.
| 원문 호출 | 결과를 받는 변수 | 그 변수의 값 |
|---|---|---|
identity(50) | Integer viewCount | 50 |
문자열 identity 호출 | String title | 제네릭 메서드 질문 |
bigger(first, second)Dog 40 · Dog 60 | Dog bigger | d2code() → B |
bigger(first, new Cat(...))Dog 40 · Cat 80 | Animal mixed | 실제 값은 Cat c1name() → c1 |
identity(50)- 결과를 받는 변수:
Integer viewCount그 변수의 값:50 - 문자열
identity호출 - 결과를 받는 변수:
String title그 변수의 값: 제네릭 메서드 질문 bigger(first, second)Dog 40 · Dog 60- 결과를 받는 변수:
Dog bigger그 변수의 값:d2code()→B bigger(first, new Cat(...))Dog 40 · Cat 80- 결과를 받는 변수:
Animal mixed그 변수의 값:실제 값은 Catc1name()→c1
마지막 행은 Animal로 선언된 변수에 결과를 받는 코드입니다. 메서드가 두 인수의 런타임 종을 같게 강제한다는 뜻은 아닙니다.
제네릭 타입과 메서드 선택
타입 관계가 객체 상태와 수명 동안 유지되면 제네릭 타입을 씁니다.
Repository<T>, Box<T>, Hospital<T>처럼 필드와 여러 연산이 같은 T를 공유합니다.
한 호출의 인수와 반환만 연결되면 제네릭 메서드가 더 작고 직접적입니다.
상한은 구현이 실제로 호출할 최소 계약으로 둡니다.
편의를 위해 거대한 부모를 만들면 재사용 가능성이 오히려 줄어듭니다.
Sized, Named처럼 작은 역할 인터페이스를 조합할 수 있습니다.
상한이 여러 개로 복잡해지고 호출자가 긴 타입을 적어야 하면 도메인 전용 추상화를 검토합니다.
메서드의 T 이름이 클래스의 T와 같으면 안쪽 제네릭 메서드 T가 바깥 타입 매개변수를 가립니다.
문법적으로 가능하지만 독자가 같은 타입이라고 오해하기 쉬우므로 R 같은 다른 이름을 사용하거나 메서드 제네릭이 정말 필요한지 확인합니다.
연습 문제
<T extends Scored> T max(T left, T right)를 작성하세요.
ScoredPost 두 개를 넘겨 반환된 객체의 고유 postId를 별도 형변환 없이 직접 사용합니다.
해설 보기
public final class BoundedGenericMethodExercise {
public static void main(String[] args) {
ScoredPost first = new ScoredPost(101L, 80);
ScoredPost second = new ScoredPost(102L, 95);
ScoredPost result = max(first, second);
System.out.println("winner=" + result.postId());
System.out.println("score=" + result.score());
}
private static <T extends Scored> T max(T left, T right) {
return left.score() >= right.score() ? left : right;
}
private interface Scored { int score(); }
private record ScoredPost(long postId, int score) implements Scored { }
}winner=102
score=95max 메서드의 상한은 비교에 필요한 score만 요구하고 T 반환은 ScoredPost의 postId를 보존합니다.
한 호출 안의 입력과 출력 관계가 핵심이므로 제네릭 메서드가 적합합니다.