본문 바로가기

Language

제네릭, 그리고 변성(Variance)에 대한 고찰 (1) - Java

공변

 가령, 타입 `S`와 `T`가 있고,  `T` 는 `S` 의 하위 타입이라고 하자. 특정 컨테이너 `C` 에 먼저 언급한 타입을 타입 파라메터로 전달한 제네릭, `C<T>` 는 `C<S>` 의 하위 타입인가? 이것이 성립되면 해당 언어는 공변적이라고 한다.

 

 자바를 생각해보자. 자바는 애초에 공변, 반공변을 지원하지 않지만, 지원하는 것처럼(?) 보여주는 컴파일러 트릭을 사용한다. `<? extends T>`, `<? super T>` 키워드가 바로 그것이다. 이러한 제네릭의 변성을 적절하게 사용하면 유연한 코드를 작성할 수 있게 된다.

 

 이와 관련하여 자바 개발자들의 필독서 중 하나인 'Effective Java'의 저자 조슈아 블로흐는 대략 2000년대 말쯤 있었던 어느 강의에서 'PECS'라는 공식을 이야기 했는데, 아래 유투브 영상의 22분 무렵부터 그에대한 설명을 시작한다. (Effective Java 책 자체의 내용으로 강연을 진행한듯 하다)

 

 

 첨언하자면, 뉴욕의 프로그래머로 잘 알려진(개인적으로 존경하는) 임백준님은 자신의 저서 '폴리글랏 프로그래밍'에서, 위 강연을 보고 아래와 같이 'PECS'에 대한 안타까움(?)을 이야기했다.

 

제네릭과 관련한 강연을 하던 조슈아 블로흐는 프로그래머들이 언제 super 키워드를 쓰고, 언제 extends 키워드를 써야 하는지 기억하는 것을 돕기 위해서 이른바 '펙스(PECS)' 라는 공식까지 만들었다. 강연 도중에 두 손을 번쩍 들어 올리면서 "Producers Extend, Consumers Super!" 라고 외치는 블로흐의 모습을 유투브로 시청하면서 나는 (존경하는 블로흐에게는 미안하지만) "세상에, 이건 아니야" 라는 말을 중얼거릴 수 밖에 없었다.

                                                                                                                                - "폴리글랏 프로그래밍 <임백준 저, 한빛 미디어>" 에서 발췌

 

 해당 책에서 저자는 자바 제네릭의 와일드카드, 공변과 반공변 따위의 철칙을 지키며 코딩을 하는 것은 무척이나 어려우며, 그것을 남용하는것은 오히려 명확하지 않고 복잡도만 높은 (다시말하면 협업하기가 매우 어려운) 코드를 양산한다고 이야기한다. 더 나아가 제네릭을 사용하지 않아도 비즈니스 로직을 작성하는데 아무런 문제가 없다는 제네릭 무용론까지 언급한다. 아마 그런 맥락에서 이해 할 수 있는 안타까움의 표현이 되겠다.

 

거두절미 하고, 지금부터 'PECS' 공식을 중심으로 자바의 변성에 대해 정리하고자 한다.

 

Producers Extend, 공변

 'Producers Extend' 라는 의미는 무엇일까. 가령, `Stack` 자료구조에 아래와 같은 'bulk' 메서드가 존재한다고 하자. (실제 `Stack` API 에는 존재하지 않는다)

 

public void pushAll(Collection<T> src) {
	for (T elem : src) {
		push(elem);
	}
}

 

위의 메서드를 아래와 같이 사용하면, 컴파일에러가 발생한다.

 

Stack<Number> stack = new Stack<Number>();
Collection<Integer> integers = Arrays.asList(1, 2, 3, 4, 5);
stack.pushAll(integers); // compile error!

 

 이유는 위에서 말한 것 처럼, 자바의 제네릭은 공변을 지원하지 않기 때문이다. 즉, `Collection<Integer>` 는 `Collection<Number>` 의 하위 타입이 아니다!

 

하지만 시그니처를 아래와 같이 바꿔주면, 공변이 적용되어 컴파일에러가 사라진다.

 

