시배's Android

Kotlin | Deep Dive into Sealed Interfalces 본문

Kotlin/Kotlin

Kotlin | Deep Dive into Sealed Interfalces

si8ae 2023. 9. 3. 21:40
 

Kotlin Sealed Interfaces: A Deep Dive into a Powerful New Feature

When Kotlin was first introduced, developers quickly fell in love with its powerful language features, including sealed classes. However…

blog.devgenius.io

Kotlin이 처음 소개되었을 때 개발자들은 sealed class를 비롯한 강력한 언어 기능에 빠르게 빠져들었습니다. 하지만 한 가지 아쉬운 점이 있었는데, 바로 sealed interface였습니다. 당시에는 Kotlin 컴파일러가 Java 코드로 인터페이스를 구현할 수 없다는 것을 보장할 수 없었기 때문에 Kotlin에서 sealed interface를 구현하기가 어려웠습니다.

하지만 시대가 변하여 이제 마침내 Kotlin 1.5 및 Java 15 이상에서 sealed interface를 사용할 수 있게 되었습니다. sealed interface를 사용하면 개발자는 sealed class를 사용할 때와 마찬가지로 더욱 강력하고 유형이 안전한 API를 만들 수 있습니다. 이 블로그 게시물에서는 Kotlin sealed interface에 대해 자세히 살펴보고 더 나은 코드를 빌드하는 데 어떻게 도움이 되는지 살펴봅니다. sealed interface의 기본 사항부터 고급 기술 및 모범 사례까지 모든 것을 다룰 예정이니 이 강력한 새 기능을 마스터할 준비를 하세요!

Basics of Sealed Interfaces in Kotlin

sealed class와 마찬가지로 sealed interface는 컴파일 시점에 가능한 모든 하위 유형을 알 수 있는 폐쇄형 유형 계층을 정의할 수 있는 방법을 제공합니다. 이를 통해 보다 강력하고 유형이 안전한 API를 만들 수 있으며, 가능한 모든 사용 사례를 보장할 수 있습니다.

Kotlin에서 sealed interface를 만들려면 인터페이스 키워드 앞에 sealed 수정자를 사용하면 됩니다. 다음은 예제입니다:

sealed interface Shape {
    fun draw()
}

이렇게 하면 단일 메서드 draw()가 있는 Shape라는 sealed interface가 생성됩니다. sealed interface는 일반 interface와 마찬가지로 추상 메서드를 가질 수 있다는 점에 유의하세요. sealed interfacesealed interface 자체와 동일한 파일 또는 동일한 패키지 내에서 선언된 클래스 또는 객체에서만 구현할 수 있습니다.

이제 실제로 sealed interface를 어떻게 사용할 수 있는지 살펴보겠습니다. 다음은 예제입니다:

sealed interface Shape {
    fun area(): Double
}

class Circle(val radius: Double) : Shape {
    override fun area() = Math.PI * radius * radius
}

class Rectangle(val width: Double, val height: Double) : Shape {
    override fun area() = width * height
}

fun calculateArea(shape: Shape): Double {
    return shape.area()
}

이 예제에서는 영역()이라는 단일 추상 메서드가 있는 Shape라는 봉인된 인터페이스를 정의합니다. 그런 다음 Shape 인터페이스를 구현하는 두 개의 클래스를 정의합니다: Circle과 Rectangle을 정의합니다. 마지막으로 Shape 유형의 인수를 받아 도형의 면적을 반환하는 계산 영역()이라는 함수를 정의합니다.

Shape 인터페이스는 봉인되어 있으므로 현재 파일이나 패키지 외부에서 구현할 수 없습니다. 즉, Circle 및 Rectangle 클래스만 Shape 인터페이스를 구현할 수 있습니다.

