Post

[Java] Generics

[Java] Generics

JDK1.5에서 처음 도입된 제네릭스는 JDK1.8부터 도입된 람다식만큼 큰 변화가 있었습니다. 그 당시만 해도 제네릭스는 선택적으로 사용하는 경우가 많았지만, 이제는 제네릭스를 모르고는 Java API문서조차 제대로 보기 어려울 만큼 중요한 위치를 차지하고 있기 때문에 반드시 짚고 넘어가야하는 필수요소가 되었습니다. 지금부터 천천히 제네릭에 대해 상세히 살펴보겠습니다.


개요


Generics는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크(compile-time type check)를 해주는 기능입니다. 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어듭니다.

타입 안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환되어 발생할 수 있는 오류를 줄여준 다는 뜻입니다.

예를 들어, ArrayList와 같은 컬렉션 클래스는 다양한 종류의 객체를 담을 수 있긴 하지만 보통 한 종류의 객체를 담는 경우가 더 많습니다. 그런데도 꺼낼 때 마다 타입체크를 하고 형변환을 하는 것은 아무래도 불편할 수 밖에 없습니다. 게다가 원하지 않는 종류의 객체가 포함되는 것을 막을 방법이 없다는 것도 문제다. 이러한 문제들을 제네릭스가 해결해줍니다.

📌  요약

제네릭스의 장점

  1. 타입 안정성을 제공한다.
  2. 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해 진다.


선언


제네릭 타입은 클래스와 메서드에 선언할 수 있는데, 먼저 클래스에 선언하는 제네릭 타입에 대해서 알아보겠습니다. 예를 들어 클래스 Box가 아래와 같이 정의되어 있다고 가정해봅니다.

1
2
3
4
5
6
7
8
9
10
11
class Box {
    Object item;

    void setItem(Object item) {
        this.item = item;
    }

    Object getItem() {
        return item;
    }
}

이 클래스를 제니릭 클래스로 변경하면 다음과 같이 클래스 옆에 ‘'를 붙이면 됩니다.그리고 'Object'를 모두 'T'로 바꿉니다.

1
2
3
4
5
6
7
8
9
10
11
class Box<T> { // 제네릭 타입 T를 선언
    T item;

    void setItem(T item) {
        this.item = item;
    }

    T getItem() {
        return item;
    }
}

Box에서 T를 '타입 변수'라고 하며, 'Type'의 첫 글자에서 따온 것입니다. 타입 변수는 T가 아닌 다른 것을 사용해도 됩니다. ArrayList의 경우, 타입 변수 E는 'Element(요소)'의 첫 글자를 따서 사용했습니다. 타입 변수가 여러 개인 경우에는 Map<K, V>와 같이 콤바 ','를 구분자로 나열하면 됩니다. K는 Key(키)를 의미하고, V는 Value(값)을 의미합니다. 무조건 'T'를 사용하기보다 가능하면, 이처럼 상황에 맞게 의미있는 문자를 선택해서 사용하는 것이 좋습니다. 이들은 기호의 종류만 다를 뿐 '임의의 참조형 타입'을 의미한다는 것은 모두 같습니다.

기존에는 다양한 종류의 타입을 다루는 메서드의 매개변수나 리턴타입으로 Object타입의 참조변수를 많이 사용했고, 그로 인해 형변환이 불가피했지만, 이젠 Object타입 대신 원하는 타입을 지정하기만 하면 되는 것입니다.

1
2
3
4
Box<String> b = new Box<String>(); // 타입 T 대신, 실제 타입을 지정
b.setItem(new Object());           // 에러. String이외의 타입은 지정불가
b.setItem("ABC");                  // OK. String타입이므로 가능
String item = b.getItem();         // 형변환이 필요없음

위의 코드에서 타입 T대신에 String타입을 지정해줬으므로, 제네릭 클래스 Box는 다음과 같이 정의된 것과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
class Box { // 제네릭 타입을 String으로 지정
    String item;

    void setItem(String item) {
        this.item = item;
    }

    String getItem() {
        return item;
    }
}

제네릭이 도입되기 이전의 코드와 호환을 위해, 제네릭 클래스인데도 예전의 방식으로 객체를 생성하는 것이 허용됩니다. 다만 제네릭 타입을 지정하지 않아서 안전하지 않다는 경고가 발생합니다.

