Primary/Kotlin

Sealed class vs. enum class

해스끼 2024. 6. 14. 22:37

2년 전 글에서 sealed class와 enum class에 대해 간략하게 다룬 적이 있다.

 

[Kotlin] sealed class vs. enum class

Kotlin을 처음 배울 때는 ``enum class``와 ``sealed class``를 혼동하곤 했다. enum을 구현할 떄 ``enum class`` 대신 ``sealed class``를 써도 되지 않나? 결론 ``sealed class``를 써도 되지만, 바람직한 사용법은 아니다

thinking-face.tistory.com

위 글에서는 enumerated value를 구현하기 위해 ``sealed class``를 사용할 필요가 없다는 결론을 내렸다. 사실 당연하다. ``enum class``가 멀쩡히 존재하기 때문이다. 그러나 ``enum class``가 있음에도 이런 혼동을 하는 이유는 ``sealed class``와 ``enum class``가 실제로 매우 비슷하기 때문이다.

 

이 글에서는 sealed class와 enum class가 할 수 있는 일과 할 수 없는 일을 비교해 보고자 한다.

공통점

우선, sealed class와 enum class 모두 상속 관계를 제한한다. sealed class는 해당 클래스가 선언된 파일 안에서만 상속받을 수 있으며, 파일 외부에서는 sealed class를 상속받을 수 없다. 

 

enum class는 애초에 final class이다.

 

또, sealed class와 enum class 모두 함수와 프로퍼티를 가질 수 있으며, 다른 클래스나 인터페이스를 상속받을 수 있다. 조금 특수한 형태이긴 하지만 둘 다 어쨌든 Kotlin 클래스이기 때문.

interface SomeInterface

enum class TestEnum(val order: Int) : SomeInterface {
    VAL1(10),
    VAL2(20),
    VAL3(30);

    fun action() {
        println("Hello! I'm ${this.name}")
    }

    val orderReified: Int
        get() = order * 10
}

sealed class SealedClass(val order: Int) : SomeInterface {
    class SealedSubclass : SealedClass(100)

    val reifiedOrder: Int
        get() = order * 10
}

위 코드처럼 생성자 프로퍼티, getter 프로퍼티, 멤버 함수 등을 모두 선언할 수 있다. 다만 enum class에서는 변수나 함수 등을 enum 값 밑에(``VAL1``, ``VAL2``, ``VAL3`` 밑에) 선언해야 한다. enum class 내부에는 enum 값이 가장 먼저 선언되어야 하기 때문.

차이점

그렇다면 sealed class와 enum class의 차이는 무엇인가?

 

우선, sealed class는 상속 관계를 제한하는 역할을 한다. 즉 sealed class의 하위 타입은 같은 파일에 선언된 서브 클래스로 한정할 수 있다. 그것도 무려 컴파일 시간에!

 

따라서 sealed class를 ``when`` 구문 등에서 사용할 때, 코드가 모든 case를 처리하고 있는지 컴파일 시간에 알 수 있으므로 일부 케이스를 빠트리는 등의 실수를 방지할 수 있다.

 

또, sealed class도 어쨌든 class이므로, 인스턴스를 생성할 수 있다. 이때 생성되는 인스턴스는 서로 다르다는 점이 중요하다. 왜냐하면 enum class의 value는 항상 동일한 객체를 반환하기 때문.

 

다음 테스트 코드를 보면 이해될 것이다.

class TestTest {
    @Test
    fun enumTest() {
        val enum1 = TestEnum.VAL1
        val enum2 = TestEnum.VAL1
        println(enum1.hashCode())
        println(enum2.hashCode())
        assert(enum1 === enum2)
    }

    @Test
    fun sealedTest() {
        val sealed1 = SealedClass.SealedSubclass(100)
        val sealed2 = SealedClass.SealedSubclass(100)
        println(sealed1.hashCode())
        println(sealed2.hashCode())
        assert(sealed1 === sealed2)
    }
}

 

``enumTest``에서는 같은 enum 값의 identity가 동일함을 확인할 수 있다. 

 

그러나 ``sealedTest``에서 생성한 ``sealed1``과 ``sealed2``는 서로 identical하지 않다. 일단 hashCode부터가 다르고, ``===`` 연산으로 비교한 결과도 ``false``이다.

225344427
1604353554

Assertion failed
java.lang.AssertionError: Assertion failed

Sealed class는 동일한 특성을 갖는 객체를 여러 개 만들어낼 수 있는 반면, enum class는 선택지 자체만을 나타낸다고 정리할 수 있겠다. 요컨대 수저는 재료에 따라 금수저, 은수저 등이 존재할 수 있지만(sealed class), 월요일은 월요일 그 자체로 의미를 갖는다는 것(enum class).

 

객체의 의미를 가장 잘 드러낼 수 있는 방법을 고민해 보자.

Real case: navigation route는?

사실 이 글을 작성하게 된 이유는, Compose type-safe navigation에서 사용할 route를 sealed class와 enum class 중 무엇으로 만들어야 할 지 고민하고 있었기 때문이다. Sealed class와 enum class 모두  route를 표현할 수 있기 때문(상속을 제한하므로).

 

이 문제를 해결하려면, navigation argument의 속성을 정확하게 이해하고 있어야 한다.

 

공지 웹뷰 페이지로 가는 route를 ``NoticeWeb`` 클래스로 표현한다고 하자. ``NoticeWeb``에는 String 타입의 ``articleUrl`` 프로퍼티가 선언되어 있으며, 공지 웹뷰 페이지는 ``NoticeWeb.articleUrl``로 주어진 URL을 로드한다. 즉 서로 다른 ``articleUrl``를 갖는 ``NoticeWeb`` 객체가 존재할 수 있다는 뜻이다.

 

따라서 서로 다른 인스턴스를 만들 수 있는 sealed class를 사용하는 것이 적절하다. 매개변수가 없는 route도 통일성을 위해 sealed class로 표현하는 것이 좋다.

애초에 enum class를 못 쓴다

사실 Compose navigation type safe를 정확히 이해했다면 애초에 할 필요가 없는 고민이다. 왜냐면 type-safe route는 클래스를 매개변수로 받기 때문. (정확히는 ``reified T: Any``를 제네릭으로 받는다)

composable<TestSealedClass> {
    // ...
}

composable<TestEnum> {
    // ...
}

composable<TestEnum.VAL1> {
    // 이게 되겠냐
}

그냥 sealed class/data class를 사용하는 게 옳다. 애초에 고민 자체가 잘못된...

 

뭐... 안그래도 sealed class와 enum class를 계속 헷갈리고 있었으니 이번 기회에 잘 정리했다고 생각해야겠다.