12-1. 제네릭스

Hyun Jun·2022년 2월 3일
0

자바의 정석

목록 보기
41/52
post-thumbnail
post-custom-banner

제네릭스 (Generics)

제네릭스의 개념

다양한 타입의 객체를 다루는 메서드나 컬렉션 클래스에 compile-time type check를 해주는 기능.

  • 타입 안정성 제공

  • 타입체크와 형변환의 생략을 통한 코드의 간결화

 

제네릭 클래스의 선언

클래스에 선언하기

아래와 같은 일반적인 클래스를

class Dummy {
    Object item;

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

타입 변수를 활용해 제네릭 클래스로 변경하면

class Dummy<T> {
    T item;

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

이렇게 클래스명 옆에 타입변수 T가 붙고, 인스턴스를 생성할 때 마치 함수의 파라미터처럼 T에 실제 사용할 타입을 받아와 클래스 내부의 타입변수 T들을 전부 실제 타입으로 치환시켜줌.

T는 Type에서 앞글자를 따온 것일 뿐이므로, 다른 글자를 써도 상관 없음.

ex) ArrayList<E> (E는 Element의 첫 글자에서 따옴)

타입변수가 여러개인 경우 콤마(,)로 구분하여 나열.

ex) Map<K, V> (K는 Key, V는 Value의 첫 글자에서 따옴)

결국 타입변수도 변수이므로 상황에 따라 이름만 다를 뿐, 임의의 참조형 타입이라는 의미는 모두 동일함

 

예시 클래스로 인스턴스를 생성해보자.

Dummy<String> d = new Dummy<String>(); // 타입변수 T 자리에 String 대입
d.setItem(new Object()); // (X)
d.setItem("Hello"); // (O)

타입변수에 TString을 대입함으로써 오로지 String 타입의 item만 받을 수 있게됨.

JDK의 하위 호환성을 위해 제네릭 클래스여도 제네릭 타입없이 인스턴스 생성이 허용됨.

Dummy d = new Dummy(); // Raw use of parameterized class 'Dummy'
d.setItem(new Object()); // Unchecked call to 'setItem(T)' as a member of raw type 'Dummy'
d.setItem("Hello"); // Unchecked call to 'setItem(T)' as a member of raw type 'Dummy'

타입 변수에 타입을 지정해주지 않았기 때문에 Raw use, Raw type라고 경고가 표시됨.

T와 같이 아직 타입이 지정되지 않은 타입변수를 Raw Type이라고 부름.

이들은 compile 시 Object 타입으로 간주됨.

 

제네릭스의 용어

class Dummy<T> { ... }
  • Dummy<T>: 제네릭 클래스

  • T: 타입 변수 (타입 매개변수)

  • Dummy: 원시 타입(raw type)

Dummy<String> d = new Dummy<String>();
  • Dummy<String>: 제네릭 타입 호출

  • String: 매개변수화된 타입 (parameterized type)

 

제네릭스의 제한

  1. 타입 변수 T는 인스턴스 변수로 간주되기 때문에 static 멤버에는 사용할 수 없음.

    class Dummy<T> {
        static T item; // (X)
        static int compare(T t1, T t2) { ... } // (X)
        ...
    }

 

  1. 제네릭 타입의 배열은 직접 생성할 수 없음.

    class Dummy<T> {
        T[] itemArr; // (O) 제네릭 배열 타입의 참조변수 선언은 가능.
    
        T[] toArray() {
            T[] tmpArr = new T[itemArr.length]; // (X) 제네릭 배열을 직접 생성하는 것은 불가능.
            ...
        }
    }

    new 연산자는 heap 메모리 영역에 제시된 타입에 맞는 충분한 메모리가 있는지 확인하고 공간을 확보하는 역할을 함.

    그런데 compile time에는 T가 무슨 타입이 될지 알 수 없기 때문에 new가 올바른 역할 수행을 할 수 없음.

    instanceof 연산자도 compile time에 T가 무슨 타입인지 알 수 없기 때문에 T를 피연산자로 사용할 수 없음.

 

