[Java][Generics] 지네릭스(Generics)

2022. 7. 22. 15:25JAVA/Language

1. 지네릭스란 무엇인가?

지네릭스는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크(comptile-time type check)를 해주는 기능입니다.

 

지네릭스의 장점

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

예를 들어 ArrayList 컬렉션 클래스에 타입을 지정하여 해당 클래스 타입의 인스턴스만을 담을 수 있습니다.

ArrayList<String> list = new ArrayList<>();
list.add("a");
list.add("b");
list.add("c");

 

2. 지네릭 클래스의 선언

어떤 클래스를 지네릭 클래스로 변경하고자 한다면 다음과 같이 선언할 수 있습니다.

class Box<T>{ // 지네릭 타입 T를 선언
    T item;
    
    void setItem(T item){ this.item = item;}
    T getItem() { return item;}
}

 

지네릭스의 용어

class Box<T>{}
  • Box<T> : 지네릭 클래스, 'T의 Box' 또는 'T Box'라고 읽습니다.
  • T : 타입 변수 또는 타입 매개변수. (T는 타입문자)
  • Box : 원시타입(raw type)

 

지네릭스의 제한

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

    * 추후에 소개될 지네릭 메서드를 사용하여 정적 메서드를 지네릭 메서드로 정의하면 타입 변수 T를 사용할 수는 있습니다.

class Box<T>{
    static T item; // 에러
    static int compare(T t1, T t2)) { ... } // 에러
}

 

2. 지네릭 타입의 배열을 생성하는 것을 허용하지 않습니다. 지네릭 배열 타입의 참조변수를 선언하는 것은 가능하지만 'new T[10]'와 같이 배열을 생성할 수는 없습니다.

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

 

3. 지네릭 클래스의 객체 생성과 사용

지네릭 클래스 Box<T>가 다음과 같이 정의되었다고 가정합니다.

class Box<T>{
    ArrayList<T> list = new ArrayList<>();
    
    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<Apple> appleBox = new Box<Apple>();

 

지네릭 클래스의 객체 생성시 주의사항

1. 참조변수와 생성자에 대입된 타입(매개변수화된 타입)이 일치해야합니다.

Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<Grape>(); // Error

 

2. 참조변수와 생성자에 대입 타입(매개변수화된 타입)이 상속관계여도 에러가 발생합니다.

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

타입 변수의 Fruit 클래스는 Apple 클래스의 조상 클래스로써 상속 관계임에도 대입할 수 없습니다.

 

3. 두 지네릭 클래스의 타입이 상속관계에 있고, 대입된 타입이 같은 것은 괜찮습니다.

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

Box 클래스는 FruitBox 클래스의 조상 클래스이고 타입 변수의 클래스 타입이 동일하기 때문에 대입이 가능합니다.

 

4. 생성된 지네릭 클래스 타입의 객체에 'void add(T item)'과 같은 메서드로 객체를 추가할때, 대입된 타입과 다른 타입의 객체를 추가할 수 없습니다.

Box<Apple> appleBox = new Box<Apple>();
appleBox.add(new Apple()); // OK.
appleBox.add(new Grape()); // Error. Box<Apple>에는 Apple 객체만 추가 가능합니다.

 

5. 생성된 지네릭 클래스 타입의 객체에 'void add(T item)'과 같은 메서드로 객체를 추가할때, 타입 변수 T 타입을 포함한 자손 클래스들은 이 메서드의 매개변수가 될 수 있습니다.

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

Fruit 클래스는 Apple 클래스의 조상 클래스로써 상속 관계에 있습니다.

 

4. 제한된 지네릭 클래스

타입 매개변수 T에 타입의 종류 제한

class FruitBox<T extends Fruit>{
    ArrayList<T> list = new ArrayList<>();
}

지네릭 타입에 'extends'를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있습니다. 예를 들어 FruitBox 지네릭 클래스의 타입 변수를 '<T extends Fruit>'와 같이 설정하면 타입 변수 T는 Fruit 클래스 타입을 포함한 자손 클래스만을 인스턴스 생성할 수 있습니다.

 

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

Apple 클래스는 Fruit 클래스의 자손 클래스이기 때문에 Fruit 지네릭 클래스의 인스턴스를 생성할 수 있습니다. 그러나 Toy 클래스는 Fruit 클래스의 자손 클래스가 아니기 때문에 Fruit 지네릭 클래스의 인스턴스를 생성할 수 없습니다.

 

타입 매개변수 T에 인터페이스 구현 제한

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

 

타입 매개변수 T에 클래스 및 인터페이스 구현 제한

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

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

 

5. 와일드 카드

5.1 와일드 카드의 필요성

일반적으로 static 멤버(필드멤버, 메서드)는 타입 변수 T를 적용할 수 없습니다. 만약 타입 변수를 적용하고 싶다면 특정한 타입을 지정해주어야 합니다.

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

위 클래스 정의를 기반으로 makeJuice 메서드를 호출한다면 다음과 같이 호출할 수 있습니다.

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)); // Error. FruitBox<Apple>

