...
자바의 공변성 / 반공변성
제네릭의 와일드카드를 배우기 앞서 선수 지식으로 알고 넘어가야할 개념이 있다.
조금 난이도 있는 프로그래밍 부분을 학습 하다보면 한번쯤은 들어볼수 있는 공변성(Covariance) / 반공변성(Contravariance) 합쳐서 '변성(Variance)' 이라하는 개념이다.
변성은 타입의 상속 계층 관계에서 서로 다른 타입 간에 어떤 관계가 있는지를 나타태는 지표이다. 그리고 공변성은 서로 다른 타입간에 함께 변할수 있다는 특징을 말한다. 이를 객체 지향 개념으로 표현하자면 Liskov 치환 원칙에 해당된다.
예를들어 배열(Array)과 리스트(List)가 있다고 하자. 자바에서 각 변성의 특징은 다음과 같이 된다.
- 공변 : S 가 T 의 하위 타입이면,
- S[] 는 T[] 의 하위 타입이다.
- List<S> 는 List<T> 의 하위 타입이다.
- 반공변 : S 가 T의 하위 타입이면,
- T[] 는 S[] 의 하위 타입이다. (공변의 반대)
- List<T> 는 List<S> 의 하위 타입이다. (공변의 반대)
- 무공변 / 불공변 : S 와 T 는 서로 관계가 없다.
- List<S> 와 List<T> 는 서로 다른 타입이다.
언뜻 보면 다형성의 업캐스팅, 다운캐스팅을 말하는 것과 비슷해보인다. 지금 독자분들이 느끼고 있는 것이 맞다.
이를 자바 코드로 나타내보자면 다음과 같다.
// 공변성
Object[] Covariance = new Integer[10];
// 반공변성
Integer[] Contravariance = (Integer[]) Covariance;
// 공변성
ArrayList<Object> Covariance = new ArrayList<Integer>();
// 반공변성
ArrayList<Integer> Contravariance = new ArrayList<Object>();
그러나 배열과 달리 제네릭 예제 코드는 자바에선 돌아가지 않는다. 왜냐하면 자바는 일반적으로 제네릭 타입에 대해서 공변성 / 반공변성을 지원하지 않기 때문이다. 즉, 자바의 제네릭은 무공변의 성질을 지닌다라고 정의 내릴 수 있다.
제네릭은 공변성이 없다
객체 타입은 상하 관계가 있다
이부분은 우리가 너무나도 잘 알고 있는 다형성(polymorphism)의 성질의 예이다.
Object 타입으로 선언한 parent 변수와 Integer 타입으로 선언한 child 변수가 있는데 객체 지향 프로그래밍에선 이들 끼리 서로 간에 캐스팅이 가능하다.
Object parent = new Object();
Integer child = new Integer(1);
parent = child; // 다형성 (업캐스팅)
Object parent = new Integer(1);
Integer child;
child = (Integer) parent; // 다형성 (다운캐스팅)
일반 클래스가 아닌 제네릭 클래스여도 똑같이 다형성이 적용되는 건 마찬가지 이다.
대표적으로 컬렉션 프레임워크의 Collection 과 그의 하위인 ArrayList 는 서로 조상-자손 상속 관계에 있기 때문에 캐스팅이 가능하다.
Collection<Integer> parent = new ArrayList<>();
ArrayList<Integer> child = new ArrayList<>();
parent = child; // 다형성 (업캐스팅)
제네릭 타입은 상하관계가 없다
반면에 제네릭의 타입 파라미터(꺾쇠 괄호) 끼리는 타입이 아무리 상속 관계에 놓인다 한들 캐스팅이 불가능하다. 왜냐하면 제네릭은 무공변 이기 때문이다. 제네릭은 전달받은 딱 그 타입으로만 서로 캐스팅이 가능하다.
ArrayList<Object> parent = new ArrayList<>();
ArrayList<Integer> child = new ArrayList<>();
parent = child; // ! 업캐스팅 불가능
child = parent; // ! 다운캐스팅 불가능
즉, 꺾쇠 괄호 부분을 제외한 원시 타입(Raw Type) 부분은 공변성이 적용되지만, 꺾쇠 괄호 안의 실제 타입 매개변수에 대해서는 적용이 되지 않는다고 정리할 수 있는 것이다.
공변성이 없으면 나타나는 문제점
이 특징이 왜 문제시가 되냐면은 매개변수로 제네릭을 사용할때, 외부에서 대입되는 인자의 캐스팅 문제로 인해 애로사항이 발생하기 때문이다.
예를 들자면 리스트를 인자로 받아 순회해서 출력해주는 print 메서드가 있다고 하자. 배열을 이용한 아래 코드는 문제가 없다.
public static void print(Object[] arr) {
for (Object e : arr) {
System.out.println(e);
}
}
public static void main(String[] args) {
Integer[] integers = {1, 2, 3};
print(integers); // [1, 2, 3]
}
이번엔 배열이 아닌 리스트의 제네릭 객체로 넘겨보자. 그러면 메소드 호출 부분에서 컴파일 에러가 발생한다.
public static void print(List<Object> arr) {
for (Object e : arr) {
System.out.println(e);
}
}
public static void main(String[] args) {
List<Integer> integers = Arrays.asList(1, 2, 3);
print(integers); // ! Error
}
배열 같은 경우 print 메서드의 매개변수로 아규먼트가 넘어갈때 Integer[] 배열 타입이 Object[] 배열 타입으로 자연스럽게 업캐스팅이 적용되어 문제가 없지만, 리스트 제네릭 같은 경우 타입 파라미터가 오로지 똑같은 타입만 받기 때문에 캐스팅이 되지 않아 그런 것이다.
그렇다면 외부로부터 값을 받는 매개변수의 제네릭 타입 파라미터를 Integer로 고정된 타입으로 작성해주어야 하는데, 프로그램의 실행부에서 반드시 Integer 타입만 들어온다는 보장도 없으며, 만일 Double 형이나 아니면 상위 타입인 Number 형과 같은 다른 타입의 값도 받고 싶은 경우, 메서드를 오버로딩하여 즐비하게 코딩해야 한다.
public static void print(List<Integer> arr) {
}
public static void print(List<Double> arr) {
}
public static void print(List<Number> arr) {
}
...
그럼 제네릭은 자바의 특징이라고 할수 있는 객체 지향을 전혀 이용하지 못하는 것이 되는데, 결국 위와 같이 비효율적으로 코딩해야 하는것일까? 바로 이를 해결하기 위해 나온 기능이 제네릭 와일드카드 인 것이다.
제네릭 와일드카드
자바 제네릭을 이용해 프로그래밍 할때 간혹 클래스 정의문을 보다보면 꺾쇠 괄호 ? 물음표 기호가 있는 것을 한번쯤 본 적이 있을 것이다. 이 물음표가 와일드카드이며, 물음표의 의미 답게 어떤 타입이든 될 수 있다는 뜻을 지니고 있다.
하지만 단순히 <?> 로 사용하면 Object 타입과 다름이 없어지므로 보통 제네릭 타입 한정 연산자와 함께 쓰인다.
와일드 카드의 타입 범위를 제한하는 키워드는 extends 와 더불어 super가 있다. 이 extends 와 super 키워드는 클래스 상속 관계에서의 타입을 하위 타입으로만 제한할지, 상위 타입으로만 제한할지에 따라 쓰임새가 다르게 된다.
와일드카드 | 네이밍 | 설명 |
<?> | Unbounded wildcards 비한정적 와일드 카드 |
제한 없음 (모든 타입이 가능) |
<? extends U> | Upper Bounded Wildcards 상한 경계 와일드카드 |
상위 클래스 제한 (U와 그 자손들만 가능) 상한이 U라 상한 경계라고 한다. |
<? super U> | Lower Bounded Wildcards 하한 경계 와일드카드 |
하위 클래스 제한 (U와 그 조상들만 가능) 하한이 U라 하한 경계라고 한다. |
와일드카드의 공변성 / 반공변성
다음은 실제 ArrayList의 기능중 일부 메서드만 추려서 별도로 재구성한 MyArrayList 제네릭 클래스 예제 이다.
MyArrayList 생성자(Constructor)를 보면, 컬렉션(Collection)을 받아 컬렉션을 순회하여 컬렉션 내에 들어있는 모든 요소를 내부 배열(Object[])에 넣어 제네릭 객체를 생산하는 역할을 한다.
MyArrayList의 clone 메서드를 보면, 빈 컬렉션(Collection)을 받아 내부 배열을 순회하여 배열 내에 들어있는 모든 요소를 컬렉션에 넣어주는, 매개변수로 받은 제네릭 객체를 소비하는 역할을 한다.
class MyArrayList<T> {
Object[] element = new Object[5];
int index = 0;
// 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자
public MyArrayList(Collection<T> in) {
for (T elem : in) {
element[index++] = elem;
}
}
// 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해주는 메서드
public void clone(Collection<T> out) {
for (Object elem : element) {
out.add((T) elem);
}
}
@Override
public String toString() {
return Arrays.toString(element); // 배열 요소들 출력
}
}
눈치를 챘듯이 이 코드는 유연하지 않다.
MyArrayList에 정수도 받고 실수도 받게 하기위해 제네릭 타입 매개변수로 Number로 지정해 주었다. 그리고 Integer 매개변수화 타입의 Collection 객체를 MyArrayList 생성자로 넣어주었다.
public static void main(String[] args) {
// MyArrayList의 제네릭 T 타입은 Number
MyArrayList<Number> list;
// MyArrayList 생성하기
Collection<Integer> col = Arrays.asList(1, 2, 3, 4, 5);
list = new MyArrayList<>(col); // ! ERROR
// MyArrayList 출력
System.out.println(list);
}
하지만 결과는 시뻘건 컴파일 에러이다. 왜냐하면 매개변수는 Collection<Number> 타입으로 받는데 Collection<Integer> 객체를 전달해주었기 때문이다. Integer는 Number를 상속하여 둘은 부모-자식 관계이지만 제네릭의 타입 파라미터는 기본적으로 공변성이 없어 성립되지 않게 되는 것이다.
그래서 하는 수 없이 col 객체의 제네릭 타입을 똑같이 Collection<Number>로 맞춰주고 나서야 MyArrayList 인스턴스를 생산할 수 있게 되었다.
이번에는 MyArrayList의 clone 메서드에 빈 LinkedList를 인자로 줘서 MyArrayList에 들어있는 원소들을 복사하여 넣기위해 메서드를 호출하였다. 이때 String 타입이든 Number 타입이든 모든 타입의 데이터를 받을 수 있게 하기 위해 Object 타입 파라미터로 설정하였다.
public static void main(String[] args) {
// MyArrayList의 제네릭 T 타입은 Number
MyArrayList<Number> list;
// MyArrayList 생성하기
Collection<Number> col = Arrays.asList(1, 2, 3, 4, 5);
list = new MyArrayList<>(col);
// LinkedList 에 MyArrayList 요소들 복사하기
List<Object> temp = new LinkedList<>();
temp = list.clone(temp); // ! ERROR
// LinkedList 출력
System.out.println(temp);
}
역시나 결과는 꽝이다. 제네릭은 반공변 역시 성립되지 않기 때문에 Collection<Number>에 Collection<Object>가 들어가는 행위는 성립되지 않는다.
이처럼 자바의 제네릭은 기본적으로 공변, 반공변을 지원하지 않지만, <? extends T> , <? super T> 와일드카드를 이용하면 컴파일러 트릭을 통해 공변, 반공변이 적용되도록 설정 할 수 있다. 둘을 정리하자면 다음과 같다.
- 상한 경계 와일드카드
<? extends T>: 공변성 적용 - 하한 경계 와일드카드
<? super T>: 반공변성 적용
상한 경계 와일드카드 (공변)
MyArrayList를 설계한 개발자의 의도는 Collection<Integer> 와 Collection<Double> 객체를 생성자의 인수로 모두 받아 배열에 넣고 싶은 것이다. 이를 위해 제네릭에 상한 경계 와일드카드(Upper Bounded Wildcards)를 적용시킨다.
class MyArrayList<T> {
Object[] element = new Object[5];
int index = 0;
// 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자
public MyArrayList(Collection<? extends T> in) {
for(T elem : in) {
element[index++] = elem;
}
}
// ...
}
public static void main(String[] args) {
// MyArrayList의 제네릭 T 타입은 Number
MyArrayList<Number> list;
// MyArrayList 생성하기
Collection<Integer> col = Arrays.asList(1, 2, 3, 4, 5);
list = new MyArrayList<>(col);
// MyArrayList 출력
System.out.println(list); // [1, 2, 3, 4, 5]
}
그러면 공변 성질이 적용되어 컴파일 에러 없이 정상적으로 MyArrayList에 요소가 들어가 생산되었음을 확인 할 수 있다.
이를 클래스 상속관계로 표현하자면 다음과 같이 그릴수 있다.
즉, Integer 가 Object의 하위 타입일 경우, C<Integer>는 C<? extends Object>의 하위 타입이 되는 것이다.
ArrayList<? extends Object> parent = new ArrayList<>();
ArrayList<? extends Integer> child = new ArrayList<>();
parent = child; // 공변성 (제네릭 타입 업캐스팅)
하한 경계 와일드카드 (반공변)
MyArrayList의 clone 메서드를 설계한 개발자의 의도는 MyArrayList의 제네릭 타입 파라미터가 무엇이든 인자로 받은 컬렉션 매개변수에 요소들을 모두 집어넣고 싶은 것이다. 이를 위해 제네릭에 하한 경계 와일드카드(Lower Bounded Wildcards)를 적용시킨다.
class MyArrayList<T> {
Object[] element = new Object[5];
int index = 0;
// 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자
public MyArrayList(Collection<? extends T> in) {
for (T elem : in) {
element[index++] = elem;
}
}
// 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해주는 메서드
public void clone(Collection<? super T> out) {
for (Object elem : element) {
out.add((T) elem);
}
}
@Override
public String toString() {
return Arrays.toString(element); // 배열 요소들 출력
}
}
public static void main(String[] args) {
// MyArrayList의 제네릭 T 타입은 Number
MyArrayList<Number> list = new MyArrayList<>(Arrays.asList(1, 2, 3, 4, 5));
// LinkedList 에 MyArrayList 요소들 복사하기
List<Object> temp = new LinkedList<>();
temp = list.clone(temp);
// LinkedList 출력
System.out.println(temp); // [1, 2, 3, 4, 5]
}
그러면 반공변 성질이 적용되어 컴파일 에러 없이 정상적으로 MyArrayList에 요소가 들어가 소비되었음을 확인 할 수 있다.
이를 클래스 상속관계로 표현하자면 다음과 같이 그릴수 있다.
즉, Object가 Integer의 상위 타입일 경우, C<Object>는 C<? super Integer>의 하위 타입이 되는 것이다.
ArrayList<? super Object> parent = new ArrayList<>();
ArrayList<? super Integer> child = new ArrayList<>();
child = parent; // 반공변성 (제네릭 다운캐스팅)
어디다 써야할지도 애매한 이런 거꾸로의 상속 관계는 위의 사례와 같이 인스턴스화 된 클래스의 제네릭보다 상위 타입의 데이터를 적재해야 할때 반공변을 사용한다고 이해하면 된다. 이러한 제네릭의 변성을 적절하게 사용하면 유연한 코드를 작성할 수 있게 된다.
자바의 제네릭은 기본적으로 변성이 없지만, 한정적 와일드카드 타입을 통해 타입의 공변성 또는 반공변성을 지정할 수 있다. 이렇게 타입 매개변수 지점에 변성을 정하는 자바의 방식을 사용지점 변성(use-site variance)이라 한다.
자바와 같이 JVM을 이용하는 코틀린에서도 사용자 지점 변성을 제공한다.
비한정적 와일드 카드
제네릭에 extends, super 범위 따지지 않고 심플하게 <?> 비한정적 와일드 카드로만 구성해주면 어떻게 될까?
어떠한 타입도 올 수 있다는 점은 치트키이지만, 동시에 어떠한 타입도 올 수 있는 문제 때문에 매개변수를 꺼내거나 저장하는 로직은 논리적 에러가 발생하게 된다.
public MyArrayList(Collection<?> in) {
for (T elem : in) {
element[index++] = elem;
}
}
public void clone(Collection<?> out) {
for (Object elem : element) {
out.add((T) elem);
}
}
하지만 extends, super 를 통해 와일드카드의 경계를 정해주면, 타입의 범위 내에서 추론을 하기 때문에 경고는 발생할지라도 오류는 나지않게 되는 것이다.
와일드카드 꺼내기 / 넣기 제약
와일드카드 경계 범위
다음과 같이 클래스 타입의 상속 관계가 있다고 가정하자. 이를 예시로 타입 제한 범위에 대해 알아 보겠다.
// 제네릭 타입 클래스
class Box<T> {
...
}
// 타입 계층 관계
class Food {
}
class Fruit extends Food {
}
class Vegetable extends Food {
}
class Apple extends Fruit {
}
class Banana extends Fruit {
}
class Carrot extends Vegetable {
}
상한 경계 <? extends U>
- 타입 매개변수의 범위는 U 클래스이거나, U를 상속한 하위 클래스 (U와 U의 자손 타입만 가능)
- 상한의 뜻 : 타입의 최고 한도는 U 라는 의미. (최대 U 이하)
Box<? extends Fruit> box1 = new Box<Fruit>();
Box<? extends Fruit> box2 = new Box<Apple>();
Box<? extends Fruit> box3 = new Box<Banana>();
하한 경계 <? super U>
- 타입 매개변수의 범위는 U 클래스이거나, U가 상속한 상위 클래스 (U와 U의 조상 타입만 가능)
- 하한의 뜻 : 타입의 최저 한도는 U 라는 의미. (최소 U 이상)
Box<? super Fruit> box1 = new Box<Fruit>();
Box<? super Fruit> box2 = new Box<Food>();
Box<? super Fruit> box3 = new Box<Object>();
비경계 <?>
- 타입 매개변수의 범위는 제한이 없다. (모두 가능)
< ? extends Object >의 줄임 표현
Box<?> box1 = new Box<Vegetable>();
Box<?> box2 = new Box<Fruit>();
Box<?> box3 = new Box<Food>();
Box<?> box3 = new Box<Carrot>();
와일드카드 경계 꺼내기 / 넣기 고찰
이 파트는 난이도가 갑자기 수직 상승하는 부분이다.
와일드카드를 하한 경계로 지정했는가 상한 경계로 지정했는가 에 따라 제네릭 타입 객체에 원소를 꺼내거나 집어넣기 행위에 대해 복잡한 제약이 걸리기 때문이다. 실무에 임하는 현직자들도 자주 햇깔리는 부분이며, 왜 제약이 걸리는지 논리적으로 타입을 추론하여 심도있는 고민이 필요하다. (머리 풀가동 하자!)
제네릭 타입 클래스로는 가장 익숙한 List 자료형을 이용해 예를 들겠다.
독자분들에게 TIP을 드리자면 '될 것 같은데 왜 안되지?' 라고 의문을 표한 케이스는 대부분 클래스가
형제 관계에 있을때 서로 캐스팅하여 나타나는 문제점 때문에 막혀있다고 보면 된다.
List<? extends U>
- GET : 안전하게 꺼내려면 U 타입으로 받아야함
- SET : 어떠한 타입의 자료도 넣을수 없음 (null만 삽입 가능)
- 꺼낸 타입은 U / 저장은 NO
메서드의 매개변수의 제네릭 타입이 <? extends Fruit> 라는 것은 Apple, Banana, Fruit 타입을 전달받아 내부에서 다룰수 있다는 말이다.
class FruitBox {
public static void method(List<? extends Fruit> item) {
// 안전하게 꺼내려면 Fruit 타입으로만 받아야한다
Fruit f1 = item.get(0);
Apple f2 = (Apple) item.get(0); // ! 잠재적 ERROR
Banana f3 = (Banana) item.get(0); // ! 잠재적 ERROR
}
}
public class Main {
public static void main(String[] args) {
List<Banana> bananas = new ArrayList<>(
Arrays.asList(new Banana(), new Banana(), new Banana())
);
FruitBox.method(bananas);
}
}
그런데 꺼내는 것(GET)이 왜 Fruit 타입으로 받아야 되냐면,
- 만일 매개변수에
List<Banana>타입으로 들어올 경우Apple f2에 형제 캐스팅이 불가능하기 때문이다. - 만일 매개변수에
List<Fruit>타입으로 들어올 경우Apple f2에 다운 캐스팅이 불가능하기 때문이다. - 이러한 논리 오류로 와일드카드 최상위 범위인 Fruit 타입으로만 안전하게 꺼낼수 있는 것이다.
class FruitBox {
public static void method(List<? extends Fruit> item) {
// 저장은 NO
item.add(new Fruit()); // ! ERROR
item.add(new Apple()); // ! ERROR
item.add(new Banana()); // ! ERROR
item.add(null); // null 만 삽입 가능
}
}
그리고 저장 하는데(SET) 어떠한 타입도 불가능하냐면,
- 만일 매개변수에
List<Banana>타입으로 들어올 경우 형제 객체인new Apple()저장이 불가능하기 때문이다. - 만일 매개변수에
List<Fruit>타입으로 들어올 경우는 문제가 없겠지만 위의 논리 오류 때문에 그냥 컴파일 에러로 처리된다. - 따라서 만일 매개변수에 값을 넣고 싶다면 무조건 super 와일드카드를 사용하여야 한다.
List<? super U>
- GET : 안전하게 꺼내려면 Object 타입으로만 받아야한다
- SET : U와 U의 자손 타입만 넣을 수 있음 (U의 상위타입 불가능)
- 꺼낸 타입은 Object / 저장은 U와 그의 자손만
메서드의 매개변수의 제네릭 타입이 <? super Fruit> 라는 것은 Fruit, Food, Object 타입을 전달받아 내부에서 다룰수 있다는 말이다.
class FruitBox {
public static void method(List<? super Fruit> item) {
// 안전하게 꺼내려면 Object 타입으로만 받아야한다
Object f1 = item.get(0);
Food f2 = (Food) item.get(0); // ! 잠재적 ERROR
Fruit f3 = (Fruit) item.get(0); // ! 잠재적 ERROR
Apple f4 = (Apple) item.get(0); // ! 잠재적 ERROR
Banana f5 = (Banana) item.get(0); // ! 잠재적 ERROR
}
}
public class Main {
public static void main(String[] args) {
List<Food> foods = new ArrayList<>(
Arrays.asList(new Food(), new Food(), new Food())
);
FruitBox.method(foods);
}
}
그런데 꺼내는 것이 왜 Object 타입으로 받아야 되냐면,
- 만일 매개변수에
List<Food>타입으로 들어올 경우Fruit f3에 캐스팅이 불가능하기 때문이다. - 이러한 논리 오류로 와일드카드 최상위 범위인 Object 타입으로만 안전하게 꺼낼수 있는 것이다.
class FruitBox {
static void method(List<? super Fruit> item) {
// 저장은 무조건 Fruit와 그의 자손 타입만 넣을수 있다
item.add(new Fruit());
item.add(new Apple());
item.add(new Banana());
item.add(new Object()); // ! ERROR
item.add(new Food()); // ! ERROR
}
}
그리고 저장하는데 거꾸로 Fruit과 그의 자손 타입만 올수 있냐면,
- 만일 매개변수에
List<Fruit>타입으로 들어올 경우new Food()를 저장이 불가능하기 때문이다. - 따라서 어떠한 타입이 와도 업캐스팅 가능 상한인 Fruit 타입으로만 제한된다.
List<?>
- GET : 안전하게 꺼내려면 Object 타입으로만 받아야한다 (super 의 특징)
- SET : 어떠한 타입의 자료도 넣을수 없음 (null만 삽입 가능) (extends 의 특징)
- 꺼낸 타입은 Object / 저장은 NO
메서드의 매개변수의 제네릭 타입이 <?> 라는 것은 모든 타입을 전달받아 내부에서 다룰수 있다는 말이다.
class FruitBox {
static void method(List<?> item) {
// 꺼내는건 Object 타입만 가능
Object f1 = item.get(0);
Food f2 = (Food) item.get(0); // ! 잠재적 ERROR
Fruit f3 = (Fruit) item.get(0); // ! 잠재적 ERROR
Apple f4 = (Apple) item.get(0); // ! 잠재적 ERROR
Banana f5 = (Banana) item.get(0); // ! 잠재적 ERROR
}
}
class FruitBox {
static void method(List<?> item) {
// 저장은 NO (null만 저장)
item.add(new Object()); // ! ERROR
item.add(new Food()); // ! ERROR
item.add(new Fruit()); // ! ERROR
item.add(new Apple()); // ! ERROR
item.add(new Banana()); // ! ERROR
item.add(null);
}
}
와일드카드 extends / super 사용시기
언제 어디서 어느때에 와일드카드 <? extends T> 를 사용해야 할지, <? super T> 를 사용해야 할지 딱 머릿속에 잡히지 않는다. 이는 현업에서도 자주 고민되는 사항이며, 그래서 자바 개발자라면 한번 쯤 읽어봐야 하는 조슈아 블로흐(Joshua J. Bloch)의 저서 Effective Java에서도 이에 대해서 PECS 라는 공식을 소개한다.
PECS 공식
PECS란, Producer-Extends / Consumer-Super 라는 단어의 약자인데 다음을 의미한다.
- 외부에서 온 데이터를 생산(Producer) 한다면
<? extends T>를 사용 (하위타입으로 제한) - 외부에서 온 데이터를 소비(Consumer) 한다면
<? super T>를 사용 (상위타입으로 제한).
이 포스팅의 초반 파트인 자바의 공변성 / 반공변성에서 사용한 예제를 다시 가져와 살펴보자.
class MyArrayList<T> {
Object[] element = new Object[5];
int index = 0;
// 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자
public MyArrayList(Collection<? extends T> in) {
for (T elem : in) {
element[index++] = elem;
}
}
// 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해주는 메서드
public void clone(Collection<? super T> out) {
for (Object elem : element) {
out.add((T) elem);
}
}
}
Producers Extend
위의 예제에서 extends 가 쓰이는 곳은 생성자 메서드의 매개변수 부분이다. 즉, 외부에서 온 데이터를 매개변수에 담아 for문으로 순회하여 MyArrayList를 인스턴스화(생성)하는 생산자(Producer) 역할을 하고 있다고 말할 수 있다.
class MyArrayList<T> {
Object[] element = new Object[5];
int index = 0;
// 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자
public MyArrayList(Collection<? extends T> in) {
for (T elem : in) {
element[index++] = elem;
}
}
...
}
Consumers Super
외부에서 리스트를 받아 요소를 복사하여 적재하는 clone 메서드의 매개변수에는 super 와일드카드 키워드가 쓰였다. 즉, MyArrayList의 내부 배열을 소비하여 매개변수 리스트에 적재하는 행위를 하고 있다고 볼 수 있는 것이다.
class MyArrayList<T> {
Object[] element = new Object[5];
int index = 0;
...
// 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해주는 메서드
public void clone(Collection<? super T> out) {
for (Object elem : element) {
out.add((T) elem);
}
}
}
in / out 공식
오라클 공식 문서에서는 PECS 대신 in 과 out 의 개념으로 와일드카드 사용처를 설명한다.
위의 예제에서도 in 과 out 방법을 사용했는데, extends 에선 매개변수명이 in 이고, super 에선 매개변수명이 out 인걸 확인 할 수 있다. 이를 합치면 아래와 같이 정리할 수 있다.
- in 변수는 코드에 복사할 데이터를 제공이 목적 → extends
- out 변수는 다른 곳에서 사용할 데이터를 보유 → super
public static <E> void copyList(List<? extends E> in, List<? super E> out) {
for(E elem : in) {
out.add(elem);
}
}
위의 와일드카드의 제약 파트에서 배웠듯이, extends 는 애초에 원소 set은 못하고 오로지 get 만 가능하다. 따라서 제네릭 타입 매개변수의 데이터를 가져오는 역할을 하면 상한 경계 와일드카드 를 사용한다고 보면 된다.
super은 애초에 원소 get은 Object로 가져오니 의미가 없다고 보면되고, 그러면 오로지 set 만 가능하다. 따라서 제네릭 타입 매개변수의 데이터에 적재하는 역할을 하면 하한 경계 와일드카드 를 사용한다고 보면 된다.
혼동할 수 있는 와일드카드 표현
와일드카드는 설계가 아닌 사용을 위한 것
새내기 개발자분들이 가장 많이 착각하는 것이 와일드카드를 어디서나 사용할수 있다고 생각하는 것이다. 하지만 와일드카드는 아래와 같이 클래스나 인터페이스 제네릭을 설계할때는 아예 사용할수가 없다.
class Sample<? extends T> { // ! Error
}
와일드카드는 이미 만들어진 제네릭 클래스나 메서드를 사용할때 이용하는 것으로 보면 된다. 예를들어 다음과 같이 변수나 매개변수에 어떠한 객체의 타입 파라미터를 받을때 그에 대한 범위 한정을 정할때 사용된다고 보면 된다.
class Sample<T> {
public static <E> void run(List<? super E> l) {}
}
public class Main {
public static void main(String[] args) {
Sample<?> s2= new Sample<String>();
Sample<? extends Number> s1 = new Sample<Integer>();
Sample.run(new ArrayList<>());
}
}
<T extends 타입> 와 <? extends U> 차이점
바로 위에서 언급했듯이, 와일드 카드는 제네릭 클래스를 만들때 사용하는 것이 아니라, 이미 만들어진 제네릭 클래스를 사용할때 타입을 지정할때 이용되는 것이다.
즉, <T extends 타입> 는 제네릭 클래스를 설계할때 적어주는 것이고, <? extends 타입> 는 이미 만들어진 제네릭 클래스를 인스턴스화 하여 사용할때 타입 파라미터로 넘겨줄때 적어주는 것이다.
<T super 타입> 은 왜 없을까
와일드카드에 <T extends 타입> 은 존재하지만, <T super 타입> 은 없는 걸 볼 수 있다.
<T extends 타입> 는 정의할 제네릭 타입 범위를 상한 제한하기 위해 사용하는데, <T super 타입> 이 된다면 무수히 많은 자바의 클래스와 인터페이스가 올 수 있다는 뜻이기 때문에, Object와 다르지 않아 그냥 쓸모없는 코드이기 때문이다.
<?> 와 <Object> 는 다르다
비경계 와일드카드가 모든 타입이 들어올 수 있으니 Object와 다를바 없다고 말할수 있겠지만, 엄밀히 List<?> 와 List<Object> 는 다른 놈이다. 왜냐하면 List<Object>에는 Object의 하위 타입은 모두 넣을 수 있지만, List<?> 에는 오직 null만 넣을 수 있기 때문이다. (잘 모르면 위로 올라가 다시 복습하자!)
이는 타입 안정성을 지키기 위한 제네릭의 특성으로, 만약 다음과 같이 List<?>에 모든 타입을 넣을 수 있게 한다면, List<Integer>에 Double형을 추가하는 모순 발생하게 되어서 그렇다.
public static void main(String[] args) {
List<Integer> ints = new ArrayList<>();
add(ints);
}
private static void add(List<?> ints){
ints.add(3.14); // 외부에서 받은 List<Integer>에 Double을 추가하는 모순 발생
}
# 참고 자료
이펙티브 자바 Effective Java 3/E
https://www.youtube.com/watch?v=Vv0PGUxOzq0
https://www.youtube.com/watch?v=w5AKXDBW1gQ
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.