프로그래밍/Kotlin

함수 프로그래밍

시뻘건 튼튼발자 2024. 11. 30. 11:21
반응형

코틀린(Kotlin)은 함수형 프로그래밍(FP)을 지원한다. 코틀린에서는 함수가 중요한 구성 요소로, 이를 통해 간결하고 표현력 있는 코드를 작성할 수 있다. 일단 함수형 프로그래밍의 기본 개념인 부분 함수(partial function), 전 함수(pre-function), 커리한 함수(currying) 에 대해 설명하고, 이를 어떻게 활용할 수 있는지 알아보자.

 

1. 부분 함수(Partial Function)

부분 함수는 입력값에 대해 정의된 범위 내에서만 동작하는 함수다. 즉, 어떤 함수가 모든 가능한 입력에 대해 정의된 것이 아니라, 일부 입력에 대해서만 유효한 함수일 때 사용된다. 이 개념은 주로 예외 처리나 특정 조건에 맞는 함수를 정의할 때 유용하다.

예를 들어, 코틀린에서 map 함수는 null 값에 대해 작업을 하지 않도록 정의될 수 있다. 이때 특정 값에 대해서만 동작하는 부분 함수를 만들어 사용할 수 있다.

fun divide(a: Int, b: Int): Int {
    if (b == 0) throw IllegalArgumentException("b cannot be zero")
    return a / b
}

fun safeDivide(a: Int, b: Int): Int? {
    return if (b == 0) null else a / b
}

val result = safeDivide(10, 0) // 결과는 null

2. 전 함수(Pre-function)

전 함수는 함수의 인자가 특정 조건을 만족하는 경우에만 실행되도록 제한하는 함수다. 이는 조건을 사전에 체크하는 방식으로, 예를 들어 함수가 실행되기 전에 인자가 유효한지 미리 확인하는 방식이다.

코틀린에서 이를 구현할 때, 함수가 실행되기 전에 전처리 과정을 넣어 인자의 유효성을 검사하고, 조건을 만족하는 경우에만 실행하도록 할 수 있다.

fun isPositive(x: Int): Boolean {
    return x > 0
}

fun printPositiveNumber(x: Int) {
    if (isPositive(x)) {
        println("The number is positive: $x")
    } else {
        println("The number is not positive.")
    }
}

printPositiveNumber(5) // 출력: The number is positive: 5
printPositiveNumber(-5) // 출력: The number is not positive.

3. 커리한 함수(Currying)

커리한 함수는 하나의 함수가 여러 개의 인자를 받지 않고, 인자를 하나씩 받아서 결과를 반환하는 방식이다. 즉, 여러 인자를 받는 함수를 여러 단계로 나누어 하나씩 처리하는 방식이다. 코틀린에서도 커리한 함수를 손쉽게 사용할 수 있다.

커리 함수는 주로 부분 적용(Partial Application)과 결합되어, 함수의 인자 중 일부를 고정시킨 후 나머지 인자를 나중에 받을 수 있게 도와준다.

fun multiply(x: Int) = { y: Int -> x * y }

val multiplyByTwo = multiply(2) // x가 2로 고정된 함수
println(multiplyByTwo(5)) // 출력: 10

// ...

fun sum(x: Int) = { y: Int -> { z: Int -> x + y + z } }

val sumWith5 = sum(5) // x가 5로 고정된 함수
val sumWith5And10 = sumWith5(10) // y가 10으로 고정된 함수
println(sumWith5And10(20)) // 출력: 35

4. 객체 표기법과 함수 표기법 비교

코틀린에서는 함수가 객체처럼 취급된다. 이는 함수를 값처럼 다룰 수 있다는 점에서 중요할 수 있다. 코틀린에서 함수를 사용하는 방식은 크게 객체 표기법 함수 표기법 두 가지로 나눌 수 있다.

  • 객체 표기법은 fun 키워드로 정의된 함수를 객체처럼 변수에 할당하여 사용하는 방식이다.
  • 함수 표기법은 함수 이름을 직접 사용하는 방식이다.

객체 표기법을 사용하면 익명 함수(람다 함수와 유사)를 변수에 할당하고, 이를 다른 함수처럼 호출할 수 있다. 반면 함수 표기법은 정의된 함수를 이름으로 호출하는 전통적인 방식다.