public void pushAll(Collection<? extends T> source)

 

 자바는 `extends` 키워드를 사용한 타입, 즉 '한정적 와일드카드 타입'이라는 특수한 타입 파라메터를 이용해 공변이 사용 되게끔 할 수있다. 다시말해 "`Collection<Integer>` 는 `Collection<? extends Number>` 의 하위타입이다" 라는 명제를 참이 되게 만드는 것이다.

 

 이때, 여기서 말하는 '생산자'는 `pushAll` 메서드가 입력 파라메터로 전달 받는 컬렉션 자체다. 해당 메서드의 내부 구현을 보면, 입력 파라메터인  'src' 컬렉션을 순회하며 해당 컬렉션에서 요소를 생산하여 스택에 집어 넣고 있는것을 볼 수 있다.

 

Consumers Super, 반공변

아래의 `popAll` 메서드는 현재 스택에 있는 요소들을 입력 파라메터로 주어지는 컬렉션에 옮겨 담는다.

 

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

 

 가령 위의 메서드를 이용하여 `Stack<Number>` 의 원소를  `Collection<Object>` 에 담아야 한다고 했을때, 아래의 코드는 역시나 컴파일 에러를 발생시킨다.

 

Stack<Number> stack = new Stack<Number>();
Collection<Object> objects = ...;
stack.popAll(objects); // compile error!

 

 당연히 `Collection<Object>` 는 `Collection<Number>` 의 하위타입이 아니기 때문(정확히 말하면 `Object`는 `Number` 의 상위타입이지 하위타입이 아니기 때문)이다.

 

 반공변을 적용하기 위해서, 즉 `Collection<Number>` 타입이 기대되는 자리에 `Collection<Object>` 를 할당하려면 아래와 같이 `super` 키워드를 사용한 한정적 와일드카드 타입으로 시그니처를 바꾸어 주어야 한다.

 

public void popAll(Collection<? super T> dst)

 

 여기서 말하는 '소비자' 역시, 입력 파라메터로 주어지는 'dst' 컬렉션이다. 메서드 내부 구현을 보면 알겠지만, 'dst' 컬렉션은 스택의 요소를 소비하며 자기 자신안에 넣기 때문이다.

 

좀 더 명확한 예제

아래와 같은 상속구조가 있다고 하자.

 

 

 '고양이는 포유류(Mammal)의 하위타입, 포유류는 동물의 하위타입' 이라는 것을 나타내는 상속 구조이다. 이를 바탕으로 우리(cage)에 3마리의 고양이를 넣어 동물보호소(animalShelter)에 맡기는 시나리오를 아래와 같은 코드로 표현했다.

 

Stack<Mammal> cage = new Stack<>();

Collection<Cat> cats = Arrays.asList(new Cat(), new Cat(), new Cat());

cage.pushAll(cats); // 고양이들을 우리에 담는다.

Collection<Animal> animalShelter = Collections.emptyList();

cage.popAll(animalShelter); // 동물 보호소에 맡긴다.

 

 우리(cage)는 사실 고양이 전용만은 아니다. 따라서 타입 파라메터를 포유류(Mammal)로 정의했다. 3마리의 고양이 무리를 나타내는 'cats' 컬렉션이 'cage' 의 `pushAll` 메서드로 전달되면, 'cats' 컬렉션에서 한마리씩 요소를 생산하여 'cage' 스택에 넣게 된다. 이 'cage' 와 함께 동물 보호소로 가서, 다시 고양이들을 꺼내 맡겨야 한다. 이 코드에서는 'animalShelter' 컬렉션을 'cage' 스택의 `popAll` 메서드에 입력 파라메터로 전달하여 'animalShelter' 컬렉션이 'cage' 스택의 요소들을 소비하게 한다. 

 

왜 PECS 인가?

 입력 파라메터 컬렉션 성격에 따라(생산하는지, 소비하는지. 다른말로 꺼내는지 집어넣는지) 적절히 `extends` , `super` 키워드를 사용한 '한정적 와일드카드 타입'을 적용하는 것. 이것이 'PECS' 의 핵심 개념이라고 할 수 있다.

 

 여기서 "생산자에 `super` 키워드를 사용하면 안되는 걸까? 무조건 PECS 공식을 따라야 하나?" 라는 의문을 가질 수 있다.

아래의 예제를 보자.

 

List<? super Mammal> mammals = Arrays.asList(new Animal(), new Animal()); // 별로 자연스럽지 않음

