제네릭이 필요한 이유
앞 장에서 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.valueBox 클래스의 코드는 하나지만, 사용하는 쪽에서는 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> = adminRepositoryAdmin은 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") // falseinline 함수는 호출 지점에 함수 본문이 복사되는 방식으로 컴파일된다. 그래서 호출할 때의 실제 타입 정보를 활용할 수 있다.
널 허용 값을 안전하게 변환하는 함수도 만들 수 있다.
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를 설계할 수 있다. 함수, 클래스, 컬렉션, 람다, 타입 시스템이 하나로 연결되기 시작하는 지점이 바로 제네릭이다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.