예전에는 열거한 값들을 집합으로 사용할 경우, 서로 다른 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 컨테이너를 만들 수 있다."
이번 아이템에서 한정적 타입 매개변수를 사용하므로 위 동영상을 통해 아래 개념을 숙지하고 읽는 것을 추천한다.
"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 리스트가 기본 타입이 아니므로) 해당 방법을 알 필요가 있다.
첫 번째, 배열은 공변이고 제네릭은 불공변이다. 공변의 뜻은 "같이 변한다"는 뜻인데 원래 타입의 상하 관계가 컬렉션에 삽입이 되어도 살아 있다는 뜻이다. 따라서 배열 Sub[] 는 배열 Super[] 의 하위 타입이 되고, List<Sub>와 List<Super> 는 독립적인 타입으로 인식한다.
// 런타임 오류
Object[] test = new Long[1];
test[0] = "test";
// 컴파일 불가
List<Object> o1 = new ArrayList<Long>();
o1.add("test");
런타임 시에 오류를 확인하는 것보다 컴파일 시에 오류를 확인하는 것이 더 좋다.
두 번째는 배열은 실체화가 된다. 배열은 런타임에도 자신의 원소 타입을 인지하고 확인할 수 있다. 그래서 위 Long 형 test 배열에 String 을 넣으면 런타임 오류(ArrayStoreException) 가 발생하는 것이 이 때문이다. 반면, 제네릭은 타입 정보가 런타임에는 소거가 된다. 컴파일 타임에만 검사하며 런타임에는 알 수가 없다.
두 가지 차이로 인해 배열과 제네릭은 어울리지 못 한다. 그래서 배열은 제네릭 타입, 매개변수화 타입, 타입 매개변수로 사용할 수 없다. 예를 들어 new List<E>[], new List<String>, new E[] 가 컴파일이 되지 않는다. 타입 안전하지 않기 때문에 이를 허용한다면 런타임 시에 ClassCastException 이 발생할 수 있다. 제네릭이 런타임에 ClassCastException 을 방지하기 위해 불공변을 지원하는데 그 취지에 어긋나는 것이다. 배열과 제네릭을 함께 사용했을 때 벌어지는 일을 살펴보자.
1번째 문장에서 제네릭 배열을 생성한다고 가정했다. 3번째 문장에서는 배열은 공변이기 때문에 문제 없이 할당된다. 4 번째 문장은 제네릭이 소거 방식으로 구현되기 때문에 List<integer> 가 List 로 되어 충분히 할당된다. 문제는 5번째 문장이다. List<String> 인스턴스만 담겠다고 선언한 stringLists 배열에 List<Integer> 인스턴스가 저장되어 있다. get 으로 첫 번째 원소를 꺼낼 때 자동으로 String 으로 형변환되는데 Integer 를 형변환 하였으니 ClassCastException 이 발생한다. 섞어 쓰지 말고 배열을 리스트로 대체하는 방안을 생각해보자.
제네릭에서는 원소의 타입 정보가 소거되어 런타임에는 무슨 타입인지 알 수 없다는 점과 배열은 공변이고 실체화된다는 사실을 꼭 기억하자. 둘의 성격이 반대라서 컴파일 오류나 경고를 만난다면 배열을 리스트로 바꾸자.