예전에는 열거한 값들을 집합으로 사용할 경우, 서로 다른 2의 거듭제곱 값을 할당한 정수 열거 패턴을 주로 사용했었다.
이렇게 만들어진 집합은 비트 필드라고 부르고, 적은 메모리로 다양한 정보를 담을 수 있어 프로그래밍 문제에서 자주 사용하곤 한다.
public class Text {
public static final int STYLE_BOLD = 1 << 0;
public static final int STYLE_ITALIC = 1 << 1;
public static final int STYLE_UNDERLINE = 1 << 2;
public void applyStyles(int styles) { }
}
하지만 이런 기법은 정수 열거 패턴의 단점을 그대로 지니고, 해석하기가 어렵다. 비트 필드 값을 순회하기에도 까다롭고,
최대 몇 비트가 필요할 지 예상해야 한다. 구현 로직을 변경하지 않고서 비트 수를 더 늘릴 수 없기 때문에 적절한 타입을 선정하는 것도 중요하다.
이제는 java.util.EnumSet 클래스를 사용하자. Set 인터페이스를 구현했으며, 타입 안전하고, EnumSet 내부도 비트 벡터로 구현되어 있기 때문에 깔끔하게 코드를 작성할 수 있다. 비트를 직접 다룰 때 생기는 각종 오류들로부터 해방시키고 구현에 구애받지 않고 개발할 수 있어 생산성도 높다.
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH };
// EnumSet 보다 인터페이스인 Set 을 넘겨 다른 구현체를 넘겼을 때도 처리할 수 있게 하자.
public void applyStyles(Set<Style> styles) {
....
}
}
// 사용부
Text text = new Text();
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
EnumSet 이나 EnumMap 과 같이 열거 타입 기반의 자료구조에서 사용하려고 만든 메서드이다. 하지만 이 메서드를 프로그래머가 임의의 메서드에서 사용한다면 오동작할 수 있다.
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET,
SEXTET, SEPTET, OCTET, NONET, DECTET;
// 아래처럼 사용하면 안 된다.
public int numberOfMusicians() { return ordinal() + 1; }
}
상수 선언 순서를 바꾸는 순간 오동작하는 코드이며, 이미 사용 중인 정수와 값이 같은 상수라면 추가할 방법이 없다.
값을 중간에 비워둘 수도 없다. 차라리 아래와 같이 선언과 동시에 인스턴스 필드에 저장하면 된다.
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
SEXTET(6), SEPTET(7), OCTET(8), NONET(9), DECTET(10);
private final int numberOfMusicians;
Ensemble(int size) { this.numberOfMusicians = size; }
public int numberOfMusicians() { return nubmerOfMusicians; }
}
자바에서 열거 타입을 지원하기 전에는 아래와 같이 정수 상수를 한 묶음으로 선언하여 사용하였다.
정수 열거 패턴
public static final int V3_INFO = 0;
public static final int V3_UPDATE = 1;
public static final int V3_SCAN = 2;
public static final int ALYAC_INFO = 0;
public static final int ALYAC_UPDATE = 1;
public static final int ALYAC_SCAN = 2;
하지만 이런 정수 열거 패턴은 아래와 같은 단점들이 있다.
1. 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.
정수 열거 패턴을 위한 이름 공간(namespace) 을 지원하지 않기 때문에 같은 상수값을 사용하는 변수들이 모두 같은 값으로 인식될 수 있다. 예를 들어 위 코드에서 V3_INFO 와 ALYAC_INFO 값이 같은 값으로 인식된다. V3 관련 메서드에서 ALYAC 상수 값을 사용해도 컴파일러가 오류/경고 메세지를 출력하지 않는다는 말이다.
또한 이름 공간(namespace) 을 지원하지 않기 때문에 접두사를 붙여 변수 네이밍을 해야한다는 점도 단점이다.
2. 정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다.
평범한 상수를 나열한 것 뿐이라 컴파일하면 해당 값이 클라이언트 파일에 하드코딩된다. 만약 상수의 값이 바뀌면 반드시 다시 컴파일 해서 재배포해야 한다. 다시 컴파일되지 않은 클라이언트 파일이 서버에서 변해버린 상수 값을 받을 때 의도하지 않은 방향으로 동작할 수 있다.
3. 정수 상수는 문자열로 출력하기 어렵다.
public static final int V3_INFO = 0;
System.out.println(V3_INFO); // 문자열이 아닌 의미 없는 상수 출력
그렇다고 문자열 열거 패턴을 사용하면 안 된다.
문자열 값을 하드 코딩하는데 오타가 발생하면 자연스레 런타임 오류가 발생한다. 심지어 문자열 상수들을 비교할 때 성능 저하가 발생한다.
4. 같은 정수 열거 그룹에 속한 모든 상수를 한 바퀴 순회하는 방법도 마땅치 않다.
열거 그룹에 속한 정수 갯수가 총 몇 개인지 알 수 없어 순회하기도 힘들다.
이런 정수 열거 패턴의 단점을 보완하고 여러 장점들을 사용하는 자료구조가열거 타입(Enum Type)이다.
열거 타입
다른 언어와 다르게 자바에서 열거 타입은 클래스이며 사실상 싱글톤 객체이다.
열거 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개하는데 외부에서 접근할 수 있는 생성자를 제공하지 않기 때문에 사실상 final class 이며 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없기 때문이다.
열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재한다.
public enum WeekDay {
MONDAY(0),
TUESDAY(1),
WEDNESDAY(2),
THURSDAY(3),
FRIDAY(4),
SATURDAY(5),
SUNDAY(6);
private final int value;
WeekDay(int value) {
this.value = value;
}
}
1. 열거 타입은 컴파일타임 타입 안전성을 제공한다.
public static final int MONDAY = 0;
void test() {
enumTest(MONDAY); // 컴파일 오류
enumTest(WeekDay.MONDAY); // 정상 동작
}
private void enumTest(WeekDay weekDay) {
}
다른 타입의 값을 인자로 받았을 때 컴파일 오류가 발생한다. 타입을 명확히 전달할 수 있다는 강점이 있다.
2. 열거 타입에는 각자의 이름공간(namespace)이 있어서 이름이 같은 상수도 선언할 수 있다.
4. 열거 타입에는 임의의 메소드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있다.
public enum WeekDay {
MONDAY(0),
TUESDAY(1),
WEDNESDAY(2),
THURSDAY(3),
FRIDAY(4),
SATURDAY(5),
SUNDAY(6);
//임의의 필드
private final int value;
WeekDay(int value) {
this.value = value;
}
//임의의 메소드
public void test() {
}
}
상수 값들과 연관된 데이터들이나 계산 과정들을 enum 객체 안에다 모두 담을 수 있어 코드 응집력을 높일 수 있다.
참고로 클래스로써 Object 메소드들을 지원하고 Comparable, Serializable 도 구현해두었다.
직접 구현해보면서 테스트해보자.
테스트
public enum Planet {
MERCURY(3.302e+23,2.439e6),
VENUS(4.869e+24,6.052e6),
EARTH(5.975e+24, 6.378e6),
MARS(6.419e+23,3.393e6),
JUPITER(1.899e+27,7.149e7),
SATURN(5.685e+26,6.027e7),
URAUS(8.683e+25,2.556e7),
NEPTUNE(1.024e+26,2.477e7);
private final double mass;
private final double radius;
//표면중력
private final double surfaceGravity;
//중력상수
private static final double G = 6.67300E-11;
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
this.surfaceGravity = G * mass / (radius * radius);
}
public double mass() {
return mass;
}
public double radius() {
return radius;
}
public double surfaceGravity() {
return surfaceGravity;
}
public double surfaceWeight(double mass) {
return mass * surfaceGravity;
}
}
Planet Enum 은 행성들의 질량, 반지름, 표면중력 상수를 가지고 있는 데이터 집합이다.
상수인 질량과 반지름이 주어졌을 때 표면중력 상수 또한 변하지 않는 값으로 계산할 수 있는 상수 값이다.
표면중력도 마찬가지. 질량, 반지름, 표면중력 상수 값들을 토대로 만들어지기 때문에 변하지 않는 상수 값이다. 이와 같이 Enum 은 상수들과 연관된 상수 값들을 연산할 수 있는 메서드를 한 공간에 배치하여 표시할 수 있다는 장점이 있다.
열거 타입은 자신 안에 정의된 상수들을 배열에 담아 반환하는 values() 메소드를 제공한다. 상수들은 선언된 순서대로 저장된다.
for (Planet p : Planet.values()) {
System.out.printf("%s 에서의 무게는 %f 이다. %n", p, p.surfaceWeight(mass));
}
열거 타입의 toString 메소드는 상수 이름을 문자열로 반환한다. 또한 toString 메소드를 재정의하여 사용할 수 있다.
public enum Planet {
// ...중략...
//재정의한 toString, 이름을 소문자로 출력한다.
@Override
public String toString() {
return this.name().toLowerCase();
}
}
열거 타입의 장점은 열거 타입에 선언한 상수 하나를 제거하더라도제거한 상수를 참조하지 않는 클라이언트에는 아무 영향이 없다. 반대로 제거된 상수를 참조한 클라이언트는 컴파일 타임 때 에러가 발생하고 컴파일 하지 않은 클라이언트에서는 런타임 에러가 발생한다.
열거타입의 패턴
위 테스트에서는 단순히 상수 값들을 데이터들과 연관지었지만 만약 열거 타입의 메서드가 상수에 따라 다르게 동작해야 한다면 아래 패턴들을 참고해볼 수 있다.
상수별 메소드 구현
간단하게 if 나 switch 문으로 조건문을 통해 해결할 수 있다. 하지만 이는 안티패턴이다.
새로운 상수가 추가된다면 case 문도 추가해야 한다. 데이터가 추가 될 때 부수적으로 생각해야 하는 로직 변경이 있다면 OCP 원칙에 위배될 수 있다.
public enum Operation {
PLUS,MINUS,TIMES,DIVDE;
public double apply(double x, double y) {
switch (this) {
case PLUS:
return x + y;
case MINUS:
return x - y;
case TIMES:
return x * y;
case DIVDE:
return x / y;
}
throw new AssertionError("알 수 없는 연산:" + this);
}
}
여기서 상수별 메소드 구현을 사용하면 조금 더 나은 방식으로 개선할 수 있다.
상수별 메소드 구현은 열거 타입에 추상 메소드를 선언하고 각 상수별로 클래스 몸체를 자기자신이 재정의하는 방법이다.
public enum Operation {
PLUS("+") {
public double apply(double x, double y) {
return x + y;
}
},
MINUS("-") {
public double apply(double x, double y) {
return x - y;
}
},
TIMES("*") {
public double apply(double x, double y) {
return x * y;
}
},
DIVDE("/") {
public double apply(double x, double y) {
return x / y;
}
};
private final String symbol;
Operation(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
public abstract double apply(double x, double y);
}
참고로 열거 타입엔 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해주는 valueOf(String) 메소드가 자동 생성된다.
toString 메소드를 재정의했다면, toString이 반환하는 문자열을 해당 혈거 타입 상수로 변환해주는 fromString 메소드도 함께 제공하는 걸 고려해보자.위의 코드에서 toString 메소드를 재정의해 기존 상수의 이름이 아닌 각 연산자의 기호를 반환하도록 구현하였다. 반대로 fromString 메소드를 구현하여 연산자 기호를 매개변수로 전달하면 알맞은 열거 타입 객체를 반환하도록 해보자
private static final Map<String, Operation> stringToEnum =
Stream.of(Operation.values())
.collect(Collectors.toMap(Operation::toString, operation -> operation));
//Optional로 반환하여 값이 존재하지않을 상황을 클라이언트에게 알린다.
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}
여기서 중요하게 봐야할 것은 Map에 Operation 상수가 추가되는 시점이다. Operation 상수들은 정적 필드가 초기화되는 시점에 추가된다. 열거 타입에서 정적 필드는 열거 타입 상수가 생성된 후에 초기화 된다. 그렇기 때문에 열거 타입 생성자에서 정적 필드를 참조하려고 하면 컴파일 에러가 발생한다.
만약, putString 이라는 메소드를 생성자에서 Map 에 추가하려고 하면 어떻게 될까?
위 글에서는 자바 제네릭의 한계 때문에 런타임 시 타입을 확인하기 위해 타입 토큰을 사용하여 타입 안전 이종 컨테이너(THC Pattern(Typesafe Heterogenous Container Pattern)) 를 활용한다고 하지만, 책에서는 제네릭 확장 개념으로 설명하고 있다. Set<E>, Map<K, E> 처럼 제네릭은 매개변수를 한 개나 두 개 정도 밖에 사용을 못 하는데 더 확장하여 사용하고 싶지 않냐는 식이었다. 사실 취지가 마음에 와닿지는 않았다.
그래도 개념은 동일하다. 타입 안전 이종 컨테이너(THC Pattern(Typesafe Heterogenous Container Pattern))는 컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 꺼내거나 뺄 때 매개변수화한 키를 활용한다는 것이다.
// 선언부
public class SimpleTypeSafeMap {
private Map<Class<?>, Object> map = new HashMap<>();
public <T> void put(Class<T> k, T v) {
map.put(Objects.requireNonNull(k), v);
}
public <T> T get(Class<T> clazz) {
return clazz.cast(map.get(clazz));
}
}
// 사용부
simpleTypeSafeMap.put(String.class, "테스트");
simpleTypeSafeMap.put(Integer.class, 1);
String string1 = simpleTypeSafeMap.get(String.class);
Integer integer1 = simpleTypeSafeMap.get(Integer.class);
위 글 예제와 같다. HashMap Key 에 타입 토큰을 지정하여 값을 로드하고 저장하면 된다.
여기서 중요한 점은 키가 Class<?> 이라서 비한정적 와일드 카드이기 때문에 Map 안에 아무것도 못 넣을 수 있다고 생각할 수 있지만(타입을 체크하지 못하므로), 컨테이너 입장에서는 중첩되어 있기 때문에 Key 만 와일드카드 타입이기 때문에 모든 키가 가능하다. 또 맵의 값 타입이 Object 이기 때문에 이 맵은 키와 값 사이의 타입 관계를 보증하지 않는다. 하지만 메서드들에서 타입을 가져와 동적 캐스팅해 로드하고 타입을 키로 지정해 저장하기 때문에 상관없다. Map 자체로는 안전하지 않지만, 메서드들에서 이를 보장한다.
안전해보이지만, 아직 두 가지 제약이 존재한다.
첫 번째 제약은 클라이언트가 아래와 같이 Generic 타입이 아닌 Raw 타입을 전달하면 타입 안전성이 깨지게 된다. HashSet 이나 HashMap 같은 일반 컬렉션에 Raw 타입을 사용하는 것처럼 말이다.
map.put((Class)Integer.class, "hello");
int testInteger = map.get(Integer.class);
// 닮은 꼴 예제
HashSet<Integer> set = new HashSet<>();
((HashSet)set).add("hello");
단순히 cast 메서드는 Class 객체가 알려주는 타입의 인스턴스인지를 검사한 다음, 맞다면 그대로 반환하고 아니면 ClassCastException 예외를 던지기 때문이다. get 메서드에서는 cast 메서드로 맞는 타입인지 점검하는 반면에 put 메서드에서는 점검하지 않으므로 생기는 문제이다. 컴파일 타임 때 모두 점검하기 위해서 아래와 같이 코드를 수정하자.
public <T> void put(Class<T> k, T v) {
map.put(Objects.requireNonNull(k), type.cast(v));
}
type.cast 로 명시한 타입과 같은지 체크한다. 그냥 동적 형변환을 사용하면 된다. java.util.Collections 의 checkedSet, checkedList, checkedMap 과 같은 메서드들이 위와 같은 구조를 사용한다고 한다.
두 번째 제약은 제네릭(실체화 불가 타입)은 사용할 수 없다. 위 글에서 소개한 것처럼 제네릭에 대한 타입 토큰이나 클래스 리터럴이 준비되어 있지 않기 때문이다. 예를 들어 List<String>와 List<Integer> 는 List.class 클래스 객체를 공유하므로 타입 안전성이 깨지게 된다. 위 글에서 소개한 것처럼 슈퍼 타입 토큰 방식으로 처리해야 한다.
현재는 어떤 Class 객체든 받아들이는데 간혹 메서드들이 허용하는 타입을 제한하고 싶을 때가 있다. 그 때 한정적 타입 매개변수나 한정적 와일드카드를 사용하면 된다. 특히 애너테이션 API 가 한정적 타입 토큰을 적극적으로 사용한다.
public <T extends Annotation> T getAnnotation(Class<T> annotationType);
이 getAnnotation 메서드는 대상 요소에 달려있는 애너테이션을 런타임 때 읽어오는 기능을 한다. 토큰으로 명시한 타입의 애너테이션이 대상 요소에 달려 있다면 그 애너테이션을 반환하고 없다면 null 을 반환한다. THC 컨테이너다. (annotationType 인수가 한정적 타입 토큰으로 선언되어 있는 것을 볼 수 있다.)
만약 Class<?> 타입의 객체가 있고, 이를 getAnnotation 처럼 한정적 타입 토큰을 받는 메서드에 넘기려면 어떻게 해야 할까? Class<? extends Annotation> 은 비검사 형변환이므로 컴파일 경고가 떠서 안 되고, asSubclass 메서드로 동적 형변환 해야한다. 인자로 받은 클래스 객체로 형변환해준다. 컴파일 시점에는 알 수 없으나 런타임 때 읽어낼 수 있는 메서드이다.
"일반적인 제네릭은 한 컨테이너가 다룰 수 있는 타입 매개변수의 수가 고정되어 있다.
하지만 컨테이너 자체가 아닌 키를 타입 매개변수로 바꾸면 이런 제약이 없는 THC 컨테이너를 만들 수 있다."
가변인자는 매개변수의 개수가 정해지지 않은 함수의 인자를 말한다. 메서드에 넘기는 인자의 개수를 클라이언트가 조절할 수 있어, 인자의 개수만큼 메서드를 여러 번 오버라이딩하지 않고 원하는 개수만큼 인자를 넘길 수 있게 되었다.
가변인자를 정의할 때는 고정 매개변수가 하나 이상 있어야 하고, 고정 매개변수 뒤에 ... 을 붙여 개수가 정해져 있지 않다는 표시를 해주면 된다.
public static void example(String... args) {
//....
}
가변인수 메서드를 호출하면 내부적으로 가변인수를 담기 위한 배열이 자동으로 하나 만들어진다. 하지만 내부로 감춰야되는 이 배열이 클라이언트에 노출된다는 문제점이 있다. 이 문제점 때문에 varargs 매개변수에 제네릭이나 매개변수화 타입이 포함되면 컴파일 경고가 발생하게 된다. 결론부터 말하자면, 배열과 제네릭을 같이 쓰기 때문이다.
이전 아이템에서 보았듯이, 제네릭과 같은 실체화 불가 타입은 런타임에 타입 관련 정보가 소거가 된다. 타입에 대한 정보가 없기 때문에 실체화 불가 타입으로 varargs 매개 변수를 선언하면 컴파일러가 아래와 같이 경고를 보내게 된다.
warning: [unchecked] Possible heap pollution from
parameterized varargs type List<String>
매개변수화 타입의 변수가 타입이 다른 객체를 참조할 가능성이 있게 되고 이는 힙 오염을 발생시키는 원인이 된다.
이전 아이템 28 "배열보다는 리스트를 사용하라" 편에서 나온 예제와 유사하다. 인자형태가 List<String> 타입을 배열의 아이템으로 가지고 있기 때문에 배열의 공변성과 제네릭의 불공변성이 충돌해 ClassCastException 예외가 발생하게 된다.
마지막 부분에서 컴파일러가 생성한 (보이지 않는) 형변환이 숨어 있기 때문이다. 타입 안전성이 깨지니제네릭 varargs 배열 매개변수에 값을 저장하는 것은 안전하지 않다.
자바 7 이전에는 제네릭 가변인수 메서드의 작성자가 호출자 쪽에서 발생하는 경고에 대해서 해줄 수 있는 일이 없었다. 따라서 사용자는 이 경고들을 그냥 두거나 (더 흔하게는) 호출하는 곳마다 @SuppressWarnings("unchecked") 애너테이션을 달아 경고를 숨겨야 했었다.
하지만 자바 7 이후부터는 @SafeVarargs 애너테이션이 추가되어 제네릭 가변인수 메서드 작성자가 클라이언트 측에서 발생하는 경고를 숨길 수 있게 되었다.
그러나 메서드가 안전한 게 확실하지 않다면 @SafeVarargs 애너테이션을 달아서는 안 된다.varargs 배열에 아무것도 저장하지 않고 (그 매개변수들을 덮어쓰지 않고) 그 배열의 참조가 밖으로 노출되지 않는다면(신뢰할 수 없는 코드가 배열에 접근할 수 없다면) 타입 안전성이 보장될 때만 사용해야 한다. 예를 들어 아래와 같은 코드는 타입 안전하지 않다.
이 메서드가 반환하는 배열의 타입은 이 메서드에 인수를 넘기는 컴파일타임에 결정되는데, 그 시점에는 컴파일러에게 충분한 정보가 주어지지 않아 타입을 잘못 판단할 수 있다. T 배열이 Object 배열이고 String 타입과 Integer 타입이 toArray 인자로 전달된다고 가정해보자. Object 배열에 여러 가지 타입이 혼종되어 오염이 발생할 수 있다. 자신의 varargs 매개변수 배열을 그대로 반환하면 힙 오염 이 발생하게 되고, 메서드를 호출한 쪽의 콜스택으로까지 전이가 될 수 있다.
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(); // 도달할 수 없다.
}
이 코드는 위에서 선언한 toArray() 메서드를 호출하고 있는 로직이다. 컴파일러는 toArray() 에 넘길 T 인스턴스 2 개를 담을 varargs 매개변수 배열이 만드는 코드를 생성한다. 여기서 중요한 점은 pickTwo에 어떤 타입의 객체를 넘기더라도 담을 수 있게 하기 위해 Object[] 배열로 반환된다. toArray() 메서드가 돌려준 Object[] 배열이 그대로 pickTwo()를 호출한 클라이언트까지 전달된다. pickTwo()는 항상 Object[] 타입 배열을 반환하게 된다.
위에서 작성했던 코드를 사용하면, ClassCastException 예외가 발생한다. pickTwo()의 반환값인 Object[] 배열을 String[] 타입의 attributes 에 저장하기 위해 String[] 로 형변환하는 코드가 컴파일러가 자동 생성하기 때문이다. 여기서 유의해야할 점은 해당 코드가 힙 오염을 발생시킨 진짜 원인인 toArray() 로부터 두 단계나 떨어져 있다는 점이다.
제네릭 varargs 매개변수 배열에 다른 메서드가 접근하도록 허용하면 안전하지 않다는 점을 확실하게 잘 보여주고 있다.
@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;
}
위 코드가 타입 안전한 코드이다. @SafeVarargs 애너테이션을 사용했기 때문에 사용부에서도 문제없이 컴파일된다.
@SafeVarargs 애너테이션은 제네릭이나 매개변수화 타입의 Varargs 매개변수를 받는 모든 메서드에 추가하는 것이 좋다.
또한 @SafeVarargs 애너테이션은 재정의할 수 없는 메서드에만 달아야 한다. 재정의한 메서드도 안전할지는 보장할 수 없기 때문이다.
이 말인 즉슨, 타입 안전하지 않는 Varargs 메서드는 작성하면 안 된다는 것이기 때문에 개발자가 해당 메서드들이 타입 안전하도록 모두 보장해야 한다는 것이다.
첫 번째, Varargs 매개변수 배열에는 아무것도 저장하지 않는다.
두 번째, 그 배열(혹은 복제본)을 신뢰할 수 없는 코드에 노출하지 않는다.
아니면, varargs 매개변수를 List 매개변수로 바꾸는 것도 하나의 방법이다. 이 방식을 앞에서 살펴 보았던 flatten() 메서드에 적용하면 아래와 같이 작성할 수 있다. 단순히 매개변수 선언만 수정한 코드이다.
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;
}
정적 팩토리 메서드인 List.of() 을 활용하면 다음 코드와 같이 이 메서드에 임의 개수의 인수를 넘길 수 있다. List.of()에도 @SafeVarargs 애너테이션이 달려 있기 때문에 가능하다.
이 방식은 컴파일러가 이 메서드의 타입 안전성을 검증할 수 있다는 장점이 있다. @SafeVarargs 애너테이션을 직접 달지 않아도 되며, 실수로 안전하다고 판단할 염려도 없게 된다. 하지만 클라이언트 코드가 길어지고, 속도가 조금 느려질 수 있다.
이 방식을 위 pickTwo 메서드에 적용하면 다음과 같다.
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(); // 도달할 수 없다.
}
이번 아이템에서 한정적 타입 매개변수를 사용하므로 위 동영상을 통해 아래 개념을 숙지하고 읽는 것을 추천한다.
"T extends A 는 상한 한정적 타입 매개변수로 타입 매개변수의 클래스가 A 클래스이거나 A 클래스의 하위 클래스를 의미한다. 반대로 T super A 는 하한 한정적 타입 매개변수로 타입 매개변수의 클래스가 A 클래스이거나 A 클래스의 상위 클래스를 의미한다."
매개변수화 타입은 불공변이기 때문에 해당 타입 이외에 다른 타입을 사용할 수 없다. 배열의 공변 특성처럼 상위 클래스나 하위 클래스를 인식할 수 있게 유연한 장치가 필요하다.
public class Stack<E> {
public Stack();
public void push(E e);
public E pop();
public boolean isEmpty();
public void pushAll(Iterable<E> src);
public void popAll(Collection<E> dst);
}
public void pushAll(Iterable<E> src) {
for (E e : src) { push(e); }
}
public void popAll(Collection<E> dst) {
while (!isEmpty()) { dst.add(pop()); }
}
위 스택 클래스를 Number 타입으로 선언하고, pushAll 메서드에 Integer 타입을 넣으면 어떻게 될까?
Integer 가 Number 하위 타입이기 때문에 잘 동작할 것 같지만, 제네릭은 불공변이기 때문에 하위 타입을 인식하지 못해 아래와 같은 에러를 발생시킨다. "error: incompatible types: Iterable<Integer>". 자바는 이러한 불공변에 유연한 설계를 돕기 위해 한정적 와일드카드 타입이라는 특별한 매개변수화 타입을 지원한다.
pushAll 메서드는 E 타입 뿐만 아니라 E 타입의 하위 클래스도 매개 변수로 받을 수 있어야 한다. Iterable<? extends E> 타입을 사용하는 것이 적절하다. Iterable 인터페이스를 통해 E 타입을 "생성" 한다는 특징이 있는 점을 참고하자.
popAll 메서드는 상위 클래스 컬렉션에 저장할 수 있어야 하기 때문에 반대로 E 타입 자신을 포함해서 상위 클래스를 매개 변수로 받을 수 있어야 한다. Collection 인터페이스에서 E 타입을 "소비" 한다는 특징이 있는 점을 참고하자.
이처럼 "생성" 과 "소비" 측면으로 바라보면 헤깔리지 않고 상한 경계와 하한 경계를 사용할 수 있다. 책에서는 PECS (producer-extends / consumer-super) 공식을 외워서 사용하라고 권장하고 있다. 매개변수화 타입 T 가 생산자라면 <? extends T> 를 사용하고, 소비자라면 <? super T> 를 사용하면 된다. 위 Stack 예에서 pushAll 의 src 매개변수는 Stack 이 사용할 E 인스턴스를 생성하므로 Iterable<? extends E> 가 적절하다. 한편 popAll 의 dst 매개변수는 Stack 으로부터 E 인스턴스를 소비하므로 Collection<? super E> 가 적절하다.
단, 입력 매개변수가 생산자와 소비자 역할을 동시에 한다면 와일드카드 타입을 써도 좋을 게 없다. 타입을 정확히 지정해야 하는 상황으로, 이때는 와일트카드 타입을 쓰지 말아야 한다.
조금 더 복잡한 max 메서드를 살펴보자.
public static <E extends Comparable<E>> E max(List<E> list)
위 max() 메서드를 와일드카드 타입으로 적용하면 아래와 같이 고칠 수 있다.
public static <E extends Comparable<? super E>> E max(List<? extends E> list)
이번 예제는 PECS 공식이 두 번 적용되었다.
첫 번째로 입력 매개변수 List<E> 는 E 인스턴스를 생산하므로 원래의 List<E> 를 List<? extends E> 로 수정하는 것이 바람직하다.
두 번째는 타입 매개변수 부분이다. E 가 Comparable<E> 를 확장한다고 정의했는데, 이때 Comparable<E> 는 E 인스턴스를 소비한다. 그냥 이렇게 선언만 되어 있을 경우 E 타입만 비교할 수 있게 된다. 그래서 매개변수화 타입 Comparable<E>를 한정적 와일드카드 타입인 Comparable<? super E> 로 대체하는 것이 바람직하다. Comparable 은 언제나 소비자이므로,일반적으로 Comparable<E> 보다는 Comparable<? super E> 를 사용하는 편이 더 낫다. Comparator도 마찬가지.
타입 매개변수와 와일드카드에는 공통 부분이 많아서, 메서드를 정의할 때 둘 중 어느 것을 사용해도 괜찮을 때가 많다. 예를 들어 주어진 리스트에서 명시한 두 인덱스의 아이템들을 교환(swap) 하는 정적 메서드를 두 방식 모두로 정의할 수 있다.
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);
만약 public API라면 간단한 두 번째가 더 낫다. 메서드 선언에 타입 매개변수가 한 번만 나오면 와일드카드로 대체합니다.이때 비한정적 타입매개변수라면 비한정적 와일드카드로 바꾸고, 한정적 타입 매개변수라면 한정적 와일드카드로 바꾸변 된다.
하지만 두 번째 swap 선언에는 문제가 하나 있다. 컴파일이 되지 않는다.
public static void swap(List<?> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
원인은 리스트의 타입이 List<?> 인데, List<?> 에는 null 이외에는 어떤 값도 넣을 수 없기 때문이다. 이를 해결하기 위해서는 와일드카드 타입의 실제 타입을 알려주는 private 도우미 메서드를 따로 작성하여야 한다.
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}
// 와일드카드 타입을 실제 타입으로 바꿔주는 private 도우미 메서드
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
swapHelper 메서드는 리스트가 List<E> 임을 알고 있다. 이 리스트에서 꺼낸 값의 타입이 항상 E 이고, E 타입의 값이라면 이 리스트에 넣어도 안전함을 알고 있다. 이상으로 swap 메서드 내부에서는 더 복잡한 제네릭 메서드를 이용했지만, 덕분에 외부에서는 와일드카드 기반의 깔끔한 메서드를 유지할 수 있다는 장점이 있다. 즉, swap 메서드를 호출하는 클라이언트는 복잡한 swapHelper의 존재를 모른 채 그 혜택을 누릴 수 있게 된다.
이 메서드를 제네릭 메서드로 바꾸면 타입 안전해진다. union 메서드는 입력 2개, 출력 1개의 타입이 모두 같아야 한다. 컴파일 타임 때 이미 String 으로 타입을 확정지었기 때문에 안전하게 실행할 수 있다.
불변객체를 여러 타입으로 활용할 수 있게 만들어야 할 때가 있다. 제네릭은 런타임 때 타입 정보가 소거되므로 하나의 객체를 어떤 타입으로 매개변수화 할 수 있는 장점이 있다. 하지만 이렇게 하려면 요청한 매개 타입 변수에 맞게 매번 그 객체의 타입을 바꿔주는 정적 팩토리 메서드가 필요하다.
IDENTITY_FN 을 UnaryOperator<T> 로 형변환하면 비검사 형변환 경고가 발생한다. 제네릭 불공변 원칙에 따라 UnaryOperator<Object> 는 UnaryOperator<T> 가 아니기 때문이다. 하지만 항등함수란 입력 값을 수정 없이 그대로 반환하는 특별한 함수이므로, T가 어떤 타입이든 UnaryOperator<T> 를 사용해도 타입 안전하다. 이를 근거로 @SuppressWarnings 애너테이션을 추가하여 컴파일 경고를 없애줄 수 있다.
재귀적 타입 한정이라는 개념을 이용하면 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정할 수 있다. 재귀적 타입 한정은 주로 타입의 순서를 정하는 Comparable 인터페이스와 함께 쓰인다.
public interface Comparable<T> {
int compareTo(T o);
}
Comparable 인터페이스의 타입 매개변수 T는 해당 인터페이스를 구현한 타입이 비교할 수 있는 원소의 타입을 정의한다.
public static <E extends Comparable<E>> E max(Collection<E> c) {
if (c.isEmpty())
throw new IllegalArgumentException("컬렉션이 비어 있습니다.");
E result = null;
for (E e : c)
if (result == null || e.compareTo(result) > 0)
result = Object.requireNonNull(e);
return result;
}
재귀적 타입 한정인 <E extends Comparable<E>> 는 "모든 타입 E 는 자신과 비교할 수 있다" 라는 뜻이다. 위의 코드는 컬렉션에 담긴 원소의 자연적 순서를 기준으로 최댓값을 계산하며, 컴파일 오류나 경고는 발생하지 않는다.
"제네릭 타입과 마찬가지로, 입력 매개변수와 반환값을 명시적으로 형변환해야 하는 메서드보다
이전 아이템에서 살펴보았듯이 타입에 대한 자유도 때문에 사용부에서 형변환을 많이 한다면 제네릭을 고려해야 한다.
Item 7 에서 다루었던 Stack 클래스를 제네릭 타입으로 변경해보면서 변환 시 주의할 점과 꿀팁들을 알아보자.
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null;
return result;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size);
}
}
이 원본 클래스에서는 클라이언트가 스택으로부터 객체를 꺼낼 때 Object 타입이기 때문에 매번 적절히 형변환을 해주어야 한다. 클라이언트에서 실수로 잘못 형변환할 경우 런타임 오류가 날 위험이 있다. 제네릭 타입으로 구현하는 것이 좋다.
먼저 클래스 선언에 타입 매개 변수를 추가해보자.
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new E[DEFAULT_INITIAL_CAPACITY]; // 컴파일 에러
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
elements[size] = null;
return result;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size);
}
}
위 코드에서 컴파일 에러가 나는 이유는 E 와 같은 실체화 불가 타입으로는 배열을 만들 수 없기 때문이다. 이에 대한 해결책으로 두 가지 방법이 있다.
첫 번째는 제네릭 배열 생성을 금지하는 제약을 우회하는 방법이다. Object 배열을 생성한 다음 제네릭 배열로 형변환한다. 컴파일러는 해당 형변환이 타입 안전한지 알 수 없기 때문에 비검사 형변환 경고를 띄운다. 하지만, elements 배열은 private 필드이고, 클라이언트로 반환되거나 다른 메서드에 전달되는 일이 없고,push() 메서드를 통해 배열에 저장되는 원소의 타입이 E 임을 알고 있다. 따라서 비검사 형변환은 확실하게 안전하므로 @SuppressWarnnings 애너테이션으로 해당 경고를 숨기는 것이 좋다.
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// 배열 elements는 push(E)로 넘어온 E 인스턴스만 받는다.
// 따라서 타입 안전성을 보장하지만,
// 이 배열의 런타임 타입은 E[]가 아닌, Object[]다.
@SuppressWarnings("unchecked")
public Stack() {
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
elements[size] = null;
return result;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size);
}
}
두 번째 방법은 elements 필드의 타입을 E[] 에서 Object[] 로 되돌리고, 에러가 나오는 구문에서만 형변환한다. pop() 메서드에서 타입 에러가 발생하는데, 해당 원소를 E 로 형변환 합니다. 형변환하면 아까와 마찬가지로 비검사 형변환에 대한 경고가 나타나는데, push() 에서 E 타입만 허용하므로 타입 안전성을 보장할 수 있으므로 @SuppressWarnning 애너테이션으로 경고를 숨겨줄 수 있다. 배열에 삽입하는 메서드들의 타입이 하나 뿐이어야만 가능하다는 점을 상기해야 한다.
import java.util.Arrays;
import java.util.EmptyStackException;
public class Stack<E> {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
// push에서 E 타입만 허용하므로 이 형변환은 안전하다.
@SuppressWarnings("unchecked")
E result = (E) elements[--size];
elements[size] = null;
return result;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size);
}
}
두 가지 방법 모두 잘 사용되고 있다. 첫 번째 방법은 배열의 타입을 오직 E 타입의 인스턴스로만 받을 수 있도록 명시하는 효과를 주며 배열 생성 시 한 번만 타입을 정하면 된다. 반면, 두 번째 방법은 배열에서 원소를 읽을 때마다 형변환을 해주어야 한다. 보통 첫 번째 방법을 더 선호한다. 하지만 배열의 런타임 타입과 컴파일타임 타입이 달라 힙 오염을 일으킬 수 있어서 두 번째 방식을 선택할 수 있다.
이전 아이템인 "배열보다는 리스트를 우선하라" 라는 이야기와 모순처럼 보이지만 결국 제네릭 타입도 내부적으로 배열을 사용하기 때문에 (성능 때문에 or 리스트가 기본 타입이 아니므로) 해당 방법을 알 필요가 있다.