제네릭은 JDK 1.5에 도입되었습니다. 제네릭이 도입될 당시 JDK5는 기존의 코드를 모두 수용하면서 제네릭을 사용하는 새로운 코드와의 호환성을 보장할 필요성이 있었습니다.
제네릭의 정의
- 다양한 타입의 객체에 대하여 재사용성을 제공하는 프로그래밍 기법
- 타입을 파라미터화 해서 컴파일 시 구체적인 타입이 결정되도록 하는 방식
- 컴파일 시점에 강한 타입 체크를 통해 런타임 시점 에러를 방지
제네릭 타입의 네이밍 컨벤션
자바에서 정한 규칙은 아래와 같습니다.
- E : 요소(Element, 자바 컬렉션(Collection)에서 주로 사용
- K : 키
- N : 숫자
- T : 타입
- V : 값
- S, U, V : 두 번째, 세 번째, 내 번째에 선언된 타입
- ? : 와일드카드(Wildcard)는 모든 타입을 의미
반공변성(Contravariant)
제네릭은 반공변성(Contravariant) 특성을 가집니다. 아래 예시를 보시면 왠지 될 것 같은데 🤔…라는 생각이 들지만 컴파일 에러가 발생합니다.
// Generic
public class GenericTest {
public static void main(String[] args) {
List<Integer> integers = new ArrayList<>();
GenericTest.<Number>test(integers); // Error (1)
}
public static <T> void test(List<T> list) {
for(var e : list) {
System.out.println(e);
}
}
}
// Error
java: incompatible types: java.util.List<java.lang.Integer> cannot be converted to java.util.List<java.lang.Number>
(1): GenericTest 클래스의 정적 메서드인 test를 호출하였고, 명시적 호출을 통해 타입 파라미터 T를 Number로 설정했습니다. 그리고 인수로 integers를 넘겼을 때 컴파일 시점에서 에러가 발생하는 것을 볼 수 있습니다. 즉, 제네릭은 기본적으로 반공변성이라는 특성을 지닙니다.
반면에 기본적인 Array는 공변적입니다.
Object[] objList = new Integer[10];
와일드카드(?)
와일드카드는 모든 타입을 의미합니다. 와일드카드를 이용하면 제네릭의 반공변성을 회피할 수 있습니다.
public static void test(List<?> list) {
}
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
test(list);
}
그런데 와일드카드만 이용하면 타입의 범위를 너무 광범위하게 허용하는 문제가 있습니다. 안정성을 위하여 범위 설정이 필요할 것 같습니다.
제한된 타입 파라미터
extends, super 키워드를 이용하여 제네릭 타입의 범위를 지정할 수 있습니다.
- <T extends Number>: upper bound, Number의 하위 타입만 가능
- <? extends T>: upper bound, T와 T의 하위 타입만 가능
- <T super String>: lower bound, String의 상위 타입만 가능
- <? super T>: lower bound, T와 T의 상위 타입만 가능
- …
한정적 와일드카드 타입
위에서 살펴본 와일드카드와 범위 지정으로 반공변성을 회피해 보겠습니다.
public static void test(List<? extends Number> list) {
for(var e : list) {
System.out.println(e);
}
}
public static void main(String[] args) {
List<Integer> list = new ArrayList<>(List.of(1,2,3,4,5,6,7,8));
test(list);
}
이러한 타입을 (<? extends Number>) 한정적 와일드카드 타입이라고 부르며, 이를 통해 반공변성을 회피할 수 있습니다. 즉 “List<Integer>는 List<? extends Number>의 하위 타입이다” 라는 명제가 참이 되도록 만들어 공변적인 것처럼 보이게 만드는 것입니다.
이와 관련하여 “Effective Java”의 저자 조슈아 블로흐가 2000년대 말쯤 ‘PECS’라는 공식을 이야기했다고 합니다. PECS(Producers Extend, Consumers Super)란 언제 super 키워드를 쓰고, 언제 extends 키워드를 사용해야 하는지 기억하는 것을 돕기 위한 공식입니다.
PECS에서 말하는 Producer는 위 예제에서 test 메서드가 파라미터로 전달받는 List에 해당합니다. List가 test 메서드 내부에서 element를 제공(생산)하고 있기 때문입니다.
PECS 살펴보기
생산자는 super 키워드를 사용하면 안 되는지 한 번 살펴보겠습니다. 아래 코드는 위에 코드를 변형한 예제입니다.
public static void test(List<? super Integer> list) {
for(var e : list) {
System.out.println(e); // (1)
Number number = e; // (2) Error!
}
}
public static void main(String[] args) {
List<Number> list = new ArrayList<>(List.of(1,2,3,4,5,6,7,8));
test(list);
}
// build failed Error
java: incompatible types: java.lang.Object cannot be converted to java.lang.Number
일단 객체 지향 관점에서 Number 컨테이너를 Integer 컨테이너로 할당하는 것 자체가 자연스럽지 않습니다. 리스코프 치환 원칙에 따르면, 항상 상위 타입으로의 업캐스팅을 염두하여 코드를 작성해야 합니다. 물론 이 경우는 클래스 타입 간의 캐스팅이 아니고 ‘타입 파라미터’ 관점에서 바라보는 것이기 때문에 ‘캐스팅’이라고 할 수는 없지만, 변성 관점에서도 자연스럽지 않습니다.
또한 (2)를 통해서 <? super Integer> 타입의 인스턴스를 Number 타입의 참조로 할당할 수 없음을 알 수 있습니다. 즉, <? super Integer> 타입은 제네릭의 변성을 돕기 위한 장치일 뿐이고, 컴파일러는 <? super Integer>가 어떤 타입인지 정확히 알 수 없다는 의미입니다.
결국 이 문제를 해결하기 위해서는 Number number를 Object Number로 수정하거나 업캐스팅(Number)을 수행해야 합니다. 그런데❗ 제네릭의 장점은 타입 안정성을 보장하고 개발자가 직접 캐스팅하는 일을 대신해주는 것이었습니다. 따라서 이러한 행위는 제네릭 사용의 장점을 무색하게 만든다는 문제가 있습니다.
소비자 측면에서는 이와 반대로 수행하시면 됩니다. 즉, 생산자와 반대로 super 키워드를 적용해야 한다는 것이 조슈와 블로흐의 주장입니다. 왜 소비자에서는 super 키워드를 적용해야 하는지는 아래 예제를 남겨놓고 넘어가도록 하겠습니다.
List<? extends Number> list = new ArrayList<Integer>();
// Error
list.add(new Integer(1));
//
java: incompatible types: java.lang.Integer cannot be converted to capture#1 of ? extends java.lang.Number
Type erasure
앞서 제네릭이 도입될 당시 JDK5는 기존의 코드를 모두 수용하면서 제네릭을 사용하는 새로운 코드와의 호환성을 보장할 필요성이 있었다고 말씀드렸습니다. 이때 적용된 개념이 Type erasure입니다.
타입 소거(Type erasure)란 컴파일 시점에만 타입을 검사하고 타입 정보를 삭제하는 것을 말합니다. 따라서 런타임 시점에는 타입 정보를 알 수 없다는 특징이 있으므로 주의해야 합니다.
자바는 아래 과정으로 타입 소거를 진행합니다.
- 클래스, 인터페이스, 메서드의 타입 파라미터 제거
- 필요한 경우 타입 안정성을 위해 타입 캐스팅을 추가
- Generic Type에 대한 다형성 지원을 위해 Bridge 메서드 추가
- Unbounded Wildcard(<?>), Generic Type(<T>)의 경우 컴파일 시점에 Object로 치환 됩니다.
// before
class Sample<T> {
T something;
...
}
// after
class Sample {
Object something;
...
}
- bounded type(<? extends Integer>, <T extends Integer>)의 경우 상한 타입으로 치환됩니다.
// before
public static <T extends Integer> void sample(T num) {
...
}
// after
public static void sample(Integer num) {
...
}
- Parameterized Type(List<?>, List<T>, List<? extends Integer>, …)의 경우 Raw 타입으로 치환됩니다.
public static void test(List<? extends Integer> list) {
...
}
public static void test(List list) {
...
}
제네릭 사용 시 주의사항
Non-Reifiable 타입에 Varargs를 함께 사용할 때의 잠재적 위험
앞서 살펴본 타입 소거로 인해 타입 정보를 소실하는 것들을 비 구체화 타입(Non-Reifiable type)이라고 합니다. 이러한 타입들을 가변인수(Varargs)와 함께 사용할 때 문제가 발생할 수 있습니다.
public static void faultyMethod(List<String>... list) { // (1)
Object[] objectArray = list; // Valid
objectArray[0] = Arrays.asList(42); // OK
String s = l[0].get(0); // ClassCastException thrown here
}
public static void main(String[] args) {
faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));
}
(1)에서는 Varargs에 의하여 List<String> Array(List<String>[])가 생성됩니다. 앞서 말씀드렸는데, 배열은 공변적입니다. 따라서 objectArray에 list를 할당할 수 있습니다. 즉, objectArray에 List<Integer> 타입을 집어넣을 수 있습니다... 🤨 (짬뽕을 만드는 겁니다) 심지어 컴파일 에러도 런타임 에러도 발생하지 않습니다... 이러한 상태를 Heap Pollution(힙 오염)이 발생했다고 표현하며, 이로 인해 메서드 마지막에 캐스팅 오류가 발생하는 것을 볼 수 있습니다.
이는 이펙티브 자바의 아이템 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라 라는 챕터에 나오는 내용이기도 합니다. 따라서 varargs와 함께 사용할 때 위험성에 대하여 충분히 고려하고, 다른 방법에 대해서도 검토하는게 좋을 것 같습니다.
그럼에도 불구하고 제네릭과 가변인수를 함께 써야 한다면, 내부적으로 타입 안정성을 확실하게 보장해야 하고 제네릭 매개변수 배열의 참조를 노출하지 말아야 합니다.
Unchecked warning
제네릭으로 작성한 코드를 컴파일하면 아래와 같은 무점검 경고 메시지들을 자주 볼 수 있습니다. 가능하다면, 모든 무점검 경고를 없애는 것이 좋습니다. 전부 없애고 나면 타입 안정성이 보장되기 때문입니다.
- 무점검 형 변환 경고(Unchecked cast warning)
- 무점검 함수 호출 경고(Unchecked method invocation warning)
- 무점검 제네릭 배열 생성 경고(Unchecked generic array creation warning)
- 무점검 변환 경고(Unchecked conversion warning)
무점검 경고 메시지의 예시를 보여드리겠습니다. 바로 직전에 사용한 예제를 Xlint:all 옵션을 사용해서 직접 컴파일하면 아래와 같은 경고 메시지를 볼 수 있습니다.
> javac -Xlint:all .\\GenericTest.java
.\\GenericTest.java:10: warning: [unchecked] Possible heap pollution from parameterized vararg type List<String>
public static void faultyMethod(List<String>... l) { // List<String>[]
^
.\\GenericTest.java:19: warning: [unchecked] unchecked generic array creation for varargs parameter of type List<String>[]
faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));
^
2 warnings
두 개의 경고 메시지를 볼 수 있습니다. 첫 번째는 힙 오염에 대한 무점검 경고이고, 두 번째는 제네릭 배열 생성에 대한 무점검 경고 메시지입니다. 확실히 문제가 될 수 있는 부분에 대하여 경고해주고 있음을 알 수 있습니다. 따라서 해당 경고 메시지들이 뜨지 않도록 확실히 처리해준다면 타입 안정성을 보장받을 수 있는 것입니다.
만약 제가 예제에 작성한 faultyMethod가 내부적으로 타입 안정성을 보장하도록 구현되어 있다면, 이 경고 메시지들을 감출 수 있는 방법이 있습니다. 바로 @SuppressWarnings("unchecked") 어노테이션을 이용하면 됩니다. 어노테이션을 faultyMethod에 붙이고 나니 faultyMethod 내부에서 발생하는 무점검 경고 메시지는 보이지 않는 것을 확인했습니다. 그러나 이 방법은 타입 안정성이 확실히 보장되는 상황에서만 사용해야 함을 명심해야 합니다.
추가적인 내용
제네릭이나 매개변수화 타입의 varargs 매개변수를 받는 모든 메서드에는 @SafeVarargs를 적용하여 클라이언트 측에서의 경고 메시지를 감출 수 있습니다. 그러나 안전하지 않은 메서드에서는 사용하시면 안 됩니다. 따라서 아래의 규칙을 지킨 경우에만 사용하는 것이 좋습니다.
- varargs 매개변수 배열에 아무것도 저장하지 않았다.
- 해당 배열을 외부로 노출시키지 않았다.
- 재정의할 수 없는 메서드에만 적용한다. (재정의한 메서드가 안전한지 보장할 수 없기 때문)
자바 8 부터는 오직 static, final 메서드에만 적용이 가능했고, 자바 9부터는 private 인스턴스 메서드에도 적용이 가능합니다.
Ref
Improved Compiler Warnings When Using Non-Reifiable Formal Parameters with Varargs Methods
[Java] Generic Type erasure란 무엇일까?
제네릭, 그리고 변성(Variance)에 대한 고찰 (1) - Java
[Effective Java] Item 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라
'generic array creation' compile error
Item 24. 무점검 경고(unchecked warning)를 제거하라.
'JAVA' 카테고리의 다른 글
java for, for-each, forEach 성능 비교, 장단점, 특징 정리 (0) | 2022.10.26 |
---|---|
예외 처리와 SQLException (0) | 2022.10.24 |
Java.nio Selector의 필요성 (0) | 2022.09.20 |
[기본 시리즈] java.nio 버퍼 (1) | 2022.09.19 |
[기본 시리즈] JAVA 스레드에 대하여 (0) | 2022.09.08 |