본문 바로가기
JAVA/Effective Java

Item 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라

by 민휘 2023. 7. 11.
☁️ 가변인수와 제네릭은 궁합이 좋지 않다. 가변인수 기능은 내부적으로 배열을 사용하는데, 배열과 제네릭의 타입 규칙이 달라서 타입 안전하지 않다. (다만 제네릭 배열과 달리 제네릭 가변인수는 실무에서 매우 유용하게 사용되므로 제네릭 가변인수의 사용을 허용했다.) 제네릭 가변인수를 사용할 때는 배열에 값을 저장하거나 배열 참조를 외부에 노출해서는 안된다. 두 조건을 만족한다면 타입 안전한 것이므로 @SafeVarags을 사용해 경고를 무시한다. 혹은 가변인수 대신 List를 사용한다.

 

Item 28. 제네릭과 배열

 

제네릭과 배열은 매우 다른 타입 규칙이 적용된다.

  • 배열 : 공변이므로 컴파일타임에 안전하지 않지만, 실체화되므로 런타임에는 안전하다.
  • 제네릭 : 공변이 아니므로 컴파일타임에 안전하고, 실체화되지 않으므로 런타임에는 안전하지 않다.

 

컴파일 타임에 오류를 잡아주는 제네릭이 더 타입에 안전하다고 볼 수 있다. 런타임에 ClassCastException이 발생하는 일을 막아주겠다는 것이 제네릭 타입 시스템의 취지이다.

 

제네릭과 배열은 너무 다른 타입 규칙으로 사용하므로 함께 사용하면 타입이 깨지기 쉽다. 그래서 제네릭 배열은 금지된다. 만약 제네릭과 배열을 함께 사용하면 다음과 같이 잡아내기 어려운 타입 오류가 발생한다.

package org.example.item28;

import java.util.List;

public class GenericArray {
    List<String>[] stringLists = new List<String>[1]; // 제네릭 배열 생성 가능하다고 가정하자!
    List<Integer> intList = List.of(42);              // 원소가 하나인 List<Integer> 생성
    Object[] objects = stringLists;                   // List<String>은 Object의 하위 타입. 배열은 공변이므로 대입 가능.
    objects[0] = intList;                             // List<Integer>은 Object의 하위 타입이므로 대입 가능. stringList[0]의 참조도 변경됨.
    String s = stringLists[0].get(0);                 // ClassCastException - intList에서 꺼낸 Integer를 String으로 형변환 시도

}

 

가변인수와 배열

 

가변인수도 내부적으로는 배열을 사용하므로, 가변인수에 제네릭을 사용했을 때 위와 동일한 문제가 발생한다.

package org.example.item32;

import java.util.List;

public class GenericVararg {
    static void dangerous(List<String>... stringLists) {
        List<Integer> intList = List.of(42);
        Object[] objects = stringLists;     // stringLists의 타입은 List<String>[]. 배열은 공변이 허용되므로 이렇게 대입 가능.
        objects[0] = intList;               // Heap pollution 발생!
        String s = stringLists[0].get(0);   // ClassCastException - Integer를 String으로 바꿀 수 없다
    }

    public static void main(String[] args) {
        dangerous(List.of("There", "be", "dragons"));
    }
}

 

 

Heap Pollution이란?

 

제네릭 타입 시스템에서 발생하는 문제이다. 제네릭 타입은 컴파일 시점에 모든 오류를 잡아서 런타임에서 ClassCastException을 피하는 전략을 기본으로 한다. 만약 제네릭 타입이 컴파일 시점에 잡는 오류를 피한다면 런타임 시에 문제가 발생한다. 문제는 제네릭과 다른 타입 규칙의 배열을 사용하면 공변을 통해 제네릭 타입의 컴파일 오류 체크를 피할 수 있다.

 

위의 예시에서 stringlists는 List<String>으로 선언되었으므로, get으로 꺼냈을 때 String으로 사용할 수 있어야 한다. stringlists의 원소가 힙 메모리에서도 String이기를 기대한다. (그러하기에 stringLists에서 get으로 꺼낸 원소를 String으로 받는 것이 자연스럽다.) 그러나 메소드 내에서 objects[0] = intList; 때문에 힙 메모리에서 해당 Integer를 가리키게 되었다. 이를 힙 오염이라고 부른다.

 

 

제네릭 배열은 안되는데 제네릭 가변인수는 가능한 이유

Item 28에서 살펴본 제네릭 배열과 동일한 문제가 제네릭 가변인수에서 발생한다. 제네릭 배열은 허용하지 않지만 제네릭 가변인수를 허용한 이유는 실무에서 매우 유용하기 때문이다. 자바 라이브러리의 Arrays.asList(T… a), Collections.addAll(Collectrion<? super T> c, T… elements), EnumSet.of(E first, E… rest)가 대표적이다. 타입 안전하다고 판단한 메소드는 @SafeVarargs를 사용하여 경고를 무시할 수 있다.

 

 

