Skip to main content
Summary

제네릭이 필요한 이유

앞 장에서 List<String>, Map<String, Int> 같은 타입을 사용했다. 여기서 꺾쇠괄호 안의 String, Int가 바로 제네릭 타입 인자다.

제네릭은 타입을 나중에 정하는 문법이다. 로직은 같지만 다루는 타입만 다를 때, 같은 코드를 여러 번 만들지 않도록 도와준다.

예를 들어 문자열 목록의 첫 번째 값을 꺼내는 함수와 숫자 목록의 첫 번째 값을 꺼내는 함수는 거의 같다.

fun firstString(items: List<String>): String? {
    return items.firstOrNull()
}

fun firstInt(items: List<Int>): Int? {
    return items.firstOrNull()
}

타입만 다를 뿐 동작은 같다. 이때 제네릭을 쓰면 하나의 함수로 표현할 수 있다.

fun <T> first(items: List<T>): T? {
    return items.firstOrNull()
}

T는 타입 파라미터다. 함수를 호출하는 시점에 실제 타입으로 바뀐다.

val name = first(listOf("Kotlin", "Java")) // String?
val score = first(listOf(90, 80, 70))       // Int?

제네릭의 핵심은 단순하다.

타입은 유연하게 열어 두고, 잘못된 타입 사용은 컴파일 시점에 막는다.

즉, 제네릭은 재사용성과 타입 안전성을 함께 얻는 방법이다.

제네릭과 타입 시스템 깊게 이해하기

제네릭 클래스 만들기

제네릭은 함수뿐 아니라 클래스에도 사용할 수 있다.

class Box<T>(val value: T)

Box<T>는 어떤 타입의 값이든 담을 수 있다.

val stringBox = Box("hello")
val intBox = Box(100)

val text: String = stringBox.value
val number: Int = intBox.value

Box 클래스의 코드는 하나지만, 사용하는 쪽에서는 Box<String>, Box<Int>처럼 구체적인 타입을 가진 것처럼 동작한다.

조금 더 실용적인 예를 보자.

class Stack<T> {
    private val items = mutableListOf<T>()

    fun push(item: T) {
        items.add(item)
    }

    fun pop(): T? {
        return items.removeLastOrNull()
    }

    fun isEmpty(): Boolean {
        return items.isEmpty()
    }
}

이 스택은 문자열에도, 숫자에도, 직접 만든 객체에도 사용할 수 있다.

val names = Stack<String>()
names.push("Kim")
names.push("Lee")

val lastName: String? = names.pop()

val scores = Stack<Int>()
scores.push(10)
scores.push(20)

val lastScore: Int? = scores.pop()

중요한 점은 Stack<String>에는 문자열만 넣을 수 있다는 것이다.

val names = Stack<String>()
names.push("Kotlin")
// names.push(10) // 컴파일 오류

제네릭을 사용하면 내부 구현은 재사용하면서, 외부에서는 타입을 정확하게 지킬 수 있다.

타입 파라미터 이름 읽기

타입 파라미터 이름은 보통 한 글자로 쓴다. 정해진 규칙은 아니지만 관례가 있다.

  • T: Type, 일반적인 타입

  • E: Element, 컬렉션의 요소 타입

  • K: Key, 맵의 키 타입

  • V: Value, 맵의 값 타입

  • R: Return, 반환 타입

예를 들어 Map<K, V>는 키 타입과 값 타입을 따로 받는다.

val ages: Map<String, Int> = mapOf(
    "Kim" to 30,
    "Lee" to 25
)

여기서 K는 String, V는 Int가 된다.

타입 파라미터 이름은 길게 써도 된다.

class Response<Body>(val body: Body)

다만 대부분의 코틀린 코드에서는 T, E, K, V, R처럼 짧은 이름을 많이 사용한다. 중요한 것은 이름이 아니라 그 타입이 어떤 역할을 하는지다.

제네릭 함수 만들기