// 객체 표기법
val sum = fun(x: Int, y: Int): Int {
    return x + y
}

println(sum(2, 3))  // 출력: 5

// 함수 표기법
fun multiply(x: Int, y: Int): Int {
    return x * y
}

println(multiply(2, 3))  // 출력: 6

5. 함수 값 사용하기

코틀린에서 함수는 1급 객체로, 변수에 할당하거나 다른 함수에 인자로 전달하는 등 값으로 사용할 수 있다. 이를 통해 고차 함수(higher-order function)를 사용할 수 있다.

// 함수 값 사용 예제
val add: (Int, Int) -> Int = { x, y -> x + y }

fun operate(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
    return operation(x, y)
}

val result = operate(4, 5, add)
println(result)  // 출력: 9

6. 함수 참조 사용하기

함수 참조는 이미 정의된 함수를 직접 참조하여 사용할 수 있는 방법이다. 함수 참조는 함수를 간결하게 전달하고자 할 때 유용합니다. "::" 연산자를 사용하여 함수 참조를 만든다.

아래 예제를 보면 "::subtract"를 사용하여 subtract 함수를 참조하고, 이를 calculate 함수에 전달하여 x와 y에 대해 차이를 계산다.

fun subtract(x: Int, y: Int): Int = x - y

fun calculate(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
    return operation(x, y)
}

val result = calculate(10, 5, ::subtract)
println(result)  // 출력: 5

4. 함수 합성

함수 합성은 여러 함수를 조합하여 새로운 함수를 만들어내는 기법이다. 코틀린에서는 이를 let, run, map, filter 등의 고차 함수로 쉽게 구현할 수 있다. 함수 합성은 여러 단계를 거쳐 결과를 만들 때 유용하다.

fun multiplyBy2(x: Int): Int = x * 2
fun add3(x: Int): Int = x + 3

fun composeFunctions(x: Int): Int {
    return add3(multiplyBy2(x))
}

println(composeFunctions(5))  // 출력: 13

5. 함수 재사용하기

함수 재사용은 이미 정의된 함수를 다양한 상황에서 반복적으로 사용하는 기법이다. 코틀린에서는 고차 함수와 함께 함수를 재사용하는 방식으로 코드를 더욱 간결하고 유연하게 만들 수 있다.

fun isEven(x: Int): Boolean = x % 2 == 0
fun isOdd(x: Int): Boolean = !isEven(x)

fun filterNumbers(numbers: List<Int>, condition: (Int) -> Boolean): List<Int> {
    return numbers.filter(condition)
}

val numbers = listOf(1, 2, 3, 4, 5, 6)
val evenNumbers = filterNumbers(numbers, ::isEven)
val oddNumbers = filterNumbers(numbers, ::isOdd)

println(evenNumbers)  // 출력: [2, 4, 6]
println(oddNumbers)   // 출력: [1, 3, 5]

코틀린은 다양한 고급 함수 기능을 제공한다. 함수형 프로그래밍의 고급 개념들은 코드의 간결성, 재사용성, 그리고 유지보수성을 높이는 데 중요한 역할을 한다.

 

6. 인자가 여럿 있는 함수 처리하기

여러 인자를 받는 함수는 vararg 키워드를 사용하여 가변 인자를 처리할 수 있다. 이를 통해 함수에 여러 개의 값을 유연하게 전달할 수 있다.

fun sum(vararg numbers: Int): Int {
    return numbers.sum()
}

println(sum(1, 2, 3, 4))  // 출력: 10

7. 고차 함수 구현하기

고차 함수(Higher-Order Function, HOF)는 함수가 다른 함수를 인자로 받거나, 함수를 반환하는 함수다. 코틀린에서는 고차 함수를 쉽게 정의할 수 있다.

fun <T> List<T>.customFilter(predicate: (T) -> Boolean): List<T> {
    val result = mutableListOf<T>()
    for (item in this) {
        if (predicate(item)) result.add(item)
    }
    return result
}

val numbers = listOf(1, 2, 3, 4, 5)
val evenNumbers = numbers.customFilter { it % 2 == 0 }
println(evenNumbers)  // 출력: [2, 4]

8. 다형적 HOF 정의하기

다형적 고차 함수는 여러 종류의 함수가 동일한 인터페이스를 구현하여 사용되는 고차 함수다. 코틀린에서는 제네릭과 함께 다형적 HOF를 정의할 수 있다.

