개발/Java

자바 제네릭(Generics)

Debin 2022. 12. 10.
반응형

백기선님이 과거에 진행했던 Java 스터디 14주차 스터디 입니다.

제네릭 사용법

제네릭은 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크를 해주는 기능이다.

객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어든다.

제네릭 클래스의 선언

class Box<T> { //제네릭 타입 T를 선언

    T item;
    
    void setItem(T item){ this.item = item; }
    T getItem() { return item; }
}
  • Box<T>에서 T를 타입 변수라고 하며, Type의 첫 글자에서 따온 것이다. T라고 적지 않아도 된다.
  • 타입 변수가 여러 개인 경우에는 Map<K, V>와 같이 콤마를 구분자로 나열하면 된다.
  • 기호의 종류만 다를 뿐 임의의 참조형 타입을 의미한다는 것은 모두 같다.
Box<String> b = new Box<String>(); //타입 T 대신, 실제 타입을 지정.
b.setItem(new Object()); //에러. String 이외의 타입은 지정 불가.
b.setItem("ABC"); // OK. String 타입이므로 기능
String item = b.getItem(); //형변환이 필요 없다.

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

class Box { //제네릭 타입 T를 선언

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

제네릭의 용어

  • class Box <T>
  • Box<T>는 제네릭 클래스다. T의 Box 또는 T Box라고 읽는다.
  • T 타입 변수 또는 타입 매개변수라고 한다.
  • Box는 원시 타입이라고 한다.

타입 문자 T는 지네릭 클래스 Box<T>의 타입 변수 또는 타입 매개변수라고 하는데, 메서드의 매개변수와 유사한 면이 있기 때문이다.

그래서 아래와 같이 타입 매개변수에 타입을 지정하는 것을 '지네릭 타입 호출'이라고 하고,

지정된 타입 'String'을 '매개변수화된 타입'이라고 한다. 

Box<String> b = new Box<String>();

//String 대입된 타입, 매개변수화된 타입
//Box<String> 제네릭 타입 호출

제네릭의 제한

모든 객체에 대해 동일하게 동작해야 하는 static 멤버에 타입 변수 T를 사용할 수  없다.

T는 인스턴스 변수로 간주되기 때문이다. static 멤버는 인스턴스 변수를 참조할 수 없다.

static 멤버는 타입 변수에 지정된 타입, 즉 대입된 타입의 종류에 관계 없이 동일한 것이어야 하기 때문이다.

즉 Box<Apple>.item과 Box<Grape>.item이 다른것이어서는 안된다는 뜻이다.

그리고 제네릭 타입의 배열을 생성하는 것도 허용되지 않는다.

제네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만, new T[10]과 같이 배열을 생성하는 것은 안된다는 뜻이다.

class Box<T>{
    static T item; //에러
    static int compare(T t1, T t2) { ... } //에러
    T[] itemArr; // OK.
    T[] toArray(){
        T[] tmpArr = new T[itemArr.length]; //에러
        return tmpArr;
    }
}

지네릭 배열을 생성할 수 없는 것은 new 연산자 때문인데, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야한다.

그런데 위의 코드에 정의된 Box<T> 클래스를 컴파일하는 시점에서는 T가 어떤 타입이 될지 전혀 알 수 없다.

instanceof 연산자도 new 연산자와 같은 이유로 T를 피연산자로 사용할 수 없다.

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

참조변수와 생성자에 대입된 타입(매개변수화된 타입)이 일치해야 한다. 일치하지 않으면 에러가 발생한다.

Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<Grape>(); //에러

두 타입이 상속관계에 있어도 마찬가지다. Apple이 Fruit의 자손이라고 가정하자.

Box<Fruit> appleBox = new Box<Apple>(); //에러. 상속 관계라고 해도 대입된 타입이 다르므로 안된다.

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

Box<Apple> appleBox = new FruitBox<Apple>();

JDK 1.7부터는 추정이 가능한 경우 타입을 생략할 수 있게 되었다.

참조변수의 타입으로부터 Box가 Apple 타입의 객체만 저장한다는 것을 알 수 있기 때문에,

생성자에 반복해서 타입을 지정해주지 않아도 되는 것이다. 아래 두 문장은 동일하다.

Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<>(); //JDK 1.7부터 생략가능

아래와 같은 경우는 가능하다.

Box<Fruit> fruitBox = new Box<Fruit>();
fruitBox.add(new Fruit());
fruitBox.add(new Apple()); //Apple클래스가 Fruit 클래스를 상속한다.

바운디드 컨텍스트(제한된 제네릭 클래스)

제네릭 타입에 extends를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있다.

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

Fruit 클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것이다.

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

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

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

다형성에서 조상타입의 참조변수로 자손 타입의 객체를 가리킬 수 있는 것처럼, 매개변수화된 타입의 자손 타입도 가능한 것이다.

타입 매개변수 T에 Object를 대입하면, 모든 종류의 객체를 저장할 수 있게 된다.

만일 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요하다면, 이때도 extends를 사용한다.

implements를 사용하지 않는다는 점을 기억하자.

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

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

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

와일드 카드

제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않는다.

제네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해버린다

이럴 때 사용하기 위해 고안된 것이 바로 와일드 카드다.

와일드 카드는 기호 '?'로 표현하는데, 와일드 카드는 어떠한 타입도 될 수 있다.

'?'만으로는 Object 타입과 다를게 없으므로,

다음과 같이 'extends'와 'super'로 상한 (upper bound)와 하한(lower bound)을 제한할 수 있다.

 

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

제네릭 메서드 만들기

메서드의 선언부에 제네릭 타입이 선언된 메서드를 제네릭 메서드라 한다.

Collections.sort()가 대표적인 제네릭 메서드다. 제네릭 타입의 선언 위치는 반환 타입 바로 앞이다.

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

제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것이다.

같은 타입 문자 T를 사용해도 같은 것이 아니라는 것에 주의하자.

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

제네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 제네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것이다.

static 멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 제네릭 타입을 선언하고 사용하는 것은 가능하다.

메서드에 선언된 제네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해하기 쉬운데,

이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관이 없다.

아래에 나오는 두 개의 코드는 동일하다.

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);
}

