article thumbnail image
Published 2022. 8. 31. 01:40

 

 

제네릭 Generic

 

클래스와 인터페이스 선언에 타입 매개변수가 쓰이면 이를 제네릭 클래스 혹은 제네릭 인터페이스라고 한다.

우리가 흔히 사용하는 List 인터페이스를 보자.

 

 

리스트 인터페이스는 원소의 타입을 나타내는 타입 매개변수 E를 받고 있기 때문에

List<Object> 나 List<String> 등으로 사용할 수 있다.

 

제네릭 클래스와 제네릭 인터페이스를 통틀어 제네릭 타입 이라고 한다.

 

 

(참고) Raw Type 은 사용하지 말라

 

 

제네릭 타입을 정의하면 Raw Type 도 함께 정의된다.

List<E> 를 정의했다면 로 타입은 List 다.

 

Raw Type 을 사용한다는 것은 다음과 같다.

List strings = new ArrayList();
strings.add("100");
strings.add(Integer.valueOf(10));

타입 매개변수를 사용하지 않으면 String 을 넣고자하는 리스트에 Integer 값이 들어가도 문제없이 컴파일이 된다.

 

잘못 생성 된 리스트의 값을 어디선가 사용하게 된 개발자는 다음과 같은 런타임 오류를 보게된다.

// 런타임 시 ClassCastException
list1.forEach(str -> System.out.println((String)str));

 

Raw Type 을 사용하면 리스트에서 값을 꺼내기 이전에는 오류를 발견할 수 없다.

이렇게 되면 런타임 오류가 발생하는 코드와 원인이 물리적으로 멀리 떨어져있을 가능성이 커진다.

 

오류는 가능한 한 발생 즉시, 이상적으로는 컴파일할 때 발견하는 것이 좋다.

Raw Type 은 제네릭이 생기기 이전 코드와의 호환성을 위해 지원하는 것으로, 사용하면 타입 안정성을 잃게 된다.

 

 

 

 

 

공변과 불공변

 

와일드 카드를 공부하기 이전에 먼저 공변과 불공변에 대해 알아보자.

 

- 공변 Covariant : 함께 변한다.

배열은 공변이다. Object 밑에 Long 타입이 있다면 Object[] 밑에도 Long[] 타입이 있다.

 

- 불공변 Invariant

제네릭은 불공변이다. List<Object>와 List<Long> 은 서로 상하관계가 아니다.

 

 

// 배열은 공변
Object[] objectArray = new Long[1];
objectArray[0] = "타입이 달라 넣을 수 없지만 컴파일은 가능하다."; // 런타임 시 ArrayStoreException

 

Long 타입은 Object 타입의 하위 타입이기 때문에 Long 타입 배열도 Object 배열의 하위 타입으로 인식되어

Object[] objectArray = new Long[1]; 과 같은 선언이 가능하다.

 

 

불공변인 제네릭은 다음과 같이 컴파일 에러를 뱉는다.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다.");

 

두가지 예시 모두 Long 타입 저장소에 String 을 넣으려는 시도를 하지만

불공변인 제네릭을 사용하면 컴파일 시점에 오류를 알아챌 수 있다.

 

 

 

 

 

와일드카드 Wildcard

 

리스코프 치환 원칙
어떤 타입에 있어 중요한 속성이라면 그 하위 타입에서도 마찬가지로 중요하다.
따라서 그 타입의 모든 메서드가 하위 타입에서도 똑같이 잘 작동해야한다.

 

앞서 이야기했듯이 List<String> 는 List<Object> 의 하위 타입이 아니다.

자바의 다형성을 생각하면 직관적이지 않을 수 있지만, 리스코프 치환 원칙에 따르면 틀린 말이 아니다.

 

List<Object> 에는 어떠한 객체든 넣을 수 있지만, List<String> 에는 문자열만 넣을 수 있다.

List<String> 은 List<Object> 가 하는 일을 제대로 수행하지 못하기 때문에 하위 타입이 될 수 없는 것이다.

 

 

 

하지만 다형성(polymorphism) 을 활용해 줄일 수 있었던 많은 중복 코드들은 어떻게 해야할까?

 

이러한 경우에 ? 를 사용해서 유연성을 높일 수 있다.

이 물음표를 바로 와일드카드라고 부른다.

 

제네릭 타입을 쓰고 싶지만 실제 타입 매개변수가 무엇인지 신경 쓰고 싶지 않을 때 사용한다.

 

public static void main(String[] args) {
    List<String> list1 = new ArrayList<>();
    list1.add("100");

    List<Integer> list2 = new ArrayList<>();
    list2.add(10);

    printList(list1);
    printList(list2);
}

