자바처럼 코틀린에서도 클래스 안에 다른 클래스를 선언할 수 있습니다. 클래스 안에 다른 클래스를 선언하면 도우미 클래스를 캡슐화하거나 코드 정의를 그 코드를 사용하는 곳 가까이에 두고 싶을 때 유용합니다.
자바와의 차이는 코틀린의 중첩 클래스(nested class)는 명시적으로 요청하지 않는 한 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다는 점입니다.
예시를 통해 이 특성의 중요성을 확인하겠습니다.
View 요소를 만든다고 가정하겠습니다. 이 View의 상태를 직렬화하기 위해 필요한 모든 데이터를 다른 도우미 클래스를 복사할 수 있는데, 이를 위해 State 인터페이스를 선언하고 Serializable를 구현합니다. View 인터페이스 안에는 뷰의 상태를 가져와 저장할 때 사용할 getCurrentState와 restoreState 메서드 선언이 있습니다.
// Serializable을 구현하는 인터페이스
interface State: Serializable
interface View {
// 반환값이나 매개변수로 State 지정
fun getCurrentState(): State
fun restoreState(state: State)
}
직렬화(Serialize) - 자바 시스템 내부에서 사용되는 Object 또는 Data를 외부의 자바 시스템에서도 사용할 수 있도록 byte형태로 데이터를 변환
역직렬화(Deserialize) - byte로 변환된 데이터를 원래대로 Object 나 Data로 변환
java의 직렬화
이제 이를 상속받는 Button 클래스를 생성하고 Button 클래스의 상태를 저장하는 클래스를 Button 내부에 선언합니다.
public class Button implements View{
// Button의 새 인스턴스 생성
@Override
public State getCurrentState() {
return new ButtonState();
}
@Override
public void restoreState(State state) {
}
// State 인터페이스를 구현한 ButtonState 클래스를 정의해서
// Button에 대한 구체적 정보 저장
public class ButtonState implements State {
}
}
이 자바 코드에서 버튼의 상태를 직렬화하면 java.io.NotSerializableExeption: Button
이라는 오류가 발생합니다. 자바에서 다른 클래스안에 정의한 클래스는 자동으로 내부 클래스(inner class)가 됩니다. 그렇기에 위 예제에서 Button 클래스 안에 선언된 ButtonState 클래스는 바깥쪽 Button 클래스에 대한 참조를 묵시적으로 포함합니다. 그 참조로 인해 Button을 직렬화할 수 없으므로(Serializable을 구현하지 않았으므로) 버튼에 대한 참조가 ButtonState의 직렬화를 방해합니다.
자바에서는 이 문제를 해결하기 위해서 ButtonState를 static 클래스로 선언해야 합니다. 중첩 클래스를 static으로 선언하면 그 클래스를 둘러싼 바깥쪽 클래스에 대한 묵시적인 참조가 사라집니다.
코틀린에서는 중첩된 클래스가 기본적으로 동작하는 방식은 자바와 정반대입니다.
class Button: View {
override fun getCurrentState(): State {
return ButtonState()
}
override fun restoreState(state: State) {
}
// 중첩 클래스
// 자바의 static 클래스에 해당
// Outer 클래스 참조 X
class ButtonState: State {
}
}
코틀린 중첩 클래스에 아무런 변경자가 붙지 않으면 자바 static 중첩 클래스(바깥쪽 참조 X)와 같습니다.
이를 내부 클래스로 변경해서 바깥쪽 클래스에 대한 참조를 포함하게 만들고 싶다면 inner 변경자를 붙여야 합니다.
코틀린에서 바깥쪽 클래스의 인스턴스를 가리키는 참조를 표기하는 방법도 자바와 다릅니다. 내부 클래스 InnerClass 안에서 바깥쪽 클래스 OuterClass의 참조에 접근하려면 this@OuterClass와 같이 써야합니다.
class OuterClass {
val name: String = "Outer!!"
// inner를 선언하여 내부 클래스로 선언
inner class InnerClass {
fun innerFun() {
// 바깥쪽 클래스(OuterClass)에 대한 참조를 저장하기에 접근 가능
println(name)
}
// this@바깥쪽클래스명을 사용하여 바깥쪽 클래스 인스턴스 참조
fun getOuterReference() : OuterClass = this@OuterClass
}
}
클래스 B 안에 정의된 클래스 A | 자바에서 | 코틀린에서 |
---|---|---|
중첩 클래스(바깥쪽 클래스에 대한 참조를 저장하지 않음) | static class A | class A |
내부 클래스(바깥쪽 클래스에 대한 참조를 저장) | class A | inner class A |
Sealed 클래스와 인터페이스는 상속을 넘어서 더 많은 제어를 제공하는 제한된 클래스 계층을 나타냅니다. sealed 클래스의 서브 클래스는 컴파일 때 모두 알 수 있습니다. 그렇기에 sealed 클래스를 가지는 모듈을 컴파일한 후에는 다른 하위 클래스가 나타날 수 없습니다.
Sealed 인터페이스와 그 인터페이스를 구현할 때도 클래스와 동일하게 동작합니다. sealed 인터페이스를 가진 모듈을 컴파일하면 새로운 구현이 나타나지 않습니다.
sealed 클래스와 enum 클래스는 유사하지만 다릅니다. enum 클래스의 타입의 값들도 sealed와 마찬가지로 제한되지만 각각의 enum 상수는 하나의 단일 인스턴스로만 존재합니다. 하지만 sealed 클래스의 하위 클래스는 각각 자체 상태를 가진 여러 인스턴스를 가질 수 있습니다.
상위 클래스에 sealed 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스 정의를 제한할 수 있습니다.
예시를 통해 알아보겠습니다.
이러한 sealed 클래스를 when 표현식과 함께 사용하면 이점을 볼 수 있습니다. 아래에서 추가로 설명하겠습니다.
아래의 코드에서 상위 클래스인 Expr에는 숫자를 표현하는 Num과 덧셈 연산을 표현하는 Sum이라는 두 하위 클래스가 있습니다. sealed class로 선언되지 않고 interface로 선언된 Expr을 Num과 Sum이 구현하고 있습니다. 이러한 Num과 Sum을 when을 사용하여 분기하여 코드를 작성할 때 when이 받는 인자 타입을 Expr로 주면 두 클래스 모두 Expr 타입을 구현하고 있기에 편리하게 사용할 수 있습니다. 다만 이러한 방식으로 작성하면 Num과 Sum 타입뿐만 아니라 Expr을 구현한 다른 타입도 올 수 있기에 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.right) + eval(e.left)
else -> // "else" 분기가 반드시 있어야 한다
throw IllegalArgumentException("Unknown expression")
}
코틀린 컴파일러는 when을 사용해 Expr 타입의 값을 검사할 때 꼭 디폴트 분기인 else 분기를 덧붙이게 강제합니다. 왜냐하면 Expr을 구현한 다른 클래스가 when의 인자로 들어갈 수 있는데, 이러한 상황을 컴파일러는 판단하지 못하기에 else 구문을 추가하여 그러한 상황에 대처하는 것입니다. 그래서 위 예제는 else 분기를 작성하였고, 반환할 만한 의미 있는 값이 없으므로 예외를 던졌습니다.
그리고 이러한 디폴트 분기가 있으면 클래스 계층에 새로운 하위 클래스를 추가하더라도 컴파일러가 when이 모든 경우를 처리하는지 제대로 검사할 수 없습니다. 따라서 새로운 클래스의 처리를 잊어버리면 디폴트 분기가 선택되기에 버그가 발생할 수 있습니다.
코틀린에서는 이런 문제를 해결하기 위해 sealed 클래스를 사용합니다. 상위 클래스에 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" 식이 모든 하위 클래스를 검사하므로 별도의 "else" 분기가 없어도 됨
when(e) {
is Num -> e.value
is Sum -> eval(e.right) + eval(e.left)
}
when 식에서 sealed 클래스의 모든 하위 클래스를 처리한다면 디폴트 분기(else 분기)가 필요 없다는 이점이 있습니다.
sealed로 표시된 클래스는 자동으로 open 입니다. 따라서 별도로 open 변경자를 붙일 필요가 없습니다.
sealed 클래스에 속한 값에 대해 디폴트 분기를 사용하지 않고 when 식을 사용하면 나중에 sealed 클래스의 상속 계층에 새로운 하위 클래스를 추가해도 when 식이 컴파일되지 않아서 추가해야 한다는 사실을 쉽게 알 수 있습니다.
또한 Sealed 클래스는 내부적으로 private 생성자를 가집니다. 그렇기에 위의 예제에서 Sealed 클래스인 Expr 클래스는 인스턴스를 생성할 수 없습니다.