Kotlin

[Kotlin] enum & when & smart cast(스마트 캐스트)

sh1mj1 2023. 12. 27. 22:46

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처   https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

enum 클래스

`enum` 은  soft keyword 라고 한다.

`class` 앞에 붙을 때는 특별한 의미를 가지지만, 그렇지 않을 경우에는 일반 이름으로 사용이 가능하다.

 

반면에 `class` 는 그냥 keyword 이다.

그래서 클래스를 표현하는 변수 등을 정의할 때는 이름을 `class` 로 하지 못한다. 보통 `clazz` 또는 `mClass` 라고 정의한다.

enum class Color(
    val r: Int, val g: Int, val b: Int // 상수의 프로퍼티를 정의
) {
    RED(255, 0, 0), // 각 상수를 생성할 때 그에 대한 프로퍼티 값을 지정
    ORANGE(255, 156, 0),
    YELLOW(255, 255, 0),
    GREEN(0, 255, 0),
    BLUE(0, 0, 255),
    INDIGO(75, 0, 130),
    VIOLET(238, 130, 238); // 메서드를 선언하려면 여기서 반드시 세미콜론을 사용해야 함 

    fun rgb() = (r * 256 + g) * 256 + b // enum 클래스 안에서 메서드를 정의
}

`enum` 은 열거형(enumeration) 이라는 단어에서 따왔다.

하지만 단순히 값만 열거하기만 하는 존재는 아니다.

위처럼 `enum` 클래스 안에도 프로퍼티나 메서드를 정의할 수 있다. 

enum 클래스 내부에 메서드를 선언하려면 반드시 마지막 값 다음에 `;` 을 붙여주어야 한다.

when 과 enum 클래스 다루기

우리는 enum 으로 선언해 둔 `Color` 상수와 같을 때 그 상수에 대응하는 문자열을 리턴하는 함수를 만들 수 있다.

fun getMnemonic(color: Color) = when (color) {
    Color.RED -> "Richard"
    Color.ORANGE -> "Of"
    Color.YELLOW -> "York"
    Color.GREEN -> "Gaven"
    Color.BLUE -> "Battle"
    Color.INDIGO -> "In"
    Color.VIOLET -> "Vain"
}

`when` 은 `if` 처럼 값을 만들어내는 식이므로 expression body 로 `when` 을 바로 사용할 수 있다.

색이 특정 enum 상수와 같을 때  그 상수에 대응하는 문자열을 돌려주고 있다.

`Color` 가 가진 상수는 정해져 있으므로, 해당 상수를 모두 명시해 준다면, `else` 분기를 작성하지 않아도 된다.

또한 자바와 달리 각 분기의 끝에 `break` 를 넣지 않아도 된다. 

 

한 when 분기 안에 여러 값을 사용할 수도 있다.

fun getWarmth(color: Color) = when (color) {
    Color.RED, Color.ORANGE, Color.YELLOW -> "warm"
    Color.GREEN -> "neutral"
    Color.BLUE, Color.INDIGO, Color.VIOLET -> "cold"
}

 

만약 enum 상수 값을 `*` 를 통해 전부 import 하면 enum 클래스 수식자 없이 enum 을 사용할 수 있다.

import inaction.chap2.Color.*

fun getWarmth(color: Color) = when (color) {
    RED, ORANGE, YELLOW -> "warm"
    GREEN -> "neutral"
    BLUE, INDIGO, VIOLET -> "cold"
}

when 과 임의 객체를 함께 사용하기

자바의 `switch` 는 분기 조건에 `enum` 상수 혹은 숫자 리터럴만 사용가능하다.

하지만 코틀린의 `when` 에서는 분기 조건에 임의의 객체를 사용할 수 있다.

자바보다 훨씬 강력한 기능인 것이다.

fun mix(c1: Color, c2: Color) = when (setOf(c1, c2)) {
    setOf(RED, YELLOW) -> ORANGE
    setOf(YELLOW, BLUE) -> GREEN
    setOf(BLUE, VIOLET) -> INDIGO
    else -> throw Exception("Dirty color")
}

