본문 바로가기

공부방/JAVA

[Java] 제네릭과 와일드카드 타입에 대해 쉽고 완벽하게 이해하기(공변과 불공변, 상한 타입과 하한 타입)

[망나니 개발자]

https://mangkyu.tistory.com/241

 

 

이번에는 공변과 불공변에 대해서 먼저 알아보고, 이후에 제네릭과 와일드카드에 대해 알아보도록 하겠습니다. 많은 분들이 어려워하는 제네릭인 만큼 쉽게 풀어서 설명하려고 노력했는데, 많은 도움이 되기를 바라겠습니다.

 

 

 

1. 공변과 불공변


[ 공변과 불공변 ]

제네릭과 와일드카드에 대해 이해하기 위해서 우리는 먼저 공변과 불공변에 대해 알아야 한다. 공변과 불공변은 각각 다음과 같다.

  • 공변(covariant) : A가 B의 하위 타입일 때, T <A> 가 T<B>의 하위 타입이면 T는 공변
  • 불공변(invariant) : A가 B의 하위 타입일 때, T <A> 가 T<B>의 하위 타입이 아니면 T는 불공변

 

대표적으로 배열은 공변이며, 제네릭은 불공변인데 이를 코드로 살펴보도록 하자. 예를 들어 배열의 요소들을 출력하는 메소드가 있다고 하자. 이때 우리가 변수의 선언 타입은 Integer로, 메소드의 선언 타입은 Object로 해두었다고 하자.

@Test
void genericTest() {
    Integer[] integers = new Integer[]{1, 2, 3};
    printArray(integers);
}

void printArray(Object[] arr) {
    for (Object e : arr) {
        System.out.println(e);
    }
}

 

 

위의 메소드는 정상적으로 실행이 된다. 왜냐하면 배열은 공변이기 때문에 Integer가 Object의 하위 타입이므로 Integer[] 역시 Object[]의 하위 타입이기 때문이다. 하지만 제네릭은 불공변이라서 제네릭을 사용하는 컬렉션을 보면 다음의 코드는 컴파일 에러가 발생한다.

@Test
void genericTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);
    printCollection(list);   // 컴파일 에러 발생
}


void printCollection(Collection<Object> c) {
    for (Object e : c) {
        System.out.println(e);
    }
}

 

 

Integer는 Object의 하위 타입이다. 하지만 제네릭은 불공변이므로 List<Integer>는 List<Object>의 하위타입이 아니다. 둘은 아무런 관계가 없다. 그래서 위의 코드를 실행하면 다음과 같은 컴파일 에러가 발생한다. 이러한 제네릭의 불공변 때문에 와일드카드(제네릭의 ?타입)가 등장할 수 밖에 없는데, 제네릭부터 와일드카드까지 살펴보도록 하자.

java: incompatible types: java.util.List<java.lang.Integer> cannot be converted to java.util.Collection<java.lang.Object>

 

 

 

 

 

 

 

2. 제네릭과 와일드카드를 쉽고 완벽하게 이해하기


[ 제네릭의 등장 ]

제네릭이 등장하기 이전

제네릭은 JDK 1.5에 등장하였는데, 제네릭이 존재하기 전에 컬렉션의 요소를 출력하는 메소드는 다음과 같이 구현할 수 있었다.

void printCollection(Collection c) {
    Iterator i = c.iterator();
    for (k = 0; k < c.size(); k++) {
        System.out.println(i.next());
    }
}

 

 

하지만 위와 같이 컬렉션의 요소들을 다루는 메소드들은 타입이 보장되지 않기 때문에 문제가 발생하곤 했다. 예를 들어 컬렉션이 갖는 요소들의 합을 구하는 메소드를 구현했다고 하자.

int sum(Collection c) {
    int sum = 0;
    Iterator i = c.iterator();
    for (k = 0; k < c.size(); k++) {
        sum += Integer.parseInt(i.next());
    }
    return sum;
}

 

 