sealed interface는 특정 클래스 또는 객체 집합에서만 구현할 수 있는 관련 인터페이스 집합을 정의할 때 특히 유용합니다. 예를 들어 직렬화되도록 설계된 클래스에서만 구현할 수 있는 Serializable이라는 sealed interface를 정의할 수 있습니다.

Subtypes of Sealed Interfaces

sealed interface의 하위 유형을 만들려면 sealed class와 마찬가지로 클래스 키워드 앞에 봉인된 수정자를 사용하면 됩니다. 다음은 예제입니다:

sealed interface Shape {
    fun draw()
}

sealed class Circle : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

sealed class Square : Shape {
    override fun draw() {
        println("Drawing a square")
    }
}

class RoundedSquare : Square() {
    override fun draw() {
        println("Drawing a rounded square")
    }
}

이렇게 하면 Shape 인터페이스를 구현하는 두 개의 sealed class Circle과 Square가 생성되고, Square를 확장하는 비봉인 클래스 RoundedSquare가 생성됩니다. RoundedSquare는 직접적인 하위 유형이 없으므로 봉인된 클래스가 아닙니다.

Using Sealed Interfaces with When Expressions

sealed interface sealed class의 주요 이점 중 하나는 표현식이 철저한 패턴 매칭을 제공할 때 함께 사용할 수 있다는 것입니다. 다음은 예시입니다:

fun drawShape(shape: Shape) {
    when(shape) {
        is Circle -> shape.draw()
        is Square -> shape.draw()
        is RoundedSquare -> shape.draw()
    }
}

이 함수는 Shape를 매개변수로 받고 when 표현식을 사용하여 도형의 하위 유형에 따라 적절한 draw() 메서드를 호출합니다. Shape는 밀폐된 인터페이스이므로, when 표현식은 가능한 모든 하위 유형을 포괄한다는 것을 의미합니다. 

Advanced Techniques and Best Practices

sealed interface는 유형 안전 API를 만드는 데 강력한 도구를 제공하지만, 이 인터페이스로 작업할 때 염두에 두어야 할 몇 가지 고급 기술과 모범 사례가 있습니다.

Interface Delegation

sealed interface에 사용할 수 있는 한 가지 기술은 인터페이스 위임입니다. 여기에는 sealed interface를 구현하는 별도의 클래스를 만든 다음 적절한 메서드에 대한 호출을 다른 객체에 위임하는 것이 포함됩니다. 다음은 예시입니다:

sealed interface Shape {
    fun draw()
}

class CircleDrawer : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

class SquareDrawer : Shape {
    override fun draw() {
        println("Drawing a square")
    }
}

class DrawingTool(private val shape: Shape) : Shape by shape {
    fun draw() {
        shape.draw()
        // additional drawing logic here
    }
}
이 예제에서는 Shape 인터페이스를 구현하는 두 개의 클래스 CircleDrawer와 SquareDrawer를 만들었습니다. 그런 다음 Shape를 매개변수로 받고 draw() 메서드 호출을 해당 셰이프에 위임하는 DrawingTool 클래스를 만들었습니다. DrawingTool에는 도형이 그려진 후에 실행되는 추가 그리기 로직도 포함되어 있습니다.

Avoiding Subclassing

sealed interface로 작업할 때 염두에 두어야 할 또 다른 모범 사례는 가능한 한 서브클래싱을 피하는 것입니다. sealed interface를 사용하여 하위 유형의 닫힌 계층 구조를 만들 수 있지만 동일한 효과를 얻으려면 상속 대신 구성을 사용하는 것이 더 나은 경우가 많습니다.
sealed interface Shape {
    fun draw()
}

sealed class Circle : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

sealed class Square : Shape {
    override fun draw() {
        println("Drawing a square")
    }
}

class RoundedSquare : Square() {
    override fun draw() {
        println("Drawing a rounded square")
    }
}

이 계층 구조는 폐쇄적이고 유형에 안전하지만 새로운 유형이나 동작을 추가해야 하는 경우 유연성이 떨어질 수 있습니다. 대신 컴포지션을 사용하여 동일한 효과를 얻을 수 있습니다:

