Table of contents
왜 제네릭인가
5편에서 listOf("사과", "바나나")를 썼을 때, 이 리스트가 List<String> 타입이 된다는 걸 눈치챘을 것이다. 꺾쇠 안의 String이 바로 제네릭 타입 파라미터다.
제네릭이 없다면 어떻게 될까? 타입별로 컨테이너를 따로 만들어야 한다. StringList, IntList, UserList… 끝도 없다. 제네릭은 “어떤 타입이든 담을 수 있지만, 한번 정하면 그 타입만 담는다”는 약속을 컴파일 타임에 보장해준다.
제네릭 클래스
가장 기본적인 형태부터 보자. 타입 파라미터를 꺾쇠(<>) 안에 선언한다.
class Box<T>(val item: T) {
fun getContent(): T = item
fun describe(): String = "Box contains: $item"
}
fun main() {
val stringBox = Box("Hello")
val intBox = Box(42)
println(stringBox.getContent()) // Hello
println(intBox.describe()) // Box contains: 42
// stringBox.getContent() + 1 // 컴파일 에러 — String이니까
println(intBox.getContent() + 1) // 43
}
Box<T>에서 T는 관례적인 이름이다. Type의 약자로 쓰는데, 여러 타입 파라미터가 필요하면 T, R, K, V 같은 이름을 쓰는 게 관행이다.
타입 추론 덕분에 Box<String>("Hello") 대신 Box("Hello")만 써도 된다. 컴파일러가 인자의 타입에서 T = String을 추론하기 때문이다.
제네릭 함수
클래스뿐 아니라 함수에도 타입 파라미터를 붙일 수 있다.
fun <T> printAll(vararg items: T) {
for (item in items) {
println(item)
}
}
fun <T> List<T>.secondOrNull(): T? {
return if (size >= 2) this[1] else null
}
fun main() {
printAll("A", "B", "C")
// A
// B
// C
val numbers = listOf(10, 20, 30)
println(numbers.secondOrNull()) // 20
val empty = emptyList<Int>()
println(empty.secondOrNull()) // null
}
fun <T> 형태로 함수 이름 앞에 타입 파라미터를 선언한다. 확장 함수에도 그대로 쓸 수 있어서, 라이브러리 스타일의 유틸 함수를 만들 때 유용하다.
타입 제약 — upper bound
아무 타입이나 받으면 할 수 있는 게 별로 없다. T에 대해 아는 게 Any?뿐이니까. 특정 타입의 하위 타입으로 제한하면 그 타입의 메서드를 쓸 수 있게 된다.
fun <T : Comparable<T>> findMax(list: List<T>): T {
var max = list.first()
for (item in list) {
if (item > max) max = item
}
return max
}
fun main() {
println(findMax(listOf(3, 1, 4, 1, 5, 9))) // 9
println(findMax(listOf("banana", "apple", "cherry"))) // cherry
// findMax(listOf(Object(), Object())) // 컴파일 에러 — Comparable 아님
}
T : Comparable<T>는 “T는 반드시 Comparable을 구현한 타입이어야 한다”는 제약이다. 덕분에 > 연산자(실제로는 compareTo)를 쓸 수 있게 된다.
where — 여러 제약 걸기
타입 파라미터에 두 개 이상의 제약이 필요하면 where를 쓴다.
interface Printable {
fun prettyPrint(): String
}
interface Loggable {
fun log()
}
class Document(val title: String) : Printable, Loggable {
override fun prettyPrint(): String = "[ $title ]"
override fun log() = println("LOG: $title")
}
fun <T> processItem(item: T) where T : Printable, T : Loggable {
println(item.prettyPrint())
item.log()
}
fun main() {
val doc = Document("Kotlin Generics")
processItem(doc)
// [ Kotlin Generics ]
// LOG: Kotlin Generics
}
where T : Printable, T : Loggable는 T가 두 인터페이스를 모두 구현해야 한다는 뜻이다. 하나의 타입 파라미터에 여러 조건을 걸 수 있어서, 교집합에 해당하는 기능만 쓸 수 있도록 강제한다.
공변성 — out
제네릭에서 가장 헷갈리는 부분이 변성(variance)이다. 핵심 질문은 이거다: Dog가 Animal의 하위 타입일 때, List<Dog>도 List<Animal>의 하위 타입인가?
open class Animal(val name: String)
class Dog(name: String) : Animal(name)
class Cat(name: String) : Animal(name)
직관적으로는 “당연히 그렇지 않나?”라고 생각하기 쉽다. 그런데 만약 List<Animal> 타입의 변수에 List<Dog>를 넣을 수 있게 허용하면, 거기에 Cat을 추가하는 게 가능해진다. 타입 안전성이 깨지는 것이다.
Kotlin은 이 문제를 out 키워드로 해결한다.
// out — 공변 (읽기만 가능)
interface Source<out T> {
fun nextItem(): T // T를 반환하는 건 OK
// fun addItem(item: T) // 컴파일 에러 — T를 받는 건 안 됨
}
class AnimalSource(private val animals: List<Animal>) : Source<Animal> {
private var index = 0
override fun nextItem(): Animal = animals[index++]
}
fun printNext(source: Source<Animal>) {
println(source.nextItem().name)
}
fun main() {
val dogs: Source<Dog> = object : Source<Dog> {
override fun nextItem() = Dog("바둑이")
}
printNext(dogs) // OK — Source<Dog>를 Source<Animal>으로 쓸 수 있음
}
out T는 “이 클래스는 T를 생산하기만 하고, 소비하지 않는다”는 선언이다. T를 반환값으로만 쓰고, 파라미터로는 받지 않겠다는 약속. 이 약속 덕분에 Source<Dog>를 Source<Animal> 자리에 안전하게 넣을 수 있다.
Kotlin의 List<E>가 out E로 선언되어 있기 때문에 List<Dog>를 List<Animal>처럼 쓸 수 있는 것도 이 원리다.
반공변성 — in
out의 반대가 in이다. “이 클래스는 T를 소비하기만 하고, 생산하지 않는다”는 선언이다.
// in — 반공변 (쓰기만 가능)
interface Sink<in T> {
fun send(item: T) // T를 받는 건 OK
// fun receive(): T // 컴파일 에러 — T를 반환하는 건 안 됨
}
fun feedAnimals(sink: Sink<Dog>) {
sink.send(Dog("바둑이"))
}
fun main() {
val animalSink: Sink<Animal> = object : Sink<Animal> {
override fun send(item: Animal) {
println("Received: ${item.name}")
}
}
feedAnimals(animalSink) // OK — Sink<Animal>을 Sink<Dog> 자리에 쓸 수 있음
}
Sink<Animal>은 모든 동물을 받을 수 있으니, 당연히 Dog도 받을 수 있다. 그래서 Sink<Animal>을 Sink<Dog>가 필요한 자리에 넣어도 안전하다. 방향이 out과 정확히 반대인 셈이다.
기억하기 쉽게 정리하면 이렇다.
| 키워드 | 방향 | 비유 | 대표 예시 |
|---|---|---|---|
out | 생산자 | 꺼내기만 | List<out E>, Source<out T> |
in | 소비자 | 넣기만 | Comparable<in T>, Sink<in T> |
Java의 ? extends T가 out T, ? super T가 in T에 대응된다. Kotlin 쪽이 읽기 편한 건 확실하다.
스타 프로젝션
타입 인자를 알 수 없거나 신경 쓰고 싶지 않을 때는 *를 쓴다.
fun printListSize(list: List<*>) {
println("리스트 크기: ${list.size}")
}
fun main() {
printListSize(listOf(1, 2, 3)) // 리스트 크기: 3
printListSize(listOf("a", "b")) // 리스트 크기: 2
printListSize(listOf(true, false)) // 리스트 크기: 2
}
List<*>는 “어떤 타입의 리스트인지 모르지만, 리스트인 건 맞다”는 의미다. 요소를 꺼내면 Any?로 나오고, 요소를 넣는 건 안전하지 않아서 불가능하다. Java의 List<?>와 같다고 보면 된다.
제네릭은 처음에 <T>만 쓰다가 변성 개념에서 벽을 만나는 경우가 많다. 하지만 핵심은 단순하다. out은 꺼내기만, in은 넣기만. 이 원칙만 잡으면 나머지는 자연스럽게 따라온다.
다음 편에서는 sealed class와 enum을 다룬다. 타입 시스템으로 상태를 표현하는 강력한 방법을 살펴보자.
Loading comments...