Study/이펙티브 자바 / / 2023. 12. 4. 17:34

[Effective Java 3E] 제네릭과 가변인수를 함께 쓸 때는 신중하라

💥 개요

가변인수와 제네릭은 잘 어우러지지 않는다.

가변인수 메서드를 호출하면 가변인수를 담기 위한 배열이 자동으로 하나 만들어지는데(컴파일 시점에 변환됨)

내부적으로 처리해야하는 이 배열을 그만 클라이언트에 노출하는 문제가 생겼다. 그 결과 varargs 매개변수에 제네릭이나 매개변수화 타입이 포함되면 알기 어려운 컴파일 경고가 발생한다.

매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다. 이렇게 되면 typesafe하지 않아 형변환 에러가 발생하고 제네릭 타입의 타입 안정성이 흔들린다.

 

☠️문제상황

public class Dangerous {
    // 코드 32-1 제네릭과 varargs를 혼용하면 타입 안전성이 깨진다! (191-192쪽)
    static void dangerous(List<String>... stringLists) {
        List<Integer> intList = List.of(42);
        Object[] objects = stringLists;
        objects[0] = intList; // 힙 오염 발생
        String s = stringLists[0].get(0); // ClassCastException
    }

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

sringLists[0].get(0)을 하게 되면 String만을 담고 있어야 할 stringsList에 intList의 주소값이 들어가 있기 때문에 힙오염이 발생하고 문제되는 상황인 ClassCastException이 발생하게 된다. 이처럼 타입 안정성이 깨지니 제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.

 

제네릭 배열을 개발자가 직접 생성하는건 허용하지 않으면서 제네릭 varargs 매개변수를 받는 메서드를 선언할 수 있게 한 이유는 뭘까? 즉 컴파일 시점에 32-1에서 경고 정도로 끝내는 이유는 실무는 매우 유용하기 때문이다.

 

그래서 언어 설계자는 이 모순을 수용하기로 했다.(자바 제네릭의 문제라고도 할 수 있음, 공변성)

 자바 라이브러리에서도 이런 메서드를 여럿 제공한다.(Arrays.asList, Collections.addAll, EnumSet.of 등)

하지만 이들은 타입 안전하다.

 

🔍 @SafeVarargs

이 어노테이션은  메서드 작성자가 그 메서드가 타입 안전함을 보장하는 장치다. 하지만 이 말은 절대 이 어노테이션이 타입 안전하지 않은 메서드를 만들어주는것은 아니다. 그렇기에 타입 안전한 메서드를 만들고 이 어노테이션을 붙여야 한다.

단지 @SupressWarnings과 같이 오류를 숨겨준다.

그럼 메서드를 어떻게 안전하게 만들어야 할까?

  1. 이 배열에 아무것도 저장하면 안된다.
  2. 참조가 밖으로 노출되면 안된다.
  3. 호출자로 부터 그 메서드로 순수하게 인자를 전달하는 일만 해야한다.
// 코드 32-2 자신의 제네릭 매개변수 배열의 참조를 노출한다. - 안전하지 않다! (193쪽)
    static <T> T[] toArray(T... args) {
        return args;
    }

위와 같은 코드는 제네릭 배열의 참조를 노출하게 되어 절대 만들면 안된다.

 

static <T> T[] pickTwo(T a, T b, T c) {
    switch(ThreadLocalRandom.current().nextInt(3)) {
        case 0: return toArray(a, b);
        case 1: return toArray(a, c);
        case 2: return toArray(b, c);
    }
    throw new AssertionError(); // 도달할 수 없다.
}

public static void main(String[] args) { // (194쪽)
    String[] attributes = pickTwo("좋은", "빠른", "저렴한");
    System.out.println(Arrays.toString(attributes));
}

이 코드또한 ClassCaseException을 던진다. 형 변환하는곳이 보이지 않는데도 pickTwo의 반환값을 attribute에 저장하지 위해 Object[]를 String[]로 형변환 하는 부분에서 문제가 발생한다. Object[]는 String[]의 하위타입이 아니므로 이 형변환은 실패한다. 

이 예는 제네릭 varargs 매개변수 배열에 다른 메서드가 접근하면 안전하지 않다 는 점을 상기시킨다.

 

👍 제네릭 varargs 매개변수가 안전한 경우

  1. @SafeVarargs로 제대로 에노테이트 된 도 다른 varargs 메서드에 넘기는것은 안전하다.
  2. 이 배열 내용이 일부 함수를 호출만 하는(varargs를 받지 않는) 일반 메서드에 넘기는 것도 안전하다.
// 코드 32-3 제네릭 varargs 매개변수를 안전하게 사용하는 메서드 (195쪽)
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);
    }
}

이와 같이 flatten 메소드를 임의 개수의 리스트를 인수로 받아 받은 순서대로 그 안의 모든 원소를 하나의 리스트로 옮겨 반환한다. @SafeVarargs로 에러를 숨기고 사용하는쪽과 선언하는쪽에서 경고를 내지 않는다.

 

🎈 다른 방법

@SafeVarargs 에노테이션이 유일한 정답은 아니다. varargs 매개변수를 List 매개변수로 변경해도 된다.

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;
}

이 코드는 타입 안전을 보장한다.

이 방식의 장점은 컴파일러가 이 메서드의 타입 안전성을 검증할 수 있다는것이다.

직접 에노테이션을 달지않아도 되고, 안전하다고 잘못 판단할 가능성도 없다. 단점은 클라이언트 코드가 지저분해지고, 속도가 조금 느려질 수 있다는 정도다.

 

가변인수와 제네릭은 궁합이 좋지 않다. 가변인수 기능은 배열을 노출하여 추상화가 완벽하지 못하고,
배열과 제네릭 타입 규칙이 서로 다르기 때문이다.
제네릭 varargs 매개변수는 타입 안전하지 않지만, 허용된다.
메서드에 제네릭(혹은 매개변수화된) varargs 매개변수를 사용하고자 한다면, 먼저 그 메서드가 타입 안전한지 확인한 다음 @SafeVarargs 어노테이션을 달아 사용하는데 불편함이 없게끔 하자


✨내 생각

굳이 varargs를 제네릭을 받아야 하는 상황에서 사용하는건 문제가 있다고 생각한다.  제네릭과 같은 매개변수화 타입의 경우 다른 방법에서 말했던 것 처럼 제네릭을 사용하거나 래퍼클래스를 사용하는게 훨씬 나을 것 같다.

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유