fun <T> processData(data: T, processor: (T) -> String): String {
    return processor(data)
}

val processed = processData(100) { it.toString() }
println(processed)  // 출력: "100"

9. 익명 함수 사용하기

익명 함수는 이름이 없는 함수로, 보통 일회성으로 사용될 때 유용하다. 코틀린에서 익명 함수는 람다 표현식과 유사하지만, 더 복잡한 로직을 포함할 수 있다.

val multiply = fun(x: Int, y: Int): Int {
    return x * y
}

println(multiply(3, 4))  // 출력: 12

10. 로컬 함수 정의하기

로컬 함수는 함수 내에서만 유효한 함수를 정의하는 방식이다. 이를 통해 함수 내부에서만 사용되는 작은 로직을 따로 분리할 수 있다.

fun calculate(x: Int, y: Int): Int {
    fun add(a: Int, b: Int): Int = a + b
    return add(x, y)
}

println(calculate(5, 7))  // 출력: 12

11. 클로저 구현하기

클로저(Closure)는 함수가 선언될 때의 환경을 기억하여, 나중에 호출할 때 그 환경의 값을 참조하는 특성을 가지고 있다. 코틀린에서는 람다와 익명 함수가 클로저로 동작한다.

fun makeMultiplier(factor: Int): (Int) -> Int {
    return { number -> number * factor }
}

val multiplyBy3 = makeMultiplier(3)
println(multiplyBy3(4))  // 출력: 12

12. 함수 부분 적용과 자동 커링

부분 적용 함수는 고차 함수에서 인자 중 일부만 미리 제공하고, 나중에 나머지 인자를 받을 수 있도록 하는 기법이다. 코틀린에서는 자동 커링(커리된 함수)을 쉽게 활용할 수 있다.

fun power(base: Int): (Int) -> Int {
    return { exponent -> Math.pow(base.toDouble(), exponent.toDouble()).toInt() }
}

val square = power(2)  // 2의 제곱
println(square(3))  // 출력: 8

13. 부분 적용 함수의 인자 뒤바꾸기

함수의 인자를 뒤바꾸는 기능은 swap 처럼 인자의 순서를 변경하여 호출할 때 유용하다. swap은 코틀린에서 infix 함수와 Pair를 활용하여 인자를 뒤집는 방식으로 구현할 수 있다.

fun swap(x: Int, y: Int): Pair<Int, Int> {
    return Pair(y, x)
}

val (a, b) = swap(10, 20)
println("$a, $b")  // 출력: 20, 10

14. 항등 함수 정의하기

항등 함수(Identity Function)는 어떤 값도 변경하지 않고 그대로 반환하는 함수다. 이는 기본적인 함수형 프로그래밍의 예로, 종종 고차 함수에서 유용하게 사용된다.

fun <T> identity(x: T): T = x

println(identity(42))  // 출력: 42
println(identity("Hello"))  // 출력: Hello

15. 올바른 타입 사용하기

함수형 프로그래밍에서는 타입 안정성이 중요한 개념이다. 코틀린은 강타입 언어로, 타입을 명확하게 지정해야 한다. 함수의 인자와 반환 값의 타입을 올바르게 지정하여, 컴파일 타임에 타입 오류를 방지할 수 있다.

아래 예제에서는 함수의 인자와 반환 타입을 명시적으로 지정하여, 타입 오류가 발생하지 않도록 한다. 코틀린은 제네릭과 타입 추론을 잘 지원하므로, 올바른 타입 사용을 통해 코드의 안정성을 높일 수 있다.

fun sum(x: Int, y: Int): Int = x + y

val result: Int = sum(10, 20)  // 올바른 타입 사용
println(result)  // 출력: 30

함수형 프로그래밍에 대해서 많은 내용을 살펴보았는데 경우에 따라 딥하게 생각해보자.

 

클로저(Closure)를 사용하는 경우

클로저는 함수가 외부 변수를 캡처하고 그 값을 기억할 수 있는 특성을 갖는다. 클로저는 주로 상태를 기억하고 유지하는 데 유용하며, 구체적인 계산을 위해 외부 상태에 의존하는 함수에 적합하다.

