Java

Java. Generics 정리

에드박 2020. 11. 3. 11:00

 

Generics 란?

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

 

타입의 안정성을 높인다는 것은 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내올 때 원래의 타입과 다른 타입으로 잘못 형변환 되어 발생할 수 있는 오류를 줄여준다는 것입니다.
List list = new ArrayList();
list.add(new Integer(1));
int a = (Integer)list.get(0);

위와 같은 ArrayList와 같은 컬렉션 클래스에는 다음과 같은 단점이 있습니다.

  • 컬렉션 클래스에는 보통 한 종류의 객체를 담는 경우가 많음
  • 꺼낼 때 마다 타입체크를 하고 형변환을 해야함
  • 원하지 않는 종류의 객체가 포함되는것을 막을 수 없음

이러한 단점을 Generics 가 해결해줍니다.

 


Generics 의 장점

  • 타입 안정성을 제공 
  • 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해짐

 


Generic 클래스의 선언

Generic 타입은 클래스와 메서드에 선언할 수 있습니다. 다음의 Box 클래스는 Generic 클래스를 적용하기 전입니다.

 

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

 

이 클래스를 Generic 클래스로 변경하면 다음과 같이 클래스 옆에 '<T>'를 붙이면 됩니다.

그리고 클래스 내부의 Object를 모두 T로 변경하면 됩니다.

 

class Box<T> {
    T item;
    
    void setItem(T item) { this.item = item; }
    
    T getItem() { return item; }
    
}

Box<T> 에서 T는 타입 변수(Type Variable) 이라고 하며 Type의 첫글자를 따온 것입니다.

타입 변수는 꼭 T가 아닌 다른 것을 사용해도 됩니다.

 

ArrayList<E> 는 Element(요소) 의 첫 글자 E를 따온것이고

Map<K, V> 는 Key, Value의 각각 첫글자를 따온것입니다.

기호만 다를 뿐 임의의 참조형 타입을 의미한다는 것은 모두 같습니다.
상황에 맞게 의미 있는 문자를 선택해서 사용하는 것이 좋습니다.

 

Box<String> box = new Box<String>(); // 타입 T 대신, 실제 타입을 지정
b.setItem(new Object());			// 에러. String 이외의 타입은 지정 불가
b.setItem("ABC");				// String 타입 이므로 지정 가능합니다.
String item = b.getItem();			// 형변환 없이 사용가능합니다.

위의 코드는 타입 변수를 String 으로 지정해서 사용하는 코드입니다.

 


Generics 용어

class Box<T> { } 이처럼 Generic 클래스가 선언 되어있을 때 각 요소는 다음과 같습니다.

  • Box<T> : Generic 클래스. 'T의 Box' 또는 'T Box' 라고 읽습니다.
  • T : 타입 변수 또는 타입 매개변수
  • Box : 원시 타입 (raw type)

Box<String> b = new String<Box> 이 코드에서 String 은 매개변수화된 타입(parameterized type) 이라고 합니다.

 


Generics의 제한

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

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

 

class Box<T> {
    static T item;  // 에러, static멤버에 T라는 인스턴스 변수를 참조할 수 없음
    static int compare(T t1, T t2) {...}  // 에러, static멤버에 T라는 인스턴스 변수를 참조할 수 없음
}

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

static 멤버인 item 이 Box<Apple>.item 과 Box<Grape>.item이 서로 다른 것이어서는 안된다는 것입니다.

 

Generics 는 배열도 생성할 수 없습니다.

 

class Box<T> {
    T[] itemArr;    // OK. T타입 배열을 위한 참조변수
    
    T[] toArray() {
        T[] tmpArr = new T[itemArr.length];    // 에러. Generic배열 생성불가
    	
        return tmpArr;
    }
}

new 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야 합니다. 그런데 위의 코드에 정의된 Box<T>클래스를 컴파일 하는 시점에는 T가 어떤 타입일지는 알 수 없습니다.

 

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

 

꼭 Generic 배열을 사용해야 한다면 new 연산자 대신 'Reflection API'의 newInstance()와 같이 동적으로 객체를 생성하는 메서드로 배열을 생성하거나, Object배열을 생성해서 복사한 다음에 'T[ ]' 롷 형변환하는 방법 등을 사용합니다.

 


Generic 클래스의 객체 생성과 사용

만약 Box<T> 의 객체를 생성할 때는 참조변수와 생성자에 대입된 타입(매개변수화된 타입)이 일치해야 합니다.

 

Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<Grape>(); // 에러, 대입된 타입이 다릅니다.

만약 매개변수화된 타입이 상속 관계라면?

 

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

상속 관계라도 에러가 납니다.

 

단, 두 Generic 클래스 타입(원시 타입)이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮습니다.

 

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

 

JDK 1.7 부터는 추정이 가능한 경우 생성자의 타입 생략이 가능합니다.

 

Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<>(); // JDK 1.7부터 생성자의 매개변수화 타입 생략가능

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

 

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

하지만 타입 T 가 'Fruit'인 경우, 'void add(Fruit item)' 이 되므로 Fruit의 자손들은 이 메서드의 매개변수가 될 수 있습니다. Apple, Grape가 Fruit의 자손이라고 가정했습니다.

 

Box<Fruit> fruit = new Box<Fruit>();
fruitBox.add(new Fruit());    // OK
fruitBox.add(new Apple());    // OK, void add(Fruit item)
fruitBox.add(new Grape());    // OK

 


Generic 클래스 타입 제한하기

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

 

