[윤성우의 열혈 Java 프로그래밍] Chapter 22 - 제네릭(Generics) 2

Update:     Updated:

카테고리:

태그:

22-1. 제네릭의 심화 문법

- 제네릭 클래스와 상속

제네릭 클래스도 다음과 같이 상속이 가능하다.

class Box<T> {
    protected T object;

    public void setObject(T object) {
        this.object = object;
    }

    public T getObject() {
        return object;
    }
}

class SteelBox<T> extends Box<T> {
    public SteelBox(T object) { // 제네릭 클래스의 생성자
        this.object = object;
    }
}

public class GenericInheritance {
    public static void main(String[] args) {
        Box<Integer> iBox = new SteelBox<>(8000);
        Box<String> sBox = new SteelBox<>("Simple");

        System.out.println(iBox.getObject());
        System.out.println(sBox.getObject());
    }
}

image

image

SteelBox<Integer> 클래스는 Box<Integer> 클래스를 상속한다.
SteelBox<Integer> 제네릭 타입은 Box<Integer> 제네릭 타입을 상속한다.

상속 관계 파악을 잘 해야 한다.

Box<Number> box = new Box<Integer>();   // 컴파일 가능할까?

뭔가 NumberInteger가 상속하니까 위의 코드도 가능할 것 같지만,
Box<Number>와 Box<Integer>는 상속 관계를 형성하지 않는다.

- 타겟 타입(Target Types)

class Box<T> {
    protected T object;

    public void setObject(T object) {
        this.object = object;
    }

    public T getObject() {
        return object;
    }
}

class EmptyBoxFactory {
    public static <T> Box<T> makeBox() {
        Box<T> box = new Box<>();
        return box;
    }
}

public class TargetTypes {
    public static void main(String[] args) {
        Box<Integer> iBox7 = EmptyBoxFactory.<Integer>makeBox();    // java 7 이전 문법
        Box<Integer> iBox8 = EmptyBoxFactory.makeBox(); // java 7부터 가능해짐

        iBox7.setObject(7);
        iBox8.setObject(8);

        System.out.println(iBox7.getObject());
        System.out.println(iBox8.getObject());
    }
}

image

위의 코드에서 makeBox 메소드는 인자를 전달받지 않는다.
따라서, 원래는 T에 대한 타입 인자를 전달해주어야한다.
하지만 자바 7부터는 컴파일러의 자료형 유추 범위가 넓어졌기 때문에 위와 같이 호출하는 것이 가능해졌다.

Box<Integer> iBox8 = EmptyBoxFactory.makeBox(); // java 7부터 가능해짐

컴파일러는 위 문장을 보면서 makeBox 메소드는 Box<Integer> 인스턴스의 참조 값을 반환해야 한다고 판단한다.
그리고 이 때, T의 유추에 사용된 정보 Box<Integer>를 가리켜 ‘타겟 타입’이라 한다.

- 와일드카드(Wildcard)

상자의 내용물을 넣고, 확인하는 메소드가 있는 클래스를 예시로 들어보자.

class Box<T> {
    private T object;

    public void set(T object) {
        this.object = object;
    }

    public T get() {
        return object;
    }

    @Override
    public String toString() {
        return object.toString();
    }
}

class Unboxer {
    public static <T> T openBox(Box<T> box) {
        return box.get();
    }

    public static void peekBox(Box<?> box) {    // 와일드카드를 적용한 메소드
        System.out.println(box);
    }
}

public class WildcardUnboxer {
    public static void main(String[] args) {
        Box<String> box = new Box<>();
        box.set("So Simple String");
        Unboxer.peekBox(box);
    }
}

와일드카드를 사용하면 Box<T>를 기반으로 생성된, Box<Integer> 인스턴스나 Box<String> 인스턴스들을 인자로 받을 수 있다.
그런데, 가만히 잘 생각해보자. 굳이 와일드카드를 사용해야했었나?

// 제네릭 메소드의 정의
public static <T> void peekBox(Box<T> box) {
    System.out.println(box);
}