1
2
3
Box b = new Box();       // OK. T는 Object로 간주된다.
b.setItem("ABC");        // 경고. unchecked or unsafe operation
b.setItem(new Object()); // 경고. unchecked or unsafe operation

아래와 같이 타입 변수 T에 Object타입을 지정하면, 타입을 지정하지 않은 것이 아니라 알고 적은 것이므로 경고는 발생하지 않습니다.

1
2
3
Box<Object> b = new Box<Object>();
b.setItem("ABC");        // 경고발생 안함
b.setItem(new Object()); // 경고발생 안함

제네릭스가 도입되기 이전의 코드와 호환성을 유지하기 위해서 제네릭스를 사용하지 않은 코드를 허용하는 것일 뿐, 앞으로 제네릭 클래스를 사용할 때는 반드시 타입을 지정해서 제네릭스와 관련된 경고가 나오지 않도록 하는 것이 좋습니다.

객체 생성과 사용


제네릭 클래스 Box가 다음과 같이 정의되어 있다고 가정해보면, 이 Box의 객체에는 한 가지 종류, 즉 T타입의 객체만 저장할 수 있습니다. 전과 달리 ArrayList를 이용해서 여러 객체를 저장할 수 있도록 하였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Box<T> {
    ArrayList<T> list = new ArrayList<T>();

    void add(T item) {
        list.add(item);
    }

    T get(int i) {
        return list.get(i);
    }

    ArrayList<T> getList() {
        return list;
    }

    int size() {
        return list.size();
    }

    public String toString() {
        return list.toString();
    }
}

Box의 객체를 생성할 때는 다음과 같이 해야하며, 참조변수와 생성자에 대입된 타입(매개변수화된 타입)이 일치해야 합니다. 일치하지 않으면 에러가 발생합니다.

1
2
Box<Apple> appleBox = new Box<Apple>();  // OK
Box<Apple> appleBox = new Box<Graple>(); // 에러

두 타입이 상속관계에 있어도 마찬가지입니다. Apple이 Fruit의 자손이라고 아래처럼 가정해보면,

1
Box<Fruit> appleBox = new Box<Apple>(); // 에러. 대입된 타입이 다르다.

단, 두 제네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮습니다. 이번엔 FruitBox가 Box의 자손이라고 가정해보면,

1
Box<Apple> appleBox = new FruitBox<Apple>(); // OK. 다형성

JDK1.7부터는 추정이 가능한 경우 타입을 생략할 수 있게 되었습니다. 참조변수의 타입으로부터 Box가 Apple타입의 객체만 저장한다는 것을 알 수 있기 때문에, 생성자에 반복해서 타입을 지정해주지 않아도 되는 것입니다. 따라서 아래의 두 문장은 동일합니다.

1
2
Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<>();      // OK. JDK1.7부터 생략가능

생성된 Box의 객체에 'void add(T item)'으로 객체를 추가할 때, 대입된 타입과 다른 타입의 객체는 추가할 수 없습니다.

1
2
3
Box<Apple> appleBox = new Box<Apple>();
appleBox.add(new Apple());  // OK.
appleBox.add(new Grape());  // 에러. Box<Apple>에는 Apple객체만 추가가능

그러나 타입 T가 ‘Fruit’인 경우, ‘void add(Fruit item)’가 되므로 Fruit의 자손들은 이 메서드의 매개변수가 될 수 있습니다. Apple이 Fruit의 자손이라고 가정하였습니다.

1
2
3
Box<Fruit> fruitBox = new Box<Fruit>();
fruitBox.add(new Fruit()); // OK.
fruitBox.add(new Apple()); // OK. void add(Fruit item)


제한된 제네릭 클래스


타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만, 그래도 여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없습니다. 그렇다면, 타입 매개변수 T에 지정할 수 있는 타입의 종류를 제한할 수 있는 방법은 없을까요?

1
2
FruitBox<Toy> fruitBox = new FruitBox<Toy>();
fruitBox.add(new Toy());    // OK. 과일상자에 장난감을 담을 수 있습니다.

다음과 같이 제네릭 타입에 ‘extends’를 사용하면, 특정타입의 자손들만 대입할 수 있게 제한할 수 있습니다.

1
2
3
4
class FruitBox<T extends Fruit> {   // Fruit의 자손만 타입으로 지정가능
    ArrayList<T> list = new ArrayList<T>();
    ...
}

