요약

무엇을 담을지 비워 둔 상자 설계도다. 설계도에는 “여기에 들어갈 것의 종류”를 빈칸으로 두고, 상자를 실제로 쓸 때 “이 상자는 글자용”처럼 빈칸을 채운다. 설계도 하나로 글자 상자도 숫자 상자도 만들 수 있고, 글자 상자에 숫자를 넣으려 하면 프로그램을 돌려 보기도 전에 번역 단계에서 걸러 준다. 단, 빈칸에는 정수나 실수 같은 기본 값의 종류를 바로 쓸 수 없고 객체의 종류만 쓸 수 있다.

예시로 보기

내용물 하나를 담는 상자 클래스를 만든다. 문자열도 담고 정수도 담고 싶다. 빈칸 없이 만들려면 무엇이든 받는 Object로 선언하는 수밖에 없다1.

class ObjectBox {
    public Object content;
}

ObjectBox box = new ObjectBox();
box.content = 100;                      // 정수를 넣었다
String str = (String) box.content;      // 문자열인 줄 알고 꺼냈다

이 코드는 번역된다. 그리고 실행하면 마지막 줄에서 ClassCastException으로 끝난다. 꺼낼 때마다 강제 변환을 써야 하고, 잘못 꺼내도 번역기는 모른다.

내용물의 타입 자리를 빈칸 T로 둔다.

public static class Box<T> {
    public T content;
}

Box<String> box1 = new Box<>();
box1.content = "hello world!";
String str = box1.content;
System.out.println(str);                // hello world!

Box<String>이라고 쓰는 순간 이 상자의 T는 String이 된다. 꺼낼 때 강제 변환이 없다. 여기에 box1.content = 100;을 쓰면 번역 단계에서 막힌다1.

error: incompatible types: int cannot be converted to String

실행해야 드러나던 실수가 번역 때 드러난다.

정의

제네릭은 아직 정하지 않은 타입을 파라미터로 두고, 실제로 쓸 때 그 파라미터를 구체적인 타입으로 바꿔 넣는 기능이다. <T>는 T가 타입 파라미터라는 표시다. 타입이 필요한 자리에 T를 쓸 수 있다는 뜻이다.

메서드의 매개변수와 견주면 이해가 쉽다. 매개변수는 값이 들어올 빈칸이고, 타입 파라미터는 타입이 들어올 빈칸이다2.

  메서드의 매개변수 타입 파라미터
빈칸을 두는 곳 void set(int x)의 x class Box<T>의 T
채우는 것 값 (set(3)) 타입 (Box<String>)
채우는 때 실행 중, 부를 때마다 번역 때, 쓰는 코드를 적을 때

제네릭 타입

제네릭 타입은 정하지 않은 타입을 파라미터로 가진 클래스와 인터페이스다. 선언할 때 이름 뒤에 < >를 쓰고 그 사이에 타입 파라미터를 둔다. 타입 파라미터는 보통 대문자 한 글자로 쓴다. 밖에서 제네릭 타입을 쓸 때는 타입 파라미터 자리에 구체적인 타입을 적어야 한다. 적지 않으면 Object 타입이 쓰인다.

타입 파라미터는 쉼표로 나눠 여러 개 둘 수 있다.

public static class Product<K, M> {
    private K kind;
    private M model;

    public K getKind() { return this.kind; }
    public M getModel() { return this.model; }
    public void setKind(K kind) { this.kind = kind; }
    public void setModel(M model) { this.model = model; }
}

public static class Tv {}
public static class Car {}
Product<Tv, String> product1 = new Product<>();    // K는 Tv, M은 String
product1.setKind(new Tv());
product1.setModel("Smart Tv");
Tv tv = product1.getKind();
String tvModel = product1.getModel();

Product<Car, String> product2 = new Product<>();   // K는 Car, M은 String
product2.setKind(new Car());
product2.setModel("SUV");
Car car = product2.getKind();
String carModel = product2.getModel();

Product 클래스는 하나인데 product1의 getKind()는 Tv를, product2의 getKind()는 Car를 돌려준다. 변수를 선언할 때 적은 타입이 클래스 안의 K, M 자리에 그대로 들어간 것처럼 번역기가 다룬다.

new Product<>()의 빈 <>는 왼쪽에 적은 타입을 그대로 쓴다는 뜻이다. 두 번 적지 않아도 된다2.

제네릭 메서드

제네릭 메서드는 타입 파라미터를 가진 메서드다. 타입 파라미터를 메서드 선언에서 정한다. 돌려주는 타입 앞에 < >를 써서 타입 파라미터를 정하고, 그것을 돌려주는 타입과 매개변수 타입에 쓴다.

public <A, B, …> 리턴타입 메서드이름(매개변수, …) { … }
public static <T> Box<T> boxing(T t) {
    Box<T> box = new Box<>();
    box.content = t;
    return box;
}

Box<Integer> box1 = boxing(100);          // T는 Integer
Box<String>  box2 = boxing("안녕하세요");   // T는 String

부를 때 타입을 따로 적지 않는다. 번역기가 넘긴 인자의 타입을 보고 T를 구체적인 타입으로 바꾼다. 100은 int인데 T가 Integer가 됐다. 타입 파라미터 자리에는 객체의 타입만 올 수 있어서, 번역기가 int 값을 Integer 객체로 감싸 넘긴다3.

메서드의 타입 파라미터는 클래스의 타입 파라미터와 따로 논다. 아래 클래스에서 genericMethod1의 <E>는 클래스의 E와 글자만 같고 다른 빈칸이다.

class ClassName<E> {
    private E element;

    void setElement(E element) { this.element = element; }
    E get() { return element; }