// 와일드카드 기반 메소드 정의
public static void peekBox(Box<?> box) {
    System.out.println(box);
}

같은 기능을 하는 메소드는 제네릭으로도 만들 수 있다.
물론 내부적인 동작은 다르겠지만 기능적으로는 동일하다고 볼 수 있다.
즉, 제네릭 메소드와 와일드카드 기반 메소드는 상호 대체 가능한 측면이 있다.
하지만 코드가 좀 더 간결하다는 이유로 와일드카드 기반 메소드의 정의를 선호한다.
그리고 와일드카드를 쓰는 진짜 이유는 extendssuper를 통한 상한과 하한의 제한에 있다.

- 와일드카드의 상한과 하한의 제한: Bounded Wildcards

image

public static void peekBox(Box<? extends Number> box) {
    // box는 Box<T> 인스턴스를 참조하는 참조변수이다.
    // 단 이때 Box<T> 인스턴스의 T는 Number 또는 이를 상속하는 하위 클래스이어야 한다.
}

메소드의 인자로 특정 클래스 또는 그 클래스의 하위 클래스인 제네릭 타입의 인스턴스만 전달되도록 제한할 때,
다음과 같이 ‘상한 제한된 와일드카드(Upper-Bounded Wildcards)’를 사용한다.

image

public static void peekBox(Box<? super Integer> box) {
    // box는 Box<T> 인스턴스를 참조하는 참조변수이다.
    // 단 이때 Box<T> 인스턴스의 T는 Integer 또는 Integer가 상속하는 클래스이어야 한다.
}

반대로 메소드의 인자로 특정 클래스 또는 그 클래스의 상위 클래스인 제네릭 타입의 인스턴스만 전달되도록 제한할 때,
다음과 같이 ‘하한 제한된 와일드카드(Lower-Bounded Wildcards)’를 사용한다.

- 언제 와일드 카드에 제한을 걸어야 하는가?: 도입

Box의 T를 Number 또는 Number를 직간접적으로 상속하는 클래스로 제한하기 위한 것

public static void peekBox(Box<? extends Number> box) {...}

Box의 T를 Integer 또는 Integer가 직간접적으로 상속하는 클래스로 제한하기 위한 것

public static void peekBox(Box<? super Integer> box) {...}

위의 두 설명은 정확한 설명이다.
하지만 뒤에 나올 컬렉션 프레임워크에서 나올 다음과 같은 메소드를 해석하려면 여기서 좀 더 많은 의미를 끌어내야 한다.

// Collections 클래스의 복사 메소드
// 왜 dest에는 super가, src에는 extends가 쓰였을까?
public static <T> void copy(List<? super T> dest, List<? extends T> src)

- 언제 와일드 카드에 제한을 걸어야 하는가?: 상한 제한의 목적

박스에서 물건을 꺼내고 담는 기능을 하는 메소드가 들어있는 클래스를 예시로 보자.

class BoxHandler {
    public static void outBox(Box<Toy> box) {
        Toy toy = box.get();    // 상자에서 꺼내기
        System.out.println(toy);
    }

    public static void inBox(Box<Toy> box, Toy toy) {
        box.set(toy);   // 상자에 넣기
    }
}

잘 정의된 코드는 필요한 만큼만 기능을 허용하여, 코드의 오류가 컴파일 과정에서 최대한 발견되도록 해야 한다.

public static void outBox(Box<Toy> box) {
    box.get();    // 꺼내는 것! OK!
    box.set(new Toy())  // 넣는 것도 OK?!
}

이렇게 메소드를 정의할 경우 꺼내는 역할만 수행해야할 메소드에 set하는 동작이 수행되어도 컴파일 과정에서 발견되지 않게된다.

이러한 문제를 해결하기 위해서는 해당 메소드를 다음과 같이 바꿔주면 된다.

public static void outBox(Box<? extends Toy> box) {
    box.get();    // 꺼내는 것! OK!
    box.set(new Toy())  // 넣는 것은 ERROR!
}

