1. 제네릭 타입 만들기
JDK 가 제공하는 제네릭 타입과 메서드를 사용하는 것은 비교적 쉬운 일이지만, 제네릭 타입을 새로 만드는 것은 조금 어려운 일이다. 이번 아이템을 통해 배워보자. 제네릭으로 수정되었으면 좋겠는, Stack 클래스 내 Object 타입의 배열을 살펴보자.
public class Stack {
private Object[] elements;
private mnt 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;
}
public boolean isEmpty() {
return size = 0;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
Stack 클래스에서 elements 필드가 Object 타입의 배열로 선언되어있어 클라이언트는 Object 타입의 스택에서 꺼낸 객체를 형변환 해야한다. 이 때, 런타임 오류가 날 가능성이 있다. 이를 방지하고자 클라이언트 몰래 제네릭 타입으로 수정해보자.
1-1. 타입 매개변수 추가
일반 클래스를 제네릭 클래스로 만드는 첫 단계는, 클래스 선언에 타입 매개변수를 추가하는 것이다.
스택이 담을 원소에 대한 타입을 E로 지정하고, Object 타입을 제네릭으로 수정해보자.
public class Stack<E> {
// Object[] -> E[]
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
// Object -> E
elements = new E[DEFAULT_INITIAL_CAPACITY];
}
// Object -> E
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
// Object -> E
public E pop() {
if (size = 0)
throw new EmptyStackException();
// Object -> E
E result = elements[-size];
elements[size] = null; //다 쓴 참조 해제
return result;
}
... // isEmpty 와 ensureCapacity 메서드는 그대로다.
}
이 때, 컴파일 시에 하나 이상의 오류나 경고가 뜨게 되는데, 해당 클래스에서 도출된 오류를 확인해보자.
Stack.java:8: generic array creation
elements = new E[DEFAULT_INITIAL_CAPACITY];
^
실체화 불가 타입에서는, 배열을 만들 수 없다. 복기하자면, 실체화 불가 타입이란 말그대로 런타임 시에 타입 정보를 적게 가져가는 것이다. 소거 매커니즘이 작용한다. 예로 E, List<E>, List<String> 등이 있다. 배열과 소거 매커니즘은 서로 충돌하는 개념이다. 해당 에러의 해결책은 두 가지가 있다.
1-1-1. 제네릭 배열 생성을 금지하는 제약을 우회하자.
아이템 28에서, 제네릭 배열은 타입 안정성 문제로 인해 사용을 금지했다고 배웠다. 하지만, 오히려 제네릭 배열을 사용하자는 것이다. 방법은 이렇다. Object 배열을 생성한 다음, 제네릭 배열로 형 변환 해보자. 이렇게 하면 오류 대신 경고를 내보낼 것이다.
Stack.java:8: warning: [unchecked] unchecked cast
found: Object[], required: E[]
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
^
컴파일러가 타입 안전하지 않다고 경고를 주는 것이다. 한마디로 런타임 중간에 언제든지 ClassCastException 을 만나도 이상하지 않은 상황이다. 만약, 이 비검사 형변환이 프로그램의 안정성을 해치지 않는다고 확신이 든다면 (여기서는 elements 의 접근제한자가 private 이고, push 메서드를 통해 저장되는 원소 타입이 항상 E 라는 것이 확신) 범위를 최소로 좁혀 @SuppressWarnings 어노테이션으로 경고를 숨기자.
public class Stack<E> {
// ...
// 배열 elements 는 push(E)로 넘어온 E 인스턴스만 담는다.
// 따라서 타입 안전성을 보장하지만, 이 배열의 런타임 타입은 E[]가 아닌 Object[]이다.
@SuppressWarnings("unchecked")
public Stack() {
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
// ...
}
1-1-2. elements 필드 타입만 E[] 에서 Object[] 로 바꾸자.
Stack 클래스에서 private 으로 선언된 elements 필드 타입만 태초 상태로 되돌려 놓자는 것이다. 그러면, pop메서드에서 최초에 마주친 형 변환 문제가 발생할 것이다.
Stack.java:19: incompatible types
found: Object, required: E
E result = elements[--size];
^
다시 말해, elements 타입은 Object 지만, 제네릭으로 형 변환하는 도중 만난 오류이다. 배열이 반환한 원소를 E로 형변환하면, 오류 대신 경고가 뜰 것이다.
Stack.java:19: warning: [unchecked] unchecked cast
found: Object, required: E
E result = (E) elements[--size];
^
E는 실체화 불가 타입으로, 런타임 시 이뤄지는 형변환에 대해서는 소거 원칙이 발동해 안전한지 불완전한지 알 수 없다는 경고이다. 이번에도, 안전한지 직접 검증해 범위를 최소로 좁혀 할당문에만 @SuppressWarnings 어노테이션을 사용해보자.
public class Stack<E> {
// 비검사 경고를 적절히 숨긴다.
public E pop() {
if (size == 0)
throw new EmptyStackException();
// push에서 E 타입만 허용하므로 이 형변환은 안전하다.
@SuppressWarnings("unchecked")
E result = (E) elements[--size];
elements[size] = null;
return result;
}
}
1-1-3. 둘 중 어떤 해결책을 사용할까?
1-1-1 제네릭 배열을 생성하는 방법은 가독성이 좋다. 왜냐면 제네릭 배열을 사용하긴 하지만 배열의 타입을 E[] 로 선언해서 오직 E 타입 인스턴스만을 허용한다는 것을 확실히 하기 때문이다. 또한 생성자에서 단 한번만 실행되면 된다. 따라서 첫 번째 방식을 현업에서는 더 선호한다. 하지만 결국 E가 Object 가 아니라면 런타임 타입과 컴파일타임 타입이 달라 힙 오염을 일으킬 수 있다고 한다.
2. 제네릭 Stack 클래스 사용해보기
명령줄 인수들을 역순으로 바꿔 대문자로 출력하는 예시를, 위에서 수정한 제네릭 Stack 클래스를 활용해보자.
public static void main(String[] args) {
Stack<String> stack = new Stack<>();
for (String arg : args) {
stack.push(arg);
}
while (!stack.isEmpty()) {
System.out.println(stack.pop().toUpperCase());
}
}
Stack 에서 pop 을 통해 꺼낸 메서드에 대해 toUpperCase 메서드를 호출할 때, 원래 Object 타입이었다면 형변환을 했어야 했지만, 제네릭으로 수정하면서 할 필요가 없이, 항상 성공함을 보장하게 되었다.
2-1. 사용 후기
사실 위에서 소개한 제네릭 Stack 클래스는 "배열보다 리스트를 우선해라" 라는 아이템 28과 모순된다. 배열을 리스트로 바꾸지 않았고 타입만 제네릭으로 바꿨을 뿐이다. 결국, 제네릭 타입 안에서 리스트를 사용하는게 항상 좋은 건 아니다. ArrayList 와 같은 제네릭 탕비도 결국 기본 타입인 배열을 통해 구현했을 것이다. HashMap 같은 제네릭 타입도 마찬가지로 배열을 사용하기도 한다.
대다수의 제네릭 타입은 타입 매개변수에 아무런 제약을 두지 않는다.
Stack 예처럼, Stack<Object>, Stack<int[]>, Stack<List<String>>, Stack 등 모두 사용 가능하다. 단, 후에 배울 박싱된 기본 타입을 사용하지 않는다면 Stack<int> 나 Stack<double> 과 같은 매개변수에 기본 타입은 컴파일 에러가 발생해 사용 불가능하다.
물론, 타입 매개변수에 제약을 두는 제네릭 타입도 있다.
예를 들어, java.util.concurrent.DelayQueue 예를 살펴보자.
제네릭 타입이지만, java.util.councurrent.Delayed 의 하위 타입만 받겠다고 명시해두었다. 제약을 걸어두어 관련없는 타입의 유입으로 인한 ClassCastException 런타임 에러를 방지하는 것이다. 또한 클라이언트는 생성된 DelayQueue 원소에서, 형번환 없이 Delayed 클래스의 메서드를 호출할 수 있다. E를 한정적 타입 매개변수라고 부르며, 모든 타입은 자기 자신의 하위 타입이므로DelayedQueue<Delayed> 로도 사용할 수 있다.
'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글
[아이템31] 한정적 와일드카드를 사용해 API 유연성을 높이라 (0) | 2024.10.28 |
---|---|
[아이템30] 이왕이면 제네릭 메서드로 만들라 (1) | 2024.10.24 |
[아이템28] 배열보다는 리스트를 사용하라 (1) | 2024.10.23 |
[아이템27] 비검사 경고를 제거하라 (0) | 2024.10.22 |
[아이템26] 로 타입은 사용하지 말라 (0) | 2024.10.18 |