public static void printList(List<?> list) {
    list.forEach(System.out::println);
}

 

 

1. 어떠한 타입이든지 상관없이 리스트를 출력하고 싶다.

2. 현재 어떤 타입이 올지 모르고, 앞으로도 모를 것이다.

3. 어떠한 타입이 오든지 리스트의 원소와 관련있는 기능은 사용하지 않겠다. ( add() 와 같은 )

 

 

 

 

한정적 와일드 카드

 

여기까지 와일드 카드를 사용해서 어떤 타입이든 제네릭으로 처리할 수 있는 방법을 알아보았다.

타입과 관계 없이 List<Integer> 나 List<String> 을 하나의 메서드에서 받아 공통으로 처리했다.

 

그런데 개발을 하다보니 모든 타입이 아닌 특정 타입만 타입 매개변수로 받고싶다.

이럴 때는 extends 또는 super 를 사용하면 된다.

 

  • Iterable<? extends E> : E 의 하위 타입의 Iterable
  • Collection<? super E> : E 의 상위 타입의 Collection

 

와일드카드를 제대로 사용하는 경우, 클래스 사용자는 와일드카드 타입이 쓰였다는 사실을 몰라야한다.

클래스 사용자가 와일드카드 타입을 신경써야 한다면 그 코드는 잘못 구현된 것이다.

 

와일드카드를 제대로 사용하기 위해서 다음 공식을 기억해두자.

 

 

 

 

PECS

 

- 펙스(PECS): producer-extends, consumer-super

매개변수화 타입 T가 생산자라면 <? extends T> , 소비자라면 <? super T> 를 사용하라. 

 

 

매개변수화 타입이 생산자라는 것은 무슨 의미일까?

생각해보면 간단한 이야기지만 정말 생각하기 싫게 생긴 단어들이다.

 

public class Stack<E> {

    ....
    
    public void pushAll(Iterable<? extends E> src) {
        for(E e : src)
            push(e);
    }
}

 

stack 에 pushAll 하는 기능의 메서드가 있다.

여기서 매개변수 src 는 stack 이 사용할 E 타입의 인스턴스를 만들어서 push 한다.

생성(push) 된 e 는 어디선가 미래에 소비(pop)될 것이다.

 

 

Number 타입으로 스택이 생성되었다고 가정해보자.

Number 타입의 스택에 들어갈 수 있는 타입은 Number 또는 Integer, Long 등이 있을 것이다.

 

나는 어떤 타입이 올지 모르지만, 메서드에서 E 타입으로 뭔가를 생성할거야.

그러니까 E 의 하위 타입만 와줘

 

라는 의미로 ? extends E 를 사용했다.

String 이나 Object 타입으로 스택에 push 되는 것을 컴파일 단계에서 막을 수 있는 것이다.

 

 

 

 

이제 소비자로 가보자.

 

public class Stack<E> {

    ....
    
    public void popAll(Collection<? super E> dst) {
        while(!isEmpty())
            dst.add(pop());
    }
}

 

스택의 원소들을 다른 타입의 컬렉션(dst)으로 옮기려고(소비하려고) 한다.

이전에 우리는 Stack<Number> 에 Number, Integer 등의 타입을 받아서 push 했다.

 

이 스택의 원소들을 옮기려면 뭔지는 모르지만 최소 Number 의 상위 타입인 Collection 을 받아야한다.

 

어떤 타입이 올지 모르지만, 메서드 내에서 E 를 소비해서 다른 곳에 집어넣을거야.

그러니까 E 의 상위 타입만 와줘

 

라는 의미로 ? super E 를 사용해서,

아래와 같이 컴파일 시점에 타입에 안전하게 사용할 수 있게 되는 것이다.

 

List<Long> list = new ArrayList<>(Arrays.asList(10L, 20L, 100L));

Stack<Number> numberStack = new Stack<>();
numberStack.pushAll(list);

List<Object> objectList = new ArrayList<>();
numberStack.popAll(objectList);

 

 

 

 

참고 사항

 

- 생산자와 소비자 역할을 동시에 하는 매개변수는 무엇을 써야할까?

타입을 정확히 지정해야하는 상황으로 와일드카드 타입을 쓰지 말아야한다.

 

- Comparable, Comparator 인터페이스는 언제나 소비자다.

 

 

 

 

 

 

 

참조

 

https://mangkyu.tistory.com/241

https://asuraiv.tistory.com/16

Effective Java, 5장 제네릭

복사했습니다!