for (int i = 0; i < mammals.size(); i++) {
	Animal animal = mammals.get(i); // compile error!
}

 

 일단 상위타입인 '동물'의 컨테이너를 하위타입인 '포유류' 컨테이너로 할당 하는 것 자체가 자연스럽지 않다. 리스코프 치환 원칙에 따르면, 객체지향적인 코드란 항상 상위타입으로의 캐스팅 즉, '업 캐스팅'을 염두하며 작성하는 것이다. 다만 이 경우는 실제 선언된 클래스 타입간의 캐스팅이 아니고, 각 컨테이너의 '타입 파라메터' 관점에서 생각하는 것이므로 '캐스팅' 이라고 할 수 없지만, 변성 관점에서도 자연스럽지 않게 여겨진다.

 

이러한 부분을 차치한다 하더라도, 결정적으로 위의 `for each` 구문에서 컴파일에러가 발생한다. 메시지를 자세히 확인해보자.

 

 

 `? super Mammal` 타입의 인스턴스를 `Animal`타입의 참조로 할당 할 수 없다. 다시말하면, ` ? super Mammal` 이라는 구문은 타입을 나타내는게 아닌, 제네릭에 변성을 적용하기 위해 사용되는 구문이기 때문에 컴파일러는 `? super Mammal` 이 정확히 어떤 타입인지 알지 못한다!

 

결국 위의 할당문을 성립하게 하려면 아래처럼 코드를 수정해야한다.

 

Object animal = mammals.get(i);

 

 제네릭을 통한 타입 파라메터를 사용하는 이유는 무엇인가? 클래스가 다루는 타입을 파라메터화 된 타입으로 바운드하여 컴파일 시간에 오류를 검출, '타입 안전'한 코드를 작성하려는 것 아닌가? 따라서 `Object` 타입의 사용은 이러한 제네릭의 장점을 무색하게 한다.

 

 소비자에 `extends` 키워드를 쓰는 것도 비슷한 이유로 문제가 된다. 위 문단에서도 언급했지만 소비자는 객체를 소비, 즉 자신 안으로 저장하는 컬렉션이다. 다시 아래 예제를 보자.

 

List<? extends Mammal> mammals = new ArrayList<Cat>();

// 아래는 죄다 compile error!
mammals.add(new Mammal());
mammals.add(new Cat());
mammals.add(new Object());

 

에러 메시지는 비슷하다.

 

 

 먼저의 경우와 동일하게, 컴파일러는 `? extends Mammal` 가 어떤 타입인지 알지 못한다. 따라서 - 심지어 `Object` 타입의 객체 마저도 - 저장할 수 없다. 한마디로 `extends` 키워드를 사용한 한정적 와일드 카드 타입의 컨테이너는 소비자로 아예 쓸 수 없다.

 

다시 정리하자. 제네릭 변성에 관한 PECS 공식은 다음의 경우에 적용해야한다. (굳이 컨테이너가 아니어도 된다)

 

  • 어떤 메서드가 입력 파라메터로 제네릭을 적용한 컨테이너를 받고, 메서드 안에서 해당 컨테이너가 생산하는 작업을 하는 경우  `? extends T` 타입 파라메터를 사용하자.
  • 어떤 메서드가 입력 파라메터로 제네릭을 적용한 컨테이너를 받고, 메서드안에서 해당 컨테이너가 소비하는 작업을 하는 경우 `? super T` 타입 파라메터를 사용하자.

 

이러한 PECS 공식을 잘 사용한다면, 한정적 와일드카드 타입을 사용한 제네릭으로 타입 안전하고 훨씬 더 유연한 API 를 만들 수 있다. (는 블로흐의 주장)

 

마치며

 한창 코틀린을 공부하다가 코틀린 제네릭의 `in` 과 `out` 키워드를 만났다. 그리고 이 녀석들이 '변성'과 관련이 있다는 것을 알게 되었기에 먼지 쌓인 'Effective Java' , 'Thinking In Java' 까지 꺼내서 다시 정리하게 되었다. 변성 즉, '공변'과 '반공변'을 정확히 이해해야 코틀린의 'in position' 과 'out position' 을 나타내는 해당 키워드들의 의미를 알 수 있기 때문이다. 다음 포스트에서는 이러한 이해를 바탕으로 코틀린의 변성에 대해 정리하겠다.

 

참고

- Effective Java 3e (Joshua Bloch)

- Thinking In Java 4e (Bruce Eckel)

- medium.com/@isuru89/java-producer-extends-consumer-super-9fbb0e7dd268

 

Java: Producer Extends, Consumer Super

Java is a statically typed language, and then there is generics. And along with generics, there is wildcards.

medium.com

- jaepils.github.io/pattern/2018/07/04/liskov-substitution-principle.html