들어가며
최근 제가 다른 실수(타입 추정이 잘 안되는 문제)를 계기로 제네릭에 대해 다시 공부할 일이 생겼습니다.
그러며 새로이 깨달은 것이 `공변성(covariance)` 과 반공변성(Contravariance)`에 대해 제대로 이해하고 있지 않기도 하고.
매번 헷갈린다는 생각이 들어서 이번 기회로 조금 정리해보려고 합니다.
이미 Java로 코드를 구현할 때, 이 개념을 자연스럽게 사용하고 있었는데요.
새삼 개념과 객체지향 리스코프 치환 원칙 LSP까지 짚어보니 좀 복잡했지만 왜 이렇게 사용해야했는지 이해할 수 있어서 유의미했던 공부였습니다.
공변성과 반공변성
먼저 변성(variance)은 타입 시스템에서 서로 다른 타입 간의 관계를 설명하는 개념입니다. 다시말해 상속관계에 있는 A, B 타입이 있을 때, 이를 Type Argument로 가지고 있는 Base Type이 어떤 관계에 있는지 나타냅니다.
각 타입의 상속 관계가 아래와 같을 때
class Animal {
public void walk()
}
class Cat extends Animal {
public void grooming()
}
List라는 Base type을 이렇게 정의한다면, `List<Cats>` 는 `List<Animals>` 를 상속받은 것일까요?
List<Animal> animals;
List<Cat> cats;
🤔 이 예시에서 정답은
"`Cats`와 `Animals` 가 상속 관계를 라고, `List<Cats>` , `List<Animals>` 간의 상속 관계를 보장하지 않는다." 입니다.
자바에서는 무변성의 특징을 갖기 때문입니다.
Type Argument의 관계를 그대로 Base Type간의 관계가 유지된다면 공변성(Convariance)라고 하고,
반대 방향의 상속 관계를 가진다면 반공변성(Contravariance) 라고 합니다.
이와는 달리 Type Argument 타입 관계가 Base Type 간의 관계에 영향을 미치지 않는다면 무변성(Invariance)라고 합니다.
Animal a = new Cat(); // ok
List<Animal> as = new List<Cat> // error
이러한 변성은 제네릭, 다형성 구현에 매우 중요한 개념입니다.
언어 프레임워크에 따라 제네릭 변성 처리 방식이 다를 수 있습니다.
다시 한 번 말하지만 자바 제네릭의 경우 기본적으로 무변성입니다.
// java
List<Object> objects = new ArrayList<String>(); // compile error 제네릭은 무공변성을 가집니다.
Object[] objectArray = new String[]; // ok, 배열은 공변성을 가집니다.
변성을 잘 써야하는 이유?
위와 같이 무변성의 경우 컴파일 시점에 타입 체크를 하므로 타입에 안정적인 코드를 작성할 수 있게 됩니다.
변성을 잘 사용한다면 다형성을 이용하여 보다 유연한 코드를 작성할 수 있게 됩니다.
무공변성을 가지는 경우 이런 사례에서 에러가 납니다.
Object는 String의 상위 타입이지만, List<Object>는 List<String> 의 상위 타입이 아니기 때문입니다.
public class Counter {
public void countTotalLength(List<Object> objects) {...}
}
Counter counter = new Counter();
List<String> strings = new ArrayList<>();
counter.coutTotalLength(strings) // error!!
Java에서 공변성과 반공변성
자바에서는 공편성과 반공변성을 각각 `extends` 와 `super` 를 사용하여 구현이 가능합니다.
위의 예시를 그대로 가져와서 공변 처리를 한다면 예외 없이 실행 가능하게 됩니다.
대신 전달받은 `strings` 의 타입을 바로 활용할 수 없습니다.
컴파일러가 확실할 수 있는 것은 List 제네릭으로 Object의 하위타입인 것인데요.
인자로 전달된 strings는 메서드 내부에 타입 전달이 되지 않습니다.
public class Counter {
public void countTotalLength(List<? extends Object> objects) {...} // 하위 타입에 대해 수용 가능
}
Counter counter = new Counter();
List<String> strings = new ArrayList<>();
counter.coutTotalLength(strings) // ok!
이 때는 반공변성을 사용하여 컴파일러가 사용할 수 있는 타입을 바꿔줄 수 있습니다.
아래와 같이 바꾼다면 String의 상위 타입에 있는 것도 할당 가능합니다.
public class Counter {
public void countTotalLength(List<? super String> objects) {...} // 상위 타입에 대해 수용 가능
}
Counter counter = new Counter();
List<String> strings = new ArrayList<>();
List<Object> objects = new ArrayList<>();
counter.coutTotalLength(strings) // ok!
counter.coutTotalLength(objects) // ok!
이 공변과 반공변을 사용할 때는 구현하다 자연스럽게 사용하게 되기 마련입니다. 어차피 컴파일 에러로 빠르게 확인할 수 있으니까요.
저는 하위 타입을 꺼내서 사용한다면(소비한다고 표현하기도 합니다.) 반공변처리를. 상위 타입으로만 사용한다면 공변처리를 하고 있습니다.
이를 read / write, producer/consumer 로 표현하기도 하는데, 이에 대한 이야기는 이펙티브 자바(Effective Java) 규칙 28에 자세히 언급되어 있습니다.
"For maximum flexibility, use wildcard types on input parameters that represent producers or consumers."
"유연성을 극대화하려면 생산자 또는 소비자를 나타내는 입력 매개변수에 와일드카드(? extends, ? super) 유형을 사용하세요."
Joshua Bloch, in his book Effective Java, 3rd Edition, explains the problem well (Item 31: "Use bounded wildcards to increase API flexibility"). He gives the name Producers to objects you only read from and Consumers to those you only write to. He recommends:
조슈아 블로흐는 그의 저서 Effective Java, 3판에서 이 문제를 잘 설명합니다(항목 31: "바운딩 와일드카드를 사용하여 API 유연성 높이기"). 그는 읽기만 하는 객체에는 생산자라는 이름을, 쓰기만 하는 객체에는 소비자라는 이름을 붙입니다. 그는 이렇게 추천합니다.
참고링크
Refs.
- https://kotlinlang.org/docs/generics.html#type-erasure
- https://kotlinlang.org/docs/generics.html#type-projections
- 이펙티브 자바