문제는 위와 같은 메소드가 String처럼 다른 타입을 갖는 컬렉션도 호출이 가능하다는 점이다. String 타입을 갖는 컬렉션은 컴파일 시점에 문제가 없다가 런타임 시점에 메소드를 호출하면 에러가 발생하였다. 그래서 Java 개발자들은 타입을 지정하여 컴파일 시점에 안정성을 보장받을 수 있는 방법을 고안하였고, 그렇게 제네릭이 등장하게 되었다.

 

 

 

제네릭의 등장

제네릭이 등장하면서 컬렉션에 타입을 지정할 수 있게 되었고, 위의 메소드를 다음과 같이 수정할 수 있게 되었다.

void sum(Collection<Integer> c) {
    int sum = 0;
    for (Integer e : c) {
        sum += e;
    }
    return sum;
}

 

 

이제는 다른 타입을 갖는 컬렉션이 위와 같은 메소드를 호출하려고 하면 컴파일 에러를 통해 안정성을 보장 받을 수 있게 되었다. 하지만 제네릭이 불공변이기 때문에 또 다른 문제가 발생하였는데, printCollection처럼 모든 타입에서 공통적으로 사용되는 메소드를 만들 방법이 없는 것이다. printCollection의 타입을 Integer에서 Object로 변경하여도 제네릭이 불공변이기 때문에 Collection<Object>는 Collection<Integer>의 하위타입이 아니므로 컴파일 에러가 발생하는 것이다. 이러한 상황은 오히려 제네릭이 등장하기 이전보다 실용성이 떨어졌기 때문에, 와일드카드라는 타입이 추가되었다.

@Test
void genericTest() {
    List<Integer> list = Arrays.asList(1, 2, 3);
    printCollection(list);   // 컴파일 에러
}

void printCollection(Collection<Object> c) {
    for (Object e : c) {
        System.out.println(e);
    }
}

 

 

 

 

 

[ 와일드카드의 등장 ]

와일드카드의 등장

제네릭이 등장했지만 오히려 실용성이 떨어지는 상황들이 생기면서, 모든 타입을 대신할 수 있는 와일드카드 타입(<?>)을 추가하였다. 와일드카드는 정해지지 않은 unknown type이기 때문에 Collection<?>로 선언함으로써 모든 타입에 대해 호출이 가능해졌다. 그래서 제네릭의 활용성을 높일 수 있게 되었는데, 여기서 중요한 것은 와일드카드가 any type이 아닌 unknown type이라는 점이다.

void printCollection(Collection<?> c) {
    for (Object e : c) {
        System.out.println(e);
    }
}

 

 

와일드카드로 선언된 타입은 unknown type이기 때문에 다음과 같은 경우에 문제가 발생하였다.

@Test
void genericTest() {
    Collection<?> c = new ArrayList<String>();
    c.add(new Object()); // 컴파일 에러
}

 

 

컬렉션의 add로 값을 추가하려면 제네릭 타입인 E 또는 E의 자식을 넣어줘야 한다. 그런데 와일드카드는 unknown type이므로 Integer, String 또는 개발자가 추가한 클래스까지 될 수 있기 때문에 범위가 무제한이다. 와일드카드의 경우 add로 넘겨주는 파라미터가 unknown 타입의 자식이여야 하는데, 정해지지 않았으므로 어떠한 타입을 대표하는지 알 수 없어서 자식 여부를 검사할 수 없는 것이다.

반면에 get으로 값을 꺼내는 작업은 와일드카드로 선언되어 있어도 문제가 없다. 왜냐하면 값을 꺼낸 결과가 unknown 타입이여도 우리는 해당 타입이 어떤 타입의 자식인지 확인이 필요하지 않으며, 심지어 적어도 Object의 타입임을 보장할 수 있기 때문이다.

결국 이러한 상황이 생기는 것은 결국 와일드카드가 any 타입이 아닌 unknown 타입이기 때문이다.

 

 

 

[ 한정적 와일드 카드 ]