제네릭 함수는 함수 이름 앞에 타입 파라미터를 선언한다.

fun <T> last(items: List<T>): T? {
    return items.lastOrNull()
}

호출할 때 타입을 직접 적을 수도 있다.

val number = last<Int>(listOf(1, 2, 3))

하지만 대부분은 타입 추론이 가능하므로 생략한다.

val number = last(listOf(1, 2, 3))

타입 파라미터를 여러 개 사용할 수도 있다.

fun <T, R> convert(value: T, transform: (T) -> R): R {
    return transform(value)
}

사용 예는 다음과 같다.

val length: Int = convert("Kotlin") { text ->
    text.length
}

val label: String = convert(100) { number ->
    "score=$number"
}

여기서 T는 입력 타입, R은 결과 타입이다. 람다와 제네릭을 함께 사용하면 컬렉션의 map, filter, groupBy 같은 함수처럼 유연한 API를 만들 수 있다.

확장 함수에도 제네릭을 사용할 수 있다.

fun <T> List<T>.secondOrNull(): T? {
    return if (size >= 2) this[1] else null
}
val names = listOf("Kim", "Lee", "Park")
val secondName: String? = names.secondOrNull()

val numbers = listOf(10, 20, 30)
val secondNumber: Int? = numbers.secondOrNull()

타입은 다르지만 "두 번째 요소를 가져온다"는 로직은 같다. 이런 경우 제네릭이 가장 자연스럽다.

제네릭과 널 가능성

코틀린의 타입 파라미터는 기본적으로 널을 허용할 수 있다.

fun <T> echo(value: T): T {
    return value
}

이 함수에는 null도 전달할 수 있다.

val result = echo(null)

타입 파라미터 T의 기본 상한은 Any?로 볼 수 있다. 즉, T에는 널이 가능한 타입도 들어올 수 있다.

널을 허용하고 싶지 않다면 T : Any로 제한한다.

fun <T : Any> requireValue(value: T): T {
    return value
}

이제 null은 전달할 수 없다.

requireValue("Kotlin")
// requireValue(null) // 컴파일 오류

T : Any는 "이 타입은 널이 될 수 없다"는 뜻이다. 제네릭 함수를 만들 때 널을 허용할지 막을지 명확히 정해야 한다.

타입 파라미터 제한하기

제네릭은 어떤 타입이든 받을 수 있다. 하지만 모든 타입에 같은 기능이 있는 것은 아니다.

예를 들어 숫자 타입만 받아서 Double로 변환하고 싶다고 하자.

fun <T> doubleValue(value: T): Double {
    // return value.toDouble() // 컴파일 오류
    return 0.0
}

T가 어떤 타입인지 모르기 때문에 toDouble()을 호출할 수 없다. String, User, File 같은 타입이 들어올 수도 있기 때문이다.

이럴 때 타입 파라미터에 제한을 건다.

fun <T : Number> doubleValue(value: T): Double {
    return value.toDouble()
}

T : Number는 "T는 Number이거나 Number의 하위 타입이어야 한다"는 뜻이다.

val a = doubleValue(10)
val b = doubleValue(3.14)

// doubleValue("10") // 컴파일 오류

이제 함수 안에서는 Number가 제공하는 기능을 안전하게 사용할 수 있다.

비교 가능한 타입만 받도록 제한할 수도 있다.

fun <T : Comparable<T>> maxOfTwo(a: T, b: T): T {
    return if (a >= b) a else b
}
val maxNumber = maxOfTwo(10, 20)
val maxName = maxOfTwo("Kim", "Lee")

Comparable<T>는 두 값을 비교할 수 있는 타입이라는 뜻이다. 그래서 >= 연산을 사용할 수 있다.

여러 타입 제한 걸기

하나의 타입 파라미터에 여러 조건이 필요할 때는 where를 사용한다.

