abstract class Message
data object EmptyMessage: Message()
data class TextMessage(val text: String) : Message()
data class ImageMessage(val imageUrl: String, val width: Int, val height: Int) : Message()
data class VideoMessage(val videoUrl: String, val duration: Long) : Message()
fun printMessage(message: Message): String {
return when (message) {
is EmptyMessage -> "message is empty"
is TextMessage -> "text: ${message.text}"
is ImageMessage -> "imageUrl: ${message.imageUrl}, width: ${message.width}, height: ${message.height}"
is VideoMessage -> "videoUrl: ${message.videoUrl}, duration: ${message.duration}"
else -> "Nothing"
}
}
위와 같은 코드가 있다고 가정하자. Message라는 추상 클래스의 하위 클래스 및 객체로 EmptyMessage, TextMessage, ImageMessage, VideoMessage가 존재한다. 그리고 printMessage() 함수를 통해서 메시지 내용을 출력한다.
그런데 메시지에 Empty, 텍스트, 이미지, 비디오가 메시지 종류의 모든 경우의 수이고 다른 메시지 종류는 존재하지 않는다고 가정해보자. 그렇다면 when 표현식의 else 브랜치는 사실상 필요없는 코드가 된다.
fun printMessage(message: Message): String {
return when (message) {
is EmptyMessage -> "message is empty"
is TextMessage -> "text: ${message.text}"
else -> "Nothing"
}
}
그리고 만약 개발자의 실수로 위와 같이 코드가 수정된다면, 오류 없이 동작은 하겠지만 개발자가 의도하지 않은 대로 동작하게 된다. ImageMessage와 VideoMessage의 경우에는 다르게 동작해야 하는데 위의 예시에서는 오류 없이 동작하여 "Nothing"이 반환될 것이다.
이렇게 우리는 개발을 하면서 종종 제한된 일들의 집합이 필요한 경우가 생긴다. 이럴 때 sealed class를 사용하는 것이 적절하다. sealed class는 상속 받는 하위 클래스의 종류를 제한하기 위해 사용한다. sealed class 자체는 추상 클래스이기 때문에 상속을 허용한다.
sealed class Message {
data object EmptyMessage : Message()
data class TextMessage(val text: String) : Message()
data class ImageMessage(val imageUrl: String, val width: Int, val height: Int) : Message()
data class VideoMessage(val videoUrl: String, val duration: Long) : Message()
}
sealed class로 바꾼 코드를 디컴파일(Decompile)해보자.
@Metadata(
mv = {1, 9, 0},
k = 1,
xi = 48,
d1 = {"\u0000\u001e\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0005\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0000\b6\u0018\u00002\u00020\u0001:\u0004\u0003\u0004\u0005\u0006B\u0007\b\u0004¢\u0006\u0002\u0010\u0002\u0082\u0001\u0004\u0007\b\t\n¨\u0006\u000b"},
d2 = {"Lcom/hongstudio/kakaosearchimage/Message;", "", "()V", "EmptyMessage", "ImageMessage", "TextMessage", "VideoMessage", "Lcom/hongstudio/kakaosearchimage/Message$EmptyMessage;", "Lcom/hongstudio/kakaosearchimage/Message$ImageMessage;", "Lcom/hongstudio/kakaosearchimage/Message$TextMessage;", "Lcom/hongstudio/kakaosearchimage/Message$VideoMessage;", "app_debug"}
)
public abstract class Message { ... }
디컴파일 해보면 Message 클래스가 추상 클래스로 선언되어 있는 것을 볼 수 있다. 그리고 @Metadata 어노테이션에 Message 클래스의 하위 클래스 정보가 포함되어 있는 것을 볼 수 있다. 이를 통해 sealed class의 직접적인 하위 클래스(direct sub class)가 컴파일 타임에 이미 알려진다는 것을 알 수 있다. 쉽게 말해서 컴파일러가 하위 클래스들을 미리 알고 있게 된다는 뜻이다.
sealed class Message {
data object EmptyMessage : Message()
data class TextMessage(val text: String) : Message()
data class ImageMessage(val imageUrl: String, val width: Int, val height: Int) : Message()
data class VideoMessage(val videoUrl: String, val duration: Long) : Message()
}
fun printMessage(message: Message): String {
return when (message) {
is Message.EmptyMessage -> "message is empty"
is Message.TextMessage -> "text: ${message.text}"
is Message.ImageMessage -> "imageUrl: ${message.imageUrl}, width: ${message.width}, height: ${message.height}"
is Message.VideoMessage -> "videoUrl: ${message.videoUrl}, duration: ${message.duration}"
}
}
이전의 코드를 sealed class로 바꾸었다. 컴파일러가 모든 하위 클래스를 알다는 것은 Message의 모든 경우의 수를 알고 있다는 뜻이다. 그래서 when 표현식의 else 브랜치를 추가할 필요가 없다.
fun printMessage(message: Message): String {
return when (message) {
is Message.EmptyMessage -> "message is empty"
is TextMessage -> "text: ${message.text}"
is ImageMessage -> "imageUrl: ${message.imageUrl}, width: ${message.width}, height: ${message.height}"
}
}
그리고 컴파일러가 모든 하위 클래스를 알고 있기 때문에, 위의 예시처럼 VideoMessage에 대한 코드가 작성되어 있지 않으면 컴파일 오류를 발생시킨다.
이렇게 sealed class를 사용하면 보다 효율적이고 안전하게 프로그래밍을 할 수 있다.
예시의 sealed class Message의 하위 클래스가 전부 data object라면 enum class로 바꿔도 내부적으로 동작은 다르겠지만 사용하는 데 큰 문제가 없을 것이다.
하지만 현재 예시에서는 하위 클래스 생성자 파라미터의 개수도 각각 다르고 파라미터 데이터 타입도 다양하다. 이처럼 다양한 데이터 타입을 가진 여러 종류의 하위 클래스가 존재한다면, 하나의 생성자로만 인스턴스를 생성해야 하는 enum class로 변경하는 것은 불가능하다.
그리고 enum class의 enum constant는 싱글 인스턴스이기 때문에 새로운 인스턴스를 생성하는 것이 불가능하다. 하지만 sealed class의 하위 클래스들은 새로운 인스턴스 생성이 가능하다. (data object로 선언된 EmptyMessage는 싱글톤 객체라서 예외)
밑의 예시는 data class로 선언된 TextMessage와 data object로 선언된 객체의 차이를 이해하기 위한 예시다.
fun main() {
val textMessage1 = TextMessage("text")
val textMessage2 = TextMessage("text")
val emptyMessage1 = EmptyMessage
val emptyMessage2 = EmptyMessage
println(textMessage1 === textMessage2) // 출력 : false
println(emptyMessage1 === emptyMessage2) // 출력 : true
}
sealed interface Message {
data object EmptyMessage : Message
data class TextMessage(val text: String) : Message
data class ImageMessage(val imageUrl: String, val width: Int, val height: Int) : Message
data class VideoMessage(val videoUrl: String, val duration: Long) : Message, Playable()
}
abstract class Playable {
fun play() {
println("Video is playing...")
}
}
sealed class와 sealed interface를 각각 언제 사용해야 하는지에 대한 질문은 abstract class와 interface를 각각 언제 사용해야 하는지에 대한 질문과 똑같다.
sealed class는 상속이 하나만 가능하고 sealed interface는 다중 상속이 가능하다.
그리고 sealed class는 상태를 정의할 수 있지만 sealed interface는 상태를 정의할 수 없다. 즉 sealed class에서는 생성자를 전달하거나 body에 프로퍼티를 선언하여 초기화를 할 수 있지만, sealed interface에서는 추상 프로퍼티를 정의만 할 수 있다는 말이다.
대부분의 경우에는 sealed interface만 사용해도 무방하다.
실제 사용 사례로는 여러가지가 있다.
sealed class UIState {
data object Loading : UIState()
data class Success(val data: String) : UIState()
data class Error(val exception: Exception) : UIState()
}
fun updateUI(state: UIState) {
when (state) {
is UIState.Loading -> showLoadingIndicator()
is UIState.Success -> showData(state.data)
is UIState.Error -> showError(state.exception)
}
}
위의 예시는 sealed class를 사용하여 애플리케이션의 다양한 UI 상태를 나타낼 수 있다. 이 방식은 UI 변경 처리를 보다 구조화되고 안전한 방식으로 가능하게 한다.
sealed interface HomeScreenEvent {
data object ShowAnimation : HomeScreenEvent
data object DismissDialog : HomeScreenEvent
data class ShowToast(val message: String) : HomeScreenEvent
}
UIState 예시와 비슷한 방식으로 화면에서 일어나는 이벤트를 sealed로 묶어줄 수 있다.
sealed class ApiResponse {
data class UserSuccess(val user: UserData) : ApiResponse()
data object UserNotFound : ApiResponse()
data class Error(val message: String) : ApiResponse()
}
마찬가지로 ApiResponse도 sealed를 사용함으로써 보다 구조적이고 안전하게 여러가지 Response에 대응할 수 있다.
package a
sealed interface Response
package a
data class Success(val data: String) : Response
Response 인터페이스와 Success 데이터 클래스는 같은 패키지 내에 있어서 Success 클래스가 Response 인터페이스를 상속(구현)이 가능하다.
package b
import a.Response
data class Fail(val data: String) : Response
하지만 Fail 데이터 클래스는 다른 패키지에 있어서 Response 인터페이스를 상속(구현)하는 것이 불가능하다.
이렇게 sealed class, sealed interface는 같은 패키지 내에서만 상속을 받을 수 있다. 하지만 abstract class와 interface는 public이라는 가정 하에 어디서든 상속을 받는 것이 가능하다.
추가로, sealed class, sealed interface가 같은 패키지 내에서 상속 받을 수 있기 때문에 다른 파일에 sealed class와 하위 클래스들을 정의해도 된다. 하지만 보통 같은 파일 내에 혹은 클래스 body에 선언한다. 다른 파일에 선언하면 관리하기 어렵기 때문이다.