티스토리 뷰
저번 시간에는 Generic을 사용하는 이유에 대해 자세하게 살펴보았다. 하지만 Generic은 너무 딱딱하다. 하나의 타입을 정의해놓으면 그 타입 외에는 어떠한 요소도 들어올 수 없다. 상속을 받더라도 마찬가지이다.
이러한 동작은 리스코프 치환원칙에 위배되는 설계이다. 상위 타입이 하는 일을 제대로 수행할 수 없기 때문이다.
자바는 이러한 문제에 대해서도 해결책을 갖고 있다.
한정적 와일드 카드 타입
Stack 클래스를 한 번 살펴보자
public class Stack<E> {
public Stack();
public void push(E e);
public E pop();
public boolean isEmpty();
}
여기에 모든 원소를 스택에 넣는 메서드를 추가해보자
public void pushAll(Iterable<E> src) {
for (E e : src) {
push(e);
}
}
컴파일이 되는 코드이다. 하지만 원소 타입이 일치한다는 가정하에 동작한다. 예를 들어보자.
public class FoodStack<E> {
private Stack<E> foodStack = new Stack<>();
public void pushAll(Iterable<E> elements) {
for (E e : elements) {
push(e);
}
}
private void push(E e) {
foodStack.push(e);
}
}
일급 컬렉션을 한 번 만들어 봤다. 이제 실제로 동작을 시켜보자.
public class FoodStackApp {
public static void main(String[] args) {
List<Food> foods = new ArrayList<>();
FoodStack<Food> foodStack = new FoodStack<>();
for (int i = 0; i < 5; i++) {
foods.add(new Food("음식 " + (i + 1), (i + 1) + " 만큼 맛있음"));
}
foodStack.pushAll(foods);
List<Fruit> fruits = new ArrayList<>();
for (int i = 0; i < 5; i++) {
fruits.add(new Fruit("과일 " + (i + 1), (i + 1) + " 만큼 맛있음"));
}
foodStack.pushAll(fruits);
}
}
다음과 같은 코드로 실행을 하게 된다면 foodStack.pushAll(fruits)에서 오류가 발생하면서 컴파일 조차 안될 것이다. 상속을 받았음에도 불구하고 이러한 불편한 상황에 연출 된다. 이러한 연출은 결국 설계를 유연하게 할 수 없다.
그렇기에 이를 해결하기 위해 ? super E라는 타입과 ? extends E라는 타입이 존재한다. 먼저 extends E 타입으로 바꾸게 된다면 E 타입을 상속받은 모든 타입에 대해 허용한다는 뜻이다. 그렇게 설계를 한다면 명확하게 문제가 해결된다.
마찬가지로 super E는 상위 타입에 대해 허용하겠다 라는 뜻이다. 메시지도 분명하기에 유연성을 극대화 하기 위해서는 원소이 생산자나 소비자용 매개변수에 와일드카드 타입을 사용하라.
public class FoodStack<E> {
private Stack<E> foodStack = new Stack<>();
public void pushAll(Iterable<? extends E> elements) {
for (E e : elements) {
push(e);
}
}
private void push(E e) {
foodStack.push(e);
}
}
하지만 모든 경우에 적용되는 것은 아니다. 잘 가려서 사용해야한다. 무의미한 유연성은 항상 실수를 불러오기 마련이기 때문이다.
예를들어 매개변수 타입 T가 생산자라면 <? extends T>를 사용하고, 소비자라면 <? super T>를 사용하자. Stack 예에서 pushAll의 src 매개변수는 Stack이 사용할 E 인스턴스를 생산하므로 적절한 타입은 Iterable<? extends E>이다.
한편, popAll의 dst 매개 변수는 Stack으로부터 E 인스턴스를 소비하므로 dst 매개변수는 Stack으로부터 E 인스턴스를 소비하므로 dst의 적절한 타입은 Collection<? super E>이다.
이것을 PECS 공식이라고 하는데 와일드 카드 타입을 사용하는 기본 원칙이 된다.
생성자로 넘어지는 컬렉션은 타입 값을 생산하기만 하니 확장하는 와일드카드 타입을 사용해 선언해야한다. PECS 공식에 따라 장난감을 넣고 찍어내는 프로그램을 만들어 보자
public class ToyList<E> {
private final List<E> toys;
public ToyList(Collection<? extends E> toys) {
this.toys = addAll(toys);
}
private List<E> addAll(Collection<? extends E> collection) {
return new ArrayList<>(collection);
}
public void print() {
for (E toy : toys) {
System.out.println(toy);
}
}
}
이 코드에서 Collection<? extends E>로 생성자 인자를 받아오는 것에 주목하자. 현재 필드 내부엔 List가 존재한다. 하지만 Collection으로 인자를 받아오고 와일드 카드 타입을 사용해 좀 더 유연하게 받아오는 것이 가능해졌다.
public class ToyApp {
public static void main(String[] args) {
Set<Doll> dollStack = new HashSet<>();
dollStack.add(new Doll("인형 1", "바비인형"));
dollStack.add(new Doll("인형 2", "곰인형"));
dollStack.add(new Doll("인형 3", "카카오프렌즈"));
dollStack.add(new Doll("인형 4", "라인프렌즈"));
ToyList<Toy> toyList = new ToyList<>(dollStack);
toyList.print();
}
}
Set으로 값을 넣어도 전혀 문제 없이 컴파일되고 동작한다. 이런 식의 사용은 좀 더 API를 유연하게 설계 할 수 있다는 장점이 있고 더 나아가서 사용자는 와일드 카드가 사용된다는 사실도 모를 수 있다.
사실 클래스 사용자가 와일드 카드 타입을 신경써야 한다면 잘못된 API일 가능성이 크기 때문에 주의하여야 한다.
우리는 한정적 와일드 카드 타입을 이용하여 좀 더 상세하게 Generic에 대한 사용법에 대해 알아보았다. 그렇다면 Generic은 컴파일에서 어떻게 처리될까?
Generic Type Eraser
Generic은 컴파일 단계에서 검사하고 런타입에서는 정보를 알 수 없다. 그래서 이 타입을 제거하기 때문에 Type Eraser가 붙는다.
타입을 소거할 때 규칙은 다음과 같다.
- unbounded Type(<?>, <T>)는 Object로 변환된다.
- bound Type(<E extends Comparable>)의 경우는 Object가 아닌 Comparable로 변환 한다.
- 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메소드에만 소거 규칙을 적용한다.
- 타입 안정성 보존을 위해 필요하다면 type casting을 넣는다.
- 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridge method를 생성한다.
public class FoodStack<E> {
private Stack<E> foodStack = new Stack<>();
private void push(E e) {
foodStack.push(e);
}
}
일부 코드를 가져와 봤다. 이 코드에가 런타임 시에는 다음 과 같이 변한다고 생각하면 된다.
public class FoodStack {
private Stack foodStack = new Stack<>();
private void push(Object e) {
foodStack.push(e);
}
}
다음과 같이 제네릭이 소거되고 매개타입 변수는 Object로 변환된다.
결국 제네릭은 실제로 강력한 형 제한이 들어가진 않는다. 컴파일 단계에서 알려 실수를 줄여줄 뿐이다. 실제로 바이트 코드를 살펴보자
위와 같이 어떠한 타입 정보를 갖고 있지 않다. 그져 Object를 갖고 있을 뿐이다. 그리고 제네릭의 타입 안정성을 위해 자바 컴파일러가 bridge method로 만들어낼 수 있다.
public class MyComparator implements Comparator {
public int compare(Integer a, Integer b) {
//
}
}
이 코드를 보면 타입이 소거된 상태로 런타임이 되는데 이 경우에 compare 메서드의 매개변수 타입은 Object로 변환이 될텐데. 다음 코드를 보면
public class MyComparator implements Comparator<Integer> {
public int compare(Integer a, Integer b) {
//
}
//THIS is a "bridge method"
public int compare(Object a, Object b) {
return compare((Integer)a, (Integer)b);
}
}
이러한 메소드 시그니처 사이에 불일치를 없애기 위해서 컴파일러는 런타임에 해당 제네릭 타입의 타임소거를 위한 bridge method를 만들어 줍니다.
Reference.
Effective Java 3 Edition - 조슈아 블로크
https://devlog-wjdrbs96.tistory.com/263
'Java' 카테고리의 다른 글
<자바 고급 스터디 3주차 - 4부> Enum 타입 2번째 (0) | 2022.02.26 |
---|---|
<자바 고급 스터디 3주차 - 3부> Enum 타입 (0) | 2022.02.25 |
<자바 고급 스터디 3주차 - 1부> Generic을 사용하는 이유는 뭘까? (0) | 2022.02.21 |
Abstract와 Interface사이에서 발생할 수 있는 오해(?) (0) | 2022.02.16 |
HashMap과 HashTable의 차이 (0) | 2022.02.16 |
- Total
- Today
- Yesterday
- 면접 준비
- 면접준비
- MySQL
- Kotlin
- 개발자
- 백엔드
- java
- IT
- 취업
- 인터뷰
- Spring
- thread
- 코딩
- Redis
- docker
- 개발
- 취준
- DB
- JPA
- 자바
- 면접
- CS
- 프로젝트
- swarm
- 프로그래밍
- DevOps
- 게시판
- 코드
- 동시성
- 취업준비
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |