thenullpage.com

목록에 문자열만 담기로 했는데 어디선가 숫자가 섞여 들어간다. 꺼내 쓰는 쪽은 문자열인 줄 알고 받다가 그 자리에서 터진다. 무엇을 담는지 타입으로 못 박아 두면 이런 사고는 실행 전에 걸러진다. 자바가 그 일을 하는 장치가 제네릭이다.


타입을 안 적으면 형변환이 남는다. 꺾쇠 없이 목록을 만들면 아무거나 들어가고, 꺼낼 때마다 사람이 타입을 우겨넣어야 한다.


List list = new ArrayList();
list.add("사과");
list.add(10);

String s = (String) list.get(1);
// 출력:
// Exception in thread "main" java.lang.ClassCastException:
// class java.lang.Integer cannot be cast to class java.lang.String


컴파일은 멀쩡히 통과했고 실행 중에 죽었다. 꺾쇠 안에 담을 타입을 적으면 이야기가 달라진다.


List<String> names = new ArrayList<>();
names.add("사과");
// names.add(10);
// 컴파일 에러: no suitable method found for add(int)

String s = names.get(0);
System.out.println(s.length()); // 2


잘못 넣는 줄이 실행 전에 걸리고, 꺼낼 때 앞에 붙이던 형변환 괄호도 사라진다. new ArrayList<>()의 빈 꺾쇠는 다이아몬드라고 부르며 왼쪽 타입을 그대로 가져다 채운다.


타입 자리를 비워 둔 클래스를 직접 만든다. 클래스 이름 뒤에 꺾쇠로 이름표를 하나 달면 그 이름을 필드와 메서드에서 타입처럼 쓸 수 있다.


public class Box<T> {
private T value;

public void set(T value) {
this.value = value;
}

public T get() {
return value;
}
}

Box<String> b1 = new Box<>();
b1.set("책");
System.out.println(b1.get().length()); // 1

Box<Integer> b2 = new Box<>();
b2.set(42);
System.out.println(b2.get() + 1); // 43


b1.get()은 String이라 length가 바로 붙고 b2.get()은 Integer라 더하기가 된다. T는 관례상 붙이는 이름이고 요소는 E, 키와 값은 K와 V를 쓴다. 기본형은 이 자리에 못 들어가 Box<int>는 에러이고 Box<Integer>라고 적어야 한다.


메서드 하나만 제네릭으로 만들 수도 있다. 반환 타입 앞에 꺾쇠를 적으면 그 메서드 안에서만 통하는 이름표가 된다.


static <T> T firstOrNull(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}

System.out.println(firstOrNull(List.of("가", "나"))); // 가
System.out.println(firstOrNull(List.of(1, 2))); // 1


부를 때 타입을 따로 적지 않았는데도 넘긴 값을 보고 컴파일러가 알아서 정한다.


아무 타입이나 받으면 안에서 할 수 있는 게 없다. T가 무엇인지 모르니 Object가 가진 메서드만 부른다. 범위를 좁혀 두면 그 범위의 기능을 쓸 수 있다.


static <T extends Number> double sum(List<T> list) {
double total = 0;
for (T n : list) {
total += n.doubleValue();
}
return total;
}

System.out.println(sum(List.of(1, 2, 3))); // 6.0
System.out.println(sum(List.of(1.5, 2.5))); // 4.0
// sum(List.of("가"));
// 컴파일 에러: inference variable T has incompatible bounds


extends Number를 붙인 덕에 doubleValue를 부를 수 있다. 인터페이스를 적을 때도 implements가 아니라 extends를 쓴다.


List<Integer>는 List<Number>가 아니다. Integer가 Number를 물려받았어도 그 관계가 목록으로 옮겨 붙지는 않는다.


List<Integer> ints = List.of(1, 2, 3);
// List<Number> nums = ints;
// 컴파일 에러: incompatible types

static double sumAll(List<? extends Number> list) {
double t = 0;
for (Number n : list) t += n.doubleValue();
return t;
}

System.out.println(sumAll(ints)); // 6.0


물음표는 이름을 붙일 필요도 없는 어떤 타입이라는 뜻이다. ? extends Number는 Number거나 그 아래 무엇이든 받는다. 대신 꺼내 읽기만 되고 넣지는 못한다. 실제로 들어온 것이 List<Integer>인지 List<Double>인지 모르는 상태라 list.add(1)은 컴파일 에러다.


넣어야 하면 반대로 super를 쓴다.


static void addNumbers(List<? super Integer> list) {
list.add(1);
list.add(2);
}

List<Number> nums = new ArrayList<>();
addNumbers(nums);
System.out.println(nums); // [1, 2]


? super Integer는 Integer거나 그 위 무엇이든이라 Integer를 넣는 건 언제나 안전하다. 대신 꺼내면 Object로만 받는다. 읽기만 할 자리에는 extends, 넣을 자리에는 super를 쓴다는 이 규칙을 PECS(producer extends, consumer super)라고 부른다.


런타임에는 꺾쇠 안이 지워진다. 컴파일이 끝나면 List<String>도 List<Integer>도 그냥 List다. 이걸 타입 소거라고 한다.


List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true

// 아래는 전부 컴파일되지 않는다
// if (a instanceof List<String>) { }
// T[] arr = new T[10];
// static void f(List<String> x) {}
// static void f(List<Integer> x) {}
// name clash: both methods have the same erasure


둘의 클래스가 같다고 나온다. 정보가 안 남으니 instanceof로 안에 든 타입을 물을 수 없고, T로 배열을 새로 만들 수도 없고, 꺾쇠만 다른 메서드 두 개는 지우고 나면 서명이 겹쳐 같이 둘 수 없다. 제네릭은 컴파일러가 검사하고 지우는 장치다.


정리하면 셋이다. 담을 타입을 꺾쇠에 적으면 잘못 넣는 코드가 실행 전에 걸리고 형변환이 사라진다는 것, 읽을 자리에는 ? extends를 넣을 자리에는 ? super를 쓴다는 것, 꺾쇠 안 정보는 런타임에 지워져 instanceof나 new T[]가 안 된다는 것이다. Box를 직접 쳐 놓고 Box<String>에 숫자를 넣어 보면 에러가 어느 줄에서 나는지 바로 보인다.