타입 안전하게 사용하기

 

제네릭 가변인수를 사용하더라도 타입에 안전하게 사용하는 방법이 있다. 위의 코드가 문제였던 이유는 배열에 제네릭 참조를 저장하고 값을 덮어썼기 때문이다. ⭐️(1) 배열에 값을 저장하지 않거나, (2) 이 배열에 쓰기 용도로 접근하지 못하도록 배열 참조를 외부로 노출하지 않는다⭐️면 안전하게 사용할 수 있다. 즉, 가변인수가 원래 목적대로 그 메소드로 순수하게 인수들을 전달하는 일만 한다면 타입 안전하다고 할 수 있다.

 

배열 참조를 외부로 노출해서 타입이 깨지는 예시

 

배열에 값을 저장하지는 않지만, 이 배열의 참조를 반환하는 toArray 메소드이다.

static <T> T[] toArray(T... args) {
	return args;
}

 

pickTwo는 T 타입의 인자를 세개 받아서 랜덤으로 두개를 뽑아 배열로 구성해 반환한다.

static <T> T[] pickTwo(T a, T b, T c) {
	switch(ThreadLocalRandom.current().nextInt(3)) {
		case 0: return toArray(a, b); // Object[]
		case 1: return toArray(a, c); // Object[]
		case 2: return toArray(b, c); // Object[]
	}
	throw new AssertionError(); // Can't get here
}

public static void main(String[] args) {
	String[] attributes = pickTwo("Good", "Fast", "Cheap"); // Class Cast Exception - Object -> String
	System.out.println(Arrays.toString(attributes));
}

 

별다른 문제가 없어보이는데, pickTwo를 사용해보면 Class Cast Exception가 발생한다. 그 이유는 toArray가 반환하는 args의 타입이 항상 Object[]로 정해지기 때문이다. Object[]는 T에 어떤 타입의 객체를 넘기더라도 담을 수 있는 가장 구체적인 타입이기 때문이다. toArray의 반환값을 반환하는 pickTwo도 Object[]를 반환한다. main에서 사용하는 pickTwo의 반환값인 Object[]는 더 구체적인 타입인 String[]으로 형변환할 수 없으므로 타입 오류가 발생한다.

 

가변인수 대신 List 사용하기

 

위의 코드를 안전하게 사용하고 싶다면, 가변인수 배열 대신 리스트를 반환하면 된다. 가변인수와 달리 List.of()는 배열의 개입으로 인한 heap pollution이 없다는 것을 @SafeVarags로 보장하므로 안심하고 사용할 수 있다.

public class SafePickTwo {
    static <T> List<T> pickTwo(T a, T b, T c) {
        switch(ThreadLocalRandom.current().nextInt(3)) {
            case 0: return List.of(a, b);
            case 1: return List.of(a, c);
            case 2: return List.of(b, c);
        }
        throw new AssertionError();
    }

    public static void main(String[] args) {
        List<String> attributes = pickTwo("Good", "Fast", "Cheap");
        System.out.println(attributes);
    }
}

 

 

타입 안전하다면 @SafeVarags로 경고 무시하기

 

타입 안전의 조건

  • 배열에 값을 저장하지 않는다
  • 배열 참조를 외부로 노출하지 않는다

 

타입 안전한 제네릭 가변인수 메소드 예시

  • lists를 읽기로만 사용하고, 참조를 노출하지 않음
public class FlattenWithVarargs {
    @SafeVarargs
    static <T> List<T> flatten(List<? extends T>... lists) {
        List<T> result = new ArrayList<>();
        for (List<? extends T> list : lists)
            result.addAll(list);
        return result;
    }

    public static void main(String[] args) {
        List<Integer> flatList = flatten(
                List.of(1, 2), List.of(3, 4, 5), List.of(6,7));
        System.out.println(flatList);
    }
}

 

제네릭 가변인수 대신 리스트를 사용하는 메소드 예시

  • 타입 안전하게 만들 수 없다면 리스트를 사용하는 것도 방법!
public class FlattenWithList {
    static <T> List<T> flatten(List<List<? extends T>> lists) {
        List<T> result = new ArrayList<>();
        for (List<? extends T> list : lists)
            result.addAll(list);
        return result;
    }

    public static void main(String[] args) {
        List<Integer> flatList = flatten(List.of(
                List.of(1, 2), List.of(3, 4, 5), List.of(6,7)));
        System.out.println(flatList);
    }
}

 

위의 두 조건을 만족한다면 타입 안전하다고 할 수 있다. 그러나 가변인수를 받는 메소드의 클라이언트는 잠재적으로 ClassCastException가 발생할 수 있다는 경고를 받는다. 이 경고를 무시하려면 @SafeVarargs를 사용하면 된다. 가변인수를 받는 메소드인 Arrays.asList(T... a)도 이 애노테이션을 사용한다.