공부가 필요한 문법에 대해서 정리한 글입니다.
코틀린 코드를 보다 보면 함수나 클래스 이름 옆에 <T> 같은 문법이 붙어 있는 경우를 자주 보게 됩니다.
처음 보면 조금 낯설지만, 이것이 바로 제너릭(Generic) 입니다.
제너릭은 쉽게 말해 타입을 나중에 정할 수 있도록 만드는 기능입니다.
즉, 타입을 직접 고정하지 않고 타입도 하나의 매개변수처럼 다룰 수 있게 해주는 문법이라고 볼 수 있습니다.
이 기능을 사용하면 같은 코드를 여러 타입에 재사용할 수 있고,
잘못된 타입 사용도 컴파일 시점에 막을 수 있어서 코드의 안정성도 높아집니다.
제너릭을 처음 설명할 때는 비유로 이해하는 편이 훨씬 쉽습니다.
마트에 사과만 담을 수 있는 박스가 있다고 생각해보겠습니다.
class Apple
class AppleBox(val apple: Apple)
이 박스는 이름 그대로 사과만 담을 수 있습니다.
즉, 용도가 하나로 고정된 클래스입니다.
이번에는 마트 주인이 사과 박스를 다용도 박스로 바꾸었다고 생각해보겠습니다.
class Box<T>(val item: T)
여기서 T는 아직 정해지지 않은 타입입니다.
즉, Box는 사과 박스가 될 수도 있고, 바나나 박스가 될 수도 있고, 책 상자가 될 수도 있습니다.
class Apple
class Banana
class Book
fun main() {
val appleBox = Box(Apple())
val bananaBox = Box(Banana())
val bookBox = Box(Book())
}
같은 Box 클래스를 두고도, 안에 들어가는 타입만 바꿔서 다양하게 사용할 수 있습니다.
이것이 제너릭의 가장 기본적인 개념입니다.
제너릭의 가장 큰 장점은 타입 안정성(Type Safety) 입니다.
즉, 잘못된 타입 사용을 실행 전에 컴파일 단계에서 막아줍니다.
// 제너릭이 없는 경우
class AppleBox(val item: Any)
fun main() {
val appleBox = AppleBox("책")
val apple = appleBox.item as String
}
위 코드는 Any를 받기 때문에 사과 박스 안에 "책"도 들어갈 수 있습니다.
이런 방식은 타입이 너무 넓어서, 실제 사용할 때 형변환을 해야 하고 런타임 오류 가능성도 생깁니다.
반면 제너릭을 사용하면 타입이 더 명확해집니다.
class Box<T>(val item: T)
fun main() {
val appleBox = Box("사과")
val apple: String = appleBox.item
}
이 경우 item의 타입이 String으로 고정되기 때문에,
불필요한 형변환 없이 안전하게 값을 사용할 수 있습니다.
제너릭을 사용하면 같은 구조의 클래스를 여러 타입에 재사용할 수 있습니다.
class Box<T>(val item: T)
fun main() {
val appleBox = Box("사과")
val bananaBox = Box("바나나")
val bookBox = Box("책")
}
만약 제너릭이 없다면 AppleBox, BananaBox, BookBox처럼
비슷한 클래스를 여러 개 만들어야 할 수도 있습니다.
즉, 제너릭은 중복 코드를 줄이고,
하나의 구조를 다양한 타입에 재사용할 수 있게 해줍니다.
제너릭을 공부하다 보면 out, in 같은 키워드를 만나게 됩니다.
이 부분은 처음에는 헷갈릴 수 있지만, 핵심만 보면 생각보다 단순합니다.
기본적으로 코틀린의 제너릭은 무공변(invariant) 입니다.
즉, Box<Apple>과 Box<Fruit>는 서로 다른 타입으로 취급됩니다.
그런데 상황에 따라 읽기 전용, 쓰기 전용처럼 사용 목적이 분명한 경우에는
out, in을 통해 타입 관계를 더 유연하게 표현할 수 있습니다.
outout은 꺼내기 전용이라고 생각하면 이해하기 쉽습니다.
즉, 이 박스에서는 값을 안전하게 읽어 올 수만 있다는 뜻입니다.
open class Fruit
class Apple : Fruit()
class Banana : Fruit()
class Box<out T>(val item: T)
fun main() {
val appleBox: Box<Apple> = Box(Apple())
val fruitBox: Box<Fruit> = appleBox
println(fruitBox.item)
}
위 코드에서는 Box<Apple>을 Box<Fruit>로 대입할 수 있습니다.
왜냐하면 Apple은 Fruit의 하위 타입이고,
이 박스는 값을 꺼내기만 하기 때문에 타입 안전성이 깨지지 않기 때문입니다.
out이 붙은 타입 파라미터는 반환 타입처럼 값을 내보내는 위치에서는 사용할 수 있지만,
함수의 매개변수처럼 값을 받아들이는 위치에서는 사용할 수 없습니다.
fun getItem(): T // 가능
fun putItem(item: T) // 불가능
즉, out은 생산자(Producer) 역할이라고 생각하시면 됩니다.
inin은 반대로 넣기 전용이라고 생각하면 이해하기 쉽습니다.
즉, 이 박스는 값을 꺼내는 용도가 아니라 받아들이는 용도입니다.
open class Fruit
class Apple : Fruit()
class Banana : Fruit()
class Box<in T> {
fun put(item: T) {
println("$item 넣기")
}
}
fun main() {
val fruitBox: Box<Fruit> = Box()
val appleBox: Box<Apple> = fruitBox
appleBox.put(Apple())
}
여기서는 Box<Fruit>를 Box<Apple>처럼 사용할 수 있습니다.
왜냐하면 Fruit를 받을 수 있는 박스라면, Apple도 당연히 넣을 수 있기 때문입니다.
in이 붙은 타입 파라미터는 함수의 매개변수처럼 값을 받아들이는 위치에서는 사용할 수 있지만,
반환 타입처럼 값을 꺼내는 위치에서는 사용할 수 없습니다.
fun putItem(item: T) // 가능
fun getItem(): T // 불가능
즉, in은 소비자(Consumer) 역할이라고 볼 수 있습니다.
제너릭은 어떤 타입이든 받을 수 있지만,
필요하다면 특정 타입만 허용하도록 제한할 수도 있습니다.
예를 들어 과일만 받을 수 있도록 제한하고 싶다면 다음과 같이 작성할 수 있습니다.
open class Fruit
class Apple : Fruit()
class Banana : Fruit()
class Book
fun <T : Fruit> getFruit(item: T) {
println("과일: $item")
}
fun main() {
getFruit(Apple()) // 가능
getFruit(Banana()) // 가능
// getFruit(Book()) // 에러
}
<T : Fruit>는
“T는 Fruit 또는 Fruit의 하위 타입만 가능하다”는 뜻입니다.
즉, 제너릭을 쓰되 아무 타입이나 다 받는 것이 아니라
원하는 범위 안에서만 타입을 허용할 수 있습니다.
*제너릭 타입을 다룰 때, 타입을 정확히 알 수 없지만
일단 어떤 타입이든 들어 있는 컬렉션을 받아 처리하고 싶을 때가 있습니다.
이럴 때 사용하는 것이 스타 프로젝션(*) 입니다.
fun printList(list: List<*>) {
list.forEach { println(it) }
}
fun main() {
printList(listOf(1, "Hello", 3.5))
}
// 출력
// 1
// Hello
// 3.5
List<*>는
“원소 타입은 정확히 모르지만, 어쨌든 어떤 타입의 리스트다” 정도로 이해하면 됩니다.
즉, 읽어 와서 출력하는 것은 가능하지만
구체적인 타입이 무엇인지 알 수 없기 때문에 타입에 맞춘 안전한 쓰기는 제한됩니다.
제너릭에서 사용하는 T, E, K, V 같은 문자는 문법적으로 정해진 것은 아니지만,
관례적으로 아래처럼 많이 사용합니다.
| 타입 기호 | 의미 |
|---|---|
| T | Type |
| E | Element |
| K | Key |
| V | Value |
| N | Number |
예를 들어 K, V는 맵 구조를 설명할 때 자주 사용됩니다.
class PairBox<K, V>(val key: K, val value: V)
fun main() {
val pair = PairBox("이름", "Hood")
println(pair.key) // 이름
println(pair.value) // Hood
}
즉, 이 기호들은 특별한 기능을 갖는 것이 아니라
코드를 읽는 사람이 의미를 쉽게 이해할 수 있도록 돕는 이름이라고 보시면 됩니다.
이번 글에서는 Kotlin의 제너릭에 대해 정리해보았습니다.
제너릭은 타입을 고정하지 않고도 다양한 타입의 객체를 처리할 수 있게 해주는 기능입니다.
덕분에 하나의 클래스나 함수를 여러 타입에 재사용할 수 있고,
잘못된 타입 사용도 컴파일 단계에서 막을 수 있어 안정성도 높아집니다.
정리하면 제너릭은 다음과 같은 장점을 가집니다.
out, in으로 읽기/쓰기 방향을 더 명확하게 표현할 수 있습니다.처음에는 <T> 문법이 낯설게 느껴질 수 있지만,
컬렉션, 함수, 클래스에서 반복해서 보다 보면 제너릭이 왜 중요한지 자연스럽게 체감하게 됩니다.