순간을 성실히, 화려함보단 꾸준함을

14주차 과제: 제네릭 본문

백기선님과 함께 하는 자바 스터디

14주차 과제: 제네릭

폭발토끼 2021. 7. 31. 16:37

목표

자바의 제네릭에 대해 학습하시오

학습할 것(필수)

  1. 제네릭 사용법
  2. 제네릭 주요 개념(바운디드 타입,와일드 카드)
  3. 제네릭 메소드 만들기
  4. Erasure

제네릭이란?

다양한 타입의 객체들을 다루는 메소드나 컬렉션 클래스에 컴파일 시의 타입체크를 해주는 기능. 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움이 줄어듬

제네릭의 장점

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

제네릭 사용법

Class Box<t> {}

Box<T> : 제네릭 클래스. 'T의 Box' 또는 'T Box' 라고 읽음
T       : 타입 변수 또는 타입 매개변수(T는 타입 문자)
Box       : 원시 타입(raw type)

제네릭 타입은 클래스와 메소드에 선언할 수 있는데, 클래스 옆에 <T> 를 붙이면 된다.

class Box{
    Object item;

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

이런 소스가 있다고 가정해 보자. 이 소스를 제네릭 타입을 사용하여 변경해 보자.

class Box<T>{
    T item;

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

Box에서 T를 타입 변수(type variable) 이라고 한다.

public class Main {
    public static void main(String[] args) throws IOException {
        Box<String> box = new Box<String>();
        box.setItem("ABCD");
        String ret = box.getItem();
        System.out.println(ret);
    }
}
class Box\<T> {
    T item;

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

main 함수에서 Box 를 String 타입으로 지정해줬으므로, Box 에서 'T'는 String으로 정의가 된 것과 동일시 된다.

제네릭의 주의점

static 맴버에 타입 변수 T를 사용할 수는 없다. T는 인스턴스 변수로 간주가 되는데 아시다시피 static 맴버는 인스턴스를 참조할 수 없기 때문이다.

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

static 맴버는 타입 변수에 관계없이 동일한 것이어야 하기 때문이다. 즉, Box<Applw>.itemBox<Orange>.item은 다른 값이면 안된다는 뜻이다.

또한, 제네릭 배열을 생성하는 것도 허용이 되지 않는다. 제네릭 배열 타입의 참조변수를 선언하는 것은 허용되지만, new T[10]과 같이 배열을 생성하는 건 제한된다.

class Box<T>{
    T[] itemArr;    //T 타입의 배열을 위한 참조변수는 허용된다(O)

    T[] toArray() {
        T[] tmpArr = new T[itemArr.length];    //에러 발생, 제네릭 배열 생성불가
        ....
        return tmpArr;
    }
}

왜 제네릭 배열을 생성할 수 없을까???
바로 new키워드 때문이다. 이유는 자바는 컴파일시 new 키워드로 생성할 타입 변수를 확실히 알수 있어야 한다. 그러나 위의 소스처럼 코드를 짠다면 Box<T>클래스를 컴파일 하는 시점에 T가 어떤 타입이 될지 전혀 알 수가 없다. 따라서 instanceof 연산자 또한 new 연산자와 같은 이유로 타입변수 T를 사용할 수 없다.

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

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

상속 관계 또한 마찬가지이다.

Box<Fruit> appleBox = new Box<Apple>();    //Compile Error 발생

JDK 1.7 버전 이후로는 추정이 가능한 타입을 생략할 수 있게 되었다.

Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box<>();    //ok

제네릭 바운디드 타입

바운디드 타입이란?

- 제네릭으로 사용되는 파라미터 형식을 제한할 수 있는 것을 칭한다.

FruitBox<Toy> fruitBox = new FruitBox<Toy>();
fruitBox.add(new Toy());        

이렇게 현재 과일상자에는 과일만이 들어가야 되지만 장난감이 들어갈 수도 있다.

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

이렇게 extends 키워드를 사용하여 Fruit 클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것이다.

FruitBox<Apple> appleBox = new FruitBox<Fruit>();
fruitBox.add(new Apple());    //ok
fruitBox.add(new Toy());    //Compile Error

만약 클래스가 아닌 인터페이스를 구현해야 하는 제약이 필요하다면, implements 키워드가 아닌 extends 키워드를 사용한다는 것을 주의해야 한다.

만약 클래스의 자손이면서 인터페이스까지 구현해야 한다면 & 키워드를 사용한다.

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

제네릭 와일드 카드

제네릭의 와일드 카드 역시 제네릭으로 사용되는 파라미터 형식을 제한하는 역할을 하지만 바운디드와 달리 어떠한 타입도 될 수 있다.

와일드 카드는 ? 로 표현한다.

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

예를 들어 매개변수에 과일박스를 대입하면 주스를 만들어서 반환하는 Juicer 라는 클래스가 존재하고, 이 클래스에서는 과일을 주스로 만들어서 반환하는 makeJuice() 라는 메소드가 존재한다고 가정해 보자.

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

Juice 클래스는 제네릭 클래스가 아닌데다, 제네릭 클래스라고 해도 static 메소드에는 타입 매개변수 T를 매개변수에 사용할 수 없으므로 아에 제네릭으로 지정하지 않던가, 위의 소스와 같이 처럼 특정 타입을 지정을 해주어야 한다.

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

System.out.println(Juicer.makeJuice(fruitBox));    //ok
System.out.println(Juicer.makeJuice(appleBox));    //Compile Error

위와 같이 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);
}

그러나 위와 같이 코드를 짜게 되면 컴파일 에러가 발생하는데 그 이유는 제네릭 타입이 다른 것만으로는 오버로딩이 성립하지 않기 때문이다.

그래서 탄생하게 된 개념이 와일드 카드이다.

static Juice makeJuice(FruitBox<? extends Fruit> 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.makeJuice(fruitBox));    //ok
System.out.println(Juicer.makeJuice(appleBox));    //ok

제네릭 메소드

메소드 선언부에 제네릭 타입이 선언된 메소드를 제네릭 메소드라고 한다. 제네릭 타입의 선언 위치는 반환 타입 바로 앞이다.

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){
    ...    
    }
}

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

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