클로저를 사용해야 할 경우는 아래와 같다.

  1. 외부 상태에 의존해야 할 때
    • 클로저는 외부 함수의 변수 값을 기억하고 이를 나중에 참조할 수 있다. 그래서 함수 내부에서 외부 상태를 바탕으로 동작할 때 유용하다.
    • 예를 들어, 특정 값(상태)을 고정시키고 그 값을 여러 번 사용할 때 클로저가 적합하다.
  2. 함수 내부에서 외부 변수를 캡처해야 할 때
    • 함수 외부에 정의된 값을 캡처하여 그 값을 나중에 사용해야 할 때 클로저가 필요하다. 클로저는 상태를 유지하며, 그 상태를 나중에 사용할 수 있도록한다.
  3. 상태 기반 함수 구현
    • 예를 들어, 카운터나 누적 계산 같은 상태를 계속 업데이트하며 결과를 반환해야 하는 경우, 클로저는 상태를 저장하고 변경할 수 있는 유용한 도구가 된다.
fun createCounter(): () -> Int {
    var count = 0  // 외부 변수
    return { count++ }  // count를 캡처하여 상태를 기억
}

val counter = createCounter()
println(counter())  // 출력: 0
println(counter())  // 출력: 1
println(counter())  // 출력: 2

커링(Currying)을 사용하는 경우

커링은 함수를 여러 개의 함수로 나누어 인자를 단계별로 받게 하는 기법이다. 커링은 주로 함수를 더 유연하고 재사용 가능하게 만들기 위해 사용된다. 각 함수가 인자를 하나씩 받기 때문에, 특정 인자를 고정한 후 나머지 인자를 나중에 받아 처리할 수 있다.

커링을 사용해야 할 경우는 아래와 같다.

  1. 함수의 재사용성을 높여야 할 때
    • 커링을 통해 함수의 인자를 하나씩 제공하면서 각 인자를 고정시킬 수 있다. 이를 통해 특정 값을 고정하고 나머지 인자에 대해서만 다른 값을 제공하는 방식으로, 동일한 함수를 여러 번 재사용할 수 있다.
  2. 부분적으로 인자를 고정해야 할 때
    • 커링은 함수의 일부 인자를 미리 고정한 후, 나머지 인자를 나중에 받을 수 있도록 한다. 예를 들어, multiply(x, y)에서 x는 고정하고 y만 변경하려는 경우 커링을 사용할 수 있다.
  3. 점진적인 함수 호출이 필요할 때
    • 다수의 인자를 한 번에 받는 대신, 함수 호출을 단계별로 나누어 인자를 받는 것이 필요한 경우에 커링을 사용한다. 이렇게 하면 각 인자를 점진적으로 처리할 수 있다.
fun multiply(x: Int): (Int) -> Int {
    return { y -> x * y }  // x 값을 고정한 새로운 함수 반환
}

val multiplyBy2 = multiply(2)  // 2를 고정한 함수 반환
println(multiplyBy2(5))  // 출력: 10
println(multiplyBy2(10))  // 출력: 20

 

익명 함수(Anonymous Function)를 사용해야 하는 경우

익명 함수는 이름이 없는 함수를 정의할 때 사용되며, 주로 일시적이고 간단한 작업을 처리할 때 유용하다. 익명 함수는 일시적인 기능이나 간단한 동작을 처리하는 데 적합하다.

익명 함수를 사용하는 경우는 아래와 같다.

  1. 일시적인 동작이 필요할 때
    • 함수가 한 번만 사용되거나 짧은 범위에서만 필요할 때, 익명 함수를 사용하면 불필요한 이름 정의 없이 간단하게 기능을 정의할 수 있다.
    • 예를 들어, map, filter, reduce와 같은 고차 함수에서 인라인으로 함수를 정의할 때 유용하다.
  2. 함수를 변수나 매개변수로 전달할 때
    • 고차 함수에 함수를 인자로 전달해야 할 때, 익명 함수는 매우 유용하다. 이때 함수를 별도로 정의할 필요 없이 바로 작성할 수 있다.
    • 예를 들어, 이벤트 핸들러나 콜백 함수를 정의할 때 사용된다.
  3. 코드의 가독성을 높이기 위해
    • 특정 기능을 수행하는 간단한 함수를 그 자리에서 정의할 수 있기 때문에, 코드의 가독성을 높일 수 있다. 복잡한 함수 정의를 피하고, 코드 내에서 바로 이해할 수 있는 작은 기능을 작성하는 데 유리하다.
  4. 짧고 간단한 동작을 정의할 때
    • 여러 번 사용되지 않을, 짧은 코드 블록을 작성할 때 익명 함수가 적합하다.