fun <T> pickBetter(a: T, b: T): T
where T : CharSequence,
      T : Comparable<T> {
    return when {
        a.length != b.length -> if (a.length > b.length) a else b
        a >= b -> a
        else -> b
    }
}

이 함수는 두 가지 조건을 요구한다.

  • CharSequence: length를 사용할 수 있어야 한다.

  • Comparable<T>: 서로 비교할 수 있어야 한다.

따라서 함수 안에서 length와 비교 연산을 모두 안전하게 사용할 수 있다.

val selected = pickBetter("Kotlin", "Java")

타입 제한의 목적은 강제하는 데 있지 않다. 함수가 실제로 필요한 기능을 타입으로 표현하는 데 있다.

좋은 타입 제한은 코드를 읽는 사람에게도 말해 준다.

이 함수는 아무 타입이나 받는 것이 아니라, 이 기능을 가진 타입만 받는다.

불변성부터 이해하기

제네릭의 변성을 이해하려면 먼저 "불변성"을 알아야 한다.

다음 클래스를 보자.

open class Animal(val name: String)
class Cat(name: String) : Animal(name)
class Dog(name: String) : Animal(name)

Cat은 Animal의 하위 타입이다.

val cat: Cat = Cat("Navi")
val animal: Animal = cat

이것은 자연스럽다. 고양이는 동물이기 때문이다.

그렇다면 MutableList<Cat>은 MutableList<Animal>의 하위 타입일까?

val cats: MutableList<Cat> = mutableListOf(Cat("Navi"))
// val animals: MutableList<Animal> = cats // 컴파일 오류

코틀린은 이것을 허용하지 않는다. 이유는 안전하지 않기 때문이다.

만약 허용된다면 이런 일이 가능해진다.

fun addDog(animals: MutableList<Animal>) {
    animals.add(Dog("Bori"))
}

val cats: MutableList<Cat> = mutableListOf(Cat("Navi"))
// addDog(cats) // 허용된다면 고양이 목록에 개가 들어간다

MutableList<Cat>에 Dog가 들어가면 타입 안전성이 깨진다. 그래서 코틀린의 제네릭 타입은 기본적으로 불변이다.

불변이라는 말은 다음을 뜻한다.

Cat이 Animal의 하위 타입이어도, MutableList<Cat>은 MutableList<Animal>의 하위 타입이 아니다.

이 규칙은 처음에는 불편해 보이지만, 잘못된 값을 넣는 문제를 막아 준다.

out: 값을 생산하는 타입의 공변성

어떤 제네릭 타입이 값을 "꺼내 주기만" 한다면 더 유연하게 다룰 수 있다.

interface Producer<out T> {
    fun produce(): T
}

out T는 이 타입이 T를 밖으로 내보낸다는 뜻이다. 즉, T를 반환값으로 사용할 수 있다.

class CatProducer : Producer<Cat> {
    override fun produce(): Cat {
        return Cat("Navi")
    }
}

Producer<Cat>은 Producer<Animal>처럼 사용할 수 있다.

val catProducer: Producer<Cat> = CatProducer()
val animalProducer: Producer<Animal> = catProducer

val animal: Animal = animalProducer.produce()

고양이를 만들어 내는 생산자는 동물을 만들어 내는 생산자로 보아도 안전하다. 고양이는 동물이기 때문이다.

이런 관계를 공변성이라고 한다.

Cat이 Animal의 하위 타입일 때, Producer<Cat>도 Producer<Animal>의 하위 타입이 되도록 허용하는 것.

코틀린에서는 타입 파라미터 앞에 out을 붙여 공변성을 선언한다.

interface ReadOnlyRepository<out T> {
    fun findAll(): List<T>
    fun findFirst(): T?
}

읽기 전용 저장소는 데이터를 밖으로 꺼내 주기만 한다. 그래서 out T가 어울린다.

open class User(val name: String)
class Admin(name: String) : User(name)