제네릭 클래스의 객체 생성과 사용

  1. 참조변수에 대입된 타입과 생성자에 대입된 타입이 일치해야함.

    Dummy<String> d1 = new Dummy<String>(); // (O)
    Dummy<String> d2 = new Dummy<Integer>(); // (X)
    Dummy<Number> d3 = new Dummy<Integer>(); // (X)

    3번 케이스처럼 두 타입이 상속관계에 있더라도 안됨.

 

  1. 두 제네릭 클래스가 상속관계이고, 대입된 타입이 같으면 괜찮음.

    Parent<String> p = new Child<String>();

    제네릭 클래스 간의 다형성은 O, 제네릭 타입 간의 다형성은 X

 

  1. 추정이 가능한 경우에는 타입 생략이 가능함. (JDK 1.7부터)

    Dummy<Integer> d = new Dummy<>();
    // Dummy<Integer> d = new Dummy<Integer>();

    참조변수의 타입으로부터 Integer타입이 대입된다는 것을 알고 있기 때문에 생성자의 타입은 생략하여 <>로 표시

 

  1. 제네릭 클래스의 메서드의 매개변수 타입이 타입변수로 되어있을 때, 인스턴스에 대입된 타입과 다른 타입의 객체는 넘길 수 없으나, 대입된 타입의 자손 타입 객체는 넘길 수 있음.

    Dummy<ParentType> d = new Dummy<ChildType>();
    d.add(new OtherType()); // (X)
    d.add(new ParentType()); // (O)
    d.add(new ChildType()); // (O)

    Dummy<T> 클래스에 객체를 추가하는 메서드 add(T item)가 있다고 가정

 

제한된 제네릭 클래스

extends 키워드를 통해 타입 매개변수 T에 지정할 수 있는 타입의 종류를 좁힐 수 있음.

class Dummy<T extends ParentType> { // T에 ParentType 혹은 그 자손 타입만 대입 가능
    ...
}
Dummy<ParentType> d1 = new Dummy<ParentType>(); // (O)
Dummy<ChildType> d2 = new Dummy<ChildType>(); // (O)
Dummy<OtherType> d3 = new Dummy<OtherType>(); // (X) 자손이 아닌 경우는 대입 불가.

클래스가 아니라 인터페이스를 구현하는 타입 변수일 때도 extends를 사용

interface Thinkable { ... }
class Dummy<T extends Thinkable> { ... }

implements가 아님에 주의

특정 클래스의 자손이면서 특정 인터페이스를 구현하는 타입 변수는 & 기호 사용

class Dummy<T extends ParentType & Thinkable> { ... }

 

와일드 카드

제네릭 클래스의 인스턴스를 메소드의 매개변수로 받을 때, 그 인스턴스의 타입 변수를 제한적인 범위 내에서 여러 가지로 받을 수 있게 해주는 기능.

class Juicer {
    static Juice makeJuice(FruitBox<Fruit> b) {
        ...
    }
}

makeJuice 메서드의 매개변수는 FruitBox<Fruit> 타입만을 받을 수 있음.

Fruit의 자손으로 AppleOrange가 있을 때, 이들을 대입하고 싶어도 허용되지 않음.

Juicer.makeJuice(new Fruitbox<Apple>()); // (X)
Juicer.makeJuice(new Fruitbox<Orange>()); // (X)
Juicer.makeJuice(new Fruitbox<Fruit>()); // (O)

다른 제네릭 타입도 허용하려고 오버로딩을 시도해도

static Juice makeJuice(FruitBox<Fruit> b) {
        ...
    }

static Juice makeJuice(FruitBox<Apple> b) {
    ...
}

제네릭은 컴파일 할 때만 사용되고 제거되기 때문에 위처럼 작성하면 정상적인 오버로딩이 아니라 메서드 중복 정의가 되어버림.