여전히 한 종류의 타입만 담을 수 있지만, Fruit클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것입니다.

1
2
FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK
FruitBox<Toy> toyBox = new FruitBox<Toy>();       // 에러. Toy는 Fruit의 자손이 아님

게다가 add()의 매개변수의 타입 T도 Fruit와 그 자손 타입이 될 수 있으므르, 아래와 같이 여러 과일을 담을 수 있는 상자가 가능하게 됩니다.

1
2
3
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple()); // OK. Apple이 Fruit의 자손
fruitBox.add(new Grape()); // OK. Grape Fruit의 자손

다형성에서 조상타입의 참조변수로 자손타입의 객체를 가리킬 수 있는 것처럼, 매개변수화된 타입의 자손타입도 가능합니다. 타입 매개변수 T에 Object를 대입하면, 모든 종류의 객체를 저장할 수 있게 됩니다. 만일 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요하다면, 이때도 ‘extends’를 사용합니다. ‘implements’를 사용하지 않는 다는 점에 주의해야합니다.

1
2
interface Eatable {}
class FruitBox<T extends Eatable> { ... }

클래스 Fruit의 자손이면서 Eatable인터페이스도 구현해야한다면 아래와 같이 ‘&’기호로 연결합니다.

1
class FruitBox<T extends Fruit & Eatable> { ... }

이제 FruitBox에는 Fruit의 자손이면서 Eatable을 구현한 클래스만 타입 매개변수 T에 대입될 수 있습니다.


와일드 카드


매개변수에 과일박수를 대입하면 주스를 만들어서 반환하는 Juicer라는 클래스가 있고, 이 클래스에는 과일을 주스로 만들어서 반환하는 makeJuice()라는 static메서드가 다음과 같이 정의되어 있다고 가정해봅니다.

1
2
3
4
5
6
7
class Juicer {
    static Juice makeJuice(FruitBox<Fruit> box) {  // <Fruit>으로 지정
        String tmp = "";
        for(Fruit f : box.getList()) tmp += f + " ";
        return new Juice(tmp);
    }
}

Juicer클래스는 제네릭 클래스가 아닌데다, 제네릭 클래스라고 해도 static메서드에는 타입 매개변수 T를 매개변수에 사용할 수 없으므로 아예 제네릭을 적용하지 않던가, 위와 같이 타입 매개변수 대신, 특정 타입을 지정해줘야 합니다.

1
2
3
4
5
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.makeJuice(fruitBox)); // OK. FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); // 에러. FruitBox<Apple>

이렇게 제네릭 타입을 ‘FruitBox'로 고정해 놓으면, 위의 코드에서 알 수 있듯이 'FruitBox'타입의 객체는 makeJuice()의 매개변수가 될 수 없으므로, 다음과 같이 여러 가지 타입의 매개변수를 갖는 makeJuice()를 만들 수 밖에 없습니다.

