013. 클래스 계층의 정의

클래스 계층 정의

이번 포스팅에서는 코틀린의 클래스 계층에 대해 알아보도록 하자.

Java와 비슷하지만 조금 다른 기본값과 가시성을 가진 접근자들에 대해 살펴보고, 상속을 제한할 수 있는 sealed에 대해서도 살펴본다.

코틀린의 인터페이스

코틀린에서 인터페이스를 어떻게 선언하는 지 살펴보자.

코틀린의 인터페이스 또한 Java 8과 매우 유사하다.

참고 Java 8의 인터페이스는 default 키워드를 선언할 수 있다.

1
2
3
interface Clickable {
fun click()
}

위의 인터페이스는 click() 이라는 단일 추상 메소드를 가진 인터페이스를 선언한 코드이다.

모든 비추상(non-abstract) 클래스는 이 메소드를 구현해야한다.

1
2
3
4
5
6
7
8
class Button : Clickable {
override fun click() = println("I was clicked")
}

/* 출력 결과 */

>>> Button().click()
I was clicked

위의 코드를 통해 아래와 같은 사실들을 유추할 수 있다.

코틀린은 클래스명 다음 콜론(:)을 사용하여 확장을 나타내는 데, 상속 뿐만 아니라 인터페이스의 구현 또한 :으로 표현한다.

Java와 마찬가지로 여러 인터페이스를 구현할 수 있고, 상속은 하나의 클래스로 제한된다.

오버라이딩한 메소드를 표현하기 위해 Java에선 @Override 어노테이션을 쓰지만 코틀린은 override 키워드로 대체할 수 있다.

단 오버라이딩 메소드가 있을 시 override 키워드는 무조건 작성해야한다.

Java 8과 마찬가지로 인터페이스는 default로 정의된 메소드가 있을 수 있다.

허나 default 키워드와 같은 방식으로 표기할 필요는 없고, 단순히 구현체만 있으면 default와 동일하게 취급된다.

1
2
3
4
interface Clickable {
fun click()
fun showOff() = println("I'm clickable!") // default가 필요없다.
}

위의 Clickable 인터페이스는 click()에 대한 구현체가 없으므로 클래스에서 Clickable 인터페이스를 구현시 click() 메소드에 대한 구현을 작성해야 한다.

반대로 showOff() 메소드는 동작이 동일하면 별도의 구현이 필요없고, 동작이 다르면 오버라이딩 처리할 수 있다.

이번엔 다른 인터페이스가 동일한 이름을 가진 showOff() 메소드를 재정의한 경우를 살펴보자.

1
2
3
4
5
interface Focusable {
fun setFocus(b: Boolean) =
println("I ${if (b) "got" else "lost"} focus.")
fun showOff() = println("I'm focusable!")
}

만약 하나의 클래스가 ClickableFocusable 인터페이스를 모두 구현해야 하는 경우엔 showOff() 메소드를 명시적으로 구현해야 한다.

별도의 처리가 없으면 아래와 같은 오류 메시지를 출력한다.

1
2
3
The class 'Button' must
override public open fun showOff() because it inherits
many implementations of it.

코틀린 컴파일러가 출력하는 위의 오류를 통해 개발자는 인터페이스의 구현체를 놓치지않고 작성할 수 있게 된다.

1
2
3
4
5
6
7
class Button : Clickable, Focusable {
override fun click() = println("I was clicked")
override fun showoff() {
super<Clickable>.showOff()
super<Focusable>.showOff()
}
}

Button 클래스에서 Clickable 인터페이스와 Focusable 인터페이스를 모두 구현해보자.

showOff() 메소드는 모두 default 메소드이므로 Java와 같은 super 키워드를 통해 해당 구현체를 호출할 수 있다.

만약 두 인터페이스를 구분해야할 경우 해당 인터페이스 이름을 <> 안에 넣어서 구분할 수 있다.

1
override fun showOff() = super<Clickable>.showOff()

Button 객체를 생성 후 Focusable 인터페이스의 setFocus() 메소드를 통해 아래와 같은 코드로 모든 메소드를 호출할 수 있다.

1
2
3
4
5
6
fun main(args: Array<String>) {
val button = Button()
button.showOff()
button.setFocus(true)
button.click()
}

open, final, abstract 키워드

상속을 지원하는 객체지향 언어들이 가진 문제점 중에 fragile base class 라는 게 있다.