위 상황에서 set 메소드의 호출이 불가능한 이유는 간단하다(사실 처음 보면 좀 생각해봐야하지도..)
하위 클래스 타입의 변수로는 상위 클래스의 인스턴스를 참조할 수 없고,
메소드의 매개변수로 Toy 인스턴스를 저장할 수 있는 상자(Box<T> 인스턴스만) 전달된다는 사실을 보장할 수 없기 때문이다.

image
즉, 이렇게 T에 Toy를 상속하는 Car나 Robot이 전달될 수도 있기 때문에 컴파일 과정에서 오류를 뱉게된다.

정리하자면, 다음과 같은 매개변수 선언을 보고, 이렇게 판단할 수 있어야 한다.

box가 참조하는 인스턴스를 대상으로 저장하는 기능의 메소드 호출은 불가능하다.

public static void outBox(Box<? extends Toy> box) {
    /*
    이 안에서는 box가 참조하는 인스턴스에 Toy 인스턴스를 저장하는(전달하는) 메소드 호출은 불가능하다.
    */
}

이렇게 적용함으로써 코드의 안전성을 더 높일 수 있다.

- 언제 와일드 카드에 제한을 걸어야 하는가?: 하한 제한의 목적

다음 메소드도 앞선 예시와 같이 문제가 있다.

public static void inBox(Box<Toy> box, Toy toy) {
    box.set(toy);   // 넣는 것 OK!
    Toy myToy = box.get();  // 꺼내는 것도 OK?!
}

넣는 역할만 수행해야하는 메소드에서 get하는 동작이 수행되어도 컴파일 과정에서 발견되지 않는다.

이러한 문제를 해결하기 위해서는 해당 메소드를 다음과 같이 바꿔주면 된다.

public static void inBox(Box<? super Toy> box, Toy toy) {
    box.set(toy);   // 넣는 것 OK!
    Toy myToy = box.get();  // 꺼내는 것 ERROR!
}

위 상황같은 경우 get 메소드 호출 자체는 문제되지 않지만, 반환값을 저장하기 위해 선언한 참조변수의 형을 Toy로 결정했다는 점에서 문제가 생긴다.
즉, get을 보면 컴파일 과정에서 무엇이 반환되는지 알 수가 없다.

image
이렇게 만약 Toy가 Plastic을 상속한다면 상위 클래스의 인스턴스를 하위 클래스 타입형 변수로 참조하는 것이 되므로 오류가 나는 것이다.

정리하자면, 다음과 같은 매개변수 선언을 보고, 이렇게 판단할 수 있어야 한다.

box가 참조하는 인스턴스를 대상으로 꺼내는 기능의 메소드 호출은 불가능하다.

public static void outBox(Box<? super Toy> box) {
    /*
    이 안에서는 box가 참조하는 인스턴스에서 Toy 인스턴스를 꺼내는(반환하는) 메소드 호출은 불가능하다.
    */
}

마찬가지로, 이렇게 적용함으로써 코드의 안전성을 더 높일 수 있다.

- 언제 와일드 카드에 제한을 걸어야 하는가?: 정리하기

최종으로 정리된 코드를 살펴보자.

class Box<T> {
   protected T object;

   public void set(T object) {
       this.object = object;
   }

   public T get() {
       return object;
   }
}

class Toy {
    @Override
    public String toString() {
        return "I am a Toy";
    }
}

class BoxHandler {
    public static void outBox(Box<? extends Toy> box) { // get만 가능하도록 제한
        Toy toy = box.get();
        System.out.println(toy);
    }

    public static void inBox(Box<? super Toy> box, Toy toy) {   // set만 가능하도록 제한
        box.set(toy);
    }
}

public class BoundedWildcardBase {
    public static void main(String[] args) {
        Box<Toy> box = new Box<>();

        BoxHandler.inBox(box, new Toy());
        BoxHandler.outBox(box);
    }
}


다음과 같이 정리해두자!
image
image

그렇다면, 앞부분에 언급했던 컬랙션 클래스의 copy 메소드를 해석해보자!

