제네릭 심화 문법

이한주·2023년 2월 19일
0

제네릭 클래스 상속

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

class Box<T> {
    protected T ob;

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

    public T get() {
        return ob;
    }
}

class StellBox<T> extends Box<T> {
    public StellBox(T o) {
        ob = o;
    }
}

public class GenericInheritance {

    public static void main(String[] args) {
        Box<Integer> iBox = new StellBox<>(7959);
        Box<String> sBox = new StellBox<>("Simple");

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

}

// 실행 결과
7959
Simple

Box<Integer> 와 같은 것을 매개변수화 타입 또는 제네릭 타입이라 한다.

타입이라는 단어는 Box를 일종의 자료형으로, 정확히는 클래스의 이름으로 간주함을 뜻한다.

따라서 다음과 같은 상속 관계를 표현할 수 있다.

하지만 다음은 성립하지 않는다.

Box<Number> box = new Box<Integer>(); // 컴파일 불가

타겟 타입 (Target Types)

컴파일러가 자료형 유추를 진행하는 상황은 다양하다.

class Box<T> {
    private T ob;

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

    public T get() {
        return ob;
    }
}

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> iBox = EmptyBoxFactory.makeBox();
        iBox.set(25);
        System.out.println(iBox.get());
    }

}

// 실행 결과
25

EmptyBoxFactory 클래스의 makeBox 메소드는 인자를 전달받지 않는다.

따라서 다음과 같이 T에 대한 타입 인자를 전달해야 한다.

Box<Integer> iBox = EmptyBoxFactory.<Integer>makeBox();

그런데 자바 7부터 다음과 같이 호출하는 것이 가능하다.

Box<Integer> iBox = EmptyBoxFactory.makeBox();

makeBox 메소드는 Box 인스턴스의 참조 값을 반환해야 한다고 판단한다.

왼편에 선언된 매개변수의 형을 보고 이러한 판단을 한다.

따라서 makeBox 메소드 호출 시 T는 Integer가 되어야함을 알 수 있다.

위 상황에서 T의 유추에 사용된 정보 Box<Integer>를 가리켜 타겟 타입이라 한다.

와일드카드(Wildcard)

다음과 같은 제네릭 메소드가 있다.

public static <T> void peekBox(Box<T> box) {
		System.out.println(box);
}

이 메소드를 제네릭으로 정의한 이유는 Box<Integer>, Box<String>의 인스턴스를 인자로 전달받도록 하기 위함이다.

그러면 다음과 같이 정의하는 것도 생각할 수 있다.

public static void peekBox(Box<Object> box) {
		System.out.println(box);
}