sealed interface Shape {
    fun draw()
}

class CircleDrawer : (Circle) -> Unit {
    override fun invoke(circle: Circle) {
        println("Drawing a circle")
    }
}

class SquareDrawer : (Square) -> Unit {
    override fun invoke(square: Square) {
        println("Drawing a square")
    }
}

class RoundedSquareDrawer : (RoundedSquare) -> Unit {
    override fun invoke(roundedSquare: RoundedSquare) {
        println("Drawing a rounded square")
    }
}

class DrawingTool(private val drawer: (Shape) -> Unit) {
    fun draw(shape: Shape) {
        drawer(shape)
        // additional drawing logic here
    }
}

이 예제에서는 각 도형 유형에 대해 별도의 클래스와 도형을 그리는 방법을 알고 있는 함수를 취하는 DrawingTool 클래스를 만들었습니다. 이 접근 방식은 기존 코드를 수정하지 않고도 새로운 도형이나 동작을 추가할 수 있으므로 하위 유형의 폐쇄적인 계층 구조를 사용하는 것보다 더 유연합니다.

Extending Sealed Interfaces

마지막으로, sealed interface도 일반 인터페이스처럼 확장할 수 있다는 점에 주목할 필요가 있습니다. 이는 기존 코드를 손상시키지 않고 sealed interface에 새로운 동작을 추가해야 하는 경우에 유용할 수 있습니다. 다음은 예시입니다:

sealed interface Shape {
    fun draw()
}

interface FillableShape : Shape {
    fun fill()
}

sealed class Circle : Shape {
    override fun draw() {
        println("Drawing a circle")
    }
}

class FilledCircle : Circle(), FillableShape {
    override fun fill() {
        println("Filling a circle")
    }
}

이 예제에서는 fill() 메서드를 포함하는 새로운 FillableShape 인터페이스를 사용하여 Shape 인터페이스를 확장했습니다. 그런 다음 Circle을 확장하고 FillableShape를 구현하는 새로운 FilledCircle 클래스를 만들었습니다. 이를 통해 기존 코드를 손상시키지 않고 Shape 계층 구조에 새로운 동작(fill())을 추가할 수 있습니다.

Sealed Classes vs Sealed Interfaces

sealed class와 sealed interface는 모두 변수 또는 함수 매개 변수의 가능한 유형을 제한하는 방법을 제공하는 Kotlin 언어 기능입니다. 하지만 둘 사이에는 몇 가지 중요한 차이점이 있습니다.
sealed class는 한정된 수의 하위 클래스로 확장할 수 있는 클래스입니다. 클래스를 sealed class로 선언하면 해당 클래스의 가능한 모든 서브클래스가 sealed class 자체와 동일한 파일 내에 선언되어야 한다는 의미입니다. 이렇게 하면 sealed 클래스의 서브클래스를 when 표현식에서 사용할 수 있으므로 가능한 모든 경우를 처리할 수 있습니다.

sealed class Vehicle {
    abstract fun accelerate()
}

class Car : Vehicle() {
    override fun accelerate() {
        println("The car is accelerating")
    }
}

class Bicycle : Vehicle() {
    override fun accelerate() {
        println("The bicycle is accelerating")
    }
}

이 예제에서는 Vehicle이라는 sealed class를 선언합니다. Vehicle의 서브클래스도 두 개 정의합니다: Car와 Bicycle입니다. Vehicle은 봉인되어 있으므로 Vehicle의 다른 가능한 서브클래스도 같은 파일에 선언해야 합니다.

반면에 sealed interface는 한정된 수의 클래스 또는 객체로 구현할 수 있는 인터페이스입니다. 인터페이스를 봉인된 것으로 선언한다는 것은 해당 인터페이스의 가능한 모든 구현이 봉인된 인터페이스 자체와 동일한 파일 또는 동일한 패키지 내에 선언되어야 함을 의미합니다.