위 코드에서 appleBox의 제너릭 클래스 타입의 타입 변수가 Apple이기 때문에 Fruit 클래스의 자손 클래스라도 대입할 수없습니다. makeJuice 메서드에 appleBox 인스턴스를 대입하는 것을 풀어서 설명하면 다음과 같습니다.

FruitBox<Apple> appleBox = new FruitBox<Apple>();
Juicer.makeJuice(appleBox);
FruitBox<Fruit> box = new FruitBox<Apple>(); // Error. 타입 변수가 다르기 때문에 안됨

위와 같이 지네릭 타입을 'FruitBox<Fruit>'으로 고정해 놓으면, 위의 코드에서 알 수 있듯이 'FruitBox<Apple>' 타입의 객체는 makeJuice()의 매개변수가 될 수 없으므로, 다음과 같이 여러 가지 타입의 매개변수를 갖는 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);
}

 그러나 위와 같이 오버로딩하면 컴파일 에러가 발생합니다. 지네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문입니다. 지네릭 타입은 컴파일러가 컴파일할때만 사용하고 제거해버립니다. 그래서 위의 두 메서드는 오버로딩이 아니라 '메서드 중복 정의'입니다.

 

위와 같은 문제를 해결하기 위해서 '와일드 카드'를 사용할 수 있습니다.

 

5.2 와일드 카드의 개념

  • 와일드 카드는 기호 "?"로 표현하고 어떤한 타입도 될 수 있습니다.
  • 와일드 카드는 'extends'와 'super' 키워드로 상한(upper bound)과 하한(lower bound)을 제한할 수 있습니다.
<? extends T> 와일드 카드의 상한 제한. T와 그 자손들만 가능
<? super   T> 와일드 카드의 하한 제한. T와 그 조상들만 가능
<?>           제한 없음. 모든 타입이 가능. <? extends Object>와 동일

 

5.3 와일드 카드를 적용한 매개변수화된 타입 제한

와일드 카드를 사용해서 makeJuice()의 매개변수 타입을 Fruit 클래스 타입을 포함한 자손 클래스로 제한하면 다음과 같이 표현할 수 있습니다.

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> 타입도 makeJuice 메서드에 매개변수로 전달할 수 있습니다.

FruitBox<Friut> 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>

 

5.4 Collections.sort 메서드를 통한 와일드 카드 분석

Collections 클래스의 sort 메서드는 다음과 같이 정의되어 있습니다.

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

위의 코드에서 타입 매개변수 T에 Apple 클래스 타입을 적용하면 다음과 같습니다.

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

위 메서드 정의에서 Comparator<? super Apple> c의 의미는 Comparator의 타입 매개변수로 Apple 클래스과 그 조상이 가능하다는 뜻입니다. 이는 Comparator<Apple>, Comparator<Fruit>, Comparator<Object> 주으이 하나가 두번째 매개변수로 올 수 있다는 의미입니다.

 

Collections.sort 메서드의 매개변수 중 Comparator<? super T> c를 넣기 위해서는 다음과 같이 Comparator 인터페이스를 구현할 수 있습니다.

class FruitComp implements Comparator<Fruit>{
    public int compare(Fruit t1, Fruit t2){
        return t1.weight - t2.weight;
    }
}

// List<Apple>과 List<Grape>들 모두 Comparator<Fruit>으로 정렬이 가능합니다.
Collections.sort(appleBox.getList(), new FruitComp());
Collections.sort(grapeBox.getList(), new FruitComp());

 

6. 지네릭 메서드

지네릭 메서드 개념 및 특징

  • 지네릭 메서드란 메서드의 선언부에 지네릭 타입이 선언된 메서드를 의미합니다.
  • 대표적인 지네릭 메서드에는 Collections.sort()가 있습니다.
  • 지네릭 타입의 선언 위치는 반환 타입 바로 앞입니다.
static <T> void sort(List<T> list, Comparator<? super T> c)

 

 

  • 지네릭 클래스에 정의된 타입 매개변수와 지네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것입니다.