class MemoryRepository<T : Any>(
    private val items: List<T>
) : ReadOnlyRepository<T> {
    override fun findAll(): List<T> = items
    override fun findFirst(): T? = items.firstOrNull()
}

val adminRepository: ReadOnlyRepository<Admin> =
    MemoryRepository(listOf(Admin("Kim")))

val userRepository: ReadOnlyRepository<User> = adminRepository

Admin은 User의 하위 타입이다. 읽기만 하는 저장소라면 ReadOnlyRepository<Admin>을 ReadOnlyRepository<User>로 다루어도 안전하다.

단, out T로 선언한 타입은 보통 T를 함수의 파라미터로 받을 수 없다.

interface BadProducer<out T> {
    fun produce(): T
    // fun consume(value: T) // 컴파일 오류
}

out은 "밖으로 내보내는 타입"에 적합하다. 대표적인 예가 읽기 전용 컬렉션인 List<out E>다.

in: 값을 소비하는 타입의 반공변성

반대로 어떤 제네릭 타입이 값을 "받아서 사용하기만" 한다면 in을 사용할 수 있다.

interface Consumer<in T> {
    fun consume(value: T)
}

in T는 이 타입이 T를 안으로 받아들인다는 뜻이다.

class AnimalPrinter : Consumer<Animal> {
    override fun consume(value: Animal) {
        println(value.name)
    }
}

동물을 출력할 수 있는 소비자는 고양이도 출력할 수 있다.

val animalConsumer: Consumer<Animal> = AnimalPrinter()
val catConsumer: Consumer<Cat> = animalConsumer

catConsumer.consume(Cat("Navi"))

처음에는 방향이 헷갈릴 수 있다.

Consumer<Animal>을 Consumer<Cat>으로 사용할 수 있다. 왜냐하면 Animal을 받을 수 있는 객체는 당연히 Cat도 받을 수 있기 때문이다.

이런 관계를 반공변성이라고 한다.

Cat이 Animal의 하위 타입일 때, Consumer<Animal>을 Consumer<Cat>처럼 사용할 수 있게 하는 것.

실제 코틀린에서 자주 만나는 예는 Comparator<in T>다.

val animalComparator: Comparator<Animal> =
    Comparator { a, b -> a.name.compareTo(b.name) }

val catComparator: Comparator<Cat> = animalComparator

동물 이름을 비교할 수 있는 비교기는 고양이 이름도 비교할 수 있다. 그래서 Comparator는 소비자 성격을 가진다.

in T로 선언한 타입은 보통 T를 반환값으로 내보낼 수 없다.

interface BadConsumer<in T> {
    fun consume(value: T)
    // fun produce(): T // 컴파일 오류
}

정리하면 다음과 같다.

  • out: 값을 꺼내는 생산자에 적합하다.

  • in: 값을 넣는 소비자에 적합하다.

  • 읽고 쓰기를 모두 해야 한다면 보통 불변으로 둔다.

짧게 기억하려면 이렇게 보면 된다.

생산자는 out, 소비자는 in.

선언 지점 변성과 사용 지점 변성

앞에서 본 Producer<out T>와 Consumer<in T>는 클래스나 인터페이스를 선언할 때 변성을 정했다. 이것을 선언 지점 변성이라고 한다.

interface Producer<out T>
interface Consumer<in T>

하지만 어떤 클래스는 기본적으로 불변이어야 한다. 대표적으로 MutableList<T>는 읽기와 쓰기를 모두 하기 때문에 불변이다.

그런데 특정 함수 안에서는 "읽기만 할 것" 또는 "쓰기만 할 것"이 분명할 때가 있다. 이때는 사용하는 지점에서 out과 in을 붙일 수 있다.

fun copyNumbers(
    from: MutableList<out Number>,
    to: MutableList<in Number>
) {
    for (number in from) {
        to.add(number)
    }
}

from은 숫자를 읽기만 한다. 그래서 MutableList<out Number>로 받는다.

val ints = mutableListOf(1, 2, 3)
val numbers = mutableListOf<Number>()

