[Kotlin] 8. 클래스 계층 정의

2023. 3. 26. 20:09Programming Languages/Kotlin

1. 인터페이스

interface Clickable {
    // 추상 메소드. Clickable을 구현하는 클래스는 이를 구현해야 함
    fun click()

    // 디폴트 메소드. Java와 달리 구현만 하면 디폴트 메소드로 선언된다.
    fun showOff = println("I'm clickable!")
}
interface Focusable {
    fun setFocus(b: Boolean) =
        println("I ${if (b) "get" else "lost"} focus.")

    fun showOff() = println("I'm focusable!")
}

두 개의 인터페이스에 같은 메서드 시그니처를 가진 디폴트 메소드가 존재할 경우 이를 구현하는 클래스에서는 해당 디폴트 메서드를 명시적으로 구현해야 한다. 그렇지 않으면 컴파일 에러가 발생한다.

class Button: Clickable, Focusable {
    override fun click() = println("I was clicked")
    override fun showOff() {
        // Java에서는 Clickable.supere.showOff()처럼 작성한다.
        super<Clickable>.showOff()
        super<Focusable>.showOff()
    }
}
💡Kotlin은 Java 6과 호환되도록 설계됐다. 따라서 인터페이스의 디폴트 메소드를 지원하지 않는다. 따라서 Kotlin은 디폴트 메소드가 있는 인터페이스를 일반 인터페이스와 디폴트 메소드 구현이 정적 메소드로 들어있는 클래스로 구분한다. 인터페이스에는 메소드 선언만 들어가며 인터페이스와 함께 생성되는 클래스에는 모든 디폴트 메소드 구현이 정적 메소드로 들어간다. 그러므로 디폴트 인터페이스가 포함된 인터페이스를 Java 클래스에 상속해 구현하고 싶다면 Kotlin에서 본문을 제공하는 메소드를 포함하는 모든 메소드에 대한 본문을 작성해야 한다. 즉 Java에서는 Kotlin의 디폴트 메소드 구현에 의존할 수 없다.

2. open, final, abstract 변경자: 기본적으로 final

Java에서는 final로 명시하지 않으면 클래스를 상속하여 사용할 수 있다. 이는 강력한 기능이지만 취약한 기반 클래스 문제가 발생한다. 이는 기반 클래스에서 가졌던 가정을 상속한 클래스에서 오버라이딩할 때 깨지는 경우 발생한다. 어떤 클래스가 자신을 상속하는 정확한 규칙을 제공하지 않으면 이를 상속하여 사용할 때 기반 클래스의 의도와 다르게 오버라이딩될 위험이 있다. Effective Java에서는 상속을 위한 설계 문서를 갖추거나, 그럴 수 없다면 상속을 금지하라는 조언을 한다. Kotlin도 이러한 철학을 따른다.
Kotlin의 클래스는 기본적으로 final이며 open 키워드를 사용해야 상속할 수 있다.

open class RichButton: Clickable {
    // final 메소드. 오버라이딩 X
    fun disable() {}

    // 열려 있는 메소드. 오버라이딩 O
    open fun animate() {}

    // 열려 있는 메소드를 오버라이드한다.
    // 오버라이드한 메소드는 기본적으로 열려 있다.
    override fun click() {}

    // 열려 있는 클래스에서 오버라이드한 메소드를 하위 클래스가 오버라이드하지 못하게 하려면 final 키워드를 추가한다.
    // final override fun click() {}
}

추상 클래스를 만드는 방법은 Java와 마찬가지로 abstract 키워드를 사용한다. 추상 클래스는 기본적으로 열려(open) 있다.

abstract class Animated {
    // 추상 메서드. 하위 클래스는 이를 반드시 구현해야 한다.
    abstract fun animate()

    // 추상 클래스에 속하더라도 비추상 메서드는 기본적으로 final이다.
    // 오버라이드를 원한다면 open 키워드를 선언한다.
    open fun stopAnimating() {}

    // 오버라이드 X
    fun animateTwice() {}
}

3. 가시성 변경자: 기본적으로 공개(public)

Java는 클래스의 가시성에 대해 public 또는 package-private(생략 시 기본값은 package-private)을 지정한다. 그러나 Kotlin에서는 아무 변경자가 없는 경우 public이 적용된다.
그리고 Java의 package-private의 개념이 Kotlin에는 없다. 대안으로 Kotlin에는 internal이라는 가시성 변경자가 있다. 이는 모듈 내부에서만 볼 수 있음을 의미한다. 모듈은 한 번에 한꺼번에 컴파일되는 Kotlin 파일을 의미한다. IntelliJ, Eclipse, Maven, Gradle 등의 프로젝트가 모듈이 될 수도 있고, Ant Task가 한 번 실행될 때 함께 컴파일되는 파일의 집합도 모듈이 될 수 있다.
internal 가시성 변경자는 모듈의 구현에 대해 진정한 캡슐화를 제공한다는 장점이 있다. Java에서는 패키지가 같은 클래스를 선언만 하면 어떤 프로젝트의 외부에서든 패키지 내부의 클래스에 접근할 수 있어서 쉽게 캡슐화가 깨진다.
또 다른 차이점은 최상위 선언에 private이 적용 가능하다는 점이다. 이렇게 사용하면 해당 파일 내부에서만 사용 가능하다. 이는 하위 시스템의 자세한 구현 사항을 숨기고 싶을 때 유용한 방법이다.