val numbers = listOf(1, 2, 3, 4)
val doubled = numbers.map { it * 2 }  // 익명 함수 사용
println(doubled)  // 출력: [2, 4, 6, 8]

이름이 있는 함수(Named Function)를 사용해야 하는 경우

이름이 있는 함수는 이름을 통해 함수를 재사용하거나, 더 복잡하고 명확한 의미를 가진 동작을 수행할 때 사용된다. 이름이 있는 함수는 일반적으로 여러 번 사용될 필요가 있는 동작이나 코드의 의미를 명확하게 표현하고자 할 때 유리하다.

이름이 있는 함수를 사용하는 경우는 아래와 같다.

  1. 복잡하거나 명확한 동작을 처리할 때
    • 복잡한 로직을 여러 번 사용하는 경우, 이름이 있는 함수를 정의하여 함수의 목적과 역할을 명확하게 할 수 있다. 이름을 통해 함수의 의도를 알 수 있으므로 코드의 가독성과 유지보수성이 높아진다.
  2. 함수가 여러 번 호출될 때
    • 동일한 동작을 여러 번 호출하거나 재사용할 필요가 있을 때는 이름이 있는 함수를 사용하는 것이 좋다. 함수의 재사용성을 높일 수 있기 때문이다.
  3. 디버깅 및 테스트가 필요할 때
    • 이름이 있는 함수는 디버깅이나 단위 테스트에서 유리하다. 함수의 이름을 통해 해당 함수의 동작을 추적할 수 있고, 그 함수에 대한 테스트를 독립적으로 작성할 수 있다.
  4. 함수의 역할이 명확할 때
    • 함수가 특정한 역할을 맡고 있고, 그 역할이 다른 사람에게 명확하게 전달되어야 할 때 이름을 주는 것이 중요하다. 함수 이름을 통해 코드의 의도를 분명히 전달할 수 있다.
  5. 성능 최적화가 필요할 때
    • 동일한 함수를 반복 호출할 경우, 이름이 있는 함수는 성능 최적화에 유리할 수 있다. 익명 함수는 매번 새로 생성되므로, 같은 로직을 반복 사용할 경우 이름이 있는 함수를 사용하는 것이 효율적일 수 있다.
fun doubleNumber(number: Int): Int {
    return number * 2
}

val numbers = listOf(1, 2, 3, 4)
val doubled = numbers.map { doubleNumber(it) }  // 이름이 있는 함수 사용
println(doubled)  // 출력: [2, 4, 6, 8]

 

표준 타입을 사용할 때 생기는 문제와 그 해결 방법

타입 시스템은 코드에서 데이터의 구조와 형태를 정의하는 중요한 개념이다. 특히 Kotlin과 같은 정적 타입 언어에서는 타입을 명확히 정의함으로써 코드의 안전성과 오류를 줄일 수 있다. 하지만 표준 타입(Standard Types)을 사용할 때는 몇 가지 문제점이 발생할 수 있으며, 이를 피하기 위한 방법도 존재한다.

표준 타입을 사용할 때 생기는 문제

  1. 타입 안전성 부족
    • Kotlin의 표준 타입 중에는 널(null) 값을 처리하는 방법에 있어 안전하지 않은 부분이 존재할 수 있습니다. 예를 들어, Any 타입이나 Object 타입을 사용하면, 실제로 어떤 타입이 올지 예측할 수 없기 때문에 타입 안전성이 떨어질 수 있다. Any 타입은 Kotlin에서 가장 일반적인 수퍼타입이지만, 그 자체로 특정 타입의 속성을 알 수 없기 때문에 의도하지 않은 오류가 발생할 수 있다.
  2. 타입 캐스팅의 위험
    • Any나 Object 타입을 사용하면 명시적인 타입 캐스팅이 자주 필요하게 된다. 이때 잘못된 캐스팅이 발생할 가능성이 있으며, 이는 ClassCastException 등의 런타임 오류로 이어질 수 있다. 타입을 잘못 캐스팅하면 예기치 않은 동작을 초래할 수 있다.
  3. 의도와 맞지 않는 값의 처리
    • Any 타입을 사용하면 값의 의미를 명확히 알 수 없다. 예를 들어, Any 타입의 변수에 다양한 종류의 데이터가 들어올 수 있는데, 이는 코드의 의도를 모호하게 만들고, 실수로 잘못된 데이터가 들어갈 수 있는 위험을 초래할 수 있다.
  4. 기본 컬렉션 타입의 부적합성
    • 표준 타입인 List, Set, Map 등의 컬렉션 타입을 사용할 때, 타입 안전성을 보장하지 않으면 문제가 발생할 수 있다. 예를 들어, List<Any>처럼 타입을 Any로 설정하면, 컬렉션 내부에 예상치 못한 타입의 요소가 들어갈 수 있기 때문에 코드의 안정성이 떨어질 수 있다.
