이펙티브 자바 서적을 기반으로 제네릭을 정리해보았다.
이미 이해가 된 부분은 제외하고, 집중해서 학습했던 내용만 기록으로 남기려고 한다.
제네릭이 없던 시절의 주요 문제점은 타입 안정성(type safety) 부족과 코드 재사용성의 한계였다.
List와 같은 로 타입 컬렉션(raw type Collection)을 사용하여 Object 타입 객체를 처리할 때 객체를 꺼낼 때마다 형변환(type casting)을 해야 했다. 형변환 할 때 해당 타입에 맞지 않는 객체가 들어있다면 런타임 예외가 발생할 수 있다. 이러한 형변환 예외는 컴파일 타임에 잡을 수 없었다.
또한, 다운 캐스팅과 같이 특정 타입을 처리하는 로직을 여러 번 반복해야 했고 코드가 중복됐다.
제네릭은 컴파일 타임에 담을 수 있는 타입을 명확히 지정할 수 있게 하여, 타입 불일치로 인한 런타임 예외를 미리 방지할 수 있다. (타입 안정성 제공)
또한, 한 번 작성한 제네릭 클래스를 다양한 타입에 대해 재사용할 수 있어, 코드 중복을 줄이고 유지보수하기 좋은 코드를 작성할 수 있다. (코드 재사용성 제공)
제네릭은 Java 5 버전부터 사용할 수 있다.
제네릭을 지원하기 전에는 컬렉션을 다음과 같이 선언했다.
private final Collection stamps = ...;
이 코드를 사용하면 실수로 도장(Stamp) 대신 동전(Coin)을 넣어도 아무 오류 없이 컴파일되고 실행된다. (경고 메시지를 보여주긴 한다.)
stamps.add(new Coin(...)); // "unchecked call" 경고를 내뱉는다.
컬렉션에서 이 동전을 다시 꺼내기 전에는 오류를 알아 채지 못한다.
for(Iterator i = stamps.iterator(); i.hasNext(); ) {
Stamp stamp = (Stamp) i.next(); // ClassCastException 발생
stamp.canecel();
}
런타임에 오류가 발생한 코드와 오류의 원인을 제공한 코드가 물리적으로 상당히 떨어져 있을 가능성이 크다.
💡쓰면 안 좋은 로 타입 기능을 왜 유지시키는 걸까?
로 타입을 쓰면 제네릭이 주는 장점(안전성과 표현력)을 모두 잃게 된다. 그럼에도 불구하고 로 타입 기능을 살려놓은 이유는 오롯이 과거 버전과의 호환성 때문이다. 로 타입을 사용하는 메서드에 매개변수화 타입의 인스턴스를 넘겨도 동작해야만 했다. 마이그레이션 과정에서 호환성을 위해 로 타입을 지원하고 제네릭 구현에는 타입 소거 방식을 사용하기로 했다.
List 같은 로 타입을 사용하면 타입 안전성을 잃게 된다.
public static void main(String[] args) {
List<String> list = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(1));
unsafeAdd(strings, Integer.valueOf(2));
String s = list.get(1);
}
private static void unsafeAdd(List list, Object o) {
list.add(o);
}
이 프로그램을 이대로 실행하면 strings.get(0)의 결과를 형변환하려 할 때 ClassCaseException이 발생한다. list.get(1)코드가 수행될 때 Integer를 String으로 형변환하려 시도했기 때문이다.
제네릭 타입을 쓰고는 싶지만 실제 타입 매개변수가 무엇인지 신경쓰고 싶지 않을 때 비한정적 와일드카드 타입(unbounded wildcard type)을 사용하는 것이 좋다. List<E>의 비한정적 와일드카드 타입은 List<?>다.
public class UnboundedWildcardExample {
public static void main(String[] args) {
List<String> strings = List.of("apple", "banana", "cherry");
List<Integer> numbers = List.of(1, 2, 3);
printAll(strings);
printAll(numbers);
}
public static void printAll(List<?> list) {
for (Object element : list) {
System.out.println(element);
}
}
}
로 타입을 쓰지 말라는 규칙에도 예외가 몇 개 있다.
자바 명세는 class 리터럴에 매개변수화 타입을 사용하지 못하게 했다. (배열과 기본 타입은 허용한다.)
예를 들어 List.class, String[].class, int.class는 허용하고 List<String>.class, List<?>.class는 허용하지 않는다.
런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.
그리고 로 타입(Set)이든 비한정적 와일드카드 타입(Set<?>)이든 instanceof는 완전히 동일하게 동작한다. 비한정적 와일드카드의 꺽쇠괄호와 물음표는 불필요하므로 로 타입을 쓰는 편이 깔끔하다.
if(o instanceof Set) { // if(o instanceof Set<?>) 과 동일
Set<?> s = (Set<?>) o;
}
o의 타입이 Set임을 확인한 다음 와일드카드 타입인 Set<?>로 형변환하는 경우, 이는 검사 형변환(checked cast)이므로 컴파일러 경고가 뜨지 않는다.
제네릭을 사용하기 시작하면 컴파일러로부터 수많은 비검사 경고를 보게 될 것이다. 모든 비검사 경고는 런타임에 ClassCastException을 일으킬 수 있는 잠재적 가능성을 뜻한다.
비검사 경고를 마주했을 때 아래와 같이 대응할 수 있다.
할 수 있는 한 모든 비검사 경고를 제거하라. 모든 비검사 경고를 제거한다면 타입 안정성을 보장 받을 수 있다. 런타임에 ClassCastException이 발생할 일이 없다.
경고를 제거할 수는 없지만 타입 안전하다고 확신할 수 있다면 @SupperssWarnings("unchecked") 어노테이션을 사용하라. 그리고 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다.
@SuppressWarnings 어노테이션은 항상 가능한 좁은 범위에 적용해라. 자칫 심각한 경고를 놓칠 수 있으니 절대로 클래스 전체에 적용해서는 안 된다.
배열은 공변(covariant)이고, 제네릭은 불공변(invariant)이다.
단어는 어려워 보이지만 뜻은 간단하다.
List<type1>은 List<type2>의 하위 타입도 아니고 상위 타입도 아니다.배열은 실체화(reification) 되지만, 제네릭은 타입 소거(type erasure) 되기 때문에 함께 사용하기 어렵다. 이왕 사용할거면 리스트만 사용해라.
List<String>과 List<Integer>가 같은 List 타입으로 취급된다.List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();
System.out.println(stringList.getClass() == intList.getClass()); // true (타입 소거됨)
💡 실체화 가능/불가 타입에 대한 정의는 다음과 같다.
실체화 가능 타입(reifiable type): 런타임에도 타입 정보가 완전히 유지되는 타입
- 원시 타입: int, double, float, boolean, ...
- 제네릭이 아닌 일반 타입(non-generic type)
- 로 타입: List, Set, ...
- 비한정적 와일드카드(<?>)을 사용하는 제네릭 타입: List<?>, Map<?, ?>, ...
실체화 불가 타입(non-reifiable type): 컴파일 시점에 타입 소거로 인해 일부 타입 정보가 제거된 타입
- 정규 타입 매개변수: E
- 제네릭 타입: List<E>
- 매개변수화 타입:List<String>
실체화가 가능하다(reifiable)는 것은 "타입 정보가 소거되어도 런타임에서 그 타입을 안전하게 식별하고 사용할 수 있는가?"를 의미한다. 즉, 코드에서 선언된 타입과 런타임에서 사용되는 타입이 일관되면 실체화 가능한 것이다.
List<T>의 경우, 런타임에 T의 타입이 무엇인지 알아야 안전한 작업을 할 수 있다. 하지만 런타임에 그 정보가 없기 때문에 안전하지 않다고 할 수 있다.
List<?>의 경우, 어떤 타입이든 상관 없다는 확정된 의미를 가진다. 런타임에 어떤 타입이 사용되든 의도된 것이므로 안전하다고 할 수 있다.
매개변수화 타입을 받는 정적 유틸리티 메서드는 보통 제네릭이다.
ex. Collections 의 binarySearch(), sort() 등
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
return result;
}
public static Function<Integer, Integer> integerIdentityFunction() {
return (t) -> t;
}
public static Function<Double, Double> doubleIdentityFunction() {
return (t) -> t;
}
public static Function<String, String> stringIdentityFunction() {
return (t) -> t;
}
위의 코드를 제네릭 싱글턴 팩터리로 축약하여 표현할 수 있다.
private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
return (UnaryOperator<T>) IDENTITY_FN;
}
중복 코드를 줄일 수 있고, 타입마다 Function 객체를 생성하지 않고 하나의 싱글턴 인스턴스를 재사용하므로 메모리를 절약할 수 있다는 장점이 있다.
T)가 어떤 특정 타입의 하위 타입이어야 한다고 지정하는데, 그 특정 타입이 타입 매개변수 자기 자신으로 표현되는 제네릭 타입일 때 "재귀적 타입 한정"이라고 한다.<T extends SomeType<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 = Objects.requireNonNull(e);
return result;
}
public enum Status implements Comparable<Status> {
LOW, MEDIUM, HIGH;
}
ordinal()이 비교 기준이 된다@Test
void testSort() {
ArrayList<Status> statuses = new ArrayList<>(List.of(Status.HIGH, Status.MEDIUM, Status.LOW));
Collections.sort(statuses);
System.out.println(statuses); // [LOW, MEDIUM, HIGH]
}
SomeType<E>을 정렬할 수 있는 범용적인 비교기(Comparator)를 만들고 싶을 때 사용할 수 있다enum Status {
LOW, MEDIUM, HIGH;
}
class EnumComparator<E extends Enum<E>> implements Comparator<E> {
@Override
public int compare(E e1, E e2) {
// reverse order
return e1.ordinal() - e2.ordinal();
}
}
@Test
void testSortByComparator() {
ArrayList<Status> statuses = new ArrayList<>(List.of(Status.LOW, Status.MEDIUM, Status.LOW, Status.HIGH));
statuses.sort(new EnumComparator<>());
System.out.println(statuses); // [LOW, LOW, MEDIUM, HIGH]
}
class Builder<T extends Builder<T>> {
private String value;
public T setValue(String value) {
this.value = value;
return (T) this;
}
public String getValue() {
return value;
}
}
class ChildBuilder extends Builder<ChildBuilder> {
public ChildBuilder customMethod() {
return this;
}
}
public class Main {
public static void main(String[] args) {
ChildBuilder builder = new ChildBuilder()
.setValue("Hello") // 재귀적 타입 한정을 사용하여 메서드 체이닝이 가능해짐
.customMethod();
// 재귀적 타입 한정을 사용하지 않은 경우
// 메서드 체이닝을 사용할 때마다 타입 캐스팅해줘야 함.
// ChildBuilder builder = ((ChildBuilder) new ChildBuilder()
// .setValue("Hello"))
// .customMethod();
System.out.println(builder.getValue()); // "Hello"
}
}
<? extends T>)값을 생산(Produce)하는 주체임을 의미하다.
데이터를 읽기만 할 때 사용한다.
<? super T>)값을 소비(Consume)하는 주체임을 의미한다.
데이터를 쓰기만 할 때 사용한다.
/**
* PECS(Producer Extends, Consumer Super) 예제
* ? extends T: Upper Bounded Wildcard. Producer 역할. 읽기 전용.
* ? super T: Lower Bounded Wildcard: Consumer 역할. 쓰기 전용.
*/
public static <T> void copy(List<? extends T> src, List<? super T> dst) {
final int size = src.size();
for (int i = 0; i < size; i++) {
dst.add(src.get(i));
}
}
dst.get()을 통해 dst로부터 값을 읽어오려고 하거나
src.add()를 통해 src에 요소를 추가하려고 하면 컴파일 에러가 발생한다.
| 한글 용어 | 영어 | 예 | 비고 |
|---|---|---|---|
| 제네릭 클래스, 제네릭 인터페이스 | generic class, generic interface | public class Box<T> | 선언에 타입 매개변수가 쓰이면 제네릭 클래스 혹은 제네릭 인터페이스다. |
| 로 타입(원시 타입) | raw type | List | 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때 |
| 제네릭 타입 | generic type | List<E> | 제네릭 클래스와 제네릭 인터페이스를 통틀어 제네릭 타입이라 한다. |
| 정규 타입 매개변수 (형식 타입 매개변수) | formal type parameter | E | |
| 매개변수화 타입 | parameterized type | List<String> | 꺽쇠괄호 안에 들어간 실제 타입 매개변수 |
| 실제 타입 매개변수 | actual type parameter | String | |
| 비한정적 와일드카드 타입 | unbounded wildcard type | List<?> | |
| 한정적 와일드카드 타입 | bounded wildcard type | List<? extends Number> | |
| 한정적 타입 매개변수 | bounded type parameter | <E extends Number> | |
| 재귀적 타입 한정 | recursive type bound | <T extends Comparable<T>> | |
| 제네릭 메서드 | generic method | static <E> List<E> asList(E[] a) | 제네릭 타입을 메서드에서 선언할 때는 접근제한자와 반환타입 사이에 선언한다. |
| 타입 토큰 | type token | String.class |
11장. 동시성