Java에서는 위와 같은 문제를 해결하고자 한정적 와일드카드(Bounded Wildcard)를 제공하고 있다. 한정적 와일드카드를 사용하면 특정 타입을 기준으로 상한 범위와 하한 범위를 지정함으로써 호출 범위를 확장 또는 제한할 수 있다. 한정적 와일드카드에는 상한 경계 와일드카드(Upper Bounded Wildcard)와 하한 경계 와일드카드(Lower Bounded Wildcard)가 있는데, 각각에 대해 자세히 알아보도록 하자. 상황을 이해하기 위해 다음과 같이 3가지 클래스가 존재한다고 가정하고 살펴보도록 하자.

class MyGrandParent {

}

class MyParent extends MyGrandParent {

}

class MyChild extends MyParent {

}

 

 

 

상한 경계 와일드카드

상한 경계 와일드카드는 와일드카드 타입에 extends를 사용해서 와일드카드 타입의 최상위 타입을 정의함으로써 상한 경계를 설정한다. 예를 들어 다음과 같이 매개변수를 출력하는 메소드에 MyParent로 상한 경계를 주었다고 하자. 아래의 코드에서 MyChild 타입으로 꺼내는 경우에는 컴파일 에러가 발생하고, 나머지 타입으로 꺼내는 것은 가능하다.

void printCollection(Collection<? extends MyParent> c) {
    // 컴파일 에러
    for (MyChild e : c) {
        System.out.println(e);
    }

    for (MyParent e : c) {
        System.out.println(e);
    }

    for (MyGrandParent e : c) {
        System.out.println(e);
    }

    for (Object e : c) {
        System.out.println(e);
    }
}

 

 

extends는 자식 클래스를 만들 때 사용되므로, 위의 <? extends MyParent>으로 가능한 타입은 MyParent와 미지(unknown)의 모든 MyParent 자식 클래스들이다. 미지의 MyParent 자식 클래스라는 것은 자식이 어떤 타입인지 알 수 없다는 것으로, 그 타입이 MyChild 일 수도 있지만, 아닐 수도 있다. 예를 들어 또 다른 MyParent의 자식인 AnotherChild 라는 클래스가 있다고 하자.

class AnotherChild extends MyParent {

}

 

 

<? extends MyParent> 타입으로는 MyChild와 AnotherChild (또는 그 외의 타입)이 될 수도 있다. 컬렉션 c에서 꺼내서 만들어지는 객체(produce)가 반드시 MyChild 타입이 아닌 AnotherChild가 될 수도 있다. 그렇기 때문에 MyChild 타입으로 꺼내려고 시도하면 컴파일 에러가 발생한다. 하지만 적어도 MyParent 임은 확실하므로 MyParent와 그 부모 타입으로 꺼내는 것은 문제가 없다.

갖고 있는 원소를 사용 또는 소모(consume)하여 컬렉션에 추가하는 경우에는 상황이 달라진다. 다음과 같이 원소를 추가하는 코드는 모든 타입에 대해 컴파일 에러가 발생한다.

void addElement(Collection<? extends MyParent> c) {
    c.add(new MyChild());        // 불가능(컴파일 에러)
    c.add(new MyParent());       // 불가능(컴파일 에러)
    c.add(new MyGrandParent());  // 불가능(컴파일 에러)
    c.add(new Object());         // 불가능(컴파일 에러)
}

 

 

왜냐하면 컬렉션의 타입인 <? extends MyParent> 으로 가능한 타입은 MyParent와 미지(unknown)의 모든 MyParent 자식 클래스들이므로, 우리는 c가 MyParent의 하위 타입 중에서 어떤 타입인지 모르기 때문이다. 먼저 하위 타입으로는 MyChild가 될 수도 있지만, AnotherChild와 같은 또 다른 하위 타입이 될 수도 있으므로 하위 타입을 결정할 수 없다. 또한 MyGrandParent와 같이 상위 타입은 적어도 MyParent 타입은 절대 아니므로, 상위 타입 역시 원소를 사용 또는 소모(consume)하는 경우는 불가능하다.