이런 문제점을 해소하기 위해 와일드 카드라는 개념이 등장함.

 

와일드 카드는 ? 기호로 나타내고, 3가지 종류가 있음

  1. <? extends T>

    와일드 카드의 상한 제한. T와 그 자손들만 허용

  2. <? super T>

    와일드 카드의 하한 제한. T와 그 조상들만 허용

  3. <?>

    제한 없음. 모든 타입 허용

    <? extends Object>와 동일 (Object와 그의 자손들 = 모든 클래스)

 

다시 makeJuice() 메서드로 돌아와 와일드 카드를 적용하면,

static Juice makeJuice(FruitBox<? extends Fruit> b) {
    ...
}

이제는 타입 변수에 Fruit 뿐만 아니라 Apple이나 Orange도 대입 가능해짐

 

제네릭 메서드

선언부에 제네릭 타입이 선언된 메서드.

 

ex) Collections.sort()

static 바로 오른쪽에 붙은 <T>가 바로 이 메서드의 제네릭 타입

static <T> void sort(List<T> list, Comparator<? super T> c) { ... }

 

제네릭 메서드의 타입변수와 제네릭 클래스의 타입변수는 완전히 별개의 것이므로 같은 이름을 쓰고 있을지라도 구별해서 생각해야함.

class Dummy<T> {
    ...
    static <T> void sort(List<T> list, Comparator<? super T> c) { ... }
}

앞서 봤던 규칙에서 static 멤버에는 타입변수를 사용할 수 없다고 했는데, 이처럼 static 메서드일지라도 제네릭 타입을 선언해 제네릭 메서드로서 사용하는 것은 가능함.

제네릭 메서드에서 사용하는 제네릭 타입은 지역변수처럼 메서드의 스코프 내에서만 사용 가능.

 

Juicer클래스의 makeJuice() 메서드를 제네릭 메서드로 변경해보자

static Juice makeJuice(FruitBox<? extends Fruit> b) {
    ...
}
static <T extends Fruit> Juice makeJuice(FruitBox<T> b) {
    ...
}

이제 makeJuice()를 호출할 때는 제네릭 메서드의 타입 변수에 타입을 대입해야함.

FruitBox<Apple> appleBox = new FruitBox<>();
FruitBox<Orange> orangeBox = new FruitBox<>();

Juicer.<Apple>makeJuice(appleBox); // 메서드 이름 앞에 제네릭 타입 지정
Juicer.<Orange>makeJuice(orangeBox);

컴파일러가 appleBoxorangeBox의 선언부를 보고 대입될 타입을 알 수 있기 때문에 제네릭 메서드 앞에 대입된 타입은 생략 가능.

Juicer.makeJuice(appleBox);
Juicer.makeJuice(orangeBox);

원래 같은 클래스 내에서 메서드를 호출할 때는 메서드 앞에 this.클래스명.을 생략해도 되지만, 만약 그 메서드가 제네릭 메서드라서 대입된 타입을 명시적으로 적어주려는 경우엔 this.클래스명.을 생략할 수 없음.

<Apple>makeJuice(appleBox); // (X) 제네릭 메서드에 대입된 타입을 적어줄 때는 this나 클래스명 생략 불가
this.<Apple>makeJuice(appleBox); // (O)
Juicer.<Apple>makeJuice(appleBox); // (O)

 

제네릭 메서드를 이용하면 복잡한 매개변수의 타입을 간결하게 표현 가능.

public static void printAll(ArrayList<? extends Product> list1, ArrayList<? extends Product> list2) { ... }
public static <T extends Product> void printAll(ArrayList<T> list1, ArrayList<T> list2) { ... }

 

