2025년 1월 6일 작성
Kotlin Generics - Type을 Parameter로 받기
Generics는 type을 parameter로 받아 compile 시점에 type safety를 보장하면서 재사용 가능한 component를 작성합니다.
Generics
- Generics는 type을 parameter로 받아 class, interface, function을 정의합니다.
- compile 시점에 type을 검증하여 runtime error를 방지합니다.
- code 중복 없이 여러 type에서 동작하는 logic을 작성합니다.
- Java generics와 호환되며 추가 기능을 제공합니다.
// generic 없이 : 각 type마다 별도 class
class IntBox(val value: Int)
class StringBox(val value: String)
// generic 사용 : 하나의 class로 모든 type 지원
class Box<T>(val value: T)
val intBox: Box<Int> = Box(1)
val stringBox: Box<String> = Box("hello")
Generic Class
- generic class는 type parameter를 가진 class입니다.
<T>로 type parameter를 선언합니다.- 여러 개의 type parameter를 가질 수 있습니다.
class Box<T>(val value: T) {
fun get(): T = value
}
// 사용 시 type 지정
val box1: Box<Int> = Box(42)
val box2 = Box("hello") // type 추론 : Box<String>
println(box1.get()) // 42
println(box2.get()) // hello
여러 Type Parameter
- 여러 개의 type parameter를 comma로 구분하여 선언합니다.
class Pair<A, B>(val first: A, val second: B)
val pair = Pair("name", 25) // Pair<String, Int>
println(pair.first) // name
println(pair.second) // 25
class Triple<A, B, C>(val first: A, val second: B, val third: C)
Generic Interface
- generic interface도 class와 동일하게 type parameter를 선언합니다.
interface Repository<T> {
fun findById(id: Long): T?
fun save(entity: T): T
fun delete(entity: T)
}
class UserRepository : Repository<User> {
override fun findById(id: Long): User? = TODO()
override fun save(entity: User): User = TODO()
override fun delete(entity: User) = TODO()
}
Generic Function
- generic function은 함수 level에서 type parameter를 선언합니다.
- 함수 이름 앞에
<T>를 붙입니다. - class가 generic이 아니어도 함수만 generic일 수 있습니다.
- 함수 이름 앞에
fun <T> singletonList(item: T): List<T> = listOf(item)
val list1 = singletonList(1) // List<Int>
val list2 = singletonList("hello") // List<String>
// 여러 type parameter
fun <K, V> mapOf(key: K, value: V): Map<K, V> = mapOf(key to value)
// extension function에서 generic
fun <T> List<T>.secondOrNull(): T? = if (size >= 2) this[1] else null
listOf(1, 2, 3).secondOrNull() // 2
listOf("a").secondOrNull() // null
Type Constraint
- type constraint는 type parameter에 상한(upper bound)을 지정합니다.
- upper bound는 type parameter가 될 수 있는 type의 “최상위 범위”입니다.
T : Number로 지정하면Number와 그 subtype(Int,Double등)만 허용됩니다.- constraint를 통해 해당 type의 member에 접근합니다.
// T는 Comparable을 구현해야 함
fun <T : Comparable<T>> max(a: T, b: T): T = if (a > b) a else b
max(1, 2) // 2
max("a", "b") // b
// max(listOf(1), listOf(2)) // error : List는 Comparable 아님
where 절
- 여러 constraint를 지정할 때
where절을 사용합니다.
fun <T> ensureTrailingPeriod(seq: T): T
where T : CharSequence,
T : Appendable {
if (!seq.endsWith('.')) {
seq.append('.')
}
return seq
}
val sb = StringBuilder("Hello")
ensureTrailingPeriod(sb)
println(sb) // Hello.
Nullable Constraint
- type parameter는 기본적으로 nullable입니다.
- 기본 upper bound가
Any?이기 때문입니다. - non-null을 강제하려면
Any를 upper bound로 지정합니다.
- 기본 upper bound가
class Processor<T> {
fun process(value: T) {
value?.hashCode() // T가 nullable일 수 있으므로 safe call 필요
}
}
class NonNullProcessor<T : Any> {
fun process(value: T) {
value.hashCode() // T는 non-null 보장
}
}
Variance
- variance는 generic type 간의 subtype 관계를 정의합니다.
List<Dog>가List<Animal>의 subtype인지를 결정합니다.- Java의
? extends와? super에 해당합니다.
Invariance
- invariance(무공변)은 type parameter가 정확히 일치해야 합니다.
- Kotlin의 mutable collection은 invariant입니다.
open class Animal
class Dog : Animal()
// MutableList는 invariant
val dogs: MutableList<Dog> = mutableListOf(Dog())
// val animals: MutableList<Animal> = dogs // error
// 만약 허용된다면
// animals.add(Cat()) // Cat을 Dog list에 추가!
Covariance (out)
- covariance(공변)은 subtype 관계를 유지합니다.
outkeyword로 선언합니다.- type parameter를 반환(output)만 할 수 있습니다.
Producer<Dog>는Producer<Animal>의 subtype입니다.
interface Producer<out T> {
fun produce(): T
// fun consume(item: T) // error : out 위치에서 사용 불가
}
class DogProducer : Producer<Dog> {
override fun produce(): Dog = Dog()
}
val dogProducer: Producer<Dog> = DogProducer()
val animalProducer: Producer<Animal> = dogProducer // 허용
// List는 covariant (읽기 전용)
val dogs: List<Dog> = listOf(Dog())
val animals: List<Animal> = dogs // 허용
// immutable이므로 Cat 추가 불가 -> 안전
Contravariance (in)
- contravariance(반공변)은 subtype 관계를 역전합니다.
inkeyword로 선언합니다.- type parameter를 받기(input)만 할 수 있습니다.
Consumer<Animal>은Consumer<Dog>의 subtype입니다.
interface Consumer<in T> {
fun consume(item: T)
// fun produce(): T // error : in 위치에서 사용 불가
}
class AnimalConsumer : Consumer<Animal> {
override fun consume(item: Animal) {
println("Consuming ${item::class.simpleName}")
}
}
val animalConsumer: Consumer<Animal> = AnimalConsumer()
val dogConsumer: Consumer<Dog> = animalConsumer // 허용
// Animal을 처리할 수 있으면 Dog도 처리 가능
Variance 정리
| 구분 | Keyword | 사용 위치 | Subtype 관계 | 예시 |
|---|---|---|---|---|
| 공변 | out |
반환만 | 유지 | Producer<out T> |
| 반공변 | in |
받기만 | 역전 | Consumer<in T> |
| 무공변 | 없음 | 모두 | 없음 | MutableList<T> |
flowchart TD
subgraph Covariance["Covariance (out)"]
direction TB
Dog1[Dog] --> Animal1[Animal]
PD[Producer of Dog] --> PA[Producer of Animal]
end
subgraph Contravariance["Contravariance (in)"]
direction TB
Dog2[Dog] --> Animal2[Animal]
CA[Consumer of Animal] --> CD[Consumer of Dog]
end
Use-Site Variance
- declaration-site variance는 class 선언 시 지정합니다.
- use-site variance는 사용 시점에 지정합니다.
- Java의 wildcard(
? extends,? super)와 유사합니다.
- Java의 wildcard(
// MutableList는 invariant로 선언됨
// 하지만 사용 시점에 variance 지정 가능
fun copy(from: MutableList<out Animal>, to: MutableList<Animal>) {
for (animal in from) {
to.add(animal)
}
}
val dogs: MutableList<Dog> = mutableListOf(Dog())
val animals: MutableList<Animal> = mutableListOf()
copy(dogs, animals) // dogs는 out Animal로 projection
// in projection
fun fill(list: MutableList<in Dog>, dog: Dog) {
list.add(dog)
}
val animals: MutableList<Animal> = mutableListOf()
fill(animals, Dog()) // Animal list에 Dog 추가 가능
Star Projection
- star projection(
*)은 type argument를 모를 때 사용합니다.- Java의 raw type이나 unbounded wildcard(
?)에 해당합니다. - 안전하게 읽기만 가능합니다.
- Java의 raw type이나 unbounded wildcard(
fun printAll(list: List<*>) {
for (item in list) {
println(item) // Any?로 취급
}
}
printAll(listOf(1, 2, 3))
printAll(listOf("a", "b"))
// MutableList<*>는 MutableList<out Any?>로 취급
fun readOnly(list: MutableList<*>) {
val first: Any? = list[0] // 읽기 가능
// list.add("x") // error : 쓰기 불가
}
Star Projection 규칙
| 선언 | Star Projection 의미 |
|---|---|
Foo<out T> |
Foo<*> = Foo<out Any?> |
Foo<in T> |
Foo<*> = Foo<in Nothing> |
Foo<T> |
읽기 : out Any?, 쓰기 : in Nothing |
Type Erasure
- JVM에서 generic type 정보는 compile 후 지워집니다.
- runtime에
List<String>과List<Int>를 구분할 수 없습니다. is List<String>같은 type check는 불가능합니다.
- runtime에
val list = listOf("a", "b", "c")
// if (list is List<String>) { } // error : Cannot check for erased type
// raw type check는 가능
if (list is List<*>) {
println("It's a List")
}
Type Erasure 우회
- class에 type 정보를 저장하여 우회합니다.
abstract class TypedList<T>(private val clazz: Class<T>) {
fun isOfType(item: Any): Boolean = clazz.isInstance(item)
}
class StringList : TypedList<String>(String::class.java)
val stringList = StringList()
println(stringList.isOfType("hello")) // true
println(stringList.isOfType(123)) // false
Reified Type Parameter
reified는 inline 함수에서 runtime에 type 정보를 유지합니다.- type erasure를 우회합니다.
- type check, casting, class 참조가 가능합니다.
inline fun <reified T> isType(value: Any): Boolean = value is T
isType<String>("hello") // true
isType<Int>("hello") // false
isType<List<*>>(listOf(1, 2)) // true
reified 활용
reified를 활용하면 type filtering, class 참조, JSON parsing 등을 간결하게 작성합니다.
// type으로 filtering
inline fun <reified T> List<*>.filterIsInstance2(): List<T> =
filter { it is T }.map { it as T }
val mixed = listOf(1, "a", 2, "b", 3)
val strings: List<String> = mixed.filterIsInstance2() // [a, b]
val ints: List<Int> = mixed.filterIsInstance2() // [1, 2, 3]
// class 참조
inline fun <reified T> printClassName() {
println(T::class.simpleName)
}
printClassName<String>() // String
printClassName<Int>() // Int
// JSON parsing
inline fun <reified T> Gson.fromJson(json: String): T =
fromJson(json, T::class.java)
val user: User = gson.fromJson("""{"name": "Kim"}""")
reified 제약
- inline 함수에서만 사용 가능합니다.
- class나 property에서는 사용 불가합니다.
inline fun <reified T> works() { }
// class Foo<reified T> // error
// val <reified T> prop: T // error
실전 예제
- 실무에서 generics는 type에 독립적인 공통 logic을 정의할 때 유용합니다.
- 여러 type에서 동일한 구조나 동작을 재사용합니다.
- type safety를 유지하면서 추상화 수준을 높입니다.
Generic Repository
- generic repository는 entity type에 독립적인 CRUD 연산을 정의합니다.
interface Repository<T, ID> {
fun findById(id: ID): T?
fun findAll(): List<T>
fun save(entity: T): T
fun delete(entity: T)
}
abstract class InMemoryRepository<T, ID> : Repository<T, ID> {
protected val store = mutableMapOf<ID, T>()
abstract fun getId(entity: T): ID
override fun findById(id: ID): T? = store[id]
override fun findAll(): List<T> = store.values.toList()
override fun save(entity: T): T {
store[getId(entity)] = entity
return entity
}
override fun delete(entity: T) {
store.remove(getId(entity))
}
}
data class User(val id: Long, val name: String)
class UserRepository : InMemoryRepository<User, Long>() {
override fun getId(entity: User): Long = entity.id
}
Result Type
- Result type은 성공 또는 실패를 표현하는 generic sealed class입니다.
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: Throwable) : Result<Nothing>()
fun <R> map(transform: (T) -> R): Result<R> = when (this) {
is Success -> Success(transform(data))
is Failure -> this
}
fun getOrNull(): T? = when (this) {
is Success -> data
is Failure -> null
}
fun getOrElse(default: @UnsafeVariance T): T = when (this) {
is Success -> data
is Failure -> default
}
}
fun fetchUser(id: Long): Result<User> =
try {
Result.Success(api.getUser(id))
} catch (e: Exception) {
Result.Failure(e)
}
val result = fetchUser(1)
.map { it.name.uppercase() }
.getOrElse("Unknown")
Reference
- https://kotlinlang.org/docs/generics.html
- https://kotlinlang.org/docs/inline-functions.html#reified-type-parameters