`when` 식의 인자로 `Set<Color>` 가 들어가는 것을 볼 수 있다.

`when` 은 인자로 받은 객체가 각 분기 조건에 있는 객체와 같은지 검사한다.

`setOf(c1, c2)` 와 분기 조건에 있는 객체 사이를 매치할 때는 equility(동등성) 을 사용하여 비교한다. (동등성 관련 설명 포함 글)

모든 분기 식에서 만족하는 조건을 찾을 수 없다면 `else` 분기의 문장을 실행한다.

when 의 대상을 변수에 포획하기

코틀린 1.3 부터는 `when` 의 대상을 변수에 대입할 수 있다.

fun Request.getBody() = when(val response = executeRequest()){
    is Success -> response.body
    is HttpError -> throw HttpException(response.status)
}

 

위처럼 `when` 식에서만 사용되는 변수가 있다면, when 의 괄호 안에서 변수를 선언하고 대입하여 when 바깥이 더렵혀지는 것을 방지할 수 있다.

인자 없는 when 사용하기

위에서 사용한 `mix` 함수는 함수 호출 때마다 여러 `Set` 인스턴스를 생성한다.

이는 비효율적이다. 불필요한 가비지 객체가 늘어나기 때문이다.

인자 없는 `when` 식을 사용하면 불필요한 객체 생성을 막을 수 있다.

물론 코드는 약간 읽기 어려워질 수 있지만, 성능 향상을 위한 가독성이 떨어지는 문제 감수는 자주 일어나는 일이다.

(거꾸로 가독성을 위한 성능이 떨어지는 문제 감수도 자주 일어난다.)

fun mixOptimized(c1: Color, c2: Color) = when {
    (c1 == RED && c2 == YELLOW) ||
            (c1 == YELLOW && c2 == RED) -> ORANGE

    (c1 == YELLOW && c2 == BLUE) ||
            (c1 == BLUE && c2 == YELLOW) -> GREEN

    (c1 == BLUE && c2 == VIOLET) ||
            (c1 == VIOLET && c2 == BLUE) -> INDIGO

    else -> throw Exception("Dirty color")
}

`when` 에 아무 인자도 없으려면 각 분기의 조건이 `boolean` 값을 리턴하는 식이어야 한다.

`mixOptimized` 는 추가 객체를 만들지 않지만 가독성은 더 떨어진다.

스마트 캐스트: 타입 검사 & 타입 캐스트

코틀린의 스마트 캐스팅을 알아보기 위해 예제로 `(1 + 2) + 4` 와 같은 간단한 덧셈 산술식을 계산하는 함수를 만들 것이다.

 

식은 트리 구조로 저장하며 노드는 `Sum`(합계)나 `Num`(수) 중 하나이다. 

`Num` 은 항상 leaf 노드이고 `Sum` 은 자식 노드를 2개 가진 노드이다. 그 자식 노드는 덧셈의 두 인자이다.

 

아무 메서드도 없고, 단지 식 객체라는 것을 명시해주는 공통 타입 역할의 `Expr`  인터페이스를 만들고,

`Num` 과 `Sum` 이 이를 구현하도록 만들자.

interface Expr 
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr

 

 

이제 `(1 + 2) + 4` 라는 식을 저장하려면 `Sum( Sum( Num(1), Num(2) ), Num(4) )` 구조의 객체를 만들어야 한다.

이 식의 값을 계산하는 `evaluate` 라는 함수를 만들자.

`Expr` 인터페이스에는 두 가지 구현 클래스가 있으므로 두 경우를 고려해야 한다.

  • 어떤 식이 `Num` 이면, 그 값을 리턴한다.
  • 어떤 식이 `Sum` 이면, 좌항과 우항의 값을 계산한 후에 그 두 값을 합한 값을 리턴한다.

이를 자바 스타일과 `if` 문을 사용하여 구현해보자.