제네릭 타입의 형변환

  1. 제네릭 타입과 원시 타입(raw type) 간의 형변환

    Box box = null;
    Box<String> strBox = null;
    
    box = (Box) strBox; // (O)
    // box = strBox; 로 형변환 생략 가능
    strBox = (Box<String>) box; // (O) Unchecked cast: 'Box' to 'Box<java.lang.String>'

    양쪽 방향 다 형변환이 가능은 하지만, "원시 타입 → 제네릭 타입"은 경고 메시지가 동반됨.

    원시 타입인 경우 <Object>로 취급해서 모든 타입을 다 수용할 수 있게 되는데 만약 Integer 같은게 들어오면 <String>으로 형변환이 불가능하기 때문

 

  1. 대입된 타입이 다른 제네릭 타입 간의 형변환

    Box<Object> objBox = null;
    Box<String> strBox = null;
    
    objBox = (Box<Object>) strBox; // (X) Inconvertible types; cannot cast 'Box<java.lang.String>' to 'Box<java.lang.Object>'
    strBox = (Box<String>) objBox; // (X) Inconvertible types; cannot cast 'Box<java.lang.Object>' to 'Box<java.lang.String>'

    양쪽 다 불가능하고, 두 타입의 관계가 상속관계여도 허용되지 않음.

    와일드 카드가 아닌 제네릭 타입 간의 다형성은 허용되지 않는다는 점을 기억하자.

 

  1. 제네릭 타입과 와일드 카드 타입 간의 형변환

    FruitBox<Apple> appleBox = new FruitBox<>();
    FruitBox<? extends Fruit> wildCardBox = (FruitBox<? extends Fruit>) appleBox; // (O)
    // FruitBox<? extends Fruit> wildCardBox = appleBox; 로 형변환 생략 가능
    FruitBox<? extends Fruit> wildCardBox = new FruitBox<>();
    FruitBox<Apple> appleBox = (FruitBox<Apple>) wildCardBox; // (O) Unchecked cast: 'FruitBox<capture<? extends Fruit>>' to 'FruitBox<Apple>'

    양쪽 방향 다 형변환이 가능하지만, "와일드카드 타입 → 제네릭 타입"은 경고 메시지가 동반됨.

    만약 와일드 카드 안에 Apple이 아닌 다른 종류의 Fruit 자손이 들어가버리면 형변환이 불가능해지기 때문

 

2번처럼 금지된 형변환은 와일드 카드를 이용해서 가능하게 만들 수 있음. (대신 Unchecked cast 경고가 뜸)

Dummy<Object>Dummy<String> 은 불가능하지만,

Dummy<Object>Dummy<?>Dummy<String> 은 가능.

 

제네릭 타입의 제거

컴파일러는 컴파일 타임 동안 제네릭을 참고하여 소스파일을 체크하며 필요한 곳에 프로그래머를 대신해 형변환 작업을 해주고 난 뒤에 모든 제네릭을 제거함. (Type Erasure)

제네릭이 없던 시절 원시 타입으로만 짜여진 레거시 코드와의 호환성 때문

따라서 컴파일이 완료된 파일(.class)에는 제네릭이 존재하지 않음.

 

제네릭 제거 과정

  1. 경계 제거

    제네릭 클래스의 선언부에 있는 타입을 확인해보고,

    <T extends Fruit>이라면 T를 전부 Fruit으로 치환

    <T>라면 T를 전부 Object로 치환

    class FruitBox<T extends Fruit> {
        void add(T item) {
            ...
        }
    }
    class FruitBox {
        void add(Fruit item) {
            ...
        }
    }

 

  1. 형변환 추가

    내부에서 필요한 곳에 형변환을 추가함

    예컨대 메서드의 반환타입이 있던 TFruit으로 치환된 상태에서는 반환 값에 적절한 형변환이 필요

    T get(int index) {
        return list.get(index);
    }
    Fruit get(int index) {
        return (Fruit) list.get(index);
    }
profile
Back-end Engineer 👨‍💻
post-custom-banner

0개의 댓글