copyNumbers(ints, numbers)

MutableList<Int>는 원래 MutableList<Number>가 아니다. 하지만 from에서 값을 읽기만 한다면 Int 목록을 Number를 생산하는 목록처럼 볼 수 있다.

반대로 to는 숫자를 넣기만 한다. 그래서 MutableList<in Number>로 받는다.

val objects = mutableListOf<Any>()
copyNumbers(ints, objects)

Any 목록에는 Number를 넣을 수 있다. 따라서 목적지로 사용할 수 있다.

다만 제한이 생긴다.

  • MutableList<out Number>에는 값을 추가할 수 없다.

  • MutableList<in Number>에서 꺼낸 값은 구체적인 Number라고 확신할 수 없다.

이 제한 덕분에 타입 안전성이 유지된다.

스타 프로젝션으로 알 수 없는 타입 다루기

가끔 타입 인자가 무엇인지 모르는 컬렉션을 다뤄야 할 때가 있다.

예를 들어 어떤 값이 리스트인지만 확인하고, 그 안의 요소 타입은 중요하지 않은 경우다.

이때 List<*>를 사용할 수 있다.

fun printListInfo(items: List<*>) {
    println("size=${items.size}")

    val first: Any? = items.firstOrNull()
    println("first=$first")
}

List<*>는 "어떤 타입의 리스트인지는 모르지만 리스트는 맞다"는 뜻이다.

printListInfo(listOf("A", "B"))
printListInfo(listOf(1, 2, 3))

요소를 꺼낼 수는 있다. 하지만 타입을 모르기 때문에 꺼낸 값은 Any?로 다뤄야 한다.

val items: List<*> = listOf("Kotlin", "Java")
val first: Any? = items.firstOrNull()

쓰기 가능한 컬렉션에서는 더 조심해야 한다.

fun clearItems(items: MutableList<*>) {
    items.clear()
    // items.add("new") // 컴파일 오류
}

MutableList<*>에는 어떤 타입의 값이 들어 있는지 알 수 없다. MutableList<String>일 수도 있고, MutableList<Int>일 수도 있다. 그래서 새로운 값을 추가할 수 없다.

스타 프로젝션은 아무 값이나 자유롭게 넣겠다는 뜻이 아니다.

타입을 모르기 때문에 안전한 작업만 하겠다는 뜻이다.

런타임 타입 검사에서도 자주 사용한다.

fun describe(value: Any) {
    when (value) {
        is List<*> -> println("list size=${value.size}")
        is Map<*, *> -> println("map size=${value.size}")
        else -> println("unknown")
    }
}

List<String>인지 List<Int>인지는 정확히 검사하기 어렵지만, 리스트인지 아닌지는 확인할 수 있다. 그래서 is List<*> 형태를 사용한다.

타입 소거와 런타임 타입 정보

코틀린은 주로 JVM 위에서 실행된다. JVM의 제네릭은 런타임에 타입 인자 정보가 대부분 지워진다. 이것을 타입 소거라고 한다.

예를 들어 컴파일 시점에는 List<String>과 List<Int>가 구분된다.

val names: List<String> = listOf("Kim", "Lee")
val scores: List<Int> = listOf(10, 20)

하지만 런타임에는 둘 다 대체로 List로만 보인다. 그래서 다음과 같은 검사는 할 수 없다.

fun check(value: Any) {
    // if (value is List<String>) { } // 컴파일 오류
}

대신 다음처럼 검사한다.

fun check(value: Any) {
    if (value is List<*>) {
        println("list")
    }
}

제네릭 타입 파라미터도 마찬가지다.

fun <T> isType(value: Any): Boolean {
    // return value is T // 컴파일 오류
    return false
}

일반 제네릭 함수의 T는 런타임에 확인할 수 없다. 이 문제를 해결하는 방법 중 하나가 reified 타입 파라미터다.

reified 타입 파라미터

