[Java] 공변성으로 알아보는 제네릭과 와일드카드

 

 

서론

공변성제네릭 그리고 와일드카드에 대해서 알아보고 마지막으로 @SafeVarargs 어노테이션을 소개로 마무리한다.

 

공변성과 배열

자바의 공변성은 하위 클래스의 객체를 상위 클래스의 참조 변수로 참조할 수 있는 성질을 의미한다. 다르게 말하면 상속 관계에서 파생된 클래스의 객체가 부모 클래스의 객체처럼 취급될 수 있음을 말한다.

 

자바에서 공변성을 만족하는 대표적인 예시가 바로 배열이다. 예를 들어, 배열의 원소 타입이 상속 관계를 가질 때, 배열 자체의 타입 간에도 상속 관계가 유지된다. 간단히 말하면 하위 타입의 배열은 상위 타입의 배열로 취급될 수 있다.

@Test
void test_array() {
    Number[] numbers = new Number[4];
    numbers[0] = 1;
    numbers[1] = 1L;
    numbers[2] = 1.0f;
    numbers[3] = 1.0d;
}

Number와 상속 관계에 있는 Interger, Long, Float, Double을 배열에 할당할 수 있을 뿐 아니라

 

@Test
void test_array() {
    Integer[] integers = new Integer[10];
    Number[] numbers = integers;
}

공변성을 만족하기에 Integer[]가 Number[]로 취급될 수 있다.

 

@Test
void test_array() {
    Integer[] integers = new Integer[10];
    Number[] numbers = integers;

    assertThrows(ArrayStoreException.class, () -> numbers[0] = 1.0); // Heap pollution
}

그런데 다음과 같이 작성된 코드는 ArrayStoreException 예외를 발생시킨다. 그 이름에서 알 수 있듯 배열에 무언가 잘못 삽입하려고 할 때 발생하는 예외로, 선언된 타입과 할당하려는 타입이 호환되지 않을 때 발생한다. 즉, Number 배열로 참조되는 Interger 배열에 double을 삽입하면 힙 오염(Heap pollution)이 발생하므로 언어 차원에서 이를 감지하고 예외를 발생하는 것이다. 

 

그런데 여기서 한 가지 재밌는 사실은 이러한 에러가 컴파일 시점에는 감지가 안되지만 런타임 시점에는 감지된다는 것이다. 이는 배열이 런타임에 실제 타입 정보를 유지하기에 가능한 것으로 이를 Reifiable Type이라고 한다.

 

자바에서 대표적인 Reifiable Type으로는 원시 타입(int, double, char...), 원시 타입의 배열(int[], double[], char[]...), 원시 타입 래퍼(Integer, Double...), 원시 타입 래퍼의 배열 (Integer[], Double[]...)이 있다. 반대로 컴파일 과정에서 타입 정보가 소거되는 Non-Reifiable Type으로는 제네릭 타입(T, List<T>)이 있다.

 

 

제네릭의 출생

제네릭을 논하기 전에, 제네릭이 없던 시절과 등장함으로써 어떤 문제들이 해결됐는지 이해해야 한다.

 

제네릭 이전에는 컬렉션과 같은 자료구조를 다음과 같이 사용했다.

@Test
void test_list() {
    List list = new ArrayList();
    list.add(123);
    list.add(456);
}

 

