목차
이번 챕터의 목적
JDK5 부터 사용가능해진 제네릭(generic)은 불필요한 형변환 작업을 생략하게 해주고, 사용자 입장에서 한결 편하게 타입추론이 가능하게 해주는 기능이다. 제네릭을 사용하면 컬렉션이 담을 수 있는 타입을 컴파일러에게 알려주게 되기에, 컴파일러는 알아서 형변환 코드를 추가할 수 있게 되고, 애초에 컴파일 과정에서부터 잘못된 타입의 객체를 넣지 못하게 차단해서 안전하고 명확한 코드를 작성할 수 있다.
이번 장에서는 이런 제네릭의 이점을 최대로 살리고 단점을 최소화하는 방법에 대해 이야기해본다.
Previous
이번 챕터 전반에 사용되는 지네릭스 용어에 대해 설명한다.
26. 로 타입은 사용하지 말라
previous
제네릭 클래스(or 제네릭 인터페이스)
:클래스와 인터페이스 선언에 타입 매개변수(type parameter)가 쓰인 클래스 혹은 인터페이스 (Ex: List<E>, Set<E>, Map<K, V>)로 이를 통틀어 제네릭 타입(generic type)이라 한다.
제네릭 타입은 매개변수화 타입(parameterized type)을 정의하는데 이 타입이 정규 타입 매개변수에 해당하는 실제 타입 매개변수가 된다. 다음 코드를 보자.
public interface List<E> extends Collection<E> { ... }
...
private final List<String> = ...;
Java
복사
List 인터페이스에서 꺽쇠안의 작성된 E는 정규 타입 매개변수로 일종의 포맷이라 볼 수 있다. 그래서 해당 타입매개변수에 실제(actual)로 작성된 타입 매개변수를 컴파일러에서 알아서 형변환 코드를 추가해줄 수 있다.
로 타입(raw type)
제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때를 말한다.
다음과 같이 제네릭 타입에서 타입 매개변수를 사용하지 않는 경우를 의미한다.
private final Collection stamp = ...;
private final List users = ...;
Java
복사
이러한 로 타입은 타입 매개변수가 없기에 컴파일러에서 형변환 코드를 알아서 넣어주지 못하기 때문에 실수로 의도와 다른 타입의 객체를 넣어도 오류없이 컴파일되고 실행한다.
다음 코드는 실수로 음식 일급콜렉션 객체에 음식외의 객체를 넣었을 경우 생기는 문제의 코드다.
public class Foods {
private final List store = new ArrayList();
public Foods() { }
public void add(Object obj) {
store.add(obj);
}
public void print(){
for (Iterator it = store.iterator(); it.hasNext();) {
Food food = (Food) it.next(); // ClassCastException 발생
System.out.println("food = " + food);
}
}
}
...
public class FoodApp {
public static void main(String[] args) {
Foods foods = new Foods();
foods.add(new Weapon("도끼", 10));
foods.add(new Food("피자", LocalDateTime.of(2021, Month.JUNE, 25,17,30), 1000));
foods.print();
}
}
Java
복사
해당 코드를 실행해보면 add는 모두 제대로 동작을 할 것이다. 하지만 print 메서드가 수행되며 iterator로 요소 순회시 Food로 형변환시에 ClassCastException이 발생할 것이다.
오류는 가능한 발생 즉시, 이상적으로는 컴파일시에 발견하는게 제일이다. 하지만 위 코드는 print()메서드로 요소를 순회하며 형변환을 하기전까지는 문제를 발견할 수 없는데, 이렇게 문제가 발생하면 오류 추적이 상당히 힘들어진다. 주석으로 //Food 객체만 넣을 수 있다. 라고 작성하는건 컴파일러에서 이해할 수 없고 개발자가 하나하나 매번 살필 수 없기에 별 소용이 없다. 여기서 제네릭을 활용하면 이러한 정보가 주석이 아닌 타입 선언 자체에 녹아들기에 의도한 타입의 객체 외의 다른 타입의 객체는 컴파일러가 인지할 수 있게 되면서, 의도와는 다른 타입의 객체를 걸러낼 수 있다.
다음 코드는 지네릭을 이용해 리팩터링을 한 소스다.
public class Foods {
private final List<Food> store = new ArrayList();
public Foods() { }
public void add(Food obj) {
store.add(obj);
}
public void print(){
for (Iterator<Food> it = store.iterator(); it.hasNext();) {
Food food = it.next();
System.out.println("food = " + food);
}
}
}
Java
복사
Foods 일급 콜렉션 객체는 이제 Food 라는 실제(actual) 타입 매개변수를 선언해서 명시적은 형변환 코드도 제거해주었고 기존의 foods.add(new Weapon("도끼", 10)); 코드는 컴파일러에서 인지해서 에러라는 것을 고지해 줄 것이다. 즉 로 타입을 쓸 경우 제네릭의 장점(안전성과 표현력)을 모두 포기한다는 의미다.
Q.그럼 로 타입은 왜 있는걸까?
A.바로 호환성 때문이다.
제네릭은 자바가 나오면서 같이 나온 창립멤버가 아니다!
즉 길고긴 자바의 역사에서 제네릭이 들어와서 본격적으로 사용된것은 거의 자바 출시부터 10년정도가 걸린 상황인데, 기존의 코드와의 호환성을 위해서는 로 타입도 동작을 해야만 했다. 바로 이 마이그레이션 호환성을 위해서 로 타입을 지원하고 제네릭 구현에는 소거 방식을 사용하기로 했다.
List와 List<Object>
전자와 같은 로 타입은 위에서도 계속 안된다고 밝혔으나 List<Object>와 같이 임의 객체를 허용하는 매개변수화 타입은 괜찮다고 한다. 어째서일까? 로 타입과는 다르게 List<Object>는 컴파일러에게 모든 타입을 허용한다는 의사를 명확히 전달한 코드이기 때문이다.
그리고 로 타입인 List를 매개변수로 받는 메서드는 List<String>과 같은 List도 전달하는데 문제가 없지만, List<Object>를 매개변수로 받는 메서드에는 넘길 수 없다.
그 이유는 제네릭의 하위 타입 규칙 때문인데, List<String>은 List의 하위 타입이지만 List<Object>의 하위 타입은 아니기 때문이다. 그 결과 List과 같은 로 타입을 매개변수로 사용하면 타입 안정성을 잃게 된다.
다음 코드는 로 타입을 매개변수로 받아 문제가 발생하는 코드다.
public static void main(String[] args) {
List<String> list = new ArrayList<>();
add(list, "hello");
add(list, 123);
String s = list.get(1);
}
public static void add(List list, Object o) {
list.add(o);
}
Java
복사
이 코드를 실행시키면 ClassCastException이 발생을 한다. add 메서드에서는 add로 추가할 때 타입 구분없이 add로 추가를 하게 되었지만 list.get(1) 코드가 수행 될 때 컴파일러는 자동으로 타입 매개변수로 선언된 String으로 형변환을 시도하게 되고, 123 은 Integer 타입이기에 형변환 예외가 발생하는 것이다.
그럼 이 로 타입 매개변수를 List<Object>로 바꾸면 어떻게 될까?
public static void main(String[] args) {
List<String> list = new ArrayList<>();
add(list, "hello");
add(list, 123);
String s = list.get(1);
}
public static void add(List<Object> list, Object o) {
list.add(o);
}
Java
복사
이 코드는 다음과 같은 예외와 함께 컴파일조차 되지 않는다.
java: incompatible types: java.util.List<java.lang.String> cannot be converted to java.util.List<java.lang.Object>
여기까지만 보면 로 타입이 더 편해보일수도 있지만, 그 대안책으로 비한정적 와일드카드 타입이 있다.
대안책: 비한정적 와일드카드 타입
:매개변수화 타입이 ? 인 제네릭 타입
제네릭 타입을 쓰고는 싶지만 실제 타입 매개변수가 무엇인지 신경쓰고싶지 않을 때 로 타입을 사용할게 아니라 물음표를 사용하면 어떤 타입도 담을 수 있는 범용적인 매개변수화 타입이 된다.
다음 코드는 이러한 비한정적 와일드카드 타입을 사용한 코드이다.
public class UnboundWildcardApp {
public static void main(String[] args) {
HashSet<Integer> s1 = new HashSet<>() {{
add(1);
add(3);
add(4);
}};
HashSet<Integer> s2 = new HashSet<>() {{
add(1);
add(4);
add(5);
add(6);
}};
long count = numElementInCommon(s2, s1);
System.out.println("count = " + count);
}
static long numElementInCommon(Set<?> s1, Set<?> s2) {
return s1.stream()
.filter(obj-> s2.contains(obj))
.count();
}
}
Java
복사
비한정적 와일드카드 타입은 로 타입에 비교해서 안전하다.
아무 원소나 넣을 수 있어 타입 불변식을 훼손할 수 있는 로 타입 컬렉션에 비교해서 비한정적 와일드카드 타입에는 null외의 어떤 원소도 넣을 수 없다. 그래서 컬렉션의 타입 불변식을 훼손하지 못하게 막았다.
만약 이런 부분이 요구사항화 상충하여 해당 제약이 없어야 한다면 제네릭 메서드나 한정적 와일드 카드를 사용하면 된다.
로 타입을 사용해야 하는 경우
로 타입을 써야 하는경우도 있다. 바로 class 리터럴에는 로 타입을 써야 하는데, 자바 명세에는 class 리터럴에 매개변수화 타입을 사용하지 못하게 했다. (배열과 기본 타입은 허용)
허용되는 경우와 비허용되는 경우는 다음과 같다.
•
허용되는 경우
◦
List.class
◦
String[].class
◦
int.class
•
허용이 안되는 경우
◦
List<String>.class
◦
List<?>.class
또 다른 경우는 instanceof 연산자를 사용할 경우인데 런타임시 제네릭 타입정보는 지워지기 때문에 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용이 불가능하다. 또한 로 타입이나 비한정적 와일드카드 타입이나 instanceof는 동일하게 동작한다. 그렇기 때문에 불필요한 코드 작성(<?>)을 하지 않고 로 타입으로 쓰는게 낫다.
if(o instanceof Set) {
Set<?> s = (Set<?>) o;
...
}
Java
복사
⇒ instanceof에서는 로 타입을 사용해서 Set인지 확인을 했다면 내부 코드에서는 Set<?>으로 형변환을 해주자.
27. 비검사 경고를 제거하라.
비검사 경고란?
위와같이 IDE(ex: IntelliJ) 에서 개발을하다보면 에러는 아니지만 warning이 뜨거나 영역이 하이라이트되며 개발자에게 경고를 알려주는 경험을 해봤을 것이다. 이를 비검사 경고라 하는데
제네릭을 사용하면서 자주 만나게 될 비검사 경고로는 비검사 형변환, 메서드 호출, 매개변수화 가변인수 타입, 변환 경고등 다양한데 이러한 대부분의 비검사 경고들은 쉽게 해결이 가능하다.
가장 자주만나는 비검사경고중 하나를 확인해보자.
List<Food> foodList = new ArrayList();
Java
복사
위와같이 코드를 작성하면 컴파일러는 위 코드에서 무엇이 문제인지에 대해 표현될 것이다.
인텔리 제이의 경우 다음과 같이 확인이 가능하다
(만약 커맨드로 확인하고자 한다면 javac 커맨드라인에 -Xlint:uncheck 옵션을 추가하면된다.)
경고 내용은 'Raw use of parameterized class 'ArrayList'인데 즉, 로 타입이아닌 파라미터 매개변수가 있는 타입을 쓰라는 것이다. 그래서 다음과 같이 작성을 하면 경고를 없앨 수 있다.
List<Food> foodList = new ArrayList<Food>();
Java
복사
(참고: 자바 7부터는 다이아몬드 연산자(<>)만으로 해결이 가능하다. 타입추론이 가능하다.)
물론 이런 경고는 상당히 고치기 쉬운 경고이지만, 고치기 어렵거나 바로 해결할 수 없는 경고가 나타날수도 있는데 이런 비검사 경고를 최대한 모두 제거할수 있도록 하자.
이런 비검사 경고가 줄어들수록 타임 안정성이 보장된다.
@SuppressWarning("unchecked")
그런데 만약 경고를 제거할 수 없는 상황이지만 타입이 안전하다고 확신이 가능하다면 어떻게 해야할까? 그냥 경고가 발생하는걸 신경안쓰고 동작을 시키면 될까?
이를 @SuppressWarnings("unchecked") 어노테이션으로 해결할 수 있다.
해당 어노테이션을 작성하면 비검사 경고가 노출되지 않는다. 그렇기에 확실하지 않은 상황에서 섣부르게 어노테이션을 달면 예기치못한 문제가 발생할 수 있으니 신중히 붙혀야 한다.
그렇다고 너무 신중하게 다 안 붙히다간 안정성이 확보된 경고들 속에 진짜 문제를 말하는 경고가 발생해도 발견 못하는 수가 생길 수 있기 때문에 타입 안정성이 확보가되었다면 어노테이션을 붙혀주는게 좋다.
이 @SuppressWarnings 어노테이션은 모든 범위에서 달 수 있지만 되도록 좁은 범위에 달도록 하자.
즉, 변수 선언, 짧은 메서드, 생성자 정도에서 사용이 될 것이다. 범위가 넓어질수록 해당 어노테이션이 경고를 숨기는 범위가 넓어지기 때문에 위험하다. 그렇기에 범위는 최소화 하는게 좋다.
메서드나 생성자에 코드가 한 줄이 넘는다면 어노테이션을 지역변수 선언쪽으로 옮기도록 하자.
가령 예를들어 다음과 같이 toArray()라는 한 줄이 넘는 메서드가 있다고 하자.
public class TestArrayList<E> extends ArrayList<E> {
transient Object[] elements;
private int size;
@Override
public <T> T[] toArray(T[] a) {
if (a. length < size) {
return (T[]) Arrays.copyOf(elements, size, a.getClass());
}
System.arraycopy(elements, 0, a, 0, size);
if (a. length > size)
a [size] = null;
return a;
}
}
Java
복사
여기서 T[] result = (T[]) Arrays.copyOf(elements, size, a.getClass()); 코드쪽에서 비검사 경고가 날라올 것이다. 요구되는건 정규타입 매개변수인데 elements는 Object타입이기 때문이다.
여기서 범위를 최소화 하면서 이 비검사 경고를 없애기 위해서는 어떻게 해야할까?
위에서 말했다시피 @SuppressWarning 어노테이션을 붙혀줄 때 한 줄이 넘는 영역이라면 지역 변수 선언쪽으로 옮기자고 했는데 위 코드의 copyOf로 반환되는 값을 지역변수로 선언해준 뒤 반환해주도록 하자.
if (a. length < size) {
//생성한 배열과 매개변수로 받은 배열의 타입이 모두 T[]로 같으므로 올바른 형변환이다.
@SuppressWarnings("unchecked")
final T[] result = (T[]) Arrays.copyOf(elements, size, a.getClass());
return result;
}
Java
복사
이제 비검사 경고도 나타나지 않으면서 경고를 숨기는 범위도 최소화 했다.
여기서 또 기억해야할 주의점은 @SuppressWarning를 사용할때는 해당 코드가 비검사 경고를 무시해도 되는 이유를 주석으로 달아주도록 해야 한다. 그래야 다른 사람이 이 어노테이션이 왜 붙었는지 알 수 있기 때문이다.
정리
비검사 경고는 컴파일러가 당장 컴파일이 안되게 막지는 않기 때문에 신경을 끄고 개발하는 개발자들이 많다. 하지만, 이 비검사 경고는 매우 중요하기에 무시해서는 안된다. 비검사 경고를 무시하면 런타임중 언제 어디서 ClassCastException이 발생할 수 있다.
그러니 최대한 비검사 경고를 제거하고, 경고를 없애지 못하거나 안전하다는게 확정된다면 @SuppressWarning 어노테이션을 활용해 경고를 숨기고 주석으로 안전한 이유를 설명하자.
28. 배열보다는 리스트를 사용하라.
배열과 제네릭의 차이점
우리는 우선 제네릭의 특징중 하나를 알아야 한다.
제네릭은 불공변(invariant)이다.
즉, List<Super>와 List<Sub>는 하위타입, 상위타입 그 무엇도아닌 서로 다른 타입이다.
반면 객체 및 객체 배열에선 Sub가 Super의 하위 타입일 경우 Sub[] 객체 배열은 Super[]의 하위 타입이 된다. 즉 공변이라는 것인데, 코드를 작성할 때 얼핏보면 이런 공변이 더 편리해보인다.
하지만, 실제로 문제가 되는건 배열쪽이다.
다음 코드를 보자.
Object[] objectArr = new Long[1];
objectArr[0] = "문자열을 넣을 수 없다."; //ArrayStoreException 발생
Java
복사
이 코드는 문법적으로 허용되며 컴파일단계에서는 문제를 발견하지 못하고 런타임시 예외가 발생한다. 이전 챕터에서도 얘기했지만 모든 예외는 최대한 컴파일 단계에서 발견되는게 좋다.
다음은 제네릭을 사용한 코드이다.
List<Object> ol = new ArrayList<Long>();
ol.add("문자열을 넣을 수 없다.");
Java
복사
해당 코드를 IDE에 작성할경우 컴파일 에러가 발생하며 바로 코드가 문제가 있다는 것을 알 수 있다.
예외가 런타임시 발견되는 배열코드와 컴파일시 발견되는 제네릭
배열과 제네릭의 차이는 더 있다.
바로 배열은 실체화(reify)된다는 점인데, 런타임시에도 담기로 한 원소의 타입을 인지하고 확인한다는 의미이고 그렇기에 Long 배열에 String을 넣으려 할 때 담기로한 원소의 타입을 확인할 때 다른 걸 확인해서 ArrayStoreExcxeption이 발생하는 것이다.
하지만, 제네릭에서는 타입 정보가 런타임시 소거(erasure)된다. 원소 타입을 컴파일시에만 검사하고 런타임시에는 따로 확인을 하지 않는다는 것이다.
바로 이 부분(소거)덕분에 레거시한 코드에서 제네릭으로 전환이 무리없이 될 수 있게 되었다.
제네릭 배열은 불가능하다.
new List<E>[]
new List<String>[]
new E[]
Java
복사
위와 같은 제네릭 배열 선언은 타입 안정성의 문제로 사용할 수 없다.
Q. 어째서 이런 제네릭 배열을 만들지 못하게 했을까?
A. 타입 안전하지 않기 때문이다.
만약 제네릭 배열을 허용해준다면 제네릭 타입 시스템의 취지였던 런타임시 ClassCastException을 방지해준다는 장점이 사라지게 된다.
이런 제네릭 배열이 허용된다는 가정하에 다음과 같이 코드를 작성한다고 가정해보자.
(1) - List<String>[] stringLists = new List<String>[1];
(2) - List<Integer> intList = List.of(42);
(3) - Object[] objects = stringLists;
(4) - objects[0] = intList;
(5) - String s = stringLists[0].get(0);
Java
복사
만약 new List<String>[1];이라는 제네릭 배열이 허용된다고 가정한다면. 위 코드의 흐름은 다음과 같이 진행된다.
1.
(1)에서 stringList라는 List<String> 타입의 제네릭 배열이 생성된다.
2.
(2)에서 42라는 원소를 가진 Integer 타입 매개변수의 intList 리스트가 선언된다.
3.
(3)에서 stringLists를 Object 배열인 objects에 할당한다.
4.
(4)에서 intList를 Object타입인 객체 배열 objects의 0번 index에 집어 넣는다. 런타임시 List<Integer>는 로타입인 List가 되고 List<Integer>[]는 List[]가 되기에 정상적으로 objects에 들어가며 ArrayStoreException 예외는 발생하지 않는다.
5.
(5)에서 stringLists라는 제네릭 배열의 0번째 인덱스의 있는 List를 꺼내서 get으로 첫번째 원소값을 꺼내려 시도하는데 여기서 문제가 발생한다.
⇒ 컴파일러는 꺼낸 원소를 자동으로 String으로 형변환을 할텐데 실제 원소는 Integer 타입이기에 런타임시 ClassCastException이 발생한다.
⇒ 즉, 제네릭의 장점인 타입안정성이 무용지물이 된 것이다.
실체화 불가 타입(non-reifiable type)
다음 타입은 모두 실체화 불가 타입이라 한다.
•
정규 타입 매개변수 E,
•
제네릭 타입 List<E>
•
매개변수화 타입 릿 List<String>
실체화 불가 타입이라는 말은 즉, 실체화가 되지 않아서 런타임에는 컴파일타임보다 타입 정보를 적게 가지는 타입이다. 소거 메커니즘으로 인해 매개변수화 타입 중에서 실체화가 가능한 타입은 List<?>나 Map<?,?>같은 비한정적 와일드카드 타입뿐이다.
제네릭 타입과 가변인수 메서드(varargs method)
제네릭과 가변인수 메서드를 같이 사용하면 경고 메세지를 받게 되는데, 가변인수 메서드를 호출할 때마다 가변인수 매개변수를 담을 배열이 만들어지는데 이 때 이 배열의 원소가 실체화 불가 타입이라면 경고가 발생하게 된다. 그리고 이러한 경고는 @SafeVarags라는 어노테이션으로 대처할 수 있다.
예제 학습
배열로 형변환을 할 시 제네릭 배열 생성오류, 비검사 형변환 경고같은 대부분의 문제들은 배열(E[])대신 컬렉션(List<E>)을 사용하면 해결된다.
컬렉션을 사용함으로써 코드가 복잡해지고 성능이 떨어질 수 있다는 단점이 있지만, 타입안정성과 상호운용성측에서 이점을 가질 수 있다.
다음은 생성자에서 컬렉션을 받는 Chooser 클래스를 구현한 코드인데, 우선 제네릭을 사용하지 않은 Object 객체 배열을 사용한 코드이다.
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choiceArray) {
this.choiceArray = choiceArray.toArray();
}
public Object choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
Java
복사
이 코드에서 choose() 메서드를 호출할 때마다 반환되는 타입은 Object이기에 원하는 타입으로 형변환을 해줘야 한다. 그리고 내가 변환하고자하는 타입과 다를 경우 형변환 오류가 발생할 것이다.
그래서 이번에는 제네릭을 사용해서 Chooser 클래스를 리팩토링해보자.
public class ChooserV2<T> {
private final T[] choiceArray;
public ChooserV2(Collection choiceArray) {
this.choiceArray = choiceArray.toArray();
}
public Object choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}
Java
복사
근데 이렇게 제네릭으로 만들면 컴파일 에러가 발생할 것이다. 에러가 발생하는 이유는 생성자부분의 choiceArray.toArray(); 메서드의 반환타입이 Object[] 이기 때문인데, 이 문제를 해결하기위해선 T 배열로 형변환을 해주면 된다.
...
this.choiceArray = (T[])choiceArray.toArray();
...
Java
복사
하지만, 이제 경고가 여기저기 뜰텐데, 그 이유는 T가 어떤 타입인지 알 수 없기 때문에 컴파일러는 이 형변환이 런타임시 안전한지 보장할 수 없다는 내용이다. 위에서도 언급했지만 제네릭에서는 원소의 타입 정보가 소거되기때문에 런타임시에는 무슨 타입인지 알 수가 없다.
코드가 확실히 안정적으로 약속된 타입만 들어온다면 @SuppressWarning 어노테이션을 붙혀서 사용해도 되지만, 배열대신 리스트를 사용하면 이러한 비검사 경고도 사라지게 할 수 있다.
public class ChooserV3<T> {
private final List<T> choiceList;
public ChooserV3(Collection<T> choiceArray) {
this.choiceList = new ArrayList<>(choiceArray);
}
public Object choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}
Java
복사
이제 어떠한 경고도 없고 컴파일 될 것이다.
물론, 코드의 양이 조금 늘었고, 컬렉션 프레임워크를 사용하기에 속도도 조금 떨어질 수 있지만, 타입안정성을 확보할 수 있다.
정리
•
배열은 공변이고 실체화(reify)되기에 런타임시에는 타입 안전하며 컴파일시에는 불안전하다.
•
제네릭은 불공변이고 타입 정보가 소거되기에 런타임시에 타입 불안전하며 컴파일시에는 안전하다.
•
둘을 섞어쓰면 서로의 장점이 사라지기에 쉽지도 않고 따로 사용하는게 좋다.
•
컴파일 오류나 경고를 만나면 배열을 리스트로 대체하는 방법을 고려하고 적용해보자.
29. 이왕이면 제네릭 타입으로 만들라
포스팅 최초 언급했듯이, 제네릭은 타입안정성을 보장해주고 그렇기에 형변환과 타입추론을 생략해준다. 그래서 이런 장점들을 최대한 살리기위해 이왕이면 제네릭 타입으로 코드를 작성하는게 편하다.
우선 제네릭 타입을 쓰지 않고 작성한 스택 코드를 보면서 제네릭의 필요성 위주로 리팩토링을 해보자.
Object 기반 스택(Stack)
public class ObjectStack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public ObjectStack() {
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;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size) {
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
}
Java
복사
⇒ 이 스택(Stack) 코드는 제네릭을 사용하지 않고 작성한 Object 객체배열 기반의 스택이다.
물론 이 코드로 사용을해도 컴파일시에 문제는 생기지않는다. 하지만 어째서 제네릭으로 리팩토링을 해야할까?
해당 코드를 사용하면 클라이언트에서는 스택에서 pop을 통해 꺼낸 객체를 형변환을 해야하는데 이 때 런타임 오류가 날 위험이 있다. 또한 매번 형변환을 해줘야하는 코드를 작성해줘야 한다.
이런 몇가지 이유로 제네릭을 적용한 스택 코드를 구현해 볼 것인데 우선은 객체배열을 정규화 매개변수타입의 객체배열로 바꿔보면 다음과 같은 코드가 된다.
public class GenericStackV1<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public GenericStackV1() {
elements = new E[DEFAULT_INITIAL_CAPACITY]; // Compile Error!
}
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;
}
public boolean isEmpty() {
return size == 0;
}
private void ensureCapacity() {
if (elements.length == size) {
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
}
Java
복사
하지만 이 코드는 생성자 부분의 new E[DEFAULT_INITIAL_CAPACITY]; 부분에서 컴파일 에러가 발생할 것이다. 에러의 원인은 java: generic array creation 으로 E와 같은 정규 타입 매개변수는 실체화 불가 타입으로 배열을 만들 수 없다. 제네릭을 사용해야하는데 그럼 어떻게 해야할까?
해결책은 두 가지
1.
Object 배열을 생성한 다음 제네릭 배열로 형변환 하기
: 제네렉 배열 생성 금지 제약을 우회하는 방식으로 이 방식을 사용하면 이제 오류가 아닌 다음과 같은 경고를 만나게 된다.
할 수는 있지만 타입 안전하지 않다는 의미의 경고인데, 우리는 이 코드가 타입 안전한지 확인이 가능하다.
경고의 대상이 되는 elements 배열은 다음과 같은 특징을 가진다.
•
접근 제어자는 private로 외부에서 접근이 불가하다.
•
push 메서드를 통해서 elements에 저장될 때 매개변수의 타입은 항상 E다.
이런 특징들로 인해 이 비검사 형변환은 확실히 안전하다. 이렇게 우리는 이 코드가 안전하다는걸 확인했으니 이제 최소한의 범위로 @SuppressWarnings 어노테이션을 이용해 경고를 숨기도록 한다.
//배열 elemtns 는 push(E)로 넘어온 E 인스턴스만 담는다.
//따라서 타입 안전성을 보장하지만,
//이 배열의 런타임 타입은 E[]가 아닌 Object[]다.
@SuppressWarnings("unchecked")
public GenericStackV1() {
elements = (E[])new Object[DEFAULT_INITIAL_CAPACITY];
}
Java
복사
@SuppressWarnings 어노테이션과 적절한 주석으로 경고를 숨길 수 있다.
2.
elements 필드의 타입을 E[] 에서 Object[] 로 바꾸는 것.
: 이 방법을 사용하면 첫 번째 방법과는 다른 오류 및 경고가 나타난다.
public class GenericStackV2<E> {
private Object[] elements;
...
public GenericStackV2() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
...
public E pop() {
if(size == 0) throw new EmptyStackException();
E result = (E) elements[--size]; //Warning : unchecked cast
elements[size] = null;
return result;
}
...
}
Java
복사
elements는 그대로 Object 배열이지만 pop으로 꺼낼 경우 정규타입 매개변수인 E 로 형변환을 해서 반환하는 것인데 이 경우 unchecked cast 경고가 뜬다. E는 실체화 불가 타입이기에 컴파일러는 런타임에 이뤄지는 형변환이 안전한지 증명할 방법이 없기 때문인데, 이 역시 첫 번째 방법과 같이 우리가 직접 증명 후 경고를 숨길 수 있다.
public E pop() {
if(size == 0) throw new EmptyStackException();
//push 메서드에서 E타입만 허용하기에 이 형변환은 안전하다.
@SuppressWarnings("unchecked")
E result = (E) elements[--size];
elements[size] = null;
return result;
}
Java
복사
이렇게 두 가지 해결책으로 제네릭을 사용한 스택 코드를 구현해서 타입안전성과 타입추론을 안전하게 할 수 있도록 만들었다. 이 두 가지 방법은 모두 장단점이 있는데, 이를 잘 이해하고 필요에따라 맞는 방법을 사용하면 된다.
우선 첫 번째 방법은 다음과 같은 장점을 가진다.
•
가독성이 좋다.
⇒ 타입의 배열을 E[]로 선언해서 E타입 인스턴스만 받고 코드도 더 짧다.
•
형변환을 생성자 호출시 한 번만 해주면 해 줄 필요가 없다.
⇒ 첫 번째 방법은 해당 배열을 다루는 모든 곳에서 형변환을 추가로 해 줄 필요가 없다.
하지만 첫 번째 방법은 다음과 같은 단점도 가지고 있다.
•
(E 가 Object가 아닌 한) 배열의 런타임 타입이 컴파일타임 타입과 달라 힙 오염(heap pollution)을 일으킨다.
⇒ 이번 Stack예제에서는 힙 오염이 해가 되지 않았다.
왜 리스트가 아닌 배열을 사용했을까?
왜 Stack 코드는 배열을 사용했을까? 분명 아이템 28에서는 배열보다는 리스트를 우선하라 라고 했는데...
밝히자면 제네릭 타입 안에서 리스트를 사용하는 것은 항상 가능하지도 않을 뿐더라 반드시 좋다는 보장도 없다. 어째서일까? 자바에서는 리스트를 기본 타입으로 제공하지 않는다. 그래서 콜렉션 프레임워크의 여러 구현체들(ex: ArrayList, HashMap ...)도 기본 타입인 배열을 사용해 구현해야 한다.
매개변수에 제약을 두는 제네릭 타입
지금껏 작성한 Stack 코드는 다음과 같이 사용이 가능하다.
public static void main(String[] args) {
Stack<String> stackStr = new GenericStackV1<>();
Stack<Integer> stackInt = new GenericStackV1<>();
Stack<Double> stackDouble = new GenericStackV1<>();
String str = "hello world";
for (int i = 0; i < str.length(); i++) {
stackStr.push(str.charAt(i)+"");
stackInt.push(i);
stackDouble.push(i+10D);
}
while (!stackStr.isEmpty()) {
System.out.print(stackStr.pop().toUpperCase());
}
System.out.println();
while (!stackDouble.isEmpty()) {
System.out.print(stackDouble.pop());
}
}
Java
복사
이처럼 타입 매개변수에 제약을 두지 않고 사용이 가능한데, 원한다면 타입 매개변수에 제약을 둘 수도 있다.
만약, Item이라는 최상위 클래스를 기준으로 Item - Music - Dance - BreakDance 라는 상속 구조를 가진객체가 있다고 하자. 근데 여기서 Stack 클래스에 들어갈 수 있는 타입을 Dance랑 BreakDance 로 제약을 두려면 다음과 같이 작성하면 된다.
public class Stack<E extends Dance>
Java
복사
물론 모든 타입은 자기 자신의 하위 타입이기에 Stack<Dance>로도 사용할 수 있다.
정리
제네릭을 사용하지 않는다면 클라이언트에서 매번 직접 형변환을 해야하는데, 이는 언제나 문제의 요지가 있다. 매번 직접 형변환을 해야한다는 번거로움과, 실수로인해 에러가 발생할 수 있다는 문제 그리고 타입추론이 힘들다는 고됨까지 있다. 그렇기에 제네릭 타입을 사용해서 이런 문제를 해결하도록 하자.
기존 클라이언트에 영향을 주지 않으면서 새로운 사용자를 편하게 해줄 수 있다.
30. 이왕이면 제네릭 메서드로 만들라
아이템 29인 이왕이면 제네릭 타입으로 만들라와 이어진다.
우리는 아이템 29에서 제네릭 타입으로 만들었을 경우의 장점에 대해 알게되었다.
그럼 그 장점을 그대로 메서드에서도 누릴 수 있다. 바로 제네릭 메서드로 만들면 말이다!
사실, 매개변수화 타입을 받는 많은 정적 유틸리티 메서드들은 보통 제네릭이다.
(Ex: Collection의 알고리즘 메서드(binarySearch, sort, ...)
작성법
//<정규타입 매개변수> 반환타입 메서드명([매개변수]){로직}
<E> E methodName(E e){...}
Java
복사
이것만 봐서는 아직 모호하다! 그래서 일반 로 타입 메서드를 작성해보고 이를 제네릭 메서드로 리팩터링 해보자!
•
(리팩토링 전) 로 타입(raw type) 메서드
public static Set union(Set s1, Set s2) {
Set result = new HashSet(s1); // warning: unchecked call
result.addAll(s2); //warning: unchecked call
return result;
}
Java
복사
이 코드는 여기저기 unchecked call이라며 컴파일러에서 경고를 내뱉는다. 반환타입, 매개변수가 로타입이기에 새로운 HashSet을 만들고 거기에 s1을 넣는것도 타입안전하지 못하고 addAll로 s2 를 넣는것도 안전하지 않기 때문이다.
이번에는 이런 로 타입을 모두 제네릭 타입 메서드로 변경해주도록 해보자.
•
(리팩토링 후) 제네릭 타입(generic type) 메서드
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
return result;
}
Java
복사
: 이제 모든 경고가 사라진걸 확인할 수 있다. 단순한 제네릭 메서드는 이정도로 충분하다. 이제 이 union메서드는 경고도 없이 컴파일이 될 것이고, 타입 안전하고 쓰기도 편하다.
더 리팩토링 할 수 있다.
한 번 리팩토링한 union 제네릭 메서드는 반환 타입 1개와, 매개변수로 받는 입력 타입 2개로 총 3개의 타입이 일치해야하는데, 이를 한정적 와일드카드 타입으로 더 유연하게 리팩토링이 가능하다.
(아이템 31에서 소개하겠지만 리팩터링한 코드만 미리 여기에 작성해본다. )
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
return result;
}
Java
복사
제네릭 싱글톤 팩토리
불변 객체를 여러 타입으로 활용할 수 있게 만들어야 하는 경우가 종종 생기는데, 제네릭은 런타임시 타입 정보가 소거 된다. 그래서 하나의 객체를 어떤 타입으로든 매개변수화가 가능한데, 이게 가능하려면 요청한 타입 매개변수에 맞게 객체의 타입을 바꿔주는 정적 팩토리를 만들어야 한다. (만약 불변객체를 모른다면 이 포스팅을 보고오자.)
이러한 패턴을 제네릭 싱글톤 팩토리라 하며 Collections.reverseOrder같은 함수 객체나 Collections.emptySet같은 컬렉션용으로 사용한다.
이미 만들어진 메서드로 이해하기 힘들다면 항등 함수(identity function)를 담은 클래스를 만들면서 학습해보자.
이미 말했지만 제네릭은 런타임시 타입 정보가 사라지기에 하나의 제네릭 싱글톤만 구현하면 된다.
•
제네릭 싱글톤 팩토리 패턴으로 구현한 항등함수
private static final UnaryOperator<Object> IDENTITY_FN = t -> t;
//입력 값을 그대로 반환하는 항등함수이기에 안전하다.
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction(){
return (UnaryOperator<T>) IDENTITY_FN;
}
Java
복사
•
항등함수 사용 코드
public static void main(String[] args) {
String[] strings = {"사과", "포도", "수박"};
UnaryOperator<String> sameString = identityFunction();
for (String string : strings) {
System.out.println(sameString.apply(string));
}
Number[] numbers = {1, 2.0, 3L};
UnaryOperator<Number> sameNumber = identityFunction();
for (Number number : numbers) {
System.out.println(sameNumber.apply(number));
}
}
Java
복사
재귀적 타입 한정(recursive type bound)
자기 자신이 들어간 표현식을 사용해 타입 매개변수의 허용 범위를 한정하는 개념으로 Comparable 인터페이스와함께 사용되는데, 이 인터페이스는 제네릭 타입 인터페이스이고 보통 자기자신과 비교하기위해 사용한다.
그래서 Comparable 인터페이스는 구조가 대략적으로 다음과 같다.
public interface Comparable<T> {
int compareTo(T o);
}
Java
복사
비교 대상이 되는 매개변수도 T 타입으로 거의 모든 타입이 자신과 같은 타입의 원소와만 비교할 수 있다.
(String 은 Comparable<String> Integer는Comparable<Integer>를 구현한다. )
그리고 이 Comparable 인터페이스를 구현한 구현체들을 가지는 컬렉션을 매개변수로 받는 매개변수는 일반적으로 해당 원소, 검색하거나 최솟값, 최댓값을 구하는 식으로 비교를 하는 용도로 사용한다.
그럼 이런 메서드에서 재귀적 타입 한정(recursive type bound)으로 표현하면 어떻게 작성될까?
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c);
Java
복사
이를 해석하면 다음과 같다.
•
타입 한정인 <E extends Comparable<E>>는 모든 타입 E는 자기 자신과 비교할 수 있다.
•
반환타입도 동일한 E 타입이다.
•
매개변수로 받는 Collection<E> c는 E타입의 원소 콜렉션이고 이 중 최댓값을 구하는 메서드이다.
이제 이 메서드를 구현해보자.
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c){
if(c.isEmpty()) return Optional.empty();
E result = null;
for (E e : c) {
if(result == null || e.compareTo(result) > 0)
result = Objects.requireNonNull(e);
}
return Optional.of(result);
}
Java
복사
정리
•
제네릭 타입과 마찮가지로 메서드도 제네릭 메서드로 만들면 더 안정적이고 사용하기도 편하다.
•
기존 클라이언트를 수정하지 않고 새로운 사용자에게 편리한 경험을 만들어 줄 것이다.
31. 한정적 와일드카드를 사용해 API 유연성을 높이라
이미 아이템 28.배열보다는 리스트를 사용하라 에서 언급했지만 매개변수화 타입은 불공변(invariant)이다.
이를 좀 더 풀어보자면 List<String>은 List<Object>의 하위타입이 아니라는 의미가 되고, 그렇기에 List<String>은 List<Object>가 하는 일을 제대로 수행하지 못한다.
만약 이런 방식이 아니라 좀 더 유연한 방식을 원한다면 어떻게 해야할까? 우리는 이미 위에서 사용 예제를 본적이 있다. <E extends Comparable<E> 이처럼 한정적 와일드카드를 사용하는 max 메서드 예제 코드가 바로그렇다. 그럼, 한정적 와일드카드가 어떻게 유연성을 높혀주는지 살펴보자.
와일드카드 타입을 사용하지 않은 코드
public class Stack<E> {
...
public void pushAll(Iterable<E> src) {
for(E e : src) push(e);
}
}
Java
복사
이 pushAll 메서드는 잘 컴파일되고 동작도 되지만, 유연성이 많이 떨어진다. 어째서 그런지 다음 코드를 보자.
Stack<Number> stack = new Stack<>();
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
//java: incompatible types: java.util.List<java.lang.Integer>
// cannot be converted to java.lang.Iterable<java.lang.Number>
stack.pushAll(integers);
Java
복사
Integer는 Number의 하위타입이니 논리적으로 문제가 없어야 할 것 같지만 실제로는 컴파일 에러가뜨는데,
에러의 원인은 매개변수화 타입이 불공변이기 때문에 Number타입으로 변환할 수 없다는 것이다.
즉, 매개변수화 타입 자체를 독립적으로보면 하위 객체이고 공변이 되지만 매개변수화 타입에서는 불공변이기에 서로 호환성이 떨어지게 된다. 그래서 이런 경우 한정적 와일드카드 타입이라는 특별한 매개변수화 타입으로 문제를 해결할 수 있다.
한정적 와일드카드 타입을 적용한 다음 코드를 보자.
public void pushAll(Iterable<? extends E> src) {
for (E e : src) push(e);
}
Java
복사
⇒ 매개변수의 타입을 Iterable<E> 에서 Iterable<? extends E> 으로 와일드카드 타입을 적용해준 뒤 아까 에러가 났던 코드를 다시 실행시켜보면 이제 문제없이 동작을 할 것이다. 이 말은 코드가 타입 안전하다는 것을 의미한다.
상위 객체에 대한 유연성을 늘리는 와일드카드 타입
하위 객체에 대한 유연성을 한정적 와일드카드 타입(<? extends E> )로 해결을 했다. 하지만 이쯤에서 이런 고민을 할 필요가 있다.
상위 객체에 대한 유연성은 어떻게 확보해야 하는가?
사실 이 질문에 대한 답변은 하나의 키워드로 해결을 한다. 하위 객체로 확장이 extends라면 상위 객체로의 확장은 super다.
이를 Stack 객체에 모든 값을 꺼내는 popAll 메서드를 구현하며 알아보자. 다음 코드를 보자.
public void popAll(Collection<? super E> dst) {
while (!isEmpty()) dst.add(pop());
}
...
public static void main(String[] args) {
Stack<Number> stack = new Stack<>();
List<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
stack.pushAll(integers);
Collection<Object> objects = new ArrayList<>();
stack.popAll(objects);
System.out.println("objects = " + objects);
}
Java
복사
popAll 메서드의 매개변수 타입으로 와일드카드 타입을 적용했는데, 이를 해석하면 매개변수 Collection의 매개변수화 타입은 E의 상위타입이어야 한다는 의미이다. 그래서 Number의 상위타입인 Object로 매개변수화 타입을 작성해도 에러가 나지 않고 동작한다.
이렇게 하위 타입, 상위 타입에 대한 유연성을 높히는 방법들은 모두 하나의 메세지를 던진다.
유연성을 극대화하기위해 원소의 생산자나 소비자용 입력 매개변수에 와일드카드 타입을 사용하라.
(물론, 매개변수가 생산자와 소비자 역할을 동시에 한다면 타입을 정확히 지정해야 하기에 와일드카드 타입 사용을 하면 안된다. )
그럼 어쩔때 어떤 와일드 카드 타입을 사용해야할까? 이 점은 다음 공식을 기억하자.
PECS: producer-extends, consumer-super
즉, 생산자는 extends로 하위 타입유연성을 높히고 소비자라면 super로 상위 타입 유연성을 높히자.
지금까지 사용했던 Stack 코드를 예로들면 pushAll이라는 메서드에서 매개변수(src)는 생산자로 사용되기에 하위 호환 유연성을 높히는 <? extends E> 와일드 카드 타입이 적절하고, popAll이라는 메서드의 매개변수(dst)는 Stack이라는 객체로부터 E인스턴스를 소비하기에 <? super E>가 적절하다.
참고: 나프탈리(Naftalin)과 와들러(Wadler)는 이를 겟풋원칙(Get and Put Priciple)이라 부른다.
참고: 반환타입은 한정적 와일드카드 타입을 사용하면 안된다.
⇒ 유연성을 높여주기는 커녕 클라이언트 코드에서도 와일드카드 타입을 써야하기 때문이다.
클라이언트가 와일드카드 타입을 신경써야 하는상황이오면 그 API에 문제가 있을 가능성이 크다.
조금 더 복잡한 한정적 와일드카드 타입
이번에는 하나의 와일드카드 타입이아닌 두 개 이상의 와일드카드 타입을 사용해보자.
다음 max 메서드는 아이템 29에서 작성했던 코드이다.
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c){...}
Java
복사
여기서 PECS공식을 한 번 더 적용해보자. 그럼 다음과 같이 쓸 수 있다.
public static <E extends Comparable<? super E>> Optional<E> max(Collection<? extends E> c){...}
Java
복사
변경된 부분을 하나씩 살펴보자.
•
입력 매개변수는 E 인스턴스를 생산하기에 원래의 Collection<E>를 List<? extends E> 로 수정했다.
•
원래 선언 쪽 E는 Comparable<E>를 확장한다고 했는데, 여기서 Comparable<E>는 E인스턴스를 소비한다. 그렇기에 Comparable<? super E>로 변환했다.
⇒ Comparable은 언제나 소비자이기에 보통 Comparable<E>보단 Comparable<? super E>가 낫다.
생각해볼 주제
•
메서드를 정의할 때 매개변수의 타입으로 타입 매개변수와 와일드카드 중 무엇을 선택해야 하는가?
타입 매개변수와 와일드카드에는 공통되는 부분들이 있어서 둘 중 무엇을 사용해도 괜찮은 경우가 많다.
다음 코드를 보자.
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);
Java
복사
둘 중 어떤 선언이 더 나을까? 그리고 더 나은 이유는 무엇일까?
public API라면 간단한 두 번째가 낫다. 이 메서드에 리스트를 넘기면 인덱스의 원소들을 교환해준다. 이 때 우리가 신경써야 할 타입 매개변수도 없다. 기본 규칙은 다음과 같다.
메서드 선언에 타입 매개변수가 한 번만 나오면 와일드 카드로 대체하라.
또한 위 조건을 충족한다면 다음과 같은 타입도 바꿔주면 된다.
•
비한정적 타입 매개변수 ⇒ 비한정적 와일드카드
•
한정적 타입 매개변수 ⇒ 한정적 와일드카드
하지만, 두 번째 방법이인 와일드카드 방식은 주의해야 하는 부분이 있다. 다음 코드를 보자.
public static void swap(List<?> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
Java
복사
이 코드는 문제가 없어보이지만 컴파일시 오류가 발생한다. 그 이유는 List<?>에는 null 외에는 어떤 값도 넣을수 없다는 것인데, 이를 해결하기 위해서는 와일드카드 타입의 실제 타입을 알려주는 private helper method를 작성하는 방법이다.
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)));
}
Java
복사
swapHelper라는 헬퍼 메서드는 List<E> 매개변수로 이 리스트에서 꺼낸 값이 항상 E라는 것을 알고 있다.
그래서 set으로 List에서 값을 꺼내 다시 넣는 과정도 문제가 없이 수행될 수 있다.
와일드카드 방식을 사용하면 이렇게 약간 복잡한 우회방법(제네릭 메서드)을 사용해야하지만, 덕분에 외부에서는 와일드카드 기반의 메서드를 유지할 수 있고 해당 메서드의 로직 내부에서 swapHelper라는 헬퍼 메서드가 동작하는지에 대해서도 알 필요가 없다.
정리
•
와일드카드 타입을 적용하면 API가 유연해진다.
•
PECS: Producer - Extends, Consumer - Super
•
Comparable과 Comparator는 모두 소비자다.
•
메서드 선언 타입에 타입 매개변수가 한 번만 등장하면 와일드카드를 사용하자.
32. 제네릭과 가변인수를 함께 쓸 때는 신중하라
가변인수(varargs)는 이름대로 메서드에 넘기는 인수의 개수를 클라이언트가 조절할 수 있게 함으로써 유연성을 높혀주는데, 가변인수를 받는 메서드측에서는 가변인수를 담기 위한 배열이 자동으로만들어진다. 그런데 배열을 내부로 감추지 않고 클라이언트에 노출하게 될 경우가 생기는데 이 때 이 배열안에 제네릭이나 매개변수화 타입이 포함되면 컴파일 에러가 발생한다.
이는, 대부분의 제네릭과 매개변수화 타입은 실체화 불가 타입이기에 생기는 에러로 메서드를 선언할 떄 실체화 불가 타입으로 가변인수 매개변수를 선언할 경우 컴파일러가 경고를 내보내는데, 경고내용은 매개변수화 타입의 변수가 타입이 다른 객체를 참조하면 힙 오염이 발생한다는 내용이다.
매개변수화 타입의 변수가 다른 타입의 객체를 참조하면 힙 오염이 발생한다.
다음 코드는 실제 힙 오염이 발생해서 예외가 발생하는 코드다.
public static void main(String[] args) {
dangerous(List.of("one", "two", "three"));
}
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists;
objects[0] = intList; // 힙 오염 발생
String s = stringLists[0].get(0); // ClassCastException
System.out.println("s = " + s);
}
Java
복사
dangerous 메서드는 List<String> 이라는 실체화 불가 타입을 가변인수로 사용하는 코드인데, 내부를 보면 따로 명시적으로 형변환을 하는 부분은 보이지 않는다. 하지만, stringList의 원소는 매개변수화 타입이기 때문에 컴파일시 컴파일러가 형변환하는 부분을 추가한다.
그렇기에 intList의 숫자를String으로 컴파일러가 (보이지는 않지만) 생성한 형변환을 시도하다가 ClassCastException이 발생하는 것이다.
즉, 타임 안전성이 깨지기 때문에 제네릭 가변인수 배열 매개변수에 값을 저장하는건 불안하다.
Q. 그런데 어째서 제네릭 가변인수 매개변수를 허용해주는 건가?
A. 실무에서 유용하기 때문이다.
그렇다. 위와같이 잘 못 사용할 경우 여러 문제가 발생하지만 그래도 저런 코드가 허용되는 이유는 유용하기 때문이다.
우리가 즐겨쓰는 자바 메서드중에서도 이런 허용하에 사용되는 메서드들이 있다.
Ex: Arrays.asList(T... a), Collections.addAll(Collection<? super T> c, T... elements), EnumSet.of(E first, E... rest)
물론 해당 메서드들은 모두 타입 안전하다!
그럼 이렇게 타입 안전하기에 경고를 따로 보내줄 필요가 없는 경우 어떻게 해야 할까?
이전 아이템에서 타입안전함이 보장된 곳에서 @SuppressWarning 어노테이션으로 경고를 숨겼던 기억이 있을 것이다. 하지만 제네릭 가변인수 매개변수를 선언한 메서드를 호출하는 곳 마다 어노테이션을 달아 경고를 숨기는건 번거롭다. 더욱이 잦은 어노테이션은 진짜 문제를 알려주는 경고마저 숨길 수 있다. 그럼 좋은 방법이 없을까?
@SafeVarags
자바 7 에서 추가 된 @SafeVarags 어노테이션은 제네릭 가변인수 메서드 작성자가 클라이언트 측에서 발생하는 경고를 숨길수 있도록 해준다. 이 어노테이션은 그 메서드 타입 안전함을 보장한다는 의미로 컴파일러는 이러한 약속을 믿고 경고를 내보내지 않는다.
그럼 어느 경우에 @SafeVarags 어노테이션을 달아도 될까? 메서드가 타입 안전함이 보장되야하는데 어떻게 안전함을 보장할 수 있을까?
메서드가 가변인수 배열에 아무것도 저장하지 않고 배열의 참조가 외부로 노출되지 않는다면 안전하다. 즉, 이 가변인수 매개변수 배열이 오로지 메서드로 인수들을 전달하는 일만 수행한다면 안전함이 보장된다.
주의점
가변인수 매개변수 배열에 아무 값을 저장하지 않고도 타입 안전성을 깰 수 있다. 다음 코드를 보자.
static <T> T[] toArray(T... args){
return args;
}
Java
복사
이 메서드가 반환하는 배열의 타입은 이 메서드에 인수를 넘기는 컴파일 타임에 결정된다.
하지만 이 시점에는 컴파일러에 충분한 정보가 주어지지 않아 문제가 생길 수 있다. 따라서 가변인수 매개변수 배열을 그대로 반환하면 힙 오염이 이 메서드를 호출한 쪽의 콜스택까지 전이될 수 있다.
다음은 toArray()메서드를 잘 못 호출함으로써 힙 오염이 발생하는 코드다.
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();
}
Java
복사
여기까진 컴파일상 오류가 없다. 그럼 이를 사용하는 로직을 작성해보자.
public static void main(String[] args) {
String[] strings = pickTwo("one", "two", "three");
System.out.println("strings = " + strings);
}
Java
복사
우리가 기대하기로는 one, two, three 라는 문자열 중 랜덤으로 2개가 선택되어 반환되야 할 것 같다.
하지만 실행을 해보면 ClassCastException이 발생할 것이다. 어째서일까?
우선 pickTwo 라는 메서드는 T 인스턴스 2개를 담은 매개변수 배열을 만들어 반환하는 코드다. 이 메서드의 결과는 항상 Object[] 일 수밖에 없는데 pickTwo 에 어떤 타입의 객체를 넘기더라도 담을 수 있는 가장 구체적인 타입이기 때문이다.
그래서 pickTwo()→toArray()로 진행되는 호출의 결과는 항상 Object[] 타입 배열을 반환한다.
그런데 위 main 로직을 살펴보면 pickTwo("one", "two", "three"); 는 String 타입을 인수 타입으로 전달하며 반환 값을 String[] 배열인 strings에 저장하려한다. 그렇기에 컴파일러는 String[]으로 형변환 하는 코드를 자동으로 생성해주는데, 실제로 반환되는 값은 Object[]타입이다.
근데 Object[]는 String[]의 하위타입이 아니기 때문에 ClassCastException이 발생하는 것이다.
이 예제를 통해 우린 제네릭 가변인수 매개변수 배열에 다른 메서드가 접근하도록 허용하면 안전하지 않다는 점을 알 수 있다. 물론, 다음과 같은 2가지 경우는 예외다.
1.
@SafeVarargs 어노테이션이 붙어있는 안전한 메서드
2.
가변인수 매개변수 배열을 넘기지 않아도 되는 일반 함수
올바른 사용법
실무에서 유용해서 허용해줬다면서 지금까지 본 내용은 문제들만 가득하다. 그럼 어디가 좋다는걸까? 이번에는 제네릭 가변인수 매개변수 배열을 제대로 사용해보자.
다음은 리스트를 가변인수 매개변수로 받아 내부의 값을 꺼내 하나의 리스트로 만들어 반환하는 flatten 메서드를 구현해보자.
@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;
}
Java
복사
(코드가 안전하다면) 제네릭이나 매개변수화 타입의 가변인수 매개변수를 받는 모든 메서드에 @SafeVarargs 어노테이션을 달아주도록 하자. 그럼 여러 경고를 없앨 수 있다.
메서드가 안전한지 잘 모르겠다면 다음 두 가지 조건을 만족하는지 살펴보고 만족하지 않는다면 고치도록 하자.
•
가변인수 매개변수 배열에 아무것도 저장하지 않는다.
•
자동생성되는 배열을 신뢰할 수 없는 코드에 노출하지 않는다.
참고- @SafeVarargs 어노테이션은 재정의가 불가한 메서드에만 붙혀야 한다.
다음과 같은 경우에만 붙히도록 하자.
•
정적 메서드
•
final 인스턴스 메서드
•
(java 9+) private 인스턴스 메서드
또 다른 대안
@SafeVarargs 어노테이션을 붙히지 않고도 해결을 할 수 있다.
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;
}
public static void main(String[] args) throws IOException {
List<Integer> result = flatten(List.of(List.of(1, 2, 3), List.of(4, 5, 6), List.of(7, 8, 9)));
}
Java
복사
매개변수를 가변인수가 아닌 List 매개변수로 바꾸는 것인데, 메서드를 호출하는 측에서는 List.of라는 정적 팩터리 메서드를 사용하면 임의의 개수를 넘길 수 있다.
물론, 이러한 방법이 가능한 이유는 List.of메서드가 정의된 곳을 가 보면 @SafeVarargs 어노테이션이 있기 때문이다.
이러한 방식을 사용하면 컴파일러가 메서드의 타입 안전성을 검증 할 수 있다는 장점이 있다.
우리가 직접 @SafeVarargs 어노테이션을 붙힐 필요도 없이 이미 검증된 List.of 메서드를 사용하면 된다. 이 방식은 위에서 작성했던 pickTwo메서드에서도 바로 적용해 사용할 수 있다.
static <T> 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();
}
Java
복사
정리
•
가변인수(varargs)와 제네릭은 둘 다 자바 5에서 나왔는데도 불구하고 궁합이 안좋다.
•
가변인수 기능은 배열을 노출하기에 추상화가 완벽하지 않다.
•
하지만 유용하다는 이유로 제네릭 가변인수 매개변수 타입은 허용된다.
•
메서드에 제네릭(혹은 매개변수화된) varargs 매개변수를 사용하려면 다음을 확인하자.
1.
이 메서드가 타입 안전한지 확인한다.
2.
@SafeVarargs 어노테이션을 달아 경고가 나오지 않게 한다.
33. 타입 안전 이종 컨테이너를 고려하라.
기존에 사용하던 제네릭 사용 방식은 컬렉션(Set<E>, Map<K, V>)이나 컨테이너(ThreadLocal<T>, AtomicReference<T>)였는데 이보다 더 유연하게 제네릭을 사용하고자 하면서 등장한 패턴이 타입 안전 이종 컨테이너 패턴(type safe heterogeneous container pattern)이다.
그럼 이 이름도 긴 타입 안전 이종 컨테이너 패턴은 무엇일까?
타입 안전 이종 컨테이너 패턴(type safe heterogeneous container pattern)
•
컨테이너 대신 키를 매개변수화하여 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하는 방식
•
제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해줄 수 있게 된다.
예제
타입별로 즐겨찾는 인스턴스를 저장및 검색할 수 있는 Favorites 클래스
public class Favorites {
public <T> void putFavorite(Class<T> type, T instance);
public <T> T getFavorite(Class<T> type);
}
Java
복사
Favorites.java
⇒ Class 클래스가 제네릭이기 때문에 Class 리터럴의 타입은 Class가 아닌 Class<T>이다.
→ Ex: String.class의 타입은 Class<String>이고, Integer.calss의 타입은 Class<Integer>이다.
⇒ 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴을 타입 토큰(type token)이라 한다.
⇒ 키가 매개변수화되었다는 점을 제외하면 일반 맵(Map)과 유사하다.
위에서 작성한 Favorites 클래스는 다음과 같이 사용할 수 있으며 보다시피 다양한 객체 타입에 대응이 가능하다.
public static void main(String[] args){
Favorites favorites = new Favorites();
favorites.putFavorites(String.class, "Java");
favorites.putFavorites(Integer.class, 1234);
favorites.putFavorites(Class.class, Favorites.class);
String favoriteStr = favorites.getFavorite(String.class);
Integer favoriteInt = favorites.getFavorite(Integer.class);
Class favoriteClass = favorites.getFavorite(Class.class);
System.out.printf("%s %x %s %n", favoriteStr, favoriteInt, favoriteClass.getName());
}
private static class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void putFavorites(Class<T> type, T instance){
favorites.put(Objects.requireNonNull(type), instance);
}
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
}
Java
복사
Favorites 인스턴스는 타입안전하다. 내가 요청한 클래스(Ex: String.class)와 다른 타입을 반환하는일은 없다. 그리고 맵과 다르게 여러 타입의 원소를 담을수도 있다. 이런 객체(Favorites)를 타입 안전 이종(heterogeneous) 컨테이너라 할 수 있다.
위 코드를 보면 Favorites의 실제 구현부분도 어렵지 않고 매우 간결하다.
어떻게 가능한걸까?
Favorites 코드 내부 구현을 보면 favorites 변수의 타입은 Map<Class<?>, Object>이다 .
뭔가 이상하지 않은가? favorites에서 키 타입이 비한정적 와일드카드 타입이다. 그럼 null말고는 아무것도 넣을 수 없다고 했던것 같은데 어떻게 이게 가능할까?
우린, 와일드카드 타입이 중첩(nested)되어있다는걸 알아야하는데, Map이 아니라 key가 와일드카드 타입인 것이다. 이는 모든 키가 다른 매개변수화 타입일 수 있다는 의미가 된다.
그리고, 값 타입이 Object라는 점은 키와 값 사이의 타입 관계를 보증하지 않는다는 점이다.
즉, 모든 값이 키로 명시한 타입임을 보증하지 않는다는것인데, 자바 자체적으로도 이 관계를 명시할 방법이 없다. 하지만 우리는 이 관계가 성립한다는것을 알고있기에 이 이점을 누릴 수 있다.
어떻게 구현할까?
putFavorite 메서드는 주어진 Class객체와 즐겨찾기 인스턴스를 favorites 변수에 추가하면 끝난다.
이 때 키와 값 사이의 타입 링크(type linkage)정보는 버려지기 때문에 이 값이 해당 키 타입의 인스턴스라는 정보는 사라지는 것이다. 하지만 getFavorite 메서드에서 관계를 되살릴 수 있으니 문제없다.
getFavorite 메서드는 주어진 Class객체에 해당하는 값을 favorites 변수에서 꺼낸다.
이 값은 아직은 Object타입이지만 우리는 키로 해당 값의 올바른 타입을 안다. 그래서 Class의 cast메서드를 사용해 이 객체 참조를 Class 객체가 가리키는 방향으로 동적 형변환한다.
cast 메서드는 형변환 연산자의 동적 버전인데, 주어진 인수가 Class 객체가 알려주는 타입의 인스턴스인지 검사한 뒤 맞다면 인수를 그대로 반환하고 아니면 ClassCastException을 던진다.
그래서 이 코드가 깨끗하게 컴파일되면 getFavorites에서 호출하는 cast 메서드는 ClassCastException을 던지지 않을 것이다.
즉, favorites 맵 안에 값은 해당 키의 타입과 항상 일치함을 알 수 있다.
근데 여기서 의문이 든다. cast 메서드가 인수를 그대로 반환하기만 한다면 왜 사용을 해야하는걸까?
그 이유는 cast 메서드의 시그니처가 Class 클래스가 제네릭이라는 이점을 활용하기 때문인데, cast의 반환 타입은 Class 객체의 타입 매개변수와 같다.
public class Class<T> {
T cast(Object obj);
}
Java
복사
덕분에 T로 비검사 형변환 하는 손실 없이 Favorites 를 타입 안전하게 만들 수 있다.
제약사항
Favorites 클래스에는 두 가지 제약사항이 있다.
•
클라이언트에서 Class 객체를 로 타입(raw type)으로 넘기면 Favorites 인스턴스의 타입 안전성이 쉽게 깨진다. (물론 컴파일 당시 비검사 경고가 뜰 것이다. )
⇒ putFavorites에서 instance 의 타입이 type과 같은 타입인지 확인해서 타입 불변식을 지킬 수 있다. (동적 형변환을 쓰면 간단하다.)
public <T> void putFavorites(Class<T> type, T instance){
favorites.put(Objects.requireNonNull(type), type.cast(instance));
}
Java
복사
put을 하는 시점에서 타입이 불일치하면 ClassCastException을 던진다.
•
실체화 불가 타입에는 사용할 수 없다.
:List<String>, List<Integer>같은 실체화 불가 타입은 쓸 수 없다는 의미이다. List<String>.class를 얻을 수 없기 때문이다. List<Integer>, List<String> 둘 다 List.class를 공유하기에 실체화 불가 타입이 허용되면 문제가 심각해진다.
컬렉션을 래핑하는 일급 컬렉션을 사용하여 해결할 수 있다.
엔티티 기반 예제코드 만들어보기
: 데이터베이스의 행이 아닌 열을 타입별로 타입 안전 이종 컨테이너로 관리할 수 있다는데 착안하여 만든 예제 코드이다. 과일(Entity)를 기준으로 모든 필드는 일급 객체로 래핑된 일급 객체를 가지며 이에서 착안해 일급 객체의 클래스 타입을 키로 가지는 타입안전이종컨테이너를 만들어보자.
•
Fruit
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Fruit {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Name name;
@Embedded
private Price price;
@Embedded
private Company company;
@Embedded
private ShelfLife shelfLife;
}
Java
복사
•
Name, Price, Company, ShelfLife
@Getter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Price {
private Long value;
}
Java
복사
@Getter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Company {
private String name;
}
Java
복사
@Getter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Name {
private String value;
}
Java
복사
@Getter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class ShelfLife {
private Period period;
}
Java
복사
•
FruitContainer
public class FruitContainer {
private Map<Class<?>, List<Object>> container = new HashMap<>();
private List<Fruit> fruits;
public FruitContainer() {
setUp();
}
void setUp() {
fruits = List.of(
new Fruit(1L, new Name("apple"), new Price(1000L), new Company("A"), new ShelfLife(Period.of(2022, 1, 7))),
new Fruit(2L, new Name("banana"), new Price(2000L), new Company("B"), new ShelfLife(Period.of(2022, 1, 7))),
new Fruit(3L, new Name("grape"), new Price(2000L), new Company("C"), new ShelfLife(Period.of(2022, 1, 7))),
new Fruit(4L, new Name("mango"), new Price(3000L), new Company("D"), new ShelfLife(Period.of(2022, 1, 7)))
);
for (Fruit fruit : fruits) {
putRow(Long.class, fruit.getId());
putRow(Name.class, fruit.getName());
putRow(Price.class, fruit.getPrice());
putRow(Company.class, fruit.getCompany());
putRow(ShelfLife.class, fruit.getShelfLife());
}
}
public <T> void putRow(Class<T> type, T data) {
container.merge(Objects.requireNonNull(type),
new ArrayList<>(List.of(type.cast(data))),
(o, n) -> {
o.addAll(n);
return o;
});
}
public <T> List<T> getRows(Class<T> type) {
return container.get(type).stream()
.map(type::cast)
.collect(toUnmodifiableList());
}
}
Java
복사
•
FruitContainerTest
class FruitContainerTest {
private FruitContainer fruitContainer = new FruitContainer();
@ParameterizedTest
@ValueSource(classes = {Long.class, Company.class, Name.class, Price.class, ShelfLife.class})
void getRowsTest(Class<?> type) {
final List<?> rows = fruitContainer.getRows(type);
System.out.println("rows = " + rows);
for (Object row : rows) {
assertThat(row.getClass()).isEqualTo(type);
}
}
}
Java
복사
•
결과 콘솔창
정리
•
컬렉션 API로 대표되는 일반적 제네릭 형태에선 한 컨테이너가 다룰 수 있는 타입 매개변수의 수가 고정되어 있다.
•
컨테이너 자체가 아닌 키를 타입 매개변수로 바꾸면 이런 제약이 없어진다.
•
타입 안전 이종 컨테이너는 Class를 키로 쓰며 이런 Class 객체를 타입 토큰이라 한다.
•
직접 구현한 키 타입도 쓸 수 있다.
⇒ Ex: DatabaseRow 타입에 제네릭 타입인 Column<T>를 키로 사용할 수 있다.