이전 글(링크)에서 정리할 때는 와일드카드가 다음과 같은 상황에 때문에 필요하다고 하였습니다.
Generic을 정의한 클래스에서 모든 인스턴스들이 공통적으로 사용할 메서드를 외부에서 정의할 때 문제가 된다.
다음과 같은 예시가 있었습니다.
@Test
void test() {
final List<Integer> integers = List.of(1, 2, 3);
final String convertedString = convertString(integers);
}
public String convertString(final List<?> objects) {
final StringBuilder stringBuilder = new StringBuilder();
for (final Object object : objects) {
stringBuilder.append(object.toString());
}
return stringBuilder.toString();
}
위처럼 한정된 와일드카드를 이용하여서, 모든 List에서 사용할 수 있는 메서드를 외부에서 정의하였습니다. 하지만 사실 이는 타입 파라미터로도 구현 가능합니다.
이전 예시에서는 타입 파라미터를 클래스 선언부에서만 사용했습니다.
제네릭 메서드란 타입 파라미터를 메서드의 선언부에 정의하여 사용하는 것을 의미합니다.
와일드카드를 이용한 위의 코드를 다음과 같이 수정할 수 있습니다.
@Test
void test() {
final List<Integer> integers = List.of(1, 2, 3);
final String convertedString = convertString(integers);
}
public <T> String convertString(final List<T> objects) {
final StringBuilder stringBuilder = new StringBuilder();
for (final Object object : objects) {
stringBuilder.append(object.toString());
}
return stringBuilder.toString();
}
처음엔 무의식적으로 와일드카드에 대해 배울 땐 다음과 같이 생각하였습니다. “아 이래서 필요하다고 한거구나.”
그러다 한 번 코드로 작성해보니까, 모든 와일드카드를 사용한 부분을 다 제네릭 메서드로 처리할 수 있을 것 같았습니다.
실제 라이브러리에서 다음과 같이 와일드카드를 사용하였는데, 이 또한 제네릭 메서드로 고칠 수 있습니다.
interface Collection<E> {
public boolean containsAll(Collection<?> c);
public boolean addAll(Collection<? extends E> c);
}
interface Collection<E> {
public <T> boolean containsAll(Collection<T> c);
public <T extends E> boolean addAll(Collection<T> c);
}
그럼 도대체 왜 제네릭이 나온거고 유연성을 높여줄 수 있다고 한 것일까?
상한 경계 | 하한 경계 | |
---|---|---|
타입 파라미터 | O | X |
와일드카드 | O | O |
와일드카드 같은 경우에는 일반적인 타입 파라미터보다 가독성이 좋다. 예를 들어 아래 코드를 보자
void static copy(final SimpleList<? extends T> copier, final SimpleList<T> copied) {
for (int index = 0; index < copier.size(); index++) {
final T t = copier.get(index);
copied.add(t);
}
}
copier에 있는 element들을 copied로 복사하는 코드입니다. 이 코드도 와일드카드 모두 타입 파라미터로 수정 가능합니다.
void static <K extends T> copy(final SimpleList<K> copier, final SimpleList<T> copied) {
for (int index = 0; index < copier.size(); index++) {
final T t = copier.get(index);
copied.add(t);
}
}
이 코드 또한 별 문제 없이 동작합니다.
하지만, 타입 파라미터만 봐도 되는 와일드카드와 달리, 메서드 시그니처 앞 부분까지 읽어야 copier의 타입에 대해 파악할 수 있습니다.
좀 더 극단적인 예시를 봐보도록 하죠. 다음은 Map에서 unmodfiableMap을 만드는 방식입니다.
public static <K,V> Map<K,V> unmodifiableMap(Map<? extends K, ? extends V> m) {
return new UnmodifiableMap<>(m);
}
실제 Collections의 코드입니다. 간단하게 map을 받아서 새로운 map을 만들어 반환해주죠.
만약 이 부분에서 와일드카드를 제거한다면 어떻게 될까요?
public static <K, V, T extends K, M extends V> Map<K, V> unmodifiableMap(Map<T, M> m) {
return new UnmodifiableMap<>(m);
}
가독성은 주관적인 부분이라 다르게 느낄 수 있겠지만, 저는 가독성이 매우 안 좋아보입니다.
generic이 1개일 때는 타입 파라미터나, 와일드카드나 차이가 크게 없습니다. 모두 동일한 역할을 합니다.
하지만, 2개 이상일 때는, 고려해야될게 조금 생깁니다.
예를 들어 다음과 같은 copy 코드가 있다고 생각해 봅시다.
<T extends Number> void copy(final SimpleList<T> copier, final SimpleList<T> copied) {
for (int index = 0; index < copier.size(); index++) {
final T num = copier.get(index);
copied.add(num);
}
}
위와 같은 코드는 정상적으로 컴파일 됩니다. copier의 타입과 copied의 타입이 동일한 것을 보장할 수 있습니다. 만약 이를 와일드카드로 바꾼다면 어떻게 될까요?
void static copy(SimpleList<? extends Number> copier, SimpleList<? extends Number> copied) {
for (int index = 0; index < copier.size(); index++) {
Number num = copier.get(index);
copied.add(t);
}
}
위와 같은 코드가 정상적으로 작동할까요? 오류가 발생합니다. copier의 타입과 copied의 타입이 동일하지 않을 수도 있습니다.
copy를 호출할 때 copier엔 List가 copied에는 List가 들어갈 수도 있습니다.
그렇기에 copied.add(t)에서 오류가 발생하죠. 두 파라미터 간의 연관성을 정의할 수 없습니다.
와일드카드 같은 경우에는 반환타입으로 사용해서는 안됩니다. 다음과 같은 코드를 보죠.
파라미터로 받은 List에서 연산을 하고, 해당 값을 반환하는 형태입니다.
@Test
void test() {
final List<Integer> integers = List.of(1, 2, 3);
final List<Integer> processed = processNumbers(integers);
}
public static List<? extends Number> processNumbers(List<? extends Number> numbers) {
List<? extends Number> result = new ArrayList<>();
// numbers에 대한 작업 수행
// result에 작업 결과를 저장
return result;
}
위와 같은 코드는 어떨까요? 그렇지 않습니다.
processNumbers의 반환 타입은 List<? extends Number>이지, List가 아닙니다.
마치 List를 List에 대입해줄 수 없듯이 말이죠. 아래와 같이 수정해야 할 것입니다.
@Test
void test() {
final List<Integer> integers = List.of(1, 2, 3);
final List<? extends Number> processed = processNumbers(integers);
processed.add(3);
}
그럼 이 코드를 정상적으로 사용할 수 있을까요? 아닙니다. processed.add(3)
에서 에러가 납니다.
List의 element 타입은 ? extends Number지 Integer가 될 수 없습니다.
그러면 어떻게 사용할 수 있을까요? 다음과 같이 억지로 사용할 수 있긴 합니다.
@Test
void test() {
final List<Integer> integers = List.of(1, 2, 3);
final List<Integer> numbers = (List<Integer>) processNumbers(integers);
numbers.add(3);
numbers.forEach(System.out::println);
}
사용자가 와일드카드를 직접 다루고, 타입 캐스팅을 해야한다는 점에서 와일드카드를 반환 타입을 사용하는 것은 좋지 못합니다.
반면, 타입 파라미터를 반환 타입으로 사용하면 깔끔하게 사용할 수 있죠.
@Test
void test() {
final List<Integer> integers = List.of(1, 2, 3);
final List<Integer> numbers = processNumbers(integers);
numbers.add(3);
numbers.forEach(System.out::println);
}
public <T extends Number> List<T> processNumbers(final List<T> values) {
List<T> result = new ArrayList<>();
// numbers에 대한 작업 수행
// result에 작업 결과를 저장
return result;
}
와일드카드와 타입 파라미터를 비교할 때 가장 먼저 든 생각이 왜 타입 파라미터는 하한경계를 지원하지 않을까? 였습니다.
그래서 가장 먼저 타입 파라미터와 와일드카드가 내부적으로 어떻게 상한,하한경계를 설정하는지 알아 보았습니다.
내부적으로 자바에서 Generic은 컴파일 타임에 모두 제거됩니다.
제한을 걸지 않은 타입 파라미터와 와일드카드는 Object로, <? extends Number>나 는 각각 ?와 T가 Number로 바뀝니다.
아래 예시를 볼까요
타입 파라미터와 와일드카드만 제외하면 완벽하게 동일한 두 코드가 있습니다.
public static void printFirstElementsWildCard(List<? extends Number> first, List<? extends Number> second) {
Number n = first.get(0);
Number m = second.get(0);
List<Number> third = new ArrayList<>();
third.add(n);
third.add(m);
System.out.println(third);
}
public static <T extends Number> void printFirstElementsTypeParaemter(List<T> first, List<T> second) {
T n = first.get(0);
T m = second.get(0);
List<T> third = new ArrayList<>();
third.add(n);
third.add(m);
System.out.println(third);
}
두 코드를 한 번 컴파일 시킨 후, 바이트 코드를 확인해보았습니다.
주석을 보면 Generic은 Signature에서 T를 Number로 치환합니다. <T:Ljava/lang/Number;>
그 후, 파라미터를 저장하는 부분인 L0와 L1에서 CheckCast Number 를 통해서, 실제 T가 Number인지 확인합니다.
T는 실제 코드에선 전혀 사용되지 않죠. 컴파일 타임에 한번 타입을 체크해주고 사라집니다.
와일드카드도 내부적으로 모든 코드가 동일합니다. 주석만 빼면 똑같은 코드죠.
그래서 저는 가장 궁금했던 것이 “왜 와일드카드만 하한경계를 지원하냐?” 였습니다.
저희가 코드를 작성할 때 경계값을 정하는 것은 와일드카드나 타입 파라미터나 똑같습니다.
그래서 혹시 내부적인 내용이 다르지 않을까 싶었습니다.
그래서, 바이트코드를 봤지만 상한경계 부분은 동일하게 컴파일 되는 것을 확인할 수 있었습니다.
그렇다면, JDK 설계자들이 타입 파라미터에도 하한경계를 사용할 수 있도록 구현 가능하지 않았을까요? 왜 구현하지 않았을까요?
상한 경계를 컴파일 타임에 체크하는 것은 쉽게 파악할 수 있었습니다.
바로 extends 뒤에 붙은 클래스로 타입 파라미터나 와일드카드를 대체하면 됬어요.
그러면 다형성에 의해서 체크가 가능했으니까요.
하지만, 하한경계는 어떨까요? 단순하게 어떠한 한 클래스로 타입 파라미터나 와일드카드를 대체할 순 없어보였습니다.
부모가 아닌 자식이 제한되는 것이었기에, 내부적으로 타입 파라미터나 와일드카드는 Object형식을 유지해야 합니다.
제 생각은 다음과 같습니다.
내부적으론 와일드카드나 타입 파라미터를 Object로 치환하고, 다형성을 체크하는 것처럼 상속관계를 체크한 후 컴파일시킵니다.
하지만 어디에 명확하게 나와있는 자료가 아니기에, 단순 추측이고, 의문으로만 남았습니다.
글에 대한 피드백은 항상 환영입니다.
https://stackoverflow.com/questions/18176594/when-to-use-generic-methods-and-when-to-use-wild-card
https://www.baeldung.com/java-generics-type-parameter-vs-wildcard