깔끔해(?) 보이지만 문제는 타입 안정성이 부족해서 코드를 잘못 작성하면 컴파일은 될지언정 런타임에 에러가 발생했다. 비단 타입 안정성만의 문제를 넘어 코드의 가독성 저하, 유지 보수의 어려움, 잦은 타입 캐스팅 문제도 존재했다. 

 ```
    List list = new ArrayList();
    list.add(123);
    list.add("hello");
    
    for (int i = 0; i < list.size(); i++) {
        String element = (String) list.get(i);  // ClassCastException !!
        System.out.println(element);
    }
````

 

결국 이러한 문제를 해결하기 위해 컴파일 타임에 타입을 명시하는 제네릭이 등장함으로써 타입의 안정성을 확보하고 코드의 일반화와 재사용성 그리고 타입 캐스팅의 번거로움을 해소했다. 제네릭이 컴파일 이후에 소거되는 이유도 여기서 유추할 수 있는데, 컴파일 타임에 타입 안정성을 보장함으로써 제 역할을 다 한 제네릭이 컴파일 이후에 소거되는 것은 어느 정도 합리적이어 보인다.

 

 

제네릭의 약점

마냥 좋아 보이던 제네릭도 완벽하지는 않았다.

 

@Test
void test_list() {
    List<Integer> integers = new ArrayList<>();
    List<Number> numbers = new ArrayList<>();
}

위 코드는 Non-Reifiable Type 규칙에 의해 컴파일 결과가 다음과 같다.

@Test
void test_list() {
    List integers = new ArrayList<>();
    List numbers = new ArrayList<>();
}

 

여기서 제네릭 타입이 가진 약점이 발생하는데, 컴파일 과정에서 타입 정보가 사라지므로 앞선 배열의 예제처럼 런타임 시점에 힙 오염을 언어 차원에서 감지할 방법이 없다.

@Test
void test_list() {
    List<Integer> integers = new ArrayList<>(); // List integers
    List<Number> numbers = new ArrayList<>(); // List numbers

    numbers = integers; // compile error
    numbers.add(1.0); // List add 1.0 no problem !
}

만약 위 코드에서 컴파일 에러마저 없었다면 런타임 시점에 마지막 라인에서 힙 오염이 발생한다는 사실을 알 수 없다. 그렇다 컴파일 에러는 최후의 보루였던 것이다. 이러한 이유로 타입 간의 상속 관계가 배열에서 유지되지만 리스트는 유지되지 않고 컴파일 에러를 내뱉는다. 즉 배열은 공변성 반대로 리스트는 불공변성이란 사실을 알 수 있다.

 

@Test
void print_test() {
    List<Integer> integers = Arrays.asList(1, 2, 3);
    List<Long> longs = Arrays.asList(1L, 2L, 3L);
    List<Float> floats = Arrays.asList(1.0f, 2.0f, 3.0f);
    List<Double> doubles = Arrays.asList(1.0, 2.0, 3.0);

    print(integers); // compile error
    print(longs);    // compile error
    print(floats);   // compile error
    print(doubles);  // compile error
}

private void print(List<Number> numbers) {
    numbers.forEach(System.out::println);
}

예제 코드에서 볼 수 있듯 리스트의 불공변성은 다형성을 활용하지 못하는 단점으로까지 이어지는데 자바는 이러한 문제를 해결하기 위해 와일드카드 문법을 제공한다.

 

 

와일드카드

타입 안정성, 가독성, 코드의 유지 보수 증진을 위해 등장한 제네릭이 오히려 실용성이 떨어지는 상황이 생기면서 자바는 특단의 조치를 취하게 되는데 바로 와일드카드의 등장이다. 와일드카드라는 말 그대로 어떤 타입도 될 수 있으면서 동시에 알 수 없는 타입을 표현할 때 사용한다. 

 

와일드카드의 불특정 타입이라는 개념을 활용하면 앞선 코드를 다음과 같이 수정할 수 있다.

@Test
void test_list() {
    List<Integer> integers = Arrays.asList(1, 2, 3);
    List<Long> longs = Arrays.asList(1L, 2L, 3L);

    print(integers);
    print(longs);
}

private void print(List<?> list) {
    list.forEach(System.out::println);
}

 

하지만 여기에는 몇 가지 제약 사항이 존재하는데 와일드카드로 선언된 컬렉션의 요소를 읽기는 가능하지만 쓰기는 불가능하다.

private void print(List<?> list) {
    list.forEach(System.out::println); // ok !
    
    list.add(new Object());
}

 

이는 제네릭과 와일드카드의 주요한 특성 중 하나인 타입 안정성을 보존하는 특성에서 기인한다.

list.forEach(System.out::println);와 같은 읽기 연산은 해당 타입이 구체적으로 어떤 타입인지 모르지만 적어도 Object 타입으로 반환할 수 있다. 반면 list.add(new Object());와 같은 쓰기 작업은 해당 컬렉션에 어떤 타입이 들었는지 알 수 없기에 쓰기 연산을 제한한다. 다음 예제 코드만 봐도 와일드카드에서 쓰기 연산이 불가능한 이유를 바로 이해할 수 있을 것이다.

@Test
void test_list() {
    List<Integer> integers = Arrays.asList(1, 2, 3);
    List<?> wildcards = integers;

    wildcards.add("hello world!"); // compile error!
}

 

 

제네릭과 공변성, 반공변성

와일드카드의 읽기 쓰기 문제를 해결하기 위해 자바는 한 번 더 특단의 조치를 하게 되는데, 바로 한정적 와일드카드의 등장이다.

 

상한 경계 와일드카드 <? extends Class>

읽기 문제부터 살펴보면, 캐스팅 안정성을 확보하려면 '최소한 어떤 타입'인지 제한할 필요가 있어 보인다. 와일드카드에 extends 키워드를 추가하면 앞서 언급한 최소한의 타입을 제한할 수 있고 이를 두고 상한 경계 와일드카드라고한다.

@Test
void test_wildcard() {
    List<? extends Number> numbers;
    numbers = new ArrayList<Integer>(); // ok!!
    numbers = new ArrayList<Number>(); // ok!!

    for (Number n : numbers) {} // ok!!
    for (Object o : numbers) {} // ok!!
    for (Integer i : numbers) {} // error!!
}

읽기는 명시된 타입과 그 부모 타입으로 가능하지만 그 외의 타입에 대해서는 실제 List<Double>을 참조할 수 있기에 Integer로 읽을 수 없고 Double도 동일한 논리로 읽기 불가능하다.

 

@Test
void test_wildcard() {
    List<? extends Number> list = new ArrayList<>();

    list.add(1); // error! could be List<Double>
    list.add(1.0); // error! could be List<Integer>
}

쓰기는 어떠한 타입도 불가능한데, 가령 List<Double>을 참조할 수 있기에 Integer 삽입이 불가능하고 비슷한 논리로 List<Integer>를 참조할 수 있게 Double 삽입 불가능하다.

 

 

하한 경계 와일드 카드 <? Super Class>

단순히 와일드카드를 사용 했을 때 어떠한 값도 쓰지 못했지만 super 키워드를 추가하면 제한적으로 쓰기 가능한데 이를 두고 하한 경계 와일드카드라고 한다.

@Test
void test_wildcard() {
    List<? super Integer> list;
    list = new ArrayList<Integer>();
    list = new ArrayList<Number>();
    list = new ArrayList<Object>();

    list.add(new Integer(1));
}

쓰기는 명시된 타입과 그 자식 타입으로 가능하지만 그 외의 타입에 대해서는 List<Integer>를 참조할 수 있기에 Number와 Object로는 쓰기 불가능하다.

 

@Test
void test_wildcard() {
    List<? super Integer> list = new ArrayList<>();

    Object o = list.get(0);
}

읽기는 기본적으로 Object로만 읽을 수 있다. 가령 List<Object>를 참조할 수 있기에 Integer와 Number로 읽기 불가능하고 오직 Object로만 읽었을 때 안정성을 보장받을 수 있기 때문이다.

 

여기까지 이해했다면 자연스럽게 한 가지 사실을 알 수 있는데, 바로 한정적 와일드카드의 등장으로 리스트가 공변성과 반공변성의 성질을 갖게 됐다는 것이다.

 

 

@SafeVarargs

제네릭과 가변 인수를 함께 사용하면 컴파일러가 경고를 보내는데 이제는 그 이유를 알 수 있다.

public void foo(List<String>... args) {

}

 

public void foo(List<String>... args)는 실제로 public void foo(List<String>[] args)와 동일하고 Non-Reifiable Type 규칙에 따라 컴파일된 최종 형태는 public void foo(List[] args)이다.

private void foo(List<String>... args) {
    Object[] objects = args; // Object[] = List[]
    objects[0] = Arrays.asList(1);

    String str = args[0].get(0); // ClassCastException !!
}

컴파일된 최종 형태를 상상하며, 타입 소거와 배열의 공변성을 교묘하게 이용하면 다음과 같이 힙 오염이 가능하므로 컴파일러가 경고를 표시할 뿐만 아니라 메서드 호출 코드에도 경고 메시지가 나타난다. 이러한 상황에 메서드에 잠재적인 위험이 없다는 것을 명시적으로 알리기 위해 @SafeVarargs 어노테이션을 사용한다.

 

@SafeVarargs
private void foo(List<String>... args) {
}

Java 7에 최초로 도입된 당시 생성자, static 메서드, final 메서드에만 허용됐는데 Java 9부터는 private 메서드에도 @SafeVarargs를 달아줄 수 있다.