Sealed class vs. enum class
2년 전 글에서 sealed class와 enum class에 대해 간략하게 다룬 적이 있다.
위 글에서는 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를 계속 헷갈리고 있었으니 이번 기회에 잘 정리했다고 생각해야겠다.