위와 같은 메소드가 존재하고 있다고 가정할 때 makeJuic 메소드를 호출하기 위해선

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

이렇게 타입 변수에 타입을 대입하면 된다. 그러나 대부분은 컴파일러가 타입을 추정할 수 있으므로 생략 하는 경우도 많다.

제네릭 타입의 제거(Erasure)

컴파일러는 제네릭 타입을 사용하여 소스파일을 체크하고, 필요한 곳에 형변환을 넣어준다. 그리고 제네릭 타입을 제거 한다.
즉, 컴파일이 된 파일(.class) 에는 제네릭 타입에 대한 정보가 전혀 없다는 것이다.

이렇게 하는 이유가 뭘까??
바로 호환성 문제이다. JDK1.5 버전부터 제네릭 개념이 도입되었지만, 아직도 원시 타입을 사용해서 코드를 작성하는 것을 허용한다. 그렇기 때문에 문제가 발생하지 않게 하기 위해 제네릭 타입을 컴파일시 제거해 버리는 것이다.

그렇다면 우린 궁금한 점이 들 수 있다.
바로 왜 제네릭 타입에 타입 매개변수로 primitive 타입을 사용할 수 없다는 것이다. 왜 이럴까???
이걸 알기 위해선 어떻게 제네릭 타입이 제거되는지 과정에 대해 깊게 알아볼 필요성이 있다.

public class Main {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>();
    }
}

위와 같은 코드가 존재하고 있다고 예시를 들어보자. 이 코드의 바이트 코드를 까보면 아래와 같이 나온다.

public class com/example/workspace/Main {

  // compiled from: Main.java

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 6 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
    RETURN
   L1
    LOCALVARIABLE this Lcom/example/workspace/Main; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

  // access flags 0x9
  public static main([Ljava/lang/String;)V
    // parameter  args
   L0
    LINENUMBER 8 L0
    NEW java/util/ArrayList
    DUP
    INVOKESPECIAL java/util/ArrayList.<init> ()V
    ASTORE 1
   L1
    LINENUMBER 9 L1
    RETURN
   L2
    LOCALVARIABLE args [Ljava/lang/String; L0 L2 0
    LOCALVARIABLE list Ljava/util/List; L1 L2 1
    // signature Ljava/util/List<Ljava/lang/Integer;>;
    // declaration: list extends java.util.List<java.lang.Integer>
    MAXSTACK = 2
    MAXLOCALS = 2
}

이때 주의깊게 봐야할 부분은 INVOKESPECIAL java/util/ArrayList.<init> ()V 이부분이다. 이 코드를 보면 ArrayList<>() 가 생성될때 정의하였던 Integer 타입은 존재하지 않다는 것을 확인할 수 있다. 또한, 타입 파라미터를 선언하여 사용해도 결국은 Object 타입으로 취급이 된다.

이런 과정을 타입 소거(type Erasure) 라고 한다.

바로 primitive 타입은 Object 객체를 상속받고 있지 않기 때문에 타입소거를 위해 호환성을 유지시켜주는 것이다.

제네릭 타입의 제거 과정

  1. 제네릭 타입의 경계(bound)를 제거한다.

제네릭 타입이 이라면 T는 Fruit으로 치환이 된다. 인 경우는 T는 Object로 치환된다. 그리고 클래스 옆의 선언은 제거된다.

AS-IS

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

TO-BE

class Box{
    void add(Fruit t){
        ...
    }
}
  1. 제네릭 타입을 제거한 후에 타입이 일치하지 않으면, 형변환을 추가한다.

AS-IS

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

TO-BE

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

만약 와일드 카드가 포함되어 있는 경우에는 다음과 같이 적절한 타입으로의 형변환이 추가된다.

AS-IS

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

TO-BE

static Juice makeJuice(FruitBox box){
    String tmp=" ";
    Iterator it = box.getList().iterator();
    while(is.hasNext()){
        tmp += (Fruit) it.next() + " ";
    }
    return new Juice(tmp);
}

Reference

자바의 정석(남궁민)

'백기선님과 함께 하는 자바 스터디' 카테고리의 다른 글

[번외] final 과 static 키워드  (0) 2021.08.01
[리뷰] 14주차 : 제네릭  (0) 2021.07.31
[리뷰] 13주차 : I/O  (0) 2021.07.25
13주차 과제: I/O  (0) 2021.07.21
[리뷰]12주차 : 애노테이션  (0) 2021.07.15