또한 타입 변수를 타입에 대입해야 한다.

그러나 대부분의 경우 컴파일러가 타입을 추정할 수 있다. 아래 코드를 통해 확인해보자.

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));

System.out.println(Juicer.makeJuice(fruitBox)); //타입 생략 가능
System.out.println(Juicer.makeJuice(appleBox));

참고로 제네릭 메서드를 호출 할 때, 대입된 타입을 생략할 수 없는 경우에는 참조변수나 클래스 이름을 생략할 수 없다고 한다.

같은 클래스 내에 있는 멤버들끼리는 참조변수나 클래스 이름을 생략하고 메서드 이름만으로 호출이 가능하지만,

대입된 타입이 있을 때는 반드시 써줘야 한다. 단지 기술적인 이유라고 한다.

즉 this나 클래스 이름을 생략하는 것이 불가능하다는 뜻이다.

Erasure

Erasure는 컴파일 시에만 제네릭을 적용하고, 런타임 시 요소 형식 정보를 삭제하는 프로세스로 설명할 수 있다.

컴파일러는 제네릭 타입을 이용해서 소스 파일을 체크하고, 필요한 곳에 형변환을 넣어준다. 그리고 제네릭 타입을 제고한다.

즉 컴파일된 목적 코드, 자바에서 .class 파일에서는 제네릭 타입에 대한 정보가 없다.

 

이렇게 하는 주된 이유는 제네릭이 도입되기 이전의 소스코드와의 호환성을 유지하기 때문이라고 한다.

기본적인 제거 과정은 다음과 같다.

 

  1. 제네릭 타입의 경계(bound)를 제거한다.
    제네릭 타입이 <T extends Fruit>라면 T는 Fruit로 치환된다. <T>인 경우는 Object로 치환된다.
    그리고 클래스 옆의 선언은 제거된다.
  2. 제네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.

이상으로 포스팅을 마칩니다. 감사합니다.


참고자료 

자바의 정석(남궁성)

https://www.baeldung.com/java-type-erasure

반응형

댓글