Skip to content
ioob.dev
Go back

Kotlin 7편 — 제네릭

· 4분 읽기

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 : LoggableT가 두 인터페이스를 모두 구현해야 한다는 뜻이다. 하나의 타입 파라미터에 여러 조건을 걸 수 있어서, 교집합에 해당하는 기능만 쓸 수 있도록 강제한다.

공변성 — out

제네릭에서 가장 헷갈리는 부분이 변성(variance)이다. 핵심 질문은 이거다: DogAnimal의 하위 타입일 때, 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 Tout T, ? super Tin 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을 다룬다. 타입 시스템으로 상태를 표현하는 강력한 방법을 살펴보자.

8편: sealed class와 enum


Share this post on:

Comments

Loading comments...


Previous Post
Kotlin 8편 — sealed class와 enum
Next Post
Kotlin 6편 — Null 안전성 심화