kotlin

코틀린(Kotlin) 제네릭 (Generics) 완벽 가이드

임베디드 친구 2024. 12. 17. 08:57
반응형

안녕하세요, '소프트웨어 공장'에 오신 것을 환영합니다! 오늘은 Kotlin에서 굉장히 중요한 개념 중 하나인 제네릭(Generics) 에 대해 알아보겠습니다. 제네릭은 코드의 재사용성을 높이고, 타입 안정성을 유지하는 데 큰 역할을 합니다. 이 글에서는 제네릭이 무엇인지, 어떻게 사용하는지, 그리고 여러 가지 예제들을 통해 이해를 돕도록 하겠습니다.

제네릭이란 무엇인가요?

제네릭(Generics) 은 타입을 매개변수로 받아 코드의 중복을 줄이고, 타입 안전성을 높이는 기능입니다. 제네릭을 사용하면 다양한 타입을 처리할 수 있는 범용적인 함수를 작성할 수 있습니다. 예를 들어, 동일한 로직을 처리하는 리스트나 맵 같은 컬렉션 클래스가 다양한 타입을 가질 수 있는 이유는 바로 제네릭 덕분입니다.

Kotlin에서는 Java와 비슷하게 제네릭을 사용하며, 추가적인 편리함을 제공하는 기능들도 포함되어 있습니다.

다음은 제네릭을 사용하는 기본적인 예입니다:

class Box<T>(val value: T)

fun main() {
    val intBox = Box(123)
    val stringBox = Box("Hello Kotlin")

    println(intBox.value)  // 출력: 123
    println(stringBox.value)  // 출력: Hello Kotlin
}

위 예제에서 Box 클래스는 제네릭 타입 파라미터 T를 가지며, 이는 생성자에서 초기화할 때 어떤 타입이든 가질 수 있음을 의미합니다.

제네릭의 이점

제네릭은 다음과 같은 이점들을 제공합니다:

  1. 코드의 재사용성 증가: 같은 코드를 여러 타입에 대해 사용할 수 있습니다.
  2. 타입 안정성: 컴파일 단계에서 타입 체크가 이루어지므로 런타임 오류를 줄일 수 있습니다.
  3. 유연성: 제네릭을 사용하면 다양한 타입을 유연하게 처리할 수 있습니다.

제네릭 함수

제네릭을 클래스뿐만 아니라 함수에도 사용할 수 있습니다. 다음은 제네릭 함수를 정의하는 예입니다:

fun <T> printItem(item: T) {
    println(item)
}

fun main() {
    printItem(42)            // 출력: 42
    printItem("Hello World") // 출력: Hello World
}

위 코드에서 printItem 함수는 T라는 제네릭 타입을 사용하여 어떤 타입의 인자도 받을 수 있습니다. 덕분에 IntString을 넘겨도 잘 동작합니다.

제네릭 제약 (Constraints)

때로는 제네릭 타입이 특정한 상위 클래스를 상속받거나 인터페이스를 구현하도록 제한하고 싶을 때가 있습니다. 이를 제네릭 제약 (Constraints) 이라고 합니다.

fun <T : Number> addNumbers(a: T, b: T): Double {
    return a.toDouble() + b.toDouble()
}

fun main() {
    println(addNumbers(10, 20))          // 출력: 30.0
    println(addNumbers(1.5, 2.3))        // 출력: 3.8
    // println(addNumbers("Hello", "World")) // 컴파일 오류: String은 Number의 서브클래스가 아님
}

위 예제에서 addNumbers 함수는 TNumber를 상속받는 타입으로 제한하고 있습니다. 따라서 Int, Double 등의 숫자 타입은 사용 가능하지만, String과 같은 다른 타입은 사용할 수 없습니다.

제네릭 클래스의 활용

제네릭은 클래스에도 자주 사용됩니다. Kotlin의 표준 라이브러리인 List, Set, Map 등은 모두 제네릭 클래스를 기반으로 하고 있습니다.

다음은 제네릭 클래스를 사용하는 예입니다:

class Pair<K, V>(val key: K, val value: V)

fun main() {
    val pair = Pair("name", "Kotlin")
    println("Key: ${pair.key}, Value: ${pair.value}") // 출력: Key: name, Value: Kotlin

    val intPair = Pair(1, 100)
    println("Key: ${intPair.key}, Value: ${intPair.value}") // 출력: Key: 1, Value: 100
}

Pair 클래스는 두 개의 제네릭 타입 KV를 받아 키와 값을 관리합니다. 이렇게 하면 다양한 타입의 조합으로 Pair 객체를 생성할 수 있습니다.

공변성과 반공변성

Kotlin에서 제네릭 타입은 공변성(covariance)반공변성(contravariance) 을 통해 다룰 수 있습니다. 이를 통해 클래스 간의 상속 관계를 더 세밀하게 조절할 수 있습니다.

  • 공변성 (out 키워드): 공변성은 제네릭 타입이 특정 타입의 서브타입일 수 있도록 합니다. out 키워드는 제네릭 타입을 공변으로 만듭니다.
class Box<out T>(val value: T)

fun main() {
    val strBox: Box<String> = Box("Hello")
    val anyBox: Box<Any> = strBox  // 공변성이 있기 때문에 가능
    println(anyBox.value)           // 출력: Hello
}

위 코드에서 Box<out T>T가 공변임을 의미합니다. 즉, Box<String>Box<Any>의 서브타입이 될 수 있습니다.

  • 반공변성 (in 키워드): 반공변성은 제네릭 타입이 특정 타입의 슈퍼타입으로 동작할 수 있게 합니다. in 키워드는 제네릭 타입을 반공변으로 만듭니다.
class Printer<in T> {
    fun print(value: T) {
        println(value)
    }
}

fun main() {
    val stringPrinter: Printer<String> = Printer()
    val anyPrinter: Printer<Any> = stringPrinter  // 반공변성이 있기 때문에 가능
    anyPrinter.print(123)                         // 출력: 123
}

위 코드에서 Printer<in T>T가 반공변임을 의미합니다. 따라서 Printer<String>Printer<Any>로 할당될 수 있습니다.

스타 프로젝션 (Star Projection)

제네릭 타입을 특정하지 않고 사용하는 방법으로 스타 프로젝션(Star Projection) 이 있습니다. 이는 Java의 와일드카드(?)와 비슷한 개념입니다.

fun printList(list: List<*>) {
    for (item in list) {
        println(item)
    }
}

fun main() {
    val stringList: List<String> = listOf("A", "B", "C")
    val intList: List<Int> = listOf(1, 2, 3)

    printList(stringList)  // 출력: A B C
    printList(intList)     // 출력: 1 2 3
}

printList 함수는 리스트의 타입을 알 수 없을 때 List<*>를 사용하여 어떤 타입의 리스트든 처리할 수 있도록 합니다.

마무리

오늘은 Kotlin에서 중요한 개념인 제네릭(Generics) 에 대해 알아보았습니다. 제네릭을 사용하면 코드를 더욱 재사용성 있게 만들고, 타입 안정성을 유지하면서 유연하게 다양한 타입을 처리할 수 있습니다.

코틀린 제네릭은 자주 사용하는 개념이므로, 잘 익혀두면 실제 프로젝트에서도 큰 도움이 될 것입니다.

반응형