학습 자료

제네릭 — <> 안에 든 것


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 이 들어간 것처럼 동작한다. putString 만 받고, getString 을 준다.

오른쪽 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> 가 이렇게 생긴 것이다.

관례적으로 쓰는 글자
글자
TType — 그냥 아무 타입
EElement — 컬렉션의 원소
K, VKey, Value — 짝을 이루는 것
RResult — 돌려줄 타입

아무 글자나 써도 컴파일된다. 다만 다들 이렇게 쓰니 따르는 게 낫다.

기본형은 못 들어간다

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 로 자동 변환된다

intInteger, doubleDouble, booleanBoolean 이다. 넣고 꺼낼 때 자동으로 바뀌므로 대개 신경 쓰지 않아도 된다.

다만 Integer 는 참조형이다. null 이 될 수 있다.

Map<String, Integer> map = new HashMap<>();
int n = map.get("없는 키");

16강에서 map.get() 은 없으면 null 을 준다고 했다. 그 nullint 로 바꾸려는 순간 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 가 아니다

StringObject 를 상속한다(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 이거나 그 자식인 무언가"라는 뜻이다. IntegerDoubleNumber 의 자식이라 통과한다.

물음표를 와일드카드라고 부른다. 이 안에는 add 를 할 수 없다. 무엇이 담긴 목록인지 정확히 모르니 아무것도 넣지 못하게 막아둔 것이다. 읽기 전용이다.

와일드카드는 여기까지만 알아도 충분하다. 남이 쓴 코드에서 만났을 때 읽을 수 있으면 된다.

꺾쇠는 실행할 때 사라진다

이상한 실험을 하나 해보자.

System.out.println(new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass());
콘솔
  • true

같은 클래스라고 한다. String 목록과 Integer 목록이 실행 중에는 구분되지 않는다.

제네릭은 컴파일러를 위한 것이다. javac 가 타입을 전부 검사하고 나면, 바이트코드에서는 꺾쇠를 지워버린다. 1강에서 본 그 .class 파일에는 T 가 없다.

이걸 타입 소거라고 부른다.

옛 자바에는 제네릭이 없었다. 나중에 넣으면서 기존 코드가 다 돌아가야 했기 때문에, 바이트코드는 건드리지 않는 방식을 골랐다. 그 흔적이 타입 소거다.

정리하면

제네릭은 그릇 안에 든 것의 타입을 컴파일러에게 알려주고, 실행 전에 사고를 막는다.

다시 짚어보기
  1. 01

    꺾쇠를 쓰면 캐스팅이 사라진다

    꺼내는 것이 무엇인지 컴파일러가 안다. ClassCastException 도 사라진다.

    List<String> list = new ArrayList<>();
    String s = list.get(0);   // 캐스팅 없음
  2. 02

    T 는 나중에 정할 타입의 자리다

    클래스에도 메서드에도 붙일 수 있다.

    class Box<T> {
        private T item;
        T get() { return item; }
    }
  3. 03

    기본형은 못 들어간다

    List<int> 는 안 되고 List<Integer> 를 쓴다. Integer 는 null 이 될 수 있다.

  4. 04

    List<String>List<Object> 가 아니다

    허용하면 String 목록에 정수를 넣을 수 있게 된다. 읽기만 하려면 ? extends 를 쓴다.

  5. 05

    꺾쇠는 컴파일이 끝나면 사라진다

    타입 소거. 제네릭이 지켜주는 것은 컴파일 시점이다.

Part 5 가 끝났다. 인터페이스로 약속을 정하고(15강), 늘어나는 그릇에 값을 담고(16강), 그 그릇에 이름표를 붙였다(17강).

여기까지가 자바로 프로그램의 뼈대를 세우는 법이다.

마지막 Part 에서는 뼈대가 아니라 일이 잘못됐을 때를 다룬다. 지금까지 우리는 프로그램이 죽는 장면을 여러 번 봤다. NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException, ClassCastException.

이름 끝이 전부 Exception 이다. 우연이 아니다. 다음 강의에서 이 이름들의 정체를 밝히고, 죽지 않고 붙잡는 법을 배운다.

Share
  • 자바
  • 제네릭
  • 타입
  • 타입소거
  • 와일드카드
제네릭 — <> 안에 든 것 — 디코드랩(DCODELAB)