원소를 소모하는 경우에는 상한 경계가 아닌 하한 경계를 지정하여 최소한 MyParent 타입 임을 보장하면 문제를 해결할 수 있다.

 

 

 

하한 경계 와일드카드

상한 경계와 반대로 super를 사용해 와일드카드의 최하위 타입을 정의하여 하한 경계를 설정할 수도 있는데, 이를 하한 경계 와일드카드(Lower Bounded Wildcard)라고 한다. 예를 들어 <? super MyParent>으로 가능한 타입은 MyParent와 미지의 MyParent 부모 타입들이다. 갖고 있는 원소를 사용(consume)하여 컬렉션에 추가하는 경우를 살펴보도록 하자.

void addElement(Collection<? super MyParent> c) {
    c.add(new MyChild());
    c.add(new MyParent());
    c.add(new MyGrandParent());  // 불가능(컴파일 에러)
    c.add(new Object());         // 불가능(컴파일 에러)
}

 

 

컬렉션 C가 갖는 타입은 적어도 MyParent의 부모 타입들이다. 그러므로 해당 컬렉션에는 MyParent의 자식 타입이라면 안전하게 컬렉션에 추가할 수 있고, 부모 타입인 경우에만 컴파일 에러가 발생할 것이다.

하지만 상한 경계와 반대로 컬렉션에서 값을 꺼내서 원소를 만드는(produce) 경우에는 상황이 다르다.

void printCollection(Collection<? super MyParent> c) {
    // 불가능(컴파일 에러)
    for (MyChild e : c) {
        System.out.println(e);
    }

    // 불가능(컴파일 에러)
    for (MyParent e : c) {
        System.out.println(e);
    }

    // 불가능(컴파일 에러)
    for (MyGrandParent e : c) {
        System.out.println(e);
    }

    for (Object e : c) {
        System.out.println(e);
    }
}

 

 

우선 상한 타입부터 살펴보도록 하자. <? super MyParent>으로 가능한 타입은 MyParent와 미지의 MyParent 부모 타입들이므로, 부모 타입을 특정할 수 없어 모든 부모 타입들에 제약(컴파일 에러)이 발생한다. Object 같은 경우에는 Java에서 지원하는 모든 객체의 부모임이 명확하므로, 특별히 Object 타입의 객체로 원소를 만드는(produce) 경우에는 컴파일 에러가 발생하지 않는다.

하위 타입인 경우에도 문제가 되는데, <? super MyParent>으로 가능한 타입은 MyParent와 미지의 MyParent 부모 타입들이므로 MyChild와 같이 경계 아래의 하위 타입들은 당연히 추가될 수 없기 때문이다. 

 

 

 

PECS(Producer-Extends, Consumer-Super) 공식

그렇다면 도대체 언제 super를 사용해야 하고, 언제 extends를 사용해야 하는지 헷갈릴 수 있다. 그래서 이펙티브 자바에서는 PECS라는 공식을 만들었는데, 이는 Producer-Extends, Consumer-Super의 줄임말이다. 즉, 컬렉션으로부터 와일드카드 타입의 객체를 생성 및 만들면(produce) extends를, 갖고 있는 객체를 컬렉션에 사용 또는 소비(consumer)하면 super를 사용하라는 것이다.

void printCollection(Collection<? extends MyParent> c) {
    for (MyParent e : c) {
        System.out.println(e);
    }
}

void addElement(Collection<? super MyParent> c) {
    c.add(new MyParent());
}

 

 

printCollection 같은 경우에는 컬렉션으로부터 원소들을 꺼내면서 와일드카드 타입 객체를 생성(produce)하고 있다. 반대로 addElement의 경우에는 컬렉션에 해당 타입의 원소를 추가함으로써 객체를 사용(consume)하고 있다. 그러므로 와일드카드 타입의 객체를 produce하는 printCollection은 extends가, 객체를 consume하는 addElement에는 super가 적합한 것이다.