fun evaluate(e: Expr): Int {
    if (e is Num) {
        val n = e as Num // Num 으로 형 변환하는데 이는 불필요한 중복(아래에서 설명)
        return n.value
    }
    if (e is Sum) {
        return evaluate(e.right) + evaluate(e.left) // 변수 e 에 대해서 스마트 캐스트 사용
    }
    throw IllegalArgumentException("Unknown expression")
}

`Expr` 타입의 `e` 의 타입을 검사해서 결과 정수를 리턴해주는 함수이다.

 

코틀린의 `is` 는 자바의 `instanceOf` 와 비슷하다. 하지만 다른 점이 있다.

자바에서는 `instanceOf` 로 타입을 확인한 후에,

그 타입의 멤버에 접근하기 위해서는 명시적으로 변수 타입을 캐스팅해야 한다.

하지만 코틀린에서는 `is` 로 타입 확인을 하면 컴파일러가 캐스팅을 해준다.

이를 smart cast 라고 한다.

 

위 `evaluate` 함수를 다시 보자.

`if (e is Num)` 으로 조건 검사한 후에 그 바디에서는 `val n = e as Num` 코드를 작성할 필요가 없다.

코틀린 컴파일러가 해당 바디 안에서는 `e` 를 `Num` 타입으로 알아서 캐스팅해주기 때문이다.

아래 `if (e is Sum)` 으로 조건 검사한 경우에는 `as` 를 사용하지 않고 바로 `right` 나 `left` 라는 속성을 사용하고 있다. 이것도 `e` 를 컴파일러가 `Sum` 으로 캐스팅해주기 때문에 가능한 것이다.

 

즉, 아래처럼 사용이 가능하다.

fun evaluate(e: Expr): Int {
    if (e is Num) {
        return e.value
    }
    if (e is Sum) {
        return evaluate(e.right) + evaluate(e.left)
    }
    throw IllegalArgumentException("Unknown expression")
}

불변의 경우에만 스마트 캐스트 가능?

Kotlin In Action 책에서 스마트 캐스트는 `is` 로 변수에 든 값의 타입을 검사한 후,  그 값이 바뀔 수 없는 경우에만 작동한다고 한다.

즉, 아래의 경우에는 오류를 뱉어야 한다는 것이다.

class Some(var num: Expr, val value: Int){
    init {
        if (num is Num) {
            num.value // [Compile Error] Smart cast to 'Num' is impossible, because 'num' is a mutable property that could have been changed by this time
        }
    }
}

컴파일 에러가 발생한다.

프로퍼티 num 이 `var`로 선언되어 있고 이는 가변이기 때문에 타입 검사를 하더라도,

값이 바뀔 수 있으므로 num 이 Num 타입인지 확신할 수 없다.

 

만약 클래스의 프로퍼티에 대해 스마트 캐스트를 사용한다면,

그 프로퍼티는 반드시 `val` 이어야 하며 커스텀 접근자를 사용한 것이면 안된다.

`val` 이 아니거나 `val` 이지만 커스텀 접근자를 사용하는 경우에는,

해당 프로퍼티가 항상 같은 값을 내놓는다고 확신할 수 없기 때문이다.

 

아래의 경우도 오류를 뱉어야 한다.

fun evaluateCannotSmartCast(e: Expr): Int {
    var e2 = e
    if (e2 is Num) return e2.value
    if (e2 is Sum) return evaluateCannotSmartCast(e2.left) + evaluateCannotSmartCast(e2.right)
    return throw IllegalArgumentException("Unknown expression")
}

왜냐하면 `var` 로 선언된 `e2` 는 가변이기 때문에 타입 검사를 하더라도,

값이 바뀔 수 있으므로 `e2` 가 항상 같은 값을 내놓는다고 확신할 수 없기 때문이다.

 

하지만 내가 직접 코드를 작성했을 때는 위 `evaluteCannotSmartCast` 가

컴파일 오류를 발생시키지 않았을 뿐만 아니라 테스트 코드까지 제대로 동작했다.

 