reified는 타입 파라미터를 런타임에도 사용할 수 있게 해 주는 기능이다. 단, inline 함수에서만 사용할 수 있다.

inline fun <reified T> isType(value: Any): Boolean {
    return value is T
}

사용 예는 다음과 같다.

val a = isType<String>("Kotlin") // true
val b = isType<Int>("Kotlin")    // false

inline 함수는 호출 지점에 함수 본문이 복사되는 방식으로 컴파일된다. 그래서 호출할 때의 실제 타입 정보를 활용할 수 있다.

널 허용 값을 안전하게 변환하는 함수도 만들 수 있다.

inline fun <reified T> Any?.castOrNull(): T? {
    return this as? T
}
val value: Any = "Kotlin"
val text: String? = value.castOrNull<String>()
val number: Int? = value.castOrNull<Int>()

text에는 문자열이 들어가고, number에는 null이 들어간다.

컬렉션에서 특정 타입만 골라내는 함수도 만들 수 있다.

inline fun <reified T> Iterable<*>.onlyType(): List<T> {
    val result = mutableListOf<T>()

    for (item in this) {
        if (item is T) {
            result.add(item)
        }
    }

    return result
}
val mixed = listOf(1, "two", 3, "four", true)

val numbers: List<Int> = mixed.onlyType<Int>()
val texts: List<String> = mixed.onlyType<String>()

코틀린 표준 라이브러리의 filterIsInstance<T>()도 이와 비슷한 방식으로 사용할 수 있다.

val strings = mixed.filterIsInstance<String>()

reified는 편리하지만 모든 것을 해결하지는 않는다. 예를 들어 List<String>의 모든 요소가 정말 문자열인지까지 완벽하게 보장하는 용도로 사용해서는 안 된다. 런타임에서는 중첩된 제네릭 타입 정보가 지워질 수 있기 때문이다.

따라서 reified는 다음과 같은 경우에 적합하다.

  • value is T처럼 타입 검사가 필요할 때

  • value as? T처럼 안전한 타입 변환이 필요할 때

  • T::class처럼 타입의 클래스 정보가 필요할 때

reified를 활용한 작은 등록소 예제

타입별로 값을 저장하고 꺼내는 간단한 등록소를 만들어 보자.

import kotlin.reflect.KClass

class Registry {
    private val items = mutableMapOf<KClass<*>, Any>()

    fun <T : Any> put(type: KClass<T>, value: T) {
        items[type] = value
    }

    fun getRaw(type: KClass<*>): Any? {
        return items[type]
    }
}

일반 함수만 사용하면 값을 넣거나 꺼낼 때 타입 정보를 직접 전달해야 한다.

val registry = Registry()
registry.put(String::class, "Kotlin")

val raw: Any? = registry.getRaw(String::class)

reified 확장 함수를 만들면 호출 코드가 훨씬 간결해진다.

inline fun <reified T : Any> Registry.put(value: T) {
    put(T::class, value)
}

inline fun <reified T : Any> Registry.get(): T? {
    return getRaw(T::class) as? T
}

이제 타입을 직접 넘기지 않아도 된다.

val registry = Registry()

registry.put("Kotlin")
registry.put(100)

val language: String? = registry.get<String>()
val count: Int? = registry.get<Int>()

이 예제는 작지만 중요한 패턴을 보여 준다.

  • 내부 저장은 KClass<*>와 Any로 유연하게 한다.

  • 외부 API는 제네릭과 reified로 타입 안전하게 만든다.

라이브러리 수준의 API는 이런 방식으로 내부의 복잡함을 숨기고, 사용하는 쪽에는 간결하고 안전한 인터페이스를 제공한다.

제네릭 API를 설계할 때의 기준

제네릭을 잘 쓰려면 문법보다 설계 기준이 중요하다.

첫째, 타입이 달라도 로직이 같을 때 제네릭을 사용한다.

