본문 바로가기

코틀린

4장 - (아주 쉽게 설명한) 다양한 sealed 클래스를 만들어 봅시다.

쉽게 다가가는 최신 프로그래밍: 코틀린 - 4장 클래스의 연습문제 5번을 잘 풀었나요? 문제는 아래와 같습니다.

문제 5 교재 p.195-197의 코드는 다형성(polymorphism)을 적용해 다각형의 면적을 계산하는 코드입니다.
이를 sealed class(봉인 클래스)를 사용해 구현하세요.
(1) 봉인된 부모 클래스 이름은 Shape, 자식 클래스 이름은 Circle, Rectange, Triangle 입니다.
(2) sealed class Shape 안에 자식 클래스를 모두 선언해야 합니다(교재 p.93 코드를 참고하세요).

 4장 정답해설을 보고도 잘 이해가 되지 않았나요? 조금 더 자세히 살펴볼까요. 

sealed 클래스에서 sealed는 "봉인하다"는 뜻입니다. 키스로 봉한 편지(sealed with a kiss)란 영어 팝송(1960년)이 있습니다. 또, "택배 상자를 테이프로 봉인했다"는 영어로 "...sealed with tape."라고 표현합니다. 테이프로 칭칭 둘러쌌으니까 이 상자에는 다른 물건을 더 넣을 수 없겠죠. sealed 클래스도 그런 의미로 사용합니다. sealed란 단어 뜻을 이해했으니 본격적으로 sealed 클래스를 살펴보겠습니다.

클래스 상속을 허용하려면 아래처럼 키워드 open을 클래스 이름 앞에 붙여야 합니다(182쪽, 4.5 상속).

open class Shape { ... }  // 상속 허용

class Circle(val radius: Double): Shape() { ... }  
// 기본 생성자를 사용해 클래스 Circle의 속성 radius 정의

 

이제 open을 지우고  클래스 Shape 앞에 키워드 sealed를 붙이겠습니다. sealed를 붙이면 클래스 Shape로부터 상속받을 수 있는 자식 클래스를 제한(=봉인)하겠다는 뜻입니다. open을 사용하면 누구나 상속받을 수 있는 것과 비교가 되죠. 주의할 점은 클래스 Circle의 객체(=인스턴스) 타입은 Circle도 되지만, Shape도 됩니다. 이를 공변성(covariance)이 성립한다고 합니다(284쪽, 클래스 상속 관계일 때 타입 변환).

sealed class Shape { ... }  // 봉인 클래스로 선언.
class Circle(val radius: Double): Shape() { ... }

fun main() {
    val c: Shape = Circle(5.0)  // 공변성(covariance)이 성립합니다.
    // val c: Circle = Circle(5.0)    
}

sealed 클래스의 하위 클래스를 정의할 때는 반드시 sealed 클래스 안에 중첩시켜야 합니다. 아래처럼 클래스 Shape 안에 클래스 Circle을 선언하면, 클래스 Circle을 중첩 클래스(nested class)라고 부릅니다(180쪽,  4.4 중첩 클래스와 내부 클래스). 클래스 Circle은 봉인 클래스 Shape를 상속받지만, 객체를 생성하는 방식이 조금 다릅니다. "Shape.Circle(5.0)"처럼 바깥 클래스 Shape를 포함해야 합니다.

sealed class Shape {
    class Circle(val radius: Double): Shape() { ... } // 중첩 클래스로 정의
}

fun main() {
    val c: Shape = Shape.Circle(5.0)
    // val c: Shape.Circle = Shape.Circle(5.0)
}

 4장 정답해설의 코드를 조금 더 자세히 살펴볼까요. sealed 클래스 Shape 안에 자식 클래스 Circle을 정의했습니다. 클래스 Shape는 면적을 계산하는 추상 메소드 getArea()를 선언했습니다. 다각형 종류에 따라 면적 계산 방식이 다르기 때문에 메소드의 서명(signature)만 선언하였습니다. 이 메소드를 구현하는 것은 상속받은 자식 클래스의 책임입니다. 자식 클래스에서는 추상 메소드를 구현할 때 키워드 override를 붙여야 합니다.

