devlog of ShinJe Kim

[Kotlin] 데이터 클래스와 클래스 위임

|

데이터 클래스와 클래스 위임

코틀린의 data 클래스는 equals, hashCode, toString을 자동으로 생성해줍니다. data 클래스에 대해 알아보기 이전에 기존의 방식을 살펴본다음, 코틀린의 data 클래스에는 어떤 이점이 있는지 알아보겠습니다.

자바 플랫폼에서는 클래스가 equals, hashCode, toString 등의 메소드를 구현해야 합니다. IDE에서 이러한 메소드를 자동으로 생성해주기는 하지만 반복되는 코드는 여전히 남아있습니다. 코틀린에서는 이러한 문제를 해결하기 위해 toString, equals, hashCode 메소드 구현을 보이지 않는 곳에서 자동으로 생성해줍니다.

class Client(val name: String, val postalCode: Int)

toString() : 문자열 표현

기본적으로 제공되는 객체의 문자열 표현은 ‘Client@5e9f23b4’와 같은 방식입니다. 이를 조금 더 유용하게 사용하기 위해서 toString 메소드를 오버라이드하여 사용할 수 있습니다.

class Client(val name: String, val postalCode: Int) {
    override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

// 결과
>>> val client1 = Client("김모모", 1234)
>>> println(client1)
Client(name=김모모, postalCode=4122)

equals() : 객체의 동등성

코틀린에서는 객체의 동등성을 비교하는 방식으로 == 연산자와 equals 메소드를 사용합니다. 코틀린에서의 == 연산자와 자바에서의 == 연산자는 다릅니다.

코틀린에서의 == 연산자는 참조 동일성을 검사하지 않고 객체의 동등성을 검사합니다. 따라서 == 연산을 사용하더라도 내부적으로 equals를 호출하는 방식으로 컴파일됩니다. 기존의 자바에서의 == 연산자는 코틀린에서 === 연산자로 사용됩니다. 그리고 코틀린의 is 검사는 자바의 instanceOf와도 같습니다.

class Client(val name: String, val postalCode: Int) {
    override fun equals(other: Any?): Boolean {
        if(other == null || other !is Client)
            return false
        return name == other.name && 
            postalCode == other.postalCode
    }
    override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

위의 예시에서 Anyjava.lang.Object에 대응하는 클래스로, 코틀린의 모든 클래스의 최상위 클래스입니다. 위의 예시에서 Any?는 널이 될 수 있는 타입이므로 other는 null일 수 있습니다.

hashCode() : 해시 컨테이너

자바에서는 equals를 오버라이드 할 때 반드시 hashCode도 함께 오버라이드 해야합니다. 아래의 예시를 통해 그 이유를 살펴보겠습니다.

>>> val processed = hashSetOf(Client("김모모", 4122))
>>> println(processed.contains(Client("김모모", 4122)))
false

위의 예시에서 Client의 프로퍼티는 모두 일치합니다. 따라서 결과값은 true가 나와야 할 것 같습니다. 예상과 다르게 false가 나온 이유는 Client 클래스가 hashCode 메소드를 정의하지 않았기 때문입니다. JVM 언어에서는 hashCode가 지켜야 하는 제약이 있습니다.

equals()가 true를 반환하는 두 객체는 반드시 같은 hashCode()를 반환해야 한다.

예시코드에서 Client 클래스는 이 제약을 지키지 않고 있습니다. processed의 집합은 HashSet입니다. HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 hashCode를 비교하고, hashCode가 같은 경우에만 실제 값을 비교합니다.

위의 두 Client 인스턴스는 hashCode가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단합니다. 즉, hashCode가 다를 때 equals가 반환하는 값은 판단 결과에 영향을 끼치지 못합니다. 따라서 원소 객체들이 hashCode에 대한 규칙을 지키지 않는 경우 HashSet은 제대로 작동할 수 없습니다. 이러한 문제를 해결하기 위해서는 아래와 같이 Client가 hashCode를 구현해야합니다.

class Client(val name: String, val postalCode: Int) {
    ...
    override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}

data class (데이터 클래스)

지금까지 살펴본 toString, hashCode, equals를 번거롭게 직접 생성하거나 IDE를 통해 생성할 필요 없이, 자동으로 생성해주는 클래스가 data 클래스입니다. 아래와 같이 class 앞에 data 변경자를 선언하기만 하면 됩니다.

data class Client(val nam: String, val postalCode: Int)

data 클래스는 아래와 같은 메소드를 생성해줍니다.

