제네릭 — <> 안에 든 것
Article
16강에서 꺾쇠를 빼면 안 된다고 했다. 빼면 어떻게 되는지 끝까지 가보자.
List list = new ArrayList();
list.add("자바");
list.add(123);
for (Object o : list) {
String s = (String) o;
System.out.println(s);
}
컴파일은 된다. 경고만 하나 뜬다.
- 자바
- Exception in thread "main" java.lang.ClassCastException:
- class java.lang.Integer cannot be cast to class java.lang.String
첫 줄은 찍히고 둘째 줄에서 죽었다. 실행 에러다.
123 을 넣은 것은 세 번째 줄인데, 터진 곳은 여섯 번째 줄이다. 코드가 길어지면 넣은 곳과 터지는 곳이 파일 하나만큼 떨어진다.
꺾쇠는 이 사고를 컴파일 시점으로 끌어온다.
그릇에 이름표를 붙인다
List<String> list = new ArrayList<>();
list.add("자바");
list.add(123);
- error: incompatible types: int cannot be converted to String
넣는 그 줄에서 막힌다. 실행되지도 않는다.
꺼낼 때도 캐스팅이 필요 없다. 꺼내는 것이 String 이라는 걸 컴파일러가 이미 안다.
String s = list.get(0); // (String) 캐스팅 없음
3강에서 "타입을 적는 게 귀찮은 대신 실행 전에 실수를 잡아준다"고 했다. 제네릭은 그 이야기의 결정판이다. 그릇의 타입뿐 아니라, 그릇 안에 든 것의 타입까지 적는다.
List
제네릭 없이
- 아무거나 담긴다
- 꺼내면
Object다 - 손수 캐스팅해야 한다
- 틀리면
ClassCastException— 실행 중에
List<String>
제네릭
String만 담긴다- 꺼내면
String이다 - 캐스팅이 필요 없다
- 틀리면 컴파일 에러 — 실행 전에
직접 만들어본다
T 라는 이름을 클래스 옆에 붙인다.
class Box<T> {
private T item;
void put(T item) { this.item = item; }
T get() { return item; }
}
T 는 아직 정해지지 않은 타입의 자리다. 쓸 때 정한다.
Box<String> b = new Box<>();
b.put("자바");
String s = b.get();
- 자바
Box<String> 이라고 쓰는 순간 T 자리에 전부 String 이 들어간 것처럼 동작한다. put 은 String 만 받고, get 은 String 을 준다.
오른쪽 new Box<>() 의 꺾쇠가 비어 있는 것에 주의하자. 왼쪽에 이미 적었으니 컴파일러가 알아낸다. 이걸 다이아몬드 연산자라고 부른다.
둘 이상도 된다.
class Pair<K, V> {
K key;
V value;
Pair(K k, V v) { key = k; value = v; }
}
Pair<String, Integer> p = new Pair<>("자바", 20000);
Map<String, Integer> 가 이렇게 생긴 것이다.
| 글자 | 뜻 |
|---|---|
T | Type — 그냥 아무 타입 |
E | Element — 컬렉션의 원소 |
K, V | Key, Value — 짝을 이루는 것 |
R | Result — 돌려줄 타입 |
아무 글자나 써도 컴파일된다. 다만 다들 이렇게 쓰니 따르는 게 낫다.
기본형은 못 들어간다
List<int> list = new ArrayList<>();
- error: unexpected type
꺾쇠 안에는 참조형만 들어간다. 4강의 그 구분이다.
그래서 자바는 기본형마다 짝이 되는 클래스를 하나씩 준비해뒀다. 이걸 래퍼 클래스라고 부른다.
List<Integer> nums = new ArrayList<>();
nums.add(3); // int 3 이 Integer 로 자동 변환된다
int n = nums.get(0); // Integer 가 int 로 자동 변환된다
int 는 Integer, double 은 Double, boolean 은 Boolean 이다. 넣고 꺼낼 때 자동으로 바뀌므로 대개 신경 쓰지 않아도 된다.
다만 Integer 는 참조형이다. null 이 될 수 있다.
Map<String, Integer> map = new HashMap<>();
int n = map.get("없는 키");
16강에서 map.get() 은 없으면 null 을 준다고 했다. 그 null 을 int 로 바꾸려는 순간 NullPointerException 이다. 4강에서 int 에는 null 을 넣을 수 없다고 했던 그 벽이다.
메서드에도 붙는다
메서드 하나만 제네릭으로 만들 수 있다. 반환 타입 앞에 <T> 를 적는다.
static <T> void printAll(List<T> list) {
for (T t : list) System.out.print(t + " ");
System.out.println();
}
printAll(List.of(1, 2, 3)); // 1 2 3
printAll(List.of("가", "나")); // 가 나
T 가 무엇인지 안 적어도 된다. 넘긴 값을 보고 컴파일러가 알아낸다.
List 은 List
String 은 Object 를 상속한다(14강). 그럼 List<String> 도 List<Object> 일까.
static void f(List<Object> list) { }
List<String> s = new ArrayList<>();
f(s);
- error: incompatible types: List<String> cannot be converted to List<Object>
아니다. 그리고 이건 자바가 심술을 부리는 게 아니다.
만약 됐다면, f 안에서 list.add(123) 을 할 수 있다. List<Object> 니까 뭐든 담을 수 있다. 그럼 밖에 있던 List<String> 에 정수가 들어간다. 처음에 봤던 그 사고가 그대로 재현된다.
그래서 막았다. 대신 "뭐든 담긴 목록을 읽기만 하겠다"고 약속하면 열어준다.
static double sum(List<? extends Number> nums) {
double total = 0;
for (Number n : nums) total += n.doubleValue();
return total;
}
System.out.println(sum(List.of(1, 2, 3.5))); // 6.5
? extends Number 는 "Number 이거나 그 자식인 무언가"라는 뜻이다. Integer 도 Double 도 Number 의 자식이라 통과한다.
물음표를 와일드카드라고 부른다. 이 안에는 add 를 할 수 없다. 무엇이 담긴 목록인지 정확히 모르니 아무것도 넣지 못하게 막아둔 것이다. 읽기 전용이다.
와일드카드는 여기까지만 알아도 충분하다. 남이 쓴 코드에서 만났을 때 읽을 수 있으면 된다.
꺾쇠는 실행할 때 사라진다
이상한 실험을 하나 해보자.
System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass());
- true
같은 클래스라고 한다. String 목록과 Integer 목록이 실행 중에는 구분되지 않는다.
제네릭은 컴파일러를 위한 것이다. javac 가 타입을 전부 검사하고 나면, 바이트코드에서는 꺾쇠를 지워버린다. 1강에서 본 그 .class 파일에는 T 가 없다.
이걸 타입 소거라고 부른다.
옛 자바에는 제네릭이 없었다. 나중에 넣으면서 기존 코드가 다 돌아가야 했기 때문에, 바이트코드는 건드리지 않는 방식을 골랐다. 그 흔적이 타입 소거다.
정리하면
제네릭은 그릇 안에 든 것의 타입을 컴파일러에게 알려주고, 실행 전에 사고를 막는다.
- 01
꺾쇠를 쓰면 캐스팅이 사라진다
꺼내는 것이 무엇인지 컴파일러가 안다. ClassCastException 도 사라진다.
List<String> list = new ArrayList<>(); String s = list.get(0); // 캐스팅 없음 - 02
T는 나중에 정할 타입의 자리다클래스에도 메서드에도 붙일 수 있다.
class Box<T> { private T item; T get() { return item; } } - 03
기본형은 못 들어간다
List<int> 는 안 되고 List<Integer> 를 쓴다. Integer 는 null 이 될 수 있다.
- 04
List<String>은List<Object>가 아니다허용하면 String 목록에 정수를 넣을 수 있게 된다. 읽기만 하려면 ? extends 를 쓴다.
- 05
꺾쇠는 컴파일이 끝나면 사라진다
타입 소거. 제네릭이 지켜주는 것은 컴파일 시점이다.
Part 5 가 끝났다. 인터페이스로 약속을 정하고(15강), 늘어나는 그릇에 값을 담고(16강), 그 그릇에 이름표를 붙였다(17강).
여기까지가 자바로 프로그램의 뼈대를 세우는 법이다.
마지막 Part 에서는 뼈대가 아니라 일이 잘못됐을 때를 다룬다. 지금까지 우리는 프로그램이 죽는 장면을 여러 번 봤다. NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException, ClassCastException.
이름 끝이 전부 Exception 이다. 우연이 아니다. 다음 강의에서 이 이름들의 정체를 밝히고, 죽지 않고 붙잡는 법을 배운다.