나는 kotlin 1.9 버전을 사용했다.

이 책이 쓰여질 때의 코틀린 버전에서는 책에서 설명하는 대로 코틀린 컴파일러가 동작한 모양이다.

즉, 코틀린과 코틀린 컴파일러의 버전이 계속 발전하면서 컴파일러가 더 영리해진 것으로 보인다.

 

관련해서 최신 (2024.01.04) 코틀린 문서를 찾아보니 아래와 같이 설명하고 있다.

스마트 캐스트는 컴파일러가 변수의 타입 검사와 사용 사이에 변하지 않는다는 것을 보장할 때만 동작한다.

 

결론적으로 코틀린 1.9 버전에서 스마트 캐스트는 아래의 경우 사용 가능하다.

`val 로컬 변수`     항상 스마트 캐스트 가능 (local delegated properties 의 경우는 제외)
`val 프로퍼티` 만약 프로퍼티가 `private`, `internal`이거나 프로퍼티가 선언된 동일한 모듈에서 검사가 될  때는 스마트 캐스트 가능
`open` 프로퍼티나 커스텀 getter 가 있는 프로퍼티에서는 스마트 캐스트 사용 불가.
`var 로컬 변수` 타입 검사와 사용 사이에 변수가 수정되거나,
이를 수정하는 람다에서 캡처되거나,
local delegated properties 이면 스마트 캐스트 사용불가 .
`var 프로퍼티` 항상 불가능하다. 
변수가 다른 코드에 의해 언제든지 수정될 수 있기 때문이다.

위 네가지 경우를 간단히 확인해보자. 참고로 `delegated properties` 의 경우는 다루지 않을 것이다.

val 로컬 변수인 경우

스마트 캐스트가 항상 가능하다.

fun smartCastValLocal() {
    val x: Any = "I am a String"
    if (x is String) {
        println(x.length) // 스마트 캐스트 가능
    }
}

val 프로퍼티인 경우 

`val` 속성은 `private`, `internal` 이거나 같은 모듈에서 체크되는 경우에 스마트 캐스트가 가능하다

class SmartCastValProperty {
    private val data: Any = "I am a String"

    fun printData() {
        if (data is String) {
            println(data.length) // private, internal OR 같은 모듈에서 체크 -> 스마트 캐스트 가능
        }
    }
}

open 이거나 커스텀 getter 가 있는 프로퍼티는 스마트 캐스트가 불가능하다.

open class SmartCastOpenOrCustomGetterProperty(open val openData: Any = "Hello, Kotlin"){
    val data: Any
        get() = "Hello, Kotlin"
    
    fun printData(){
        if(openData is String){
            // open 프로퍼티: 'openData'가 스마트 캐스트되지 않기 때문에 오류 발생
            println(openData.length) // 오류: 'openData'가 'String'으로 자동 캐스트되지 않음
        }
        if (data is String) {
            // 커스텀 게터가 있는 프로퍼티: 'data'가 스마트 캐스트되지 않기 때문에 오류 발생
            println(data.length) // 오류: 'data'가 'String'으로 자동 캐스트되지 않음
        }
    }
}

var 로컬 변수인 경우

`var` 지역 변수는 해당 변수가 변경되지 않는 경우에 한해 스마트 캐스트가 가능하다.

fun smartCastVarLocal() {
    var x: Any = "I am a String"
    if (x is String) {
        println(x.length) // x가 타입 검사와 사용 사이에서 변경되지 않았을 때 스마트 캐스트 가능
    }
}

var 프로퍼티인 경우

스마트 캐스트가 항상 불가능하다. 

`var` 속성은 다른 코드에 의해 언제든 변경될 수 있기 때문에 스마트 캐스트가 적용되지 않는다.

class SmartCastVarProperty(private var data: Any = "Hello, Kotlin") {
    fun printData() {
        if (data is String) {
            // 'data'가 'String' 타입으로 스마트 캐스트되지 않기 때문에 오류 발생
            println(data.length) // 오류: 'data'가 'String'으로 자동 캐스트되지 않음
        }
    }
}