  • 인스턴스 간 비교를 위한 equals
  • HashMap과 같은 해시 기반 컨테이너에서 키로 사용할 수 있는 hashCode
  • 클래스의 각 필드를 선언 순서대로 표시하는 문자열 표현을 만들어주는 toString

equals와 hashCode는 주 생성자에 나열된 모든 프로퍼티를 고려하여 만들어집니다. 생성된 equals 메소드는 모든 프로퍼티 값의 동등성을 확인하고, hashCode 메소드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환합니다. 이 떄 주 생성자 밖에 정의된 프로퍼티는 equals나 hashCode를 계산할 때 고려의 대상이 아닙니다.

이 떄 data 클래스의 프로퍼티가 꼭 val일 필요는 없습니다. 하지만 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어 데이터 클래스를 불변(immutable) 클래스로 만드는 것이 권장됩니다. 특히 HashMap 등의 컨테이너에 데이터 클래스 객체를 담는 경우에는 불변성이 필수적입니다.

코틀린 컴파일러는 데이터 클래스의 인스턴스를 더 쉽게 불변 객체로 활용할 수 있게 메소드를 제공하는데, 이것이 copy 메소드 입니다. copy 메소드는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해줍니다. 원본 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 것이 더 낫습니다. 복사본은 원본과 다른 생명주기를 가지며, 복사본의 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영향을 주지 않기 때문입니다.

위에서 예시로 나왔던 Client의 copy를 구현하면 아래와 같습니다.

class Client(val name: String, val postalCode: Int) {
    ...
    fun copy(name: String = this.name,
            postalCode: Int = this.postalCode) = 
        Client(name, postalCode)
}

아래는 copy 메소드를 사용하는 방법입니다.

>>> val lee = Client("김모모", 4122)
>>> println(lee.copy(postalCode = 4000))
Client(name=김모모, postalCode=4000)

클래스 위임

코틀린에서는 모든 클래스가 기본적으로 final이며, open 변경자로 열어둔 클래스만 상속할 수 있습니다. 하지만 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야할 때가 있습니다. 이 때 일반적으로 사용하는 것이 데코레이터(decorator) 패턴입니다.

데코레이터 패턴이란 상속을 허용하지 않는 기존 클래스 대신 사용할 수 있는 새로운 클래스(데코레이터)를 만들되, 기존 클래스와 같은 인터페이스를 데코레이터가 제공하게 만들고, 기존 클래스를 데코레이터 내부에 필드로 유지하는 것입니다. 이 때 새로 정의해야하는 기능은 데코레이터의 메소드에 새로 정의하고 기존 기능이 그대로 필요한 부분은 데코레이터의 메소드가 기존 클래스의 메소드에게 요청을 전달(forwarding)합니다.

이러한 접근 방식의 단점은 준비 코드가 상당히 많이 필요하다는 것입니다. 코틀린에서는 이점을 개선하여 보일러플레이트 코드 없이 by 키워드라는 것을 통해 간단하게 클래스 위임을 구현할 수 있습니다. 인터페이스를 구현할 때 by 키워드를 사용하여 해당 인터페이스에 대한 구현을 다른 객체에 위임중이라는 사실을 명시할 수 있습니다.

interface Base {
    fun print()
}

class BaseImpl(val x: Int) : Base {
    override fun print() { print(x) }
}

class Derived(b: Base) : Base by b

fun main() {
    val b = BasImpl(10)
    Derived(b).print()
}

Derived 클래스에 있는 by 구문은 b가 Derived 클래스의 오브젝트에 내부적으로 저장되고, 컴파일러가 b로 전달하는 Base의 모든 메소드를 생성함을 의미합니다.

다음으로는 위임으로 구현된 인터페이스의 멤버를 오버라이딩 해보겠습니다.

interface Base {
    fun printMessage()
    fun printMessageLine()
}

class BaseImpl(val x: Int) : Base {
    override fun printMessage() { print(x) }
    override fun printMessageLine() { print(x) }
}

class Derived(b: Base) : Base by b {
    override fun printMessage() { print("abc")}
}

fun main() {
    val b = BaseImpl(10)
    Derived(b).printMessage()
    Derived(b).printMessageLine()
}

여기서 주의해야 할 것이 있습니다. 이 방법으로 오버라이딩 된 멤버는 위임된 객체의 멤버에서 호출되는 것이 아닙니다. 해당 멤버는 자체적으로 구현된 인터페이스 멤버에만 접근할 수 있습니다.

interface Base {
    val message: String
    fun print()
}

class BaseImpl(val x: Int) : Base {
    override val message = "BaseImpl: x = $x"
    override fun print() { print(message) }
}

class Derived(b: Base) : Base by b {
    // This property is not accessed from b's implementation of 'print'
    override val message = "Message of Derived"
}

fun main() {
    val b = BaseImpl(10)
    val derived = Derived(b)
    derived.print()
    println(derived.message)
}

참고 문헌

Comments