// Collections 클래스의 복사 메소드
public static <T> void copy(List<? super T> dest, List<? extends T> src) {
    /*
    Q: 왜 dest에는 super가, src에는 extends가 쓰였을까?
    A: dest에서는 set만 가능하도록, src에서는 get만 가능하도록 제한을 두었다 는 의미로 해석할 수 있다.
    */
}

역으로 앞으로 SDK를 읽을 때, super, extends를 보고
아, 각각 이러한 기능으로 쓰라고 디자인한거구나! 라고 해석할줄도 알아야겠다.

- 제한된 와일드카드 선언을 갖는 제네릭 메소드

그래서 Toy 클래스를 담은 상자를 기준으로 다음과 같이 inBox와 outBox 메소드를 정의하였다.

class BoxHandler {
    public static void outBox(Box<? extends Toy> box) {
        Toy toy = box.get();    // 상자에서 꺼내기
        System.out.println(toy);
    }

    public static void inBox(Box<? super Toy> box, Toy toy) {
        box.set(toy);   // 상자에 넣기
    }
}

그렇다면 좀 더 다양한 클래스를 담는 메소드로 변경하고 싶다면 어떻게 해야할까?

먼저, 오버로딩을 시도해보자.

// 다음 두 메소드는 오버로딩 인정 안 됨.
public static void outBox(Box<? extends Toy> box) {...}
public static void outBox(Box<? extends Robot> box) {...}

// 다음 두 메소드는 두 번째 매개변수로 인해 오버로딩 인정 됨.
public static void inBox(Box<? super Toy> box, Toy n) {...}
public static void inBox(Box<? super Robot> box, Robot n) {...}

결론부터 말하자면 권장하지 않는 방법이다.
자바는 제네릭 등장 이전에 정의된 클래스들과의 상호 호환성 유지를 위해 컴파일 시 제네릭과 와일드카드 관련 정보를 지우는 과정을 거친다고한다.
즉, <…> 이 부분이 다 삭제되는 것이다.
이렇게 컴파일러가 제네릭 정보를 지우는 행위를 가리켜 ‘Type Erasure’라 한다.

따라서, 메소드를 오버로딩 해야하는 상황에서는 다음과 같이 제네릭 메소드로 만들어 문제를 해결하도록 하자.

class BoxHandler {
    public static <T> void outBox(Box<? extends T> box) {
        T t = box.get();    // 상자에서 꺼내기
        System.out.println(t);
    }

    public static <T> void inBox(Box<? super T> box, T t) {
        box.set(t);   // 상자에 넣기
    }
}

- 제네릭 인터페이스의 정의와 구현

인터페이스 역시 클래스와 마찬가지로 제네릭으로 정의할 수 있다.

interface Getable<T> {
    T get();
}

class GBox<T> implements Getable<T> {
    private T ob;

    public void set(T o) {
        ob = o;
    }

    @Override
    public T get() {
        return ob;
    }
}

class Toy {
    @Override
    public String toString() {
        return "I am a Toy";
    }
}

public class GetableGenericInterface {
    public static void main(String[] args) {
        GBox<Toy> box = new GBox<>();
        box.set(new Toy());

        // GBox<T>가 Getable<T>를 구현하므로 참조 가능
        Getable<Toy> gt = box;
        System.out.println(gt.get());
    }
}


그리고 제네릭 인터페이스를 구현할 때 T를 결정한 상태로 구현할 수도 있다.

interface Getable<T> {
    T get();
}

class GBox<T> implements Getable<String> {
    private T ob;

    public void set(T o) {
        ob = o;
    }

    @Override
    public String get() {   // 반환형은 T가 아닌 String이어야 한다.
        return ob.toString();
    }
}

class Toy {
    @Override
    public String toString() {
        return "I am a Toy";
    }
}

public class GetableGenericInterface {
    public static void main(String[] args) {
        GBox<Toy> box = new GBox<>();
        box.set(new Toy());

        Getable<String> gt = box;
        System.out.println(gt.get());
    }
}

Java lang 카테고리 내 다른 글 보러가기

댓글 남기기