    public <E> E genericMethod1(E o) { return o; }   // 메서드가 따로 정한 E
    public <T> T genericMethod2(T o) { return o; }
}
ClassName<String> a = new ClassName<>();
ClassName<Integer> b = new ClassName<>();

a.setElement("10");
b.setElement(10);

System.out.println(a.get().getClass().getName());
System.out.println(b.get().getClass().getName());

System.out.println(a.genericMethod1(10).getClass().getName());
System.out.println(b.genericMethod2("10").getClass().getName());
java.lang.String
java.lang.Integer
java.lang.Integer
java.lang.String
줄 부른 것 빈칸이 채워진 값 출력
1 a.get() 클래스의 E = String (a를 선언할 때 정했다) java.lang.String
2 b.get() 클래스의 E = Integer java.lang.Integer
3 a.genericMethod1(10) 메서드의 E = Integer (인자 10을 보고 정했다) java.lang.Integer
4 b.genericMethod2("10") 메서드의 T = String java.lang.String

셋째 줄이 핵심이다. a는 ClassName<String>인데 genericMethod1에 정수를 넘겨도 번역되고 Integer가 나온다. 메서드의 <E>가 클래스의 E를 가렸기 때문이다. 반대로 a.setElement(10)은 번역되지 않는다. setElement는 클래스의 E(여기서는 String)를 쓴다3.

검증: ObjectBox의 ClassCastException, Box와 Product 예, boxing의 타입, 위 네 줄 출력, 타입을 안 적은 Box의 내용물이 Object로 나오는 것, 카드 C2의 답 — 38_java-generics_impl.java

활용

  • 타입 파라미터 자리에 기본 타입은 못 쓴다. Box<int>는 unexpected type … required: reference, found: int 오류가 난다. Box<Integer>, Box<Double>처럼 감싸는 클래스를 쓴다3.
  • 타입을 안 적고 Box box = new Box();처럼 쓰면 번역은 되지만 content가 Object로 다뤄져서 ObjectBox와 같은 처지가 된다. 번역기가 경고(found raw type)를 낸다. 새 코드에서는 늘 타입을 적는다3.
  • 타입 파라미터 이름은 관례로 T(타입), E(원소), K(키), V(값)를 많이 쓴다. 위의 Product<K, M>은 kind와 model의 첫 글자다2.
  • 메서드의 타입 파라미터에 클래스와 같은 글자를 쓰면 위 genericMethod1처럼 헷갈린다. 다른 글자를 쓴다.
  • Box<String>과 Box<Integer>는 번역 때만 다르고, 실행 중에는 같은 Box 클래스 하나다(getClass()가 같다). 타입 검사는 번역기가 끝내 놓는다3.

연결

확인 문제

C1 제네릭 타입과 제네릭 메서드에서 타입 파라미터 <T>를 적는 자리를 각각 쓰라. 제네릭 타입을 쓸 때 타입을 안 적으면 어떻게 되는가?

답: 제네릭 타입은 클래스(인터페이스) 이름 바로 뒤에 적는다: class Box<T>. 제네릭 메서드는 돌려주는 타입 바로 앞에 적는다: public <T> Box<T> boxing(T t). 타입을 안 적으면 Object 타입이 쓰인다.
이유: 제네릭 메서드에서 <T>를 메서드 이름 뒤에 적는 것이 흔한 실수다. Box<T>의 <T>는 돌려주는 타입의 일부이고, 그 앞의 <T>가 빈칸을 새로 만드는 표시다.

C2 본문의 Box<T>와 boxing에 더해 static <T> T pick(T first, T second) { return second; }가 있다. 출력 세 줄을 쓰라. Box<String> b1 = boxing("7"); Box<Integer> b2 = boxing(7); System.out.println(b1.content + b1.content); System.out.println(b2.content + b2.content); System.out.println(pick("x", "y"));

답: 77, 14, y.
이유: b1의 T는 String이라 +가 문자열을 잇는다. b2의 T는 Integer라 +가 덧셈이다. 같은 Box 클래스, 같은 content 필드인데 선언한 타입에 따라 번역기가 다르게 다룬다. pick은 둘째 인자를 돌려준다.

C3 번역되지 않는 줄을 모두 고르라. 앞에 Box<String> a = new Box<>();가 있다. ① a.content = "hi"; ② a.content = 100; ③ Box<Integer> b = boxing(5); ④ Box<Integer> c = boxing("5"); ⑤ Box<double> d = new Box<>();

답: ②, ④, ⑤다.
이유: ②는 String 자리에 int를 넣었다. ④는 인자가 String이라 Box<String>이 나오는데 Box<Integer> 변수에 받으려 했다. ⑤는 타입 파라미터 자리에 기본 타입을 썼다. ③은 5가 Integer로 감싸져 Box<Integer>가 나오므로 된다.

C4 본문의 a는 ClassName<String>이다. a.setElement(10)은 번역되지 않는데 a.genericMethod1(10)은 번역되는 이유는?

답: setElement(E element)의 E는 클래스의 타입 파라미터이고 a에서는 String으로 정해져 있다. genericMethod1은 선언 앞에 <E>를 따로 적어 자기만의 빈칸을 새로 만들었다. 이 E는 부를 때 넘긴 인자(10)를 보고 Integer로 정해진다.
이유: 메서드 선언의 돌려주는 타입 앞에 < >가 있으면 제네릭 메서드이고, 그 안의 글자는 클래스의 것과 같아도 다른 빈칸이다.

  1. ClassCastException은 검증 코드로, 번역 오류 문구는 JDK 21 javac로 확인했다. ↩ ↩2

  2. 여기서는 줄였다. ↩ ↩2 ↩3

  3. 출력은 검증 코드로, 번역 오류와 경고는 JDK 21 javac로 확인했다. ↩ ↩2 ↩3 ↩4 ↩5