1
2
3
4
5
6
7
8
9
10
11
static Juice makeJuice(FruitBox<Fruit> box) {
    String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

static Juice makeJuice(FruitBox<Apple> box) {
    String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

그러나 위와 같이 오버로딩하면, 컴파일 에러가 발생합니다. 제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문입니다. 제네릭 타입은 컴파일러가 컴파일 할 때만 사용하고 제거합니다. 그래서 위의 두 메서드는 오버로딩이 아니라 ‘메서드 중복 정의’입니다. 이럴 때 사용하기 위해 고안된 것이 바로 ‘와일드 카드’입니다. 와일드 카드는 기호 ‘?’로 표현하는데, 와일드 카드는 어떠한 타입도 될 수 있습니다.

’?’만으로는 Object타입과 다를 게 없으므로, 다음과 같이 ‘extends’와 ‘super’로 상한(upper bound)과 하한(lower bound)을 제한할 수 있습니다.

<? extends T> : 와일드 카드의 상한 제한. T와 그 자손들만 가능 <? super T> : 와일드 카드의 하한 제한. T와 그 조상들만 가능 <?> : 제한 없음. 모든 타입이 가능. <? extends Object>와 동일

💡 참고
제네릭 클래스와 달리 와일드 카드에는 ‘&’를 사용할 수 없다. 즉, <? extends T & E>와 같이 할 수 없습니다.

와일드 카드를 사용해서 makeJuice()의 매개변수 타입을 FruitBox에서 FruitBox<? extends Fruit>으로 바꾸려면 아래와 같이 작성합니다.

1
2
3
4
5
static Juice makeJuice(FruitBox<? extends Fruit> box) {
    String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

이제 이 메서드의 매개변수로 FruitBox뿐만 아니라, FruitBox와 FruitBox도 가능해집니다.

1
2
3
4
5
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.makeJuice(fruitBox)); // OK. FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); // OK. FruitBox<Apple>

매개변수의 타입을 FruitBox<? extends Object>로 하면, 모든 종류의 FruitBox가 이 메서드의 매개변수로 가능해집니다. 대신, 전과 달리 box의 요소가 Fruit의 자손이라는 보장이 없으므로 아래의 for문에서 box에 저장된 요소를 Fruit타입의 참조변수로 못받습니다.

1
2
3
4
5
static Juice makeJuice(FruitBox<? extends Object> box) {
    String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " "; // 에러. Fruit이 아닐 수 있음.
    return new Juice(tmp);
}

그러나 실제로 테스트 해보면 문제없이 컴파일 되는데 그 이유는 제네릭 클래스 FruitBox를 제한했기 때문입니다.

1
class FruitBox<T extends Fruit> extends Box<T> {}

컴파일러는 위 문장으로부터 모든 FruitBox의 요소들이 Fruit의 자손이라는 것을 알고 있으므로 문제삼지 않는 것입니다.


제네릭 메서드


메서드의 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라고 합니다. 예를 들어 Collections.sort()가 제네릭 메서드이며, 제네릭 타입의 선언 위치는 반환 타입 바로 앞입니다.

1
static <T> void sort(List<T> list, Comparator<? super T> c)

제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것입니다. 같은 타입 문자 T를 사용해도 같은 것이 아니라는 것에 주의해야합니다.

1
2
3
4
5
6
class FruitBox<T> {
    ...
    static <T> void sort(List<T> list, Comparator<? super T> c) {
        ....
    }
}

위의 코드에서 제네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것입니다. 그리고 sort()가 static메서드라는 것에 주목해보면, 앞서 설명한 것처럼, static멤버에는 타입 매개변수를 사용할 수 없지만 이처럼 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능합니다.

메서드에 선언된 제네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해하기 쉬운데, 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관 없습니다.

앞서 나왔던 makeJuice()를 제네릭 메서드로 바꾸면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
static Juice makeJuice(FruitBox<? extends Fruit> box) {
    String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

                         ⬇️

static <T extends Fruit> Juice makeJuice(FruitBox<T> box) {
    String tmp = "";
    for(Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

이제 이 메서드를 호출할 때는 아래와 같이 타입 변수에 타입을 대입해야합니다.

1
2
3
4
5
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
FruitBox<Apple> appleBox = new FruitBox<Apple>();
...
System.out.println(Juicer.<Fruit>makeJuice(fruitBox));
System.out.println(Juicer.<Apple>makeJuice(appleBox));

그러나 대부분의 경우 컴파일러가 타입을 추정할 수 있기 때문에 생략해도 됩니다. 위의 코드에서도 fruitBox와 appleBox의 선언부를 통해 대입된 타입을 컴파일러가 추정할 수 있습니다.

1
2
System.out.println(Juicer.makeJuice(fruitBox)); // 대입된 타입을 생략할 수 있다.
System.out.println(Juicer.makeJuice(appleBox));

한 가지 주의할 점은 제네릭 메서드를 호출할 때, 대입된 타입을 생략할 수 없는 경우에는 참조변수나 클래스 이름을 생략할 수 없다는 것입니다.

1
2
3
System.out.println(<Fruit>makeJuice(fruitBox));         // 에러. 클래스 이름 생략불가
System.out.println(this.<Fruit>makeJuice(fruitBox));    // OK
System.out.println(Juicer.<Fruit>makeJuice(fruitBox));  // OK

같은 클래스 내에 있는 멤버들끼리는 참조변수나 클래스이름, 즉 ‘this.’이나 ‘클래스이름.’을 생략하고 메서드 이름만으로 호출이 가능하지만, 대입된 타입이 있을 때는 반드시 작성해주어야 합니다.


읽어주셔서 감사합니다. 😊

Reference
자바의 정석 - 남궁성
ChatGPT - OpenAI

This post is licensed under CC BY 4.0 by the author.