class FruitBox<T>{
    static <T> void sort(List<T> list, Comparator<? super T> c){
        ...
    }
}

위의 코드에서 지네릭 클래스 FruitBox에 선언된 타입 매개변수 T와 지네릭 메서드 sort()에 선언된 타입 매개변수 T는 타입 문자만 같을 뿐 서로 다른 것입니다. 정적 메서드에 사용되는 타입 매개변수 T는 임시적으로 사용하는 지역 변수처럼 메서드가 호출되는 동안 잠시 지네릭 효과를 내기 위해서 사용하는 것입니다. 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것이므로 메서드가 정적이건 아니건 상관 없습니다.

 

기존 정적 메서드에 와일드 카드를 적용하여 타입 매개변수를 적용한 것을 지네릭 메서드로 변환하면 다음과 같습니다.

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

위와 같이 지네릭 메서드의 타입 매개변수를 명시해도 되지만 대부분의 경우 컴파일러가 타입을 추정할 수 있기 때문에 타입 매개변수 명시를 생략해도 됩니다.

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

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

 

7. 지네릭 타입의 형변환

지네릭 타입과 넌지네릭(non-generic) 타입간의 형변환

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

box    = (Box) objBox;      // OK. 지네릭 타입 -> 원시   타입. 경고 발생
objBox = (Box<Object>) box; // OK. 원시   타입 -> 지네릭 타입. 경고 발생

지네릭 타입과 넌지네릭 타입간의 형변환은 가능합니다. 단, 경고는 발생합니다.

 

대입된 타입이 다른 지네릭 타입간의 형변환

Box<Object> objBox = null;
Box<String> strBox = null;

objBox = (Box<Object>) strBox; // Error. Box<String> -> Box<Object>
strBox = (Box<String>) objBox; // Error. Box<Object> -> Box<String>

대입된 타입이 다른 지네릭 타입간의 형변환은 불가능합니다. 대입되는 매개변수 타입(String)이 Object 클래스의 자손일지라도 안됩니다.

Box<Object> objBox = new Box<String>(); // Error. 형변환 불가능
=>
Box<Object> objBox = (Box<Object>) new Box<String>();

 

와일드 카드를 적용하여 대입된 타입이 다른 지네릭 타입간의 형변환

다음과 같이 와일드 카드를 적용하여 형변환을 가능하게 합니다.

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

위와 같이 와일드 카드를 적용하여 형변환을 수행하는 것은 메서드의 매개변수 전달에서 다형성이 적용되는 것과 동일합니다.

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

Juicer.makeJuice(new FruitBox<Fruit>());
Juicer.makeJuice(new FruitBox<Apple>());
Juicer.makeJuice(new FruitBox<Grape>());
=> 로직을 풀면 다음과 같습니다.
FruitBox<? extends Fruit> box = new FruitBox<Fruit>();
FruitBox<? extends Fruit> box = new FruitBox<Apple>();
FruitBox<? extends Fruit> box = new FruitBox<Grape>();

 

와일드 카드를 적용한 지네릭 클래스 참조변수를 매개변수 타입이 정해진 지네릭 클래스로 형변환

FruitBox<? extends Fruit> box = null;

FruitBox<Apple> appleBox = (FruitBox<Apple>) box; // OK. 미확인 타입으로 형변환 경고

형변환 경고를 받은 이유는 FruitBox<? extends Fruit>에 대입될 수 있는 타입이 여러개인데다, FruitBox<Apple>을 제외한 다른 타입은 FruitBox<Apple>로 형변환 될 수 없기 때문입니다.

 

지네릭 클래스 타입 변수가 와일드 카드만 있는 경우

Optional<?> EMPTY = new Optional<>();
-> Optional<? extends Object> EMPTY = new Optional<Object>();

<?>는 <? extends Object>를 줄여 쓴 것입니다. <>안에 생략된 타입은 "?"가 아니라 "Object"입니다.

 

 

8. 지네릭 타입의 제거

1. 지네릭 타입의 경계를 제거합니다.

class Box<T extends Fruit>{
    void add(T t){
        ...
    }
}

=>

class Box{
    void add(Fruit t){
        ...
    }
}

 

2. 지네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가합니다.

List의 get()은 Object 타입을 반환하므로 형변환이 필요합니다.

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

 

References

source code : https://github.com/yonghwankim-dev/java_study/tree/main/ch12/_01_generics
[도서] Java의 정석, 남궁 성 지음