하지만 위 메소드는 안된다.

  • Box와 Box은 상속 관계를 형성하지 않는다.
  • Box와 Box은 상속 관계를 형성하지 않는다.

    즉 Object와 String이 상속 관계에 있더라도 Box와 Box은 상속 관계를 형성하지 않는 별개의 자료형이다.

    대신 와일드카드라는 것을 사용하면 원하는 바를 이룰 수 있다.

    class Box<T> {
        private T ob;
    
        public void set(T o) {
            ob = o;
        }
    
        public T get() {
            return ob;
        }
    
        @Override
        public String toString() {
            return ob.toString();
        }
    }
    
    class Unboxer {
        public static <T> T openBox(Box<T> box) {
            return box.get();
        }
    
        public static void peekBox(Box<?> box) { // wildcard 사용
            System.out.println(box);
        }
    }
    
    public class WildcardUnboxer2 {
    
        public static void main(String[] args) {
            Box<String> box = new Box<>();
            box.set("So Simple String");
            Unboxer.peekBox(box);
        }
    
    }
    
    // 실행 결과
    So Simple String

    위 예제에서 다음과 같이 제네릭 메소드와 와일드카드 기반 메소드가 있다.

    public static <T> T openBox(Box<T> box) { // 제네릭 메소드
        return box.get();
    }
    
    public static void peekBox(Box<?> box) { // wildcard 메소드
        System.out.println(box);
    }

    기능적인 측면에서 보면 위 두 메소드는 완전히 동일하다.

    제네릭 메소드와 와일드카드 기반 메소드는 상호 대체 가능한 측면이 있다.

    하지만 와일드카드 기반 메소드는 코드가 조금 더 간결하다.

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

    public static void peekBox(Box<?> box) { // wildcard 메소드
        System.out.println(box);
    }

    위 메소드의 인자로, Box에서 T가 Number 또는 Number의 하위 클래스인 제네릭 타입의 인스턴스만 전달되도록 제한할 때 다음과 같이 상한 제한된 와일드카드(Upper-Bounded Wildcards)라는 것을 사용한다.

    Box<? extends Number> box
    -> box는 Box<T> 인스턴스를 참조하는 변수
    -> Box<T> 인스턴스의 TNumber 또는 이를 상속하는 하위 클래스여야 함

    또한 다음과 같이 참조변수에 하한 제한된 와일드카드(Lower-Bounded Wildcards) 선언할 수도 있다.

    Box<? super Integer> box
    -> box는 Box<T> 인스턴스를 참조하는 변수
    -> Box<T> 인스턴스의 TInteger 또는 Integer가 상속하는 클래스
    -> Box<Integer>, Box<Number>, Box<Object>로 제한한다.

    언제 와일드카드에 제한을 걸어야 하는가

    와일드카드의 상한 제한은 다음 코드를 통해 설명한다.

    class Box<T> {
        private T ob;
    
        public void set(T o) {
            ob = o;
        }
    
        public T get() {
            return ob;
        }
    }
    
    class Toy {
        @Override
        public String toString() {
            return "I am a Toy";
        }
    }
    
    class BoxHandler {
    
        public static void outBox(Box<Toy> box) {
            Toy t = box.get();
            System.out.println(t);
        }
    
        public static void inBox(Box<Toy> box, Toy n) {
            box.set(n);
        }
    }
    
    public class BoundedWildCardBase {
    
        public static void main(String[] args) {
            Box<Toy> box = new Box<>();
            BoxHandler.inBox(box, new Toy());
            BoxHandler.outBox(box);
        }
    
    }

    위 코드에서 관심을 두어야 할 부분은 BoxHandler 클래스에 정의된 다음 메소드다.

    class BoxHandler {
    
        public static void outBox(Box<Toy> box) {
            Toy t = box.get();
            System.out.println(t);
        }
    
        public static void inBox(Box<Toy> box, Toy n) {
            box.set(n);
        }
    }

    위 두 메소드는 다음 조건을 만족하지 않는다.

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

    outBox 메소드는 상자에서 내용물을 꺼내는 기능의 메소드로 설계했다.

    하지만 매개변수 box를 대상으로는 다음과 같이 get, set 호출이 가능하다.

    public static void outBox(Box<Toy> box) {
    	  box.get();
        box.set(new Toy());
    }

    위는 outBox 메소드 내에서 실수로 set 메소드를 호출하는 오류를 범할 수 있다.

    이러한 오류는 컴파일 과정에서 발견되지 않는다.

    get은 가능하지만, set은 불가능하도록 제한을 것는 것이 좋다.

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

    다음과 같이 정의하면 get은 가능하지만 set은 불가능하다.

    public static void outBox(Box<? extends Toy> box) {
        box.get();
    	  box.set(new Toy()); // Error
    }

    위 outBox 메소드의 매개변수로 Box만 전달된다는 사실이 보장되지 않는다.

    Toy 클래스는 다음과 같이 다른 클래스들에 의해 상속될 수 있다.

    class Car extends Toy {}
    class Robot extends Toy {}

    이런 상속관계가 있다면 outBox 메소드에 Box 또는 Box 인스턴스가 인자로 전달될 수 있다.

    public static void outBox(Box<? extends Toy> box) {
        // box로 Box<Car> or Box<Robot> 인스턴스가 전달되면?
    	  box.set(new Toy()); // Toy 인스턴스를 만들 수 없음, Error
    }

    설명을 바탕으로 위 예제를 다음과 같이 바꾼다.

    class Box<T> {
        private T ob;
    
        public void set(T o) {
            ob = o;
        }
    
        public T get() {
            return ob;
        }
    }
    
    class Toy {
        @Override
        public String toString() {
            return "I am a Toy";
        }
    }
    
    class BoxHandler {
    
        public static void outBox(Box<? extends Toy> box) { // set 제한
            Toy t = box.get();
            System.out.println(t);
        }
    
        public static void inBox(Box<Toy> box, Toy n) {
            box.set(n);
        }
    }
    
    public class BoundedWildCardUsage {
    
        public static void main(String[] args) {
            Box<Toy> box = new Box<>();
            BoxHandler.inBox(box, new Toy());
            BoxHandler.outBox(box);
        }
    
    }

    이번에는 inBox 메소드를 살펴본다.

    public static void inBox(Box<Toy> box, Toy n) {
        box.set(n);
    }

    inBox도 다음 조건을 만족하지 못한다.

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

    이 메소드는 상자에 인스턴스를 저장하는 것이 목적이다.

    get 메소드를 호출하는 코드가 있으면 안된다.

    public static void inBox(Box<Toy> box, Toy n) {
        box.set(n);
    		Toy myToy = box.get();
    }

    위와 같은 실수는 컴파일 과정에서 발견되지 않는다.

    이러한 실수가 컴파일 과정에서 발견될 수 있도록 매개변수를 다음과 같이 선언해야 한다.

    public static void inBox(Box<? super Toy> box, Toy n) {
        box.set(n);
    		Toy myToy = box.get();  // error
    }

    위와 같이 매개변수를 선언하면 get 메소드의 호출문에서 컴파일 오류가 발생한다.

    이유는 반환형을 Toy로 결정할 수 없기 때문이다.

    Toy 클래스의 상속 관계가 다음과 같다고 가정한다.

    class Plastic {}
    class Toy extends Plastic {}

    그러면 inBox 메소드의 첫 번째 인자로 전달 가능한 두 가지 유형의 Box 인스턴스는 다음과 같다.

    Box<Toy> tBox = new Box<Toy>();
    Box<Plastic> pBox = new Box<Plastic>();

    위의 inBox 메소드에 인자로 pBox가 전달되면 다음 문장은 문제가 된다.

    Toy myToy = box.get();  // get이 반환하는 것이 Plastic 인스턴스다.

    정리하면 다음과 같다.

    public static void inBox(Box<? super Toy> box, Toy n) {
        box.set(n);
    		Toy myToy = box.get();  // error
    }
    
    -> 이 안에서는 box가 참조하는 인스턴스에서 Toy 인스턴스를 꺼내는 메소드 호출은 불가능하다.

    위 예제를 수정한 코드는 다음과 같다.

    class Box<T> {
        private T ob;
    
        public void set(T o) {
            ob = o;
        }
    
        public T get() {
            return ob;
        }
    }
    
    class Toy {
        @Override
        public String toString() {
            return "I am a Toy";
        }
    }
    
    class BoxHandler {
    
        public static void outBox(Box<? extends Toy> box) { // set 불가
            Toy t = box.get();
            System.out.println(t);
        }
    
        public static void inBox(Box<? super Toy> box, Toy n) { // get 불가
            box.set(n);
        }
    }
    
    public class BoundedWildCardUsage2 {
    
        public static void main(String[] args) {
            Box<Toy> box = new Box<>();
            BoxHandler.inBox(box, new Toy());
            BoxHandler.outBox(box);
        }
    
    }

    정리하면 다음과 같다.

    Box<? extends Toy> box
    -> box가 참조하는 인스턴스를 대상으로 꺼내는 작업만 허용, get
    
    Box<? super Toy> box
    -> box가 참조하는 인스턴스를 대상으로 넣는 작업만 허용, set

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

    위에서 Toy 클래스를 담은 상자를 기준으로 다음과 같이 inBox, outBox 메소드를 정의했다.

    class BoxHandler {
    
        public static void outBox(Box<Toy> box) {
            Toy t = box.get();
            System.out.println(t);
        }
    
        public static void inBox(Box<Toy> box, Toy n) {
            box.set(n);
        }
    }

    위 두 메소드는 Box<Toy> 인스턴스를 대상으로 한다.

    이 상황에서 다음 클래스가 추가된다고 가정한다.

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

    그리고 Box의 인스턴스를 대상으로 outBox, inBox 메소드를 호출하고 싶다면 다음과 같이 메소드를 오버로딩하는 것을 고려할 수 있다.

    class BoxHandler {
    
        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) {}
    }

    하지만 outBox 메소드는 오버로딩이 성립하지 않는다.

    자바는 제네릭 등장 이전에 정의된 클래스들과의 상호 호환성 유지를 위해 컴파일 시 제네릭과 와일드카드 관련 정보를 지우는 과정을 거친다.

    즉 위의 두 outBox 메소드의 매개변수 선언은 컴파일 과정에서 다음과 같이 수정이된다.

    이로 인해 메소드의 오버로딩이 불가능한 상태가 된다.

    Box<? extends Toy> box -> Box box
    Box<? extends Robot> box -> Box box

    위와 같이 컴파일러가 제네릭 정보를 지우는 행위를 Type Erasure라 한다.

    Box와 Box 인스턴스를 동시에 허용할 수 있도록 하려면 제네릭 메소드를 사용한다.

    class Box<T> {
        private T ob;
    
        public void set(T o) {
            ob = o;
        }
    
        public T get() {
            return ob;
        }
    }
    
    class Toy {
        @Override
        public String toString() {
            return "I am a Toy";
        }
    }
    
    class Robot {
        @Override
        public String toString() {
            return "I am a Robot";
        }
    }
    
    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 n) { // 제네릭 메소드
            box.set(n);
        }
    }
    
    public class BoundedWildcardGenericMethod {
    
        public static void main(String[] args) {
            Box<Toy> tBox = new Box<>();
            BoxHandler.inBox(tBox, new Toy());
            BoxHandler.outBox(tBox);
    
            Box<Robot> rBox = new Box<>();
            BoxHandler.inBox(rBox, new Robot());
            BoxHandler.outBox(rBox);
        }
    }
    
    // 실행 결과
    I am a Toy
    I am a Robot

    위와 같이 메소드를 오버로딩해야 하는 상황에서는 Type Erasure라는 것 때문에 오버로딩으로 인정이 되지 않으니 제네릭 메소드 정의로 이를 대신해야 한다.

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

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

    interface Getable<T> {
        public T get();
    }
    
    class Box<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) {
            Box<Toy> box = new Box<>();
            box.set(new Toy());
    
            Getable<Toy> gt = box;
            System.out.println(gt.get());
        }
    
    }
    
    // 실행 결과
    I am toy

    Box<T>클래스는 Getable<T> 인터페이스를 구현하는 형태로 정의된다.

    따라서 Getable 형 참조변수로 Box의 인스턴스를 참조할 수 있다.

    단 T를 대신할 자료형이 다음 문장과 같이 동일해야 참조가 가능하다.

    Box<Toy> box = new Box<>();
    
    Getable<Toy> gt = box;

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

    이럴 때는 메소드를 구현할 때에도 T가 아닌 결정한 클래스 타입으로 명시하고 구현해야 한다.

0개의 댓글