위의 코드와 같이 과일 상자에 장난감을 담을 수 있다면 과일 상자의 의미가 없어집니다. 그렇다면 타입의 종류를 제한하는 방법을 알아보겠습니다.

 

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

 

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

// add() 의 매개변수 타입 T도 Fruit와 그 자손 타입이 될 수 있으므로, 
// 아래와 같이 여러 과일을 담을 수 있는 상자가 가능하게 됩니다.
FruitBox<Fruit> fruitBox = new FruitBox<Fruit>()
fruitBox.add(new Apple());    // OK, Apple이 Fruit의 자손
fruitBox.add(new Grape());    // OK, Grape가 Fruit의 자손

 

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

 

interface Eatable() { }
class Fruit<T extends Eatable> { .... }

 

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

 

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

 


 

와일드 카드

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

 

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

 

Juicer는 Generic클래스가 아니고 맞다고 해도 static메서드에는 타입 매개변수 T를 사용할 수 없습니다.

그래서 위와 같이 타입 매개변수 대신 특정 타입을 지정해줘야 합니다.

 

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
AppleBox<Apple> appleBox = new FruitBox<Apple>();

System.out.println(Juicer.makeJuice(fruitBox)); // OK, FruitBox<Fruit>
System.out.println(Juicer.makeJuice(appleBox)); // 에러, FruitBox<Apple>은 makeJuice의 매개변수 타입 FruitBox<Fruit> 과 다릅니다.

이렇게 Generic 매개변수 타입을 FruitBox<Fruit> 로 고정하면 FruitBox<Apple> 은 makeJuice의 매개변수가 될 수 없습니다. 그렇다고 다음과 같이 오버로딩 하여 여러개의 메서드를 만들면 어떻게 될까요?

 

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

 

위와 같이 오버로딩하면 컴파일 에러가 발생합니다. Generic 타입이 다른것만으로는 오버로딩이 성립하지 않고 메서드 중복 정의가 됩니다. 이를 위해 만들어 진것이 '와일드 카드' 입니다.

 

와일드 카드는 기호 "?"로 표현합니다. 와일드 카드는 어떤 타입도 될수 있습니다. 그렇다면 Object타입과 다를 게 없으므로 다음과 같이 "extends" 와 "super"로 상한과 하한을 제한할 수 있습니다.

 

<? extends T> 와일드 카드의 상한 제한, T와 그 자손들만 가능
<? super T> 와일드 카드의 하한 제한, T와 그 조상들만 가능
<?> 제한없음, 모든 타입이 가능 <? extends Object> 와 같습니다.
Generic 클래스와 달리 와일드 카드에는 '&' 를 사용할 수 없습니다. 즉, <? extends T & E>와 같이 할 수 없습니다.

 

와일드 카드를 사용해서 makeJuice()를 다음과 같이 변경할 수 있습니다.

 

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

 

이제 이 메서드의 매개변수로 FruitBox<Fruit> 뿐만 아니라, FruitBox<Apple>과 FruitBox<Grape>도 가능하게 됐습니다.

 

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

 

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

그러나 실제로 문제없이 컴파일 되는데 그 이유는 바로 FruitBox 의 Generic 클래스에서 매개변수 타입으로 FruitBox를 제한했기 때문입니다. 

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

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

 


Generic 메서드

 

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

 

'static' 옆에 있는 '<T>'는 메서드에 선언된 Generic 타입입니다. 메서드의 선언부에 Generic 타입이 선언된 메서드를 Generic 메서드 라고 합니다. Generic 클래스에 선언된 타입 매개변수와 Generic 메서드에 선언된 타입 매개변수는 별개의 것입니다.

 

class FruitBox<T> {
    ...
    
    static <T> void sort(List<T> list, Comparator<? super T> c) {
        ... // FruitBox<T> 의 T와 메서드 선언부의 static <T>의 T는 서로 다른 타입 매개변수 입니다.
    }
}

 

static 은 타입 매개변수를 사용할 수 없지만 위와 같이 메서드에 Generic 타입을 선언하고 사용하는 것은 가능합니다.

 

메서드에 선언된 Generic 타입은 지역 변수를 선언한것 처럼 지역적으로 사용될 것이므로 메서드가 static이건 아니건 상관이 없습니다.

 

앞에서 나왔던 Juicer클래스의 makeJuice() 메서드를 Generic 메서드로 변경하면 다음과 같습니다.

 

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

 

하지만 대부분의 경우 컴파일러가 타입을 추정할 수 있기 때문에 생략해도 됩니다.

위의 코드는 fruitBox 와 appleBox의 선언부를 통해 대입된 타입을 컴파일러가 추정할 수 있어서 아래와 같이 생략할 수 있습니다.

 

System.out.println(Juicer.makeJuice(fruitBox));
System.out.println(Juicer.makeJuice(appleBox));

 

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

 

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

 

 


Generic 타입의 형변환

 

Generic 타입과 non-Generic 타입같의 형변환은 항상 가능합니다. (경고가 발생할 뿐)

 

Box box = null;
Box<Object> objBox = null;

box = (Box)objBox;               // 가능, Generic 타입 -> 원시타입. 경고발생
objBox = (Box<Object>)box;       // 가능, 원시 타입 -> Generic 타입. 경고발생

 

그렇다면 아래의 코드는 형변환이 이루어 질까요?

 

Box<? extends Object> wBox = new Box<String>();

 

형변환이 됩니다. makeJuice메서드에서도 매개변수에 다형성이 적용될 수 있었습니다.