Java의 가시성 변경자

변경자 클래스 멤버 최상위 선언
public 모든 곳이서 접근할 수 있다. 모든 곳에서 접근할 수 있다.
package-private(기본값) (클래스 멤버에 적용할 수 없음) 같은 패키지에서만 접근할 수 있다.
protected 같은 패키지 또는 하위 클래스 안에서만 접근할 수 있다. (최상위 선언에 적용할 수 없음)
private 같은 클래스 안에서만 접근할 수 있다. (최상위 선언에 적용할 수 없음)

Kotlin의 가시성 변경자

변경자 클래스 멤버 최상위 선언
public(기본값) 모든 곳에서 접근할 수 있다. 모든 곳에서 접근할 수 있다.
internal 같은 모듈 안에서만 접근할 수 있다. 같은 모듈 안에서만 접근할 수 있다.
protected 같은 클래스 또는 하위 클래스 안에서만 접근할 수 있다. (최상위 선언에 적용할 수 없음)
private 같은 클래스 안에서만 접근할 수 있다. 같은 파일 안에서만 접근할 수 있다.

 

예제를 하나 살펴보자.

internal open class TalkativeButton: Focusable {
    private fun yell() = println("Hey!")
    protected fun whisper() = println("Let's talk!")
}

// 오류. public 멤버(giveSpeech)가 internal 수신 타입인 TalkativeButton을 노출함
fun TalkativeButton.giveSpeech() {
    // 오류. public 멤버가 private 멤버인 yell()에 접근함
    yell()

    // 오류. public 멤버가 proteced 멤버인 whisper()에 접근함
    whisper()
}
💡Java에서는 같은 패키지 안에서 protected 멤버에 접근할 수 있지만 Kotlin에서는 그렇지 않다. Kotlin에서는 단순히 어떤 클래스나 그 클래스를 상속한 클래스에서만 접근할 수 있다.

4. 내부 클래스와 중첩 클래스: 기본적으로 중첩 클래스

Kotlin에서도 Java처럼 클래스 안에 다른 클래스를 선언할 수 있다. 이는 도우미 클래스를 캡슐화하거나 코드 정의를 가까이서 사용하고 싶을 때 유용하다. Java와의 차이는 kotlin의 중첩 클래스는 명시적으로 선언하지 않는 한 외부 클래스 인스턴스에 대한 접근 권한이 없다는 점이다.

Kotlin의 중첩 클래스에 아무런 변경자가 붙지 않으면 Java의 static 중첩 클래스와 같다. 이를 내부 클래스로 변경해서 외부 클래스에 대한 참조를 포함하게 만들고 싶다면 inner 변경자를 붙여야 한다.

 

클래스 B 안에 정의된 클래스 A in Java in Kotlin
중첩 클래스(외부 클래스에 대한 참조 없음) static class A class A
내부 클래스(외부 클래스에 대한 참조 있음) class A inner class A

 

Kotlin에서는 외부 클래스의 인스턴스를 가리키는 참조를 표기하는 방법도 Java와 다르다. 내부 클래스 Inner 안에서 외부 클래스 Outer의 참조에 접근하려면 this@Outer라고 써야 한다.

class Outer {
    inner class Inner {
        fun getOuterReference(): Outer = this@Outer
    }
}

5. 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한

다음 클래스 계층을 보자. 상위 클래스인 Expr에는 숫자를 표현하는 Num과 덧셈 연산을 표현하는 Sum이라는 두 하위 클래스가 있다. when 식에서 이 모든 하위 클래스를 처리하면 편리하다. 하지만 when 식에서 Num과 Sum이 아닌 경우를 처리하는 else 분기를 반드시 넣어줘야 한다.

interface Expr
class Num(val value: Int): Expr
class Sum(val left: Expr, val right: Expr): Expr
fun eval(e: Expr): Int =
    when (e) {
        is Num -> e.value
        is Sum -> eval(e.left) + eval(e.right)
        else -> throw IllegalArgumentException("Unknown expression")
    }

Kotlin 컴파일러는 when 식을 사용해 Expr 타입의 값을 검사할 때 반드시 디폴트 분기인 else 분기를 덧붙이게 강제한다. 하지만 이것이 항상 편하지는 않다. 만약 Sub와 같은 다른 클래스를 만들게 되더라도 else 분기로 처리되어 심각한 오류가 발생한다. 이를 위해 Kotlin은 봉인된(sealed) 클래스를 제공한다. 상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있다. 봉인된 클래스의 하위 클래스를 정의할 때는 반드시 상위 클래스 안에 중첩시켜야 한다.

sealed class Expr {
    class Num(val value: Int): Expr()
    class Sum(val left: Expr, val right: Expr): Expr()
}

fun eval(e: Expr): Int =
    when (e) {
        is Expr.Num -> e.value
        is Expr.Sum -> eval(e.left) + eval(e.right)
    }

when 식에서 sealed 클래스의 모든 하위 클래스를 처리한다면 else 분기가 필요 없다. sealed 로 표시된 클래스는 자동으로 open이다. 따라서 별도로 open을 붙일 필요는 없다. 이런 식으로 else 분기를 사용하지 않고 when 식을 사용하면 나중에 sealed 클래스의 상속 계층에 새로운 하위 클래스를 추가해도 when 식이 컴파일되지 않는다. 따라서 when 식을 반드시 고쳐야 한다.