본문 바로가기
2023년 이전/kotlin

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

by JeongUPark 2020. 2. 25.
반응형

[출처 -  Kotlin In Action] [아래 내용들은 Kotlin In Action을 공부하면서 스스로 정리한 내용입니다] 

 

자바에서 보면 toString, hashCode, equals 제공합니다. 코틀린 컴파일러는 이런 메소드들을 생성하는 작업을 보이지 않는 곳에서 해줍니다. 그래서 소스 코드를 깔끔하게 유지 할 수 있도록 해줍니다.

 

자바와 마찬가지로 toString, equals, hashcode 등을 오버라드 해서 사용할 수 있습니다. 그런 간단하게 어떻게 사용 되는지 보겠습니다.

toString

보통 디버깅이나 로깅 시 클래스의 인스턴스를 문자열로 표현하게 되는데 그럴때 나오는 값은 클래스이름@주소 의 형태로 개발자에게 직관적인 정보를 주지 못합니다. 그래서 toString을 오버라이드 하면 직관적인 값을 볼 수 있게 됩니다.

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

fun main(args: Array<String>) {
    val client1 = Client("Alice", 342562)
    println(client1)
}

위 코드를 보면 Client 클래스에서 toString을 오버라드 하고 있습니다.  그래서 Client 클래스의 인스턴스를 println으로 확인하면 Client(name=Alice, postalCode=342562) 이런 결과가 나옵니다.

 

그럼 2가지 실험을 해보겠습니다.

1. override를 제거 

override를 제거 할 경우 toString에 error가 발생하고 error를 확인해보면

'toString' hides member of supertype 'Any' and needs 'override' modifier

문구가 나타납니다. 즉 Any의 하이드 멤버기 때문에 override 선언이 필요하다는 말입니다. 즉 toString은 Any의 멤버라는 것을 확인 할 수 있습니다. (코틀린은 자바의 object이 없습니다. 이를 대신하는 것이 Any 입니다.)

2. toString 제거

만일 toString을 제거하고 코드를 실행시키면 jeonguTest.Client@6e8cf4c6 결과를 볼 수 있습니다. 위에서 설명한 클래스이름@주소 의형태입니다.

 

아무튼 이런 toString을 override 함으로써 쫌더 직관적으로 디버깅이나 로깅시 정보를 확인 할 수 있습니다. (문자열을 사용한 다양한 표현 및 사용이 가능해 졌다는 의미 입니다.)

equals()

자바에서 ==은 원시타입과 참조 타입을 비교할 때 사용됩니다. 원시타입의 경우 ==은 두 피연사자의 값이 같은지 비교를 하고, 참조타입의 경우 ==은 두 피연산자의 주소가 같은지를 비교합니다. 그래서 자바에서는 두 객체의 동등성을 비교하려면 equals를 사용해야 합니다. 하지만 코틀린에서는 == 연산자가 두 객체를 비교할 때 내부적으로 equals를 호출해 객체를 비교합니다. 그래서 클래스가 equals를 오버라이드 하면 ==통해 안전하게 클래스의 인스턴스를 비교할 수 있습니다. 그리고 코틀린에서는 ===으로 참조 비교를 합니다.

위 설명을 기본으로 다음 코드들을 보겠습니다.

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

fun main(args: Array<String>) {
    val client1 = Client("Alice", 342562)
    val client2 = Client("Alice", 342562)
    println(client1 == client2)
}

 위의 코드를 실행 시키면 false라는 결과가 나옵니다. 그 이유는 코드상에서는 똑같을지 몰라도 생성된 client1과 client2의 인스턴스는 다른 인스터스이기 때문입니다. 그래서 Cliente 클래스에 equals를 override해서 다시 확인해 보겠습니다.

 

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

fun main(args: Array<String>) {
    val client1 = Client("Alice", 342562)
    val client2 = Client("Alice", 342562)
    println(client1 == client2)
}

