21장. Variance
20장에서 제네릭을 배웠습니다.List<String>, Box<Int>처럼
타입을 담는 상자를 만들 수 있었습니다.
그런데 여기서 까다로운 질문이 하나 생깁니다.
List<고양이>는List<동물>로 취급해도 될까요?
고양이는 동물입니다.
그렇다면 고양이 목록도 동물 목록일까요?
이 질문에 답하는 것이 바로 변성(Variance)입니다.
제네릭 타입 사이의 “상하 관계“를 다루는 규칙입니다.
이 장도 7부(심화)에 속합니다.
처음에는 큰 흐름만 잡고,
필요할 때 다시 돌아와도 좋습니다.
변성은 코틀린에서 가장 헷갈리는 주제입니다.
하지만 실무에서는 대부분 “코드를 읽기 위해” 필요합니다.
그러니 완벽히 외우기보다
“왜 이런 규칙이 있는지“를 이해하는 데 집중합시다.
21.1 왜 List는 List가 아닌가
먼저 간단한 상속 관계를 만들어 봅시다.
open class Animal
class Cat : Animal()
Cat은 Animal을 상속합니다.
그래서 이런 대입은 당연히 됩니다.
val animal: Animal = Cat() // OK: 고양이는 동물이다
그렇다면 목록도 될까요?
val cats: MutableList<Cat> = mutableListOf(Cat())
val animals: MutableList<Animal> = cats // 컴파일 오류!
직관적으로는 될 것 같은데,
코틀린은 이를 막습니다.
이유를 상상해 봅시다.
만약 위 대입이 허용된다면 이런 일이 벌어집니다.
animals.add(Animal()) // animals는 동물 목록이니 동물 추가 가능
val c: Cat = cats[0] // 그런데 cats에는 Animal이 들어가 버림!
cats는 분명 고양이 목록인데
그 안에 일반 동물이 섞이게 됩니다.
이런 사고를 막기 위해
코틀린은 기본적으로 이 대입을 금지합니다.
상속 관계가 있어도,
그 타입을 담은 제네릭까지
자동으로 상속 관계가 되지는 않는다.
21.2 Invariance (무공변)
방금 본 것이 바로 무공변(Invariance)입니다.
무공변이란Cat과 Animal이 부모-자식 관계여도Box<Cat>과 Box<Animal>은
아무 관계도 아니라는 뜻입니다.
class Box<T>(val value: T)
val catBox: Box<Cat> = Box(Cat())
val animalBox: Box<Animal> = catBox // 오류: 서로 무관한 타입
코틀린의 제네릭은 아무 표시가 없으면
전부 무공변입니다.
무공변은 안전합니다.
하지만 때로는 너무 엄격해서 불편합니다.
그래서 “이 경우엔 관계를 인정해도 안전하다“고
알려 주는 장치가 필요합니다.
그것이 다음에 나오는 out과 in입니다.
21.3 Covariance와 out (공변)
다시 생각해 봅시다.
문제가 생긴 건 목록에 값을 “추가“할 때였습니다.
만약 값을 꺼내기만 하고
절대 추가하지 않는다면 어떨까요?
고양이만 들어 있는 목록에서
값을 꺼내면 항상 고양이가 나옵니다.
고양이는 동물이므로,
이를 동물 목록처럼 “읽는” 것은 안전합니다.
이렇게 “꺼내기(생산)만 하는” 경우
부모-자식 관계를 인정해 주는 것을
공변(Covariance)이라고 합니다.
공변은 타입 앞에 out을 붙여 표시합니다.
class Box<out T>(val value: T)
val catBox: Box<Cat> = Box(Cat())
val animalBox: Box<Animal> = catBox // OK! out 덕분에 허용
out은 “이 타입은 밖으로 내보내기만 한다“는 약속입니다.
실제로 코틀린의 읽기 전용 List가
바로 이렇게 정의되어 있습니다.
interface List<out E> { ... }
그래서 이런 코드는 문제없이 동작합니다.
val cats: List<Cat> = listOf(Cat())
val animals: List<Animal> = cats // OK: List는 공변
읽기 전용이라 값을 추가할 수 없으니
21.1에서 본 사고가 일어나지 않습니다.
21.4 Contravariance와 in (반공변)
이번엔 반대 상황입니다.
값을 “받아서 처리(소비)만” 하는 경우입니다.
동물을 처리할 수 있는 도구가 있다고 합시다.
class Processor<in T> {
fun process(item: T) {
println("처리: $item")
}
}
동물을 처리할 수 있다면,
고양이도 당연히 처리할 수 있습니다.
고양이는 동물이니까요.
그래서 “동물 처리기“를 “고양이 처리기” 자리에
넣는 것이 안전합니다.
val animalProcessor: Processor<Animal> = Processor()
val catProcessor: Processor<Cat> = animalProcessor // OK! in 덕분에 허용
이렇게 “받아서 소비만 하는” 경우
관계를 반대로 인정해 주는 것을
반공변(Contravariance)이라고 합니다.
반공변은 타입 앞에 in을 붙입니다.
in은 “이 타입은 안으로 받기만 한다“는 약속입니다.
21.5 Star Projection
가끔은 타입 파라미터가 무엇인지
전혀 신경 쓰지 않을 때가 있습니다.
“무슨 타입인지는 모르겠지만
아무튼 어떤 리스트“라고만 말하고 싶을 때입니다.
이때 별표(*)를 씁니다.
이를 스타 프로젝션(Star Projection)이라고 합니다.
fun printSize(list: List<*>) {
println("크기: ${list.size}")
}
printSize(listOf(1, 2, 3)) // OK
printSize(listOf("a", "b")) // OK
List<*>는 “무언가의 리스트“라는 뜻입니다.
크기를 세는 것처럼
타입과 무관한 작업에만 안전하게 쓸 수 있습니다.
다만 *로 받은 값은
꺼낼 때 타입을 알 수 없으므로Any?로 취급됩니다.
21.6 읽는 타입과 쓰는 타입
여기까지 오면 핵심이 보입니다.out과 in은 결국 “방향“입니다.
기억하기 쉬운 한 문장으로 정리하면 이렇습니다.
out = 밖으로 내보낸다 = 생산자(Producer) = 읽기
in = 안으로 받는다 = 소비자(Consumer) = 쓰기
| 키워드 | 의미 | 역할 | 관계 |
|---|---|---|---|
| (없음) | 무공변 | 읽기+쓰기 | 관계 없음 |
| out | 공변 | 생산(읽기) | 자식→부모 허용 |
| in | 반공변 | 소비(쓰기) | 부모→자식 허용 |
이 방향 규칙은 종종
“PECS“라는 말로도 불립니다.
Producer는 out, Consumer는 in.
(Producer-Extends, Consumer-Super)
21.7 실무 코드를 읽기 위한 Variance
솔직히 말하면,
직접 out/in을 붙여 클래스를 설계하는 일은
초보 단계에서는 드뭅니다.
대신 라이브러리나 표준 API를 읽을 때
이 표시들을 자주 만나게 됩니다.
List<out E>: 읽기 전용, 공변Comparable<in T>: 비교 대상으로 소비, 반공변(T) -> R함수 타입도 내부적으로 in/out 규칙을 따름
그러니 지금은 이 정도만 기억하면 충분합니다.
out이 보이면 “여기서 값을 꺼내는구나”,in이 보이면 “여기에 값을 넣는구나”
라고 읽으면 된다.
자바에도 비슷한 개념이 있습니다.? extends T가 코틀린의 out,? super T가 코틀린의 in에 해당합니다.
코틀린은 이 표시를
클래스를 정의할 때 한 번만 붙일 수 있어서
자바보다 훨씬 깔끔합니다.
21장을 마치며
이 장에서 우리는 다음을 배웠습니다.
- 상속 관계가 있어도 제네릭까지 자동으로 상속되지 않는다는 점(무공변)
- 값을 꺼내기만 하면
out으로 공변을 허용한다는 점 - 값을 받기만 하면
in으로 반공변을 허용한다는 점 - 타입을 신경 쓰지 않을 땐
*(스타 프로젝션)를 쓴다는 점 - out=생산/읽기, in=소비/쓰기라는 방향 감각
변성은 완벽히 외우는 개념이 아니라
“방향으로 읽는” 감각에 가깝습니다.
다음 장에서는 7부의 마지막,inline과 reified를 다룹니다.