백고등어 개발 블로그
코틀린 강의 8강: 제네릭 본문
728x90
반응형
8강: 제네릭
제네릭이란?
제네릭(Generics)은 타입을 파라미터화하여 코드의 재사용성을 높이고 타입 안정성을 보장하는 기능입니다. 컬렉션에서 이미 제네릭을 사용해왔습니다: List<String>, Map<String, Int> 등.
제네릭 클래스
기본 제네릭 클래스
class Box<T>(val value: T) {
fun get(): T = value
}
fun main() {
val intBox = Box<Int>(10)
val stringBox = Box<String>("Kotlin")
println(intBox.get()) // 10
println(stringBox.get()) // Kotlin
// 타입 추론
val doubleBox = Box(3.14) // Box<Double>로 추론
println(doubleBox.get()) // 3.14
}
여러 타입 파라미터
class Pair<K, V>(val key: K, val value: V) {
fun printInfo() {
println("Key: $key, Value: $value")
}
}
fun main() {
val pair1 = Pair<String, Int>("Age", 25)
val pair2 = Pair("Name", "김코틀린") // 타입 추론
pair1.printInfo() // Key: Age, Value: 25
pair2.printInfo() // Key: Name, Value: 김코틀린
}
제네릭 함수
fun <T> printItem(item: T) {
println("Item: $item")
}
fun <T> createList(vararg items: T): List<T> {
return items.toList()
}
fun main() {
printItem(10) // Item: 10
printItem("Hello") // Item: Hello
val numbers = createList(1, 2, 3, 4, 5)
val words = createList("apple", "banana", "cherry")
println(numbers) // [1, 2, 3, 4, 5]
println(words) // [apple, banana, cherry]
}
제네릭 제약(Generic Constraints)
상한 제약(Upper Bound)
타입 파라미터가 특정 타입의 하위 타입이어야 할 때 사용합니다:
fun <T : Number> sumNumbers(a: T, b: T): Double {
return a.toDouble() + b.toDouble()
}
fun main() {
println(sumNumbers(10, 20)) // 30.0
println(sumNumbers(3.5, 2.5)) // 6.0
// println(sumNumbers("a", "b")) // 컴파일 오류!
}
Comparable 제약
정렬 가능한 타입만 받도록 제약:
fun <T : Comparable<T>> max(a: T, b: T): T {
return if (a > b) a else b
}
fun main() {
println(max(10, 20)) // 20
println(max("apple", "banana")) // banana
println(max(3.14, 2.71)) // 3.14
}
여러 제약 조건
where 절을 사용하여 여러 제약을 지정할 수 있습니다:
interface Printable {
fun print()
}
fun <T> processItem(item: T) where T : Comparable<T>, T : Printable {
item.print()
}
공변성과 반공변성
무공변(Invariant)
기본적으로 제네릭 타입은 무공변입니다:
class Container<T>(var value: T)
fun main() {
val stringContainer = Container("Hello")
// val anyContainer: Container<Any> = stringContainer // 오류!
}
공변성(Covariance) - out
out 키워드를 사용하면 하위 타입 관계를 유지합니다:
class Producer<out T>(val value: T) {
fun produce(): T = value
// fun consume(value: T) {} // 오류! out 위치에서는 소비 불가
}
fun main() {
val stringProducer: Producer<String> = Producer("Kotlin")
val anyProducer: Producer<Any> = stringProducer // OK!
println(anyProducer.produce()) // Kotlin
}
실제 예시:
fun printAll(items: List<Any>) {
for (item in items) {
println(item)
}
}
fun main() {
val strings = listOf("a", "b", "c")
printAll(strings) // OK! List<T>는 공변이므로 (List<out T>)
}
반공변성(Contravariance) - in
in 키워드를 사용하면 하위 타입 관계가 반대로 됩니다:
class Consumer<in T> {
fun consume(value: T) {
println("Consumed: $value")
}
// fun produce(): T // 오류! in 위치에서는 생산 불가
}
fun main() {
val anyConsumer = Consumer<Any>()
val stringConsumer: Consumer<String> = anyConsumer // OK!
stringConsumer.consume("Kotlin")
}
PECS 원칙
Producer Extends, Consumer Super:
- 생산자(Producer): out 사용 (공변성)
- 소비자(Consumer): in 사용 (반공변성)
fun <T> copy(from: List<out T>, to: MutableList<in T>) {
for (item in from) {
to.add(item)
}
}
fun main() {
val source: List<String> = listOf("a", "b", "c")
val destination: MutableList<Any> = mutableListOf()
copy(source, destination)
println(destination) // [a, b, c]
}
타입 프로젝션(Type Projection)
사용 지점에서 변성을 지정할 수 있습니다:
fun printAllAny(list: List<out Any>) {
for (item in list) {
println(item)
}
}
fun addAll(list: MutableList<in String>, items: List<String>) {
for (item in items) {
list.add(item)
}
}
스타 프로젝션(Star Projection)
타입을 알 수 없거나 중요하지 않을 때 *를 사용합니다:
fun printList(list: List<*>) {
for (item in list) {
println(item)
}
}
fun main() {
printList(listOf(1, 2, 3))
printList(listOf("a", "b", "c"))
printList(listOf(3.14, 2.71))
}
List<*>는 List<out Any?>와 동일합니다.
구체화된 타입 파라미터(Reified Type Parameters)
인라인 함수에서 reified 키워드를 사용하면 런타임에 타입 정보를 유지할 수 있습니다:
inline fun <reified T> isInstance(value: Any): Boolean {
return value is T
}
fun main() {
println(isInstance<String>("Hello")) // true
println(isInstance<String>(123)) // false
println(isInstance<Int>(123)) // true
}
실용적인 예제
inline fun <reified T> List<*>.filterIsInstance(): List<T> {
val result = mutableListOf<T>()
for (item in this) {
if (item is T) {
result.add(item)
}
}
return result
}
fun main() {
val mixed = listOf(1, "two", 3, "four", 5.0, "six")
val strings = mixed.filterIsInstance<String>()
val ints = mixed.filterIsInstance<Int>()
println(strings) // [two, four, six]
println(ints) // [1, 3]
}
실전 예제
제네릭 저장소 패턴
interface Repository<T> {
fun getAll(): List<T>
fun getById(id: Int): T?
fun save(item: T)
fun delete(id: Int)
}
class InMemoryRepository<T>(
private val idExtractor: (T) -> Int
) : Repository<T> {
private val items = mutableMapOf<Int, T>()
override fun getAll(): List<T> = items.values.toList()
override fun getById(id: Int): T? = items[id]
override fun save(item: T) {
val id = idExtractor(item)
items[id] = item
}
override fun delete(id: Int) {
items.remove(id)
}
}
data class User(val id: Int, val name: String)
data class Product(val id: Int, val name: String, val price: Int)
fun main() {
val userRepo = InMemoryRepository<User> { it.id }
userRepo.save(User(1, "김코틀린"))
userRepo.save(User(2, "이자바"))
println(userRepo.getAll())
println(userRepo.getById(1))
val productRepo = InMemoryRepository<Product> { it.id }
productRepo.save(Product(1, "노트북", 1500000))
productRepo.save(Product(2, "마우스", 30000))
println(productRepo.getAll())
}
제네릭 결과 타입
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String) : Result<Nothing>()
object Loading : Result<Nothing>()
}
fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> {
return when (this) {
is Result.Success -> Result.Success(transform(data))
is Result.Error -> this
is Result.Loading -> this
}
}
fun fetchUser(id: Int): Result<String> {
return if (id > 0) {
Result.Success("User-$id")
} else {
Result.Error("잘못된 ID")
}
}
fun main() {
val result = fetchUser(1)
.map { it.uppercase() }
when (result) {
is Result.Success -> println("성공: ${result.data}")
is Result.Error -> println("오류: ${result.message}")
is Result.Loading -> println("로딩 중...")
}
}
제네릭 사용 시 주의사항
- 타입 소거(Type Erasure): 런타임에 제네릭 타입 정보가 제거됩니다. reified를 사용하면 일부 보존 가능합니다.
- 배열과 제네릭: 배열은 공변이지만 제네릭 타입의 배열 생성에는 제약이 있습니다.
// val array = Array<T>(10) { ... } // 일반적으로 불가능
// 대신 List를 사용하거나 reified 사용
- null 가능성: 제네릭 타입은 기본적으로 nullable입니다.
fun <T> printItem(item: T) {
println(item?.toString()) // T는 null일 수 있음
}
fun <T : Any> printNonNull(item: T) {
println(item.toString()) // T는 null이 아님
}
마치며
이번 강의에서는 코틀린의 제네릭에 대해 알아보았습니다. 공변성(out)과 반공변성(in)의 개념, 그리고 reified 타입 파라미터를 활용한 실용적인 코드 작성 방법을 익혔습니다. 제네릭을 잘 활용하면 타입 안전하면서도 재사용 가능한 코드를 작성할 수 있습니다. 다음 강의에서는 코루틴에 대해 알아보겠습니다.
728x90
반응형
'코틀린 강의' 카테고리의 다른 글
| 코틀린 강의 10강: 실전 프로젝트와 모범 사례 (0) | 2025.10.28 |
|---|---|
| 코틀린 강의 9강: 코루틴 기초 (0) | 2025.10.28 |
| 코틀린 강의 7강: 예외 처리 (0) | 2025.10.28 |
| 코틀린 강의 6강: 컬렉션 (0) | 2025.10.28 |
| 코틀린 강의 5강: 클래스와 객체 (0) | 2025.10.28 |