val obj: Any = "Hello"
val length = obj.length  // 오류 발생: Any 타입에는 length가 정의되지 않음

// ...

val obj: Any = "Hello"
val num = obj as Int  // 오류 발생: 문자열을 정수로 캐스팅할 수 없음

// ...

fun processData(input: Any) {
    // 어떤 타입이 들어올지 알 수 없으므로 잘못된 데이터 처리가 발생할 수 있음
}

// ...

val list: List<Any> = listOf(1, "String", true)
val number = list[0] as Int  // 타입 안전하지 않음

 

문제를 피하는 방법

  1. 명확한 타입을 사용하기
    • 가능한 한 명확한 타입을 지정하여 타입 안전성을 확보하는 것이 중요하다. Any나 Object와 같은 포괄적인 타입보다는, 실제로 사용될 타입을 명확히 정의해야 한다. 이렇게 하면 컴파일 타임에 타입 체크가 이루어지므로 런타임 오류를 예방할 수 있다.
  2. null 처리에서의 안전성 확보하기
    • Kotlin은 널 안전성을 강조하는 언어다. 표준 타입인 Any를 사용할 때는 널 가능성(nullability)을 고려해야 하며, 이때 "?"를 사용해 null이 올 수 있는 타입을 명시하고, null 처리를 안전하게 할 수 있도록 해야 한다.
    • 안전 호출 연산자(?.)와 엘비스 연산자(?:)를 활용하여 null에 의한 오류를 방지할 수 있다.
  3. 타입에 대한 구체적인 제약을 추가하기
    • Any 타입 대신 구체적인 타입을 명시하거나, 제너릭 타입을 사용하여 컬렉션이나 함수의 타입을 강력히 제한할 수 있다. 예를 들어, List<Int>나 Map<String, Int>와 같이 사용하면 각 데이터의 타입을 명확히 지정하여 타입 오류를 방지할 수 있다.
  4. sealed class 또는 enum class 사용하기
    • 다양한 종류의 값을 처리해야 하는 경우, Any보다는 sealed class나 enum class와 같은 고정된 타입 집합을 사용하는 것이 좋다. 이를 통해 값의 범위를 미리 정의할 수 있고, 런타임에서 잘못된 값이 들어가는 문제를 예방할 수 있다.
  5. safe cast 사용하기
    • 캐스팅이 필요할 때, 안전한 캐스팅(safe cast)을 사용하여 null을 반환하도록 처리하면, 잘못된 캐스팅으로 인한 오류를 피할 수 있다. "as?" 연산자를 사용하면, 캐스팅이 실패할 경우 예외를 발생시키지 않고 null을 반환한다.
val numbers: List<Int> = listOf(1, 2, 3)
val firstNumber = numbers[0]  // 타입 안전하게 접근

// ...

val nullableString: String? = "Hello"
val length = nullableString?.length ?: 0  // null 안전하게 처리

// ...

fun processData(data: List<String>) {
    // data는 이제 String만 포함
}

val names = listOf("Alice", "Bob")
processData(names)  // 타입 안전성 확보

// ...

sealed class Result
data class Success(val data: String) : Result()
data class Error(val message: String) : Result()

fun handleResult(result: Result) {
    when (result) {
        is Success -> println("Success: ${result.data}")
        is Error -> println("Error: ${result.message}")
    }
}

// ...

val obj: Any = "Hello"
val str = obj as? String  // 안전한 캐스팅
println(str)  // null이 아니면 "Hello" 출력, 아니면 null 출력
반응형