Effective Java : 아이템29. 이왕이면 제네릭 타입으로 만들라.
- 프로그래밍 언어/JAVA
- 2023. 4. 18.
들어가기 전
이 글은 인프런 백기선님의 이펙티브 자바 강의를 복습하며 작성한 글입니다.
이 글의 요약
- 가급적이면 리스트를 쓰는게 좋으나, 성능 문제로 배열을 써야하는 경우가 있다. 이 때도 가급적이면 제네릭을 사용하도록 하자.
- 배열에서 제네릭을 사용하는 방법은 두 가지가 존재한다.
- Object[]을 생성하고, E[] 로 형변환 하는 방법이다. 이 방법은 Heap Polution이 발생할 수 있다.
- Object[]을 그대로 사용하고, 값을 넣고 꺼내는 시점에만 제네릭 타입으로 형변환 하는 것이다. Heap Polution이 발생하지 않는다.
- <E>는 컴파일 하고 나면 바이트 코드에 Object로 남는다. 왜냐하면 제네릭은 소거로 구현되었기 때문이다.
- 제네릭을 사용하는 코드로 바뀌면, 클라이언트 코드도 Raw 타입에서 제네릭을 사용하게 되며 타입 안정성이 좋아진다.
아이템 29. 이왕이면 제네릭 타입으로 만들라.
- 배열을 사용하는 코드를 제네릭으로 만들 때 해결책 두 가지.
- 첫번째 방법 : 제네릭 배열 (E[]) 대신에 Object 배열을 생성한 뒤에 제네릭 배열로 형변환 한다.
- 형변환을 배열 생성 시 한 번만 한다.
- 가독성이 좋다.
- 힙 오염이 발생할 수 있다.
- 두번째 방법 : 제네릭 배열 대신에 Object 배열을 사용하고, 배열이 반환한 원소를 E로 형변환한다.
- 원소를 읽을 때 마다 형변환을 해줘야한다.
- Object 배열을 그대로 사용하기 때문에 힙 오염이 발생하지 않는다.
앞서서 배열과 제네릭은 어울리지 않다고 이야기했다. 하지만 코드를 작성하다보면 반드시 배열과 제네릭을 함께 사용해야하는 경우가 있다. 주로 배열을 이용해서 자료구조를 구현할 때인데, 자료구조에는 성능이 우선이 되다보니 배열이 우선사용된다. 또 범용적으로 사용되기 위해서 제네릭이 사용된다. 이런 이유 때문에 배열과 제네릭을 함께 사용하는 시점이 있다.
기본 코드 살펴보기
자바에는 다른 객체들을 담는 역할을 하는 Stack, Queue 같은 컨테이너 클래스들이 있다. 이런 클래스들은 범용적으로 사용되기 때문에 제네릭 타입으로 만드는 것이 유연함을 제공한다. 특히 Object 타입으로 객체를 담고 있는 클래스가 있다면, 이 부분은 반드시 제네릭을 사용해서 리팩토링을 해야한다.
Object[]을 사용하는 컨테이너 객체의 경우 타입 안정성이 보장되지 않기 때문에 런타임에서 ClassCastException이 발생할 수 있다. 이 때 Object를 제네릭으로 바꾸면 타입 안정성이 강화되면서 런타임에서 발생할 ClassCastException을 줄일 수 있다.
아래의 Stack 코드를 보자. 현재는 제네릭을 사용하지 않는다. 만약 제네릭을 사용하도록 바꾼다면 아래 두 가지 장점을 가질 수 있다.
- 이 Stack 클래스를 사용하는 클라이언트 코드에서 (String)으로 형변환을 하지 않아도 된다.
- 타입 안정성이 확보되기 때문에 런타임에서 ClassCastException이 발생하지 않도록 도와준다.
따라서 성능보다 타입 안정성이 중요한 곳이라면 Object를 제네릭으로 바꿔서 이 장점을 가져갈 수 있다.
// Object를 이용한 제네릭 스택 (170-174쪽)
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.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);
}
// 코드 29-5 제네릭 Stack을 사용하는 맛보기 프로그램 (174쪽)
public static void main(String[] args) {
Stack stack = new Stack();
for (Integer arg : List.of(1, 2, 3))
stack.push(arg);
while (!stack.isEmpty())
System.out.println(((String)stack.pop()).toUpperCase());
}
}
Object 배열에 제네릭 사용하기 → 생성한 후 형변형 하기
배열에 제네릭을 사용하는 첫번째 방법은 Object 배열을 생성한 후, 제네릭 배열로 형을 변환하는 방법이다. 이렇게 하는 이유는 제네릭 타입의 배열을 만들 수는 없기 때문이다.
this.elements = (E[])new Object[DEFAULT_INITIAL_CAPACITY];
이렇게 제네릭 배열을 만들면, 런타임에는 E[]에서 E가 소거되니까 Object[]로 동작한다. 첫번째 방법의 장점은 꺼낼 때 바로 E 타입으로 꺼낼 수 있다는 장점이 있다.
배열을 선언 + 형변환 하는 과정에서 컴파일 경고가 발생하는데, 이걸 @SuppressWarnings으로 막아줄 수 있다. @SuppressWarnings을 사용하는 근거는 다음과 같다.
- Object[]에 들어오는 타입은 항상 E이다. (push() 메서드)
- elements 배열은 private로 노출되지 않는다.
따라서 항상 E 타입으로만 존재할 것이기 때문에 @SuppressWarnings("unchecked")로 컴파일 경고를 무시해도 된다.
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.elements = (E[])new Object[DEFAULT_INITIAL_CAPACITY];
}
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);
}
// 코드 29-5 제네릭 Stack을 사용하는 맛보기 프로그램 (174쪽)
public static void main(String[] args) {
Stack stack = new Stack();
for (Integer arg : List.of(1, 2, 3))
stack.push(arg);
while (!stack.isEmpty())
System.out.println(((String)stack.pop()).toUpperCase());
}
}
Object 배열에 제네릭 사용하기 → 생성한 후 형변형 하기 (단점)
Object 배열을 생성하고 제네릭 타입으로 변환하는 것의 단점은 Heap 오염이 발생할 수 있다는 것이다. 예를 들어 아래와 같이 코드를 작성하면 Heap 오염이 발생한다.
- this.elements는 E[]만 들어오기를 원한다.
- this.elements는 Object[] 배열이기 때문에 어떠한 타입들도 계속 들어올 수 있다. 따라서 E 타입이 뿐만 아니라 String 타입도 들어와서 Heap 오염이 발생한다.
this.elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
Object[] e = this.elements;
e[0] = "hello"; // Heap 오염 발생
Heap 오염은 제네릭 타입의 배열을 사용했기 때문에 발생한다. 따라서 이 부분은 Object[]을 그대로 사용하면 Heap Polution을 해결할 수 있다.
제네릭 배열 대신에 Object 배열을 사용하고, 배열이 반환한 원소를 E로 형변환한다.
앞서서 제네릭 배열을 그대로 사용하면 Heap Polution이 발생했다. 이것에 대한 해결책은 제네릭 배열 대신 Object[]을 그대로 사용하는 것이다. 다음과 같이 코드를 수정한다.
- 제네릭 배열 대신 Object[]을 그대로 사용한다.
- push 할 때 <E> 타입만 넣는다.
- pop 할 때 <E> 타입만 가져온다.
이 방법은 Object[]을 그대로 사용하기 때문에 Heap Polution이 발생하지 않는다. 그렇지만 값을 빼올 때 항상 <E>로 캐스팅 해줘야한다는 점이다. 또한 E가 Object 타입이기 때문에 컴파일러가 정확한 타입 추론을 하기 어려워 컴파일 경고가 뜨기도 한다.
// Object[] 그대로 사용 + 메서드쪽에서만 제네릭 사용한 Stack (Heap Polution 발생 X)
public class Stack<E> {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) {
ensureCapacity();
elements[size++] = e;
}
public E pop() {
if (size == 0)
throw new EmptyStackException();
/**
* push로만 값이 들어옴.
* push는 항상 E 타입임.
*/
@SuppressWarnings("unchecked") E result = (E) 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);
}
// 코드 29-5 제네릭 Stack을 사용하는 맛보기 프로그램 (174쪽)
public static void main(String[] args) {
Stack stack = new Stack();
for (Integer arg : List.of(1, 2, 3))
stack.push(arg);
while (!stack.isEmpty())
System.out.println(((String)stack.pop()).toUpperCase());
}
}
제네릭 사용 했을 때 클라이언트 코드의 변화
제네릭을 사용하지 않은 코드에서 제네릭을 사용하도록 바꾸면 클라이언트의 코드도 바뀐다. 클라이언트는 기존에는 Raw 타입으로 사용했을텐데, Raw 타입 그 자체는 타입 안정성과 표현력이 제네릭에 비해 떨어진다는 단점이 있다. 제네릭을 도입하게 되면 타입 안정성 + 표현력이 좋아지므로 사용할만하다.
// 제네릭 사용 전 클라이언트 코드. 모두 Raw 타입
public static void mainWithNoGeneric(String[] args) {
Stack stack = new Stack();
for (String arg : List.of("a", "b", "c"))
stack.push(arg);
while (!stack.isEmpty())
System.out.println(((String)stack.pop()).toUpperCase());
}
// 제네릭 사용 후 클라이언트 코드.
public static void mainWithGeneric(String[] args) {
Stack<String> stack = new Stack<>();
for (String arg : List.of("a", "b", "c"))
stack.push(arg);
while (!stack.isEmpty())
System.out.println(stack.pop().toUpperCase());
}
배열 대신에 리스트를 사용하라고 했잖아? 그런데 왜?
이전 아이템에서는 배열 대신 리스트를 사용하라고 했는데, 왜 이곳에서는 배열을 사용하는 것일까? 바로 장/단점이 있기 때문이다.
- 배열은 성능이 좋다.
- 리스트는 제네릭을 사용할 수 있어서 타입 안정성이 좋다.
일반적으로 자료 구조를 구현한다면 성능이 중요하다. 따라서 리스트 대신에 배열을 사용한다. 아이템 29에서 이야기 한 것은 피치못하게 배열을 사용하는 경우가 있는데, 이 때 제네릭을 사용하는 테크닉과 위험성에 대해서 이야기를 한 것이다.
'프로그래밍 언어 > JAVA' 카테고리의 다른 글
Effective Java : 아이템30. 이왕이면 제네릭 메서드로 만들라 (0) | 2023.04.18 |
---|---|
Effective Java : 아이템29. 완벽공략 43. 한정적 타입 매개변수 (0) | 2023.04.18 |
Effective Java : 아이템28. 완벽공략 42. @SafeVarargs (0) | 2023.04.18 |
Effective Java : 아이템31. 한정적 와일드카드를 사용해 API 유연성을 높여라 (0) | 2023.04.18 |
Effective Java : 아이템28. 배열보다는 리스트를 사용하라 (0) | 2023.04.16 |