import kotlin.math.PI

sealed class Shape {
    abstract fun getArea(): Double // 추상 메소드 선언
    
    class Circle(val radius: Double) : Shape() {
        override fun getArea(): Double { // 추상 메소드 구현
            return PI * radius * radius  // 원주율(PI)을 사용해 원의 면적 계산
        }
    }
}

이제 전체 코드를 보겠습니다. 중복 코드는 생략했지만, 소수점 이하 2째자리까지 출력을 제한하는 코드를 추가했습니다.

import kotlin.math.PI

sealed class Shape {
    abstract fun getArea(): Double
    class Circle(val radius: Double) : Shape() { ... }
    class Rectangle(val width: Double, val height: Double) : Shape() { ... }
    class Triangle(val base: Double, val height: Double) : Shape() { ... }
}

fun main() {
    val c: Shape = Shape.Circle(5.0)
    printArea(c, c.getArea())
}

fun printArea(c: Shape, area: Double) {
    val f_area = String.format("%.2f", area)
    when (c) {
        is Shape.Circle -> println("원 면적 = $f_area")
        is Shape.Rectangle -> println("사각형 면적 = $f_area")
        is Shape.Triangle -> println("삼각형 면적 = $f_area")
    }
}

 

위 코드는 사실 정답은 아닙니다(sealed 클래스 사용법을 익히기 위한 초급자용 코드인 셈이죠). 완벽한 프로그램이란 없습니다. 더 나은 코드로 언제든 개선할 수 있습니다. 그래서 소프트웨어는 버전(version)이 있는 것이구요... 지속적으로 업그레이드가 필요한 겁니다. 위 코드를 바꿔볼까요?

getArea()를 추상 메소드로 선언하지 않고 sealed 클래스의 멤버 함수로 정의하는 방법이 있습니다. 흥미로운 점은 자식 클래스(Circle, Rectangle, Triangle)에서 정의한 속성을 부모 클래스(Shape)에서 자유롭게 참조할 수 있다는 점입니다.

아래 코드 중 "when (this) { ... " 의 this는 메소드 getArea()를 호출한 객체(수신자 객체라 부릅니다)입니다. 이 객체의 타입은 Shape 입니다. 또, when 블록에서 " -> length * width"는 " -> this.length * this.width" 와 같습니다.  

sealed class Shape {
    fun getArea(): Double =
        when (this) { // this는 이 메소드를 호출한 수신자 객체(Shape 타입).
            is Circle -> Math.PI * radius * radius
            is Rectangle -> length * width // this.length * this.widht. this는 생략 가능.
            is Triangle -> 0.5 * base * height
        }
    class Circle(val radius: Double): Shape() { } // 중괄호 { }도 생략 가능.
    class Rectangle(val length: Double, val width: Double): Shape() { } 
    class Triangle(val base: Double, val height: Double): Shape() { }
}

fun main() {
    val c: Shape = Shape.Circle(5.0)
    printArea(c, c.getArea())
}

fun printArea(c: Shape, area: Double) { ... }

  

자식 클래스에서 정의한 속성을 부모 클래스에서 자유롭게 참조할 수 있다는 사실에 주목하면, 아래처럼 상위 레벨(top-leve) 함수 getArea()를 사용해 구현할 수도 있습니다. 이 코드가 살짝 아쉬운 점은 " -> shape.length * shape.width" 에서 shape를 생략할 수 없는 점입니다.

import kotlin.math.PI

sealed class Shape(){
    class Circle(val radius:Double) : Shape()
    class Rectangle(val length:Double, val width:Double) : Shape()
    class Triangle(val base:Double, val height:Double) : Shape()
}

fun getArea(shape: Shape): Double =
    when (shape) {
        is Shape.Circle -> PI * shape.radius * shape.radius
        is Shape.Rectangle -> shape.length * shape.width
        is Shape.Triangle -> shape.base * shape.height / 2
    }

fun main() { ...}
fun printArea(c: Shape, area: Double) { ... }