타입 상한과 메서드 추론
제한 없는 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>처럼 클래스 하나를 먼저, 인터페이스들을 뒤에 둡니다.
제네릭 메서드의 범위
제네릭 타입의 타입 매개변수는 인스턴스를 만드는 시점에 정해져 필드와 여러 메서드가 공유합니다.
제네릭 메서드의 타입 매개변수는 호출할 때마다 독립적으로 정해집니다.
반환 타입 왼쪽에 <T>를 선언해야 하며 static <T> T identity(T value) 순서로 씁니다.
public final class GenericMethodScope {
public static void main(String[] args) {
Integer minutes = identity(50);
String topic = identity("generic-method");
Double larger = largerNumber(10.5, 8.0);
System.out.println(topic + "=" + minutes);
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;
}
}generic-method=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로 추론되어 반환도 Animal입니다. “같은 종만 비교”가 요구라면 이런 혼합 호출을 허용하는 메서드 하나로는 충분하지 않습니다. 종을 표현하는 더 정교한 타입 계층이나 각 타입의 인스턴스 메서드가 필요합니다.
제네릭 타입과 메서드 선택
타입 관계가 객체 상태와 수명 동안 유지되면 제네릭 타입을 씁니다.
Repository<T>, Box<T>, Hospital<T>처럼 필드와 여러 연산이 같은 T를 공유합니다.
한 호출의 인수와 반환만 연결되면 제네릭 메서드가 더 작고 직접적입니다.
상한은 구현이 실제로 호출할 최소 계약으로 둡니다.
편의를 위해 거대한 부모를 만들면 재사용 가능성이 오히려 줄어듭니다.
Sized, Named처럼 작은 역할 인터페이스를 조합할 수 있습니다.
상한이 여러 개로 복잡해지고 호출자가 긴 타입을 적어야 하면 도메인 전용 추상화를 검토합니다.
메서드의 T 이름이 클래스의 T와 같으면 안쪽 제네릭 메서드 T가 바깥 타입 매개변수를 가립니다.
문법적으로 가능하지만 독자가 같은 타입이라고 오해하기 쉬우므로 R 같은 다른 이름을 사용하거나 메서드 제네릭이 정말 필요한지 확인합니다.
연습 문제
<T extends Scored> T max(T left, T right)를 작성하세요.
QuizResult 두 개를 넘겨 반환된 객체의 고유 quizId를 별도 형변환 없이 직접 사용합니다.
해설 보기
public final class BoundedGenericMethodExercise {
public static void main(String[] args) {
QuizResult first = new QuizResult("Q-1", 80);
QuizResult second = new QuizResult("Q-2", 95);
QuizResult result = max(first, second);
System.out.println("winner=" + result.quizId());
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 QuizResult(String quizId, int score) implements Scored { }
}winner=Q-2
score=95max 메서드의 상한은 비교에 필요한 score만 요구하고 T 반환은 QuizResult의 quizId를 보존합니다. 한 호출 안의 입력과 출력 관계가 핵심이므로 제네릭 메서드가 적합합니다.