fun <T> List<T>.firstOrDefault(default: T): T {
    return firstOrNull() ?: default
}

문자열 목록에도, 숫자 목록에도 같은 로직을 적용할 수 있다.

둘째, 특정 기능이 필요하면 타입 제한을 건다.

fun <T : Number> sumAsDouble(values: List<T>): Double {
    return values.sumOf { it.toDouble() }
}

toDouble()이 필요하므로 Number로 제한했다. 제한 없이 T를 받고 내부에서 억지로 캐스팅하는 것보다 안전하다.

셋째, 읽기만 하면 out을 고려한다.

interface Source<out T> {
    fun get(): T
}

넷째, 쓰기만 하면 in을 고려한다.

interface Sink<in T> {
    fun send(value: T)
}

다섯째, 타입을 모를 때는 *를 사용하되 안전한 작업만 한다.

fun printAnyList(items: List<*>) {
    items.forEach { println(it) }
}

여섯째, 런타임 타입 검사가 필요할 때만 reified를 사용한다.

inline fun <reified T> Any?.isInstanceOf(): Boolean {
    return this is T
}

제네릭은 많이 쓴다고 좋은 코드가 되는 문법이 아니다. 타입 관계를 코드로 명확히 표현할 때 좋은 코드가 된다.

흔한 실수 정리

제네릭을 처음 배울 때 자주 하는 실수가 있다.

타입 파라미터를 선언하지 않고 사용하기

// fun first(items: List<T>): T? { ... } // T가 선언되지 않아 오류

타입 파라미터는 함수 이름 앞에 선언해야 한다.

fun <T> first(items: List<T>): T? {
    return items.firstOrNull()
}

무조건 Any로 처리하기

fun first(items: List<Any>): Any? {
    return items.firstOrNull()
}

이렇게 만들면 반환 타입이 항상 Any?가 된다. 호출하는 쪽에서 다시 캐스팅해야 한다.

val name = first(listOf("Kim", "Lee"))
// name은 String?이 아니라 Any?

제네릭을 사용하면 원래 타입을 유지할 수 있다.

fun <T> first(items: List<T>): T? {
    return items.firstOrNull()
}

MutableList의 타입 관계를 착각하기

val cats: MutableList<Cat> = mutableListOf(Cat("Navi"))
// val animals: MutableList<Animal> = cats // 오류

읽기와 쓰기를 모두 하는 타입은 함부로 변성을 허용하면 위험하다. 읽기만 하는지, 쓰기만 하는지에 따라 out과 in을 사용해야 한다.

reified를 일반 함수에서 사용하려 하기

// fun <reified T> check(value: Any): Boolean = value is T // 오류

reified는 반드시 inline 함수에서만 사용할 수 있다.

inline fun <reified T> check(value: Any): Boolean {
    return value is T
}

마무리

제네릭은 타입을 유연하게 열어 두면서도 타입 안전성을 지키는 도구다.

이 장에서 배운 내용을 정리하면 다음과 같다.

  • 제네릭 클래스와 제네릭 함수로 타입에 독립적인 로직을 만들 수 있다.

  • T : Number, T : Comparable<T> 같은 타입 제한으로 필요한 기능을 가진 타입만 받을 수 있다.

  • 제네릭 타입은 기본적으로 불변이다.

  • out은 값을 생산하는 타입에, in은 값을 소비하는 타입에 사용한다.

  • List<*> 같은 스타 프로젝션은 알 수 없는 타입을 안전하게 다룰 때 사용한다.

  • inline과 reified를 함께 쓰면 런타임 타입 검사를 활용할 수 있다.

제네릭을 이해하면 단순히 컬렉션을 사용하는 수준을 넘어 직접 유연한 API를 설계할 수 있다. 함수, 클래스, 컬렉션, 람다, 타입 시스템이 하나로 연결되기 시작하는 지점이 바로 제네릭이다.

책 목록

처음 배우는 코틀린: 문법에서 실전 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기