위 코드는 우리의 예상대로 오류가 발생한다.

Smart cast to 'String' is impossible, because 'data' is a mutable property that could have been changed by this time

 

참고로 위 경우에 원하는 타입으로 명시적으로 타입 캐스팅하면 오류가 발생하지 않는다.

명시적 타입 캐스팅은 `as` 키워드를 사용한다. (`val n = e as Num`)

class CaseThatCannotSmartCast(private var data: Any = "Hello, Kotlin") {
    fun printData() {
        if (data is String) {
            // 명시적으로 String 으로 캐스팅하여 length 속성에 접근 -> 이 방식으로는  컴파일 오류가 발생 안 함
            println((data as String).length) 
        }
    }
}

 

다시 돌아와서 이제 `evaluate` 함수를 더 코틀린다운 코드로 리팩토링해보자.

if 를 when 으로 변경

자바의 3항 연산자는 `a > b ? 1: 0` (a 가 b 보다 크면 1, 그렇지 않으면 0을 리턴) 형태로 사용된다.

코틀린에서는 `if` 가 값을 직접 만들어내기 때문에 3항 연산자가 따로 없다.

 

즉, `evaulate` 함수는 아래처럼 값을 만들어내는 `if` 식을 사용하여 바꿀 수 있다.

fun evaluate(e: Expr): Int =
    if (e is Num) e.value
    else if (e is Sum) evaluate(e.right) + evaluate(e.left)
    else throw IllegalArgumentException("Unknown expression")

`if` 의 분기에 식이 하나밖에 없다면 중괄호를 생략해도 된다. 위는 해당 중괄호를 생략한 것이다.

`if` 분기에 block 을 사용하는 경우, 그 block 마지막 식이 그 분기의 결과값이다.

 

위 식을 `if` 중첩 대신 `when` 을 사용하여 변경할 수도 있다.

fun evaluate(e: Expr): Int =
    when (e) {
        is Num -> e.value
        is Sum -> evaluate(e.right) + evaluate(e.left)
        else -> throw IllegalArgumentException("Unknown expression")
    }

`when` 식은 equility(동등성) 검사가 아닌 다른 기능에도 사용할 수 있다.

`when` 으로 타입을 검사하는 분기에서도, `if` 를 사용했을 때와 마찬가지로 타입 검사 이후에는 smart cast 가 이루어진다.

if , when 분기에서 block 사용

`if` 나 `when` 모두 분기에 블록을 사용할 수 있다.

위에서 말했듯이 그런 경우, block 의 마지막 문장이 블록 전체의 결과가 된다.

 

분기에 간단한 출력문을 포함하는 함수로 바꾸어서 실행해보자.

fun evaluateWithLogging(e: Expr): Int =
    when (e) {
        is Num -> {
            println("num: ${e.value}")
            e.value
        }
        is Sum -> {
            val left = evaluateWithLogging(e.left)
            val right = evaluateWithLogging(e.right)
            println("sum: $left + $right")
            left + right
        }
        else -> throw IllegalArgumentException("Unknown expression")
    }

println(evaluateWithLogging(Sum(Sum(Num(1), Num(2)), Num(4))))

 

결과
num: 1
num: 2
sum: 1 + 2
num: 4
sum: 3 + 4
7

참고로 `if` 나 `when` 의 바디의 경우뿐만이 아니라

"block 의 마지막 식이 block 의 결과" 라는 규칙은 block 이 값을 만들어내야 하는 경우 항상 성립한다.

 

물론 함수에 대해서는 성립합지 않는다.

함수의 경우 expression body 인 함수는 block 을 본문으로 가질 수 없고,

block body 인 함수는 내부에 `return` 문이 반드시 있어야 한다.

 

 

참조

https://kotlinlang.org/docs/typecasts.html#smart-casts

https://kotlinlang.org/docs/delegated-properties.html