sealed interface Vehicle {
    fun accelerate()
}

class Car : Vehicle {
    override fun accelerate() {
        println("The car is accelerating")
    }
}

object Bicycle : Vehicle {
    override fun accelerate() {
        println("The bicycle is accelerating")
    }
}

이 예제에서는 Vehicle이라는 sealed interface를 선언합니다. 또한 Vehicle의 두 가지 구현을 정의합니다: Car와 Bicycle입니다. Vehicle은 봉인되어 있으므로 Vehicle의 다른 가능한 구현도 동일한 파일이나 패키지에 선언해야 합니다.

sealed class와 sealed interface의 한 가지 중요한 차이점은 sealed class 상태와 동작을 가질 수 있는 반면 sealed interface는 동작만 가질 수 있다는 것입니다. 즉, sealed class는 프로퍼티, 메서드, 생성자를 가질 수 있는 반면 sealed interface는 추상 메서드만 가질 수 있습니다.
또 다른 차이점은 sealed class는 일반 클래스나 다른 sealed class로 확장할 수 있지만 sealed interface는 클래스나 객체로만 구현할 수 있다는 점입니다. 또한 sealed class는 하위 클래스의 계층 구조를 가질 수 있지만 sealed interface는 구현의 평면적인 목록만 가질 수 있습니다.

Advantages

  • Type Safety : sealed interface를 사용하면 하위 유형의 폐쇄적인 계층 구조를 정의할 수 있으므로 가능한 모든 사용 사례를 포괄할 수 있습니다. 이를 통해 런타임이 아닌 컴파일 타임에 오류를 포착할 수 있으므로 코드가 더욱 견고해지고 유지 관리가 쉬워집니다.
  • Flexibility : sealed interface를 사용하면 복잡한 하위 유형 계층을 정의하는 동시에 기존 코드를 손상시키지 않고도 새로운 유형이나 동작을 추가할 수 있습니다. 따라서 코드를 전면적으로 변경하지 않고도 시간이 지남에 따라 코드를 더 쉽게 발전시킬 수 있습니다.
  • 개선된 API 디자인: sealed interface를 사용하면 작업 중인 도메인을 더 잘 반영하는 보다 직관적이고 표현력이 풍부한 API를 만들 수 있습니다. 이는 특히 코드베이스에 익숙하지 않은 다른 개발자가 코드를 더 쉽게 읽고 이해할 수 있도록 도와줍니다.

Disadvantages

  • Learning Curve : sealed interface는 강력한 기능이지만 올바르게 이해하고 사용하기 어려울 수 있습니다. 특히 유형 계층 구조로 작업하는 데 익숙하지 않은 경우 sealed interface로 작업하는 데 익숙해지는 데 다소 시간이 걸릴 수 있습니다.
  • 복잡성: 코드베이스가 커지고 복잡해지면 sealed interface로 작업하는 것이 더 어려워질 수 있습니다. 특히 하위 유형이 많거나 계층 구조를 크게 수정해야 하는 경우 더욱 그렇습니다.
  • 성능: sealed interface는 런타임에 유형 검사를 사용하여 유형 안전을 보장하기 때문에 enum 사용과 같은 다른 접근 방식에 비해 성능에 영향을 미칠 수 있습니다. 그러나 이러한 영향은 일반적으로 대부분의 애플리케이션에서 무시할 수 있는 수준입니다.

 

'Kotlin > Kotlin' 카테고리의 다른 글

Kotlin | The Big Difference Between Flows and Channels in Kotlin  (0) 2023.09.20
Kotlin | KSP vs KAPS  (0) 2023.09.20
Kotlin | Generic  (0) 2023.08.27
Kotlin | Kotlin design patterns with code example  (0) 2023.08.13
Kotlin | Flow 마스터하기  (0) 2023.08.13