Client 클래스에 equals와 toString을 override 했습니다. 그리고 실행을 하면

call Client equals
true

라는 결과를 볼 수 있습니다.

그 이유는 main의 println의 == 동작시 위의 Clinent equals가 호출 되고 거기서 인스턴스 비교가 아닌 name과 postalCode 비교를 해서 결과를 노출 하게 됩니다. 그래서 true가 나옵니다. 위에서 is는 자바의 instanceof와 같은 긴으으로 other 이 Client 타입이 될 수 있는지를 체크 한다. (!is는 될 수 없다면의 뜻으로 !( other instanceof Client) 와 같다.)

 

그런데 Client 클래스로 더 복잡한 작업을 수행하다보면 잘 동작을 하지 않는 경우가 생기는데, 그 이유는 hashcode 정의를 빠트렸기 떄문이다. 

 

hashcode

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)"
}

fun main(args: Array<String>) {
    val processed = hashSetOf(Client("Alice", 342562))
    println(processed.contains(Client("Alice", 342562)))
}

 위 코드를 실행하면 true가 나와야 할꺼 같지만 false가 나옵니다. 그 이유는 Client가 hashcode를 정의하지 않았기 때문입니다. processed집합은 hashSet인데, hashSet은 객체의 hashcode를 비고하고 hashcode가 같은 경우에만 실제 값을 비교합니다. 그래서 위의 Client 2개는 파라미터 값은 같을 지 몰라고 hashcode가 달라서 hashset이 제대로 동작을 못하게 됩니다. (contains는 hashset에서 포함 여부를 확인하는데 그 비교에 equals를 사용합니다.)

그래서 다음과 같이 고치면

class Client(val name: String, val postalCode: Int) {
    override fun hashCode(): Int = name.hashCode() * 31+postalCode
    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)"
}

fun main(args: Array<String>) {
    val processed = hashSetOf(Client("Alice", 342562))
    println(processed.contains(Client("Alice", 342562)))
}

결과에 true가 나오는 것을 볼 수 있습니다.

데이터 클래스

 

어떤 클래스가 데이터를 저장하는 역할만 수행한다면 toString, equals, hashCode가 반드시 오버라이드 되야합니다.

이런 메소드들을 정의하고 작성하는 방법은 간단하지만 코틀린에서는 더 간단한 방법이 있습니다.

 

다음을 보면

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

클래스 앞에 data가 선언 되어 있습니다. 이 선언 하나로 위의 toString, equals, hashCode등을 컴파일러가 처리해줍니다.

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

fun main(args: Array<String>) {
    val client1 = Client("Alice", 342562)
    val client2 = Client("Alice", 342562)
    println(client1 == client2)
}

이렇게 하면 true의 결과를 볼 수 있습니다. (data 하나 추가 되었을 뿐인데 false의 결과가 true가 되어 버렸습니다!)

이 3가지 메소드 뿐만아니라 다른 것도 많습니다. 그 중하나는 copy 입니다.

 

copy

 

copy 메소드는 객체를 복사하여 원본과 다른 생명주기를 가지며, 복사를 하면 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 프로그램에서 원본을 참조하는 다른 부분에 전혀 영양을 끼치지 않게 됩니다.

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

fun main(args: Array<String>) {
    val bob = Client("Bob", 973293)
    println(bob.copy(postalCode = 382555))
    println(bob)
}

결과

Client(name=Bob, postalCode=382555) 
Client(name=Bob, postalCode=973293)

 

 

클래스 위임 : by

 

상속은 자바의 언어의 특징중 하나입니다. 하지만 이런 상속을 규모가 큰 프로젝트에서 사용할 경우 하위 클래스가 상위 클래스의 일부 메소드를 오버라이드 해서 사용하게 될 것이고, 그리고 프로젝트가 진행되면서 상위 클래스의 메소드가 수정됨에 따라 하위 클래스에서 의도하지 않은 변경이 생겨 문제가 발생 할 수도 있습니다.