한국말로는 깨지기 쉬운 기반 클래스 문제 라고 번역되곤 하는데 위키피디아 링크를 첨부한다.

링크 Fragile base class in Wikipedia

이 문제는 기반 클래스의 변경으로 해당 기반 클래스르 상속한 하위 클래스들이 잘못된 동작을 하는 경우를 말한다.

이를 방지하기 위한 방법으로 여러가지가 있지만 JVM(특히 Java)계열에선 final 키워드로 해당 클래스 혹은 메소드가 최종본임을 명시하고, 하위 클래스에서의 변경을 막는다.

이 문제를 코틀린은 반대로 풀었다. 임의로 open 키워드를 통해 개방한 메소드 혹은 클래스가 아니면 모두가 최종본(즉 fianl)으로 판별한다.

1
2
3
4
5
open class RichButton : Clickable {
fun disable() {}
open fun animate() {}
override fun click() {}
}

위의 click()RichButton클래스에서 재정의하고 있으므로 Clickable에서 open상태로 지정된다.

이후 RichButton 클래스를 상속한 클래스에서의 재정의를 방지하려면 아래와 같이 click()메소드를 final로 변경한다.

1
2
3
open class RichButton : Clickable {
final override fun click() {}
}

코틀린도 Java와 마찬가지로 abstract 키워드를 통해 추상 클래스를 선언할 수 있다.

추상 클래스 특성상 자체적인 객체생성이 막혀있고, 재정의가 필요한 멤버들을 포함한다.

단 추상 클래스의 멤버들은 기본적으로 open 상태로 설정되므로 명시적으로 표기할 필요는 없다.

1
2
3
4
5
6
7
8
9
10
11

abstract class Animated {

abstract fun animate()

open fun stopAnimating() {
}

fun animateTwice() {
}
}
접근자 선언된 멤버 동작
final 오버라이딩 불가능 기본값, 생략 가능
open 오버라이딩 가능 명시적으로 표기
abstract 오버라이딩 필수 추상클래스에서만 사용, 추상 멤버 구현체가 필요없음
override 슈퍼클래스나 인터페이스의 멤버를 오버라이딩 오버라이딩 대상이 open 상태여야하고, final이면 안됨

접근 제어자 (Visibility modifiers)

접근 제어자는 클래스 외부에서의 참조 범위를 결정한다.

Java의 경우 private, protected, public와 같은 접근 제어자를 가지고 있으며, 코틀린 또한 이와 마찬가지로 private, protected, public 접근 제어자를 가지고 있다.

허나 default로 정의 즉, 접근 제어자의 생략시 동작이 다르다.

Java에서 접근 제어자를 생략하면 default로 처리되어, 패키지내부에서 참조가 가능하지만 코틀린에서 패키지는 네임스페이스 관리를 위해서만 사용된다.

대신 코틀린은 모듈 내부에서의 참조를 허용하는 internal이라는 접근 제어자를 제공하고 있다.

아래 표를 통해 코틀린의 접근 제어자를 확인해보자.

접근 제어자 멤버에 선언시 최상위에 선언시
public(기본값) 어디에서든지 접근 가능 어디에서든지 접근 가능
internal 모듈안에서 접근 가능 모듈안에서 접근 가능
protected 서브클래스에서 접근 가능 -
private 클래스 내부에서 접근 가능 파일 내부에서 접근 가능

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
internal open class TalkativeButton : Focusable {
private fun yell() = println("Hey!")
protected fun whisper() = println("Let's talk!")
}

fun TalkativeButton.giveSpeech() {
yell()
whisper()
}

giveSpeech() 메소드의 모든 코드는 접근 제어자의 가시성을 위반하므로 컴파일 오류를 출력한다.

inner, nested 클래스

코틀린도 Java와 마찬가지로 클래스 내부에 또 다른 클래스를 선언할 수 있다.

이런 식의 작업은 helperbuilder 클래스를 캡슐화하는 등 유용하게 쓰인다.

코틀린의 중첩 클래스가 Java와 다른 점은 외부 클래스의 객체에 접근할 수 없다는 것이다.

아래 코드를 통해 살펴보도록 하자.

1
2
3
4
5
6
interface State: Serializable

interface View {
fun getCurrentState(): State
fun restoreState(state: State) {}
}

먼저 직렬화를 할 수 있게 해주는 Serializable로 확장한 State 인터페이스가 있다.