그래서 코틀린에서는 기본적으로 클래스를 final로 취급하여 상속을 하려면 open으로 선언해주어야 상속을 할 수 있습니다. 그래서 open을 보고 이게 상속을 할 수 있는 클래스 이므로 쫌더 주의를 기울여서 수정을 하게됩니다.

하지만 종종 open을 사용하지 않은 클래스에서  새로운 동작을 추가해야 할 때가 있습니다. 이때 데코레이터 패턴을 사용하는것이 일반 적입니다. 근데 이 데코레이터 패턴을 사용할게 되면 작성해야할 코드가 많아 진다는 문제가 있습니다.

다음 코드를 하나하나 작성해보면 ( 작성에서 error는 InteliJ같이 코딩을 도와주는 IED에서 해야 확인 할 수 있습니다. notepad 같은 곳에서는 확인 되지 않습니다.)

class DelegationCollection<T> : Collection<T>{
    private val innerList = arrayListOf<T>()
    override val size: Int = innerList.size
    override fun contains(element: T): Boolean = innerList.isEmpty()
    override fun containsAll(elements: Collection<T>): Boolean = innerList.containsAll(elements)
    override fun isEmpty(): Boolean = innerList.isEmpty()
    override fun iterator(): Iterator<T> = innerList.iterator()
}

DelegationCollection<T> : Collection<T>{ } 작성시 DelegationCollection error가 발생하고 implement member들을 추가해야 할것입니다. 그러면 val size:Int / fun contains(element: T): Boolean /  fun containsAll(elements: Collection<T>): Boolean / fun isEmpty(): Boolean / fun iterator(): Iterator<T> 의 기본형들이 추가되고 위 처럼 수정할 수 있습니다. 

 

이렇듯 위에서 설명한 작성해야할 코드가 많아진다는 것을 알 수 있습니다.  하지만 코틀린의 경우 by 키워드를 통하여 그 인터페이스에 대한 구현을 다른 객체에 위임한다는 것을 명시 할 수 있습니다.

 

위의 코드 DelegationCollection을 by를 사용하여 변경하면

class DelegationCollection<T>(innerList: Collection<T> = ArrayList<T>()) : Collection<T> by innerList

이렇게 간단하게 위에서 복잡하게 만든 코드를 대체할 수 있습니다. ( 이 코드 역시 직접 작성해 보는 것을 추천해드립니다. 작성하다보면 by innerList 입력 전까는 error가 나타나고 by innerList를 입력하면 error가 사라질 것입니다.)

그 이유는 by innerList를 추가 해줍으로서 컴파일러가 알아서 위의 override 들을 자동으로 생성해주기 때문입니다.

 

그리고 override 중 일부 동작을 직접 작성하고 싶은 경우 그 override 메소드를 직접 작성하면 컴파일러가 생성한 메소드 대신 직접 작성한 메소드를 사용하게 됩니다. 다음 코드를 통에 확인 하실 수 있습니다.

 

import java.util.HashSet

class CountingSet<T>(
        val innerSet: MutableCollection<T> = HashSet<T>()
) : MutableCollection<T> by innerSet {

    var objectsAdded = 0

    override fun add(element: T): Boolean {
        objectsAdded++
        return innerSet.add(element)
    }

    override fun addAll(c: Collection<T>): Boolean {
        objectsAdded += c.size
        return innerSet.addAll(c)
    }
}

fun main(args: Array<String>) {
    val cset = CountingSet<Int>()
    cset.addAll(listOf(1, 1, 2))
    println("${cset.objectsAdded} objects were added, ${cset.size} remain")
}

 

위 코드에서 add를 통해 ojectsAdded의 값을 1 올리고 그 다음 innerSet에 element를 추가후 그 결과를 반환합니다. (addAll 역시 마찬가지 집니다.) 만일 위와 같은 override가 없었다면 MutableCollection의 add가 동작 했을 것입니다. 

반응형