이어서 View 인터페이스가 있는데, View 인터페이스의 멤버함수로 현재 State를 저장하고 가져올 수 있는 메소드 getCurrentState()restoreState가 정의되어 있다.

헌데, View를 구현하고 있는 Button 클래스에서 State를 저장하고 불러올 때엔 아래와 같이 Button 클래스에서 ButtonState 클래스를 정의하는 것이 좀 더 편리하다.

아래 Java 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class Button implements View {

@Override
public State getCurrentState() {
return new ButtonState();
}

@Override
public void restoreState(State state) { /*...*/ }

public class ButtonState implements State { /*...*/ }
}

State 인터페이스를 구현하고, Button의 정보를 저장하는 ButtonState 클래스를 선언하였다.

위의 코드를 실행하면 java.io.NotSerializableException을 출력한다.

ButtonState 클래스는 Button 클래스에 속해있기때문에, Button을 묵시적으로 참조하는 내부클래스로 취급된다.

따라서 ButtonState를 직렬화할 수 없기때문에 위의 오류를 출력한다.

이를 해결하려면 ButtonState클래스를 static으로 선언하여 묵시적 참조를 제거해야 한다.

1
public static class ButtonState implements State { /*...*/ }

이를 코틀린에서 구현하면 아래 코드와 같다.

1
2
3
4
5
class Button : View {
override fun getCurrentState(): State = ButtonState()
override fun restoreState(state: State) { /*...*/ }
class ButtonState : State { /*...*/ }
}

위의 ButtonState처럼 명시적인 키워드가 없는 중첩 클래스는 Java에서 static으로 선언된 클래스와 동일하다.

만약 외부 클래스에 대한 참조를 명시적으로 표현하려면 inner 키워드를 사용해 내부 클래스임을 명시한다.

클래스 B 내부에 선언된 클래스 A Java Kotlin
중첩 클래스 static class A class A
내부 클래스 class A inner class A

The syntax to reference an instance of an outer class in Kotlin also differs from Java. You write this@Outer to access the Outer class from the Inner class:

1
2
3
4
5
class Outer {
inner class Inner {
fun getOuterReference(): Outer = this@Outer
}
}

sealed 클래스

예전 예제를 다시 살펴보자.

슈퍼클래스 ExprNumSum이라는 두 개의 서브클래스를 가지고 있다.

Num은 숫자를 나타내는 클래스고, Sum은 두 식의 합을 나타내는 클래스이다.

1
2
3
4
class Expr

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

이때 Expr 객체를 파라미터로 받아 처리하는 eval()이라는 메소드가 있다고 가정하자.

해당 메소드의 경우 Expr를 구현한 NumSum은 구분할 수 있지만, 그 외의 클래스가 주어졌을 때 처리할 로직이 필요하다.

1
2
3
4
5
6
fun eval(e: Expr): Int =
when (e) {
is Num -> e.value
is Sum -> eval(e.right) + eval(e.left)
else -> throw IllegalArgumentException("Unknown expression")
}

코드의 안정성을 위해 무조건 else는 분기해줘야 하지만, 항상 else를 처리하는 것은 무조건 편리하다고 볼 수 없다.

따라서 컴파일러가 else가 없더라도 감지하지 못하도록 하는 처리(=봉인)가 필요한데, 이때 사용하는 키워드가 sealed이다.

슈퍼클래스에 sealed 키워드를 작성하면 더 이상 서브 클래스를 만들 수 없게 된다.

1
2
3
4
5
6
7
8
9
10
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.right) + eval(e.left)
}

sealed 선언된 Expr 클래스를 상속한 NumSum을 모두 분기했기 때문에 더 이상 처리할 예외가 없으므로 else 분기를 제거할 수 있게된다.

추가적으로 sealed로 지정된 클래스는 상속을 염두에 두므로 곧 open이기도 하다.

만약 sealed로 지정된 클래스에 대해 서브클래스가 추가되는 경우, when에서 컴파일 오류를 발생시키므로 개발자는 when에 대한 수정이 필요함을 인지할 수 있다.

sealded를 통한 상속 제한은 코틀린에서 가능한데, sealed로 작성한 인터페이스를 Java에서 가져다가 구현하게 되면 코틀린 컴파일러가 감지할 수 없기때문에 인터페이스에 sealed를 선언하는 것은 불가능하다.