[Kotlin] 10. 컴파일러가 생성한 메서드: 데이터 클래스와 클래스 위임

2023. 3. 28. 13:57Programming Languages/Kotlin

Java에서는 클래스가 equals, hashcode, toString 등의 메서드를 구현한다. 그리고 보통 이런 메서드들은 기계적으로 구현된다. 일반적으로 IDE에서 이런 기능을 지원해줘서 직접 구현하는 일은 많이 없지만 코드가 지저분해지는 것은 어쩔 수 없다. Kotlin에서는 이러한 메서드들을 컴파일러가 내부적으로 구현해주기 때문에 소스코드를 깔끔하게 유지할 수 있다.

1. 모든 클래스가 정의해야 하는 메소드

1. toString()

Java와 마찬가지로 Kotlin에서도 객체의 기본 toString()은 클래스이름@숫자 와 같은 형식을 띤다. 이는 별로 좋은 방식은 아니므로 객체의 필수 정보를 포함하여 toString()을 오버라이드해야 한다.

2. equals()

Java에서는 두 값에 대한 비교를 ==equals()로 수행한다. ==은 값 타입인 경우 동일성(equality)을 비교하고 참조 타입인 경우 참조값을 비교한다. 알다시피 객체의 참조값은 모두 다르기 때문에 객체를 비교할 때 ==을 사용하면 언제나 false가 나오게 된다. 이를 위해 Java는 equals() 메서드를 통해 객체의 내부 정보를 통해 참조값을 비교한다. Kotlin도 이와 같은 방식이지만 표현 방법이 조금 다르다.

코틀린에서는 객체를 비교를 할 때 ==을 사용한다. ==은 내부적으로 equals를 호출해서 객체를 비교한다. 따라서 클래스가 equals를 오버라이드하면 ==을 통해 안전하게 비교할 수 있다. 참조 비교를 위해서는 === 연산자를 사용한다. === 연산자를 사용하면 Java의 == 연산과 같은 결과를 얻을 수 있다.

Java에서는 해시를 기반으로 만들어진 컬렉션(HashSet, HashMap 등)은 성능을 위해 원소들의 hashCode()를 먼저 비교하고 같은 경우에만 equals()를 통해 비교한다. 그렇기 때문에 equals()를 오버라이드했다면 hashCode()도 함께 오버라이드해야 한다는 전제가 있다. Kotlin에서도 이를 따른다.

위와 같은 코드를 제대로 작성했다면 이제 예상한대로 코드가 동작할 것이다. 그러나 이를 위해 얼마나 많은 코드를 작성했는지 생각해보자. Java에서는 필요할 때마다 위 코드들을 모두 작성해야 했다. Lombok 같은 라이브러리도 이 귀찮음을 덜어내기 위해 @ToString, @EqualsAndHashCode, @Data 등의 기능을 지원했다. Kotlin에서는 이를 컴파일러가 지원한다.

2. 데이터 클래스: 모든 클래스가 정의해야 하는 메서드 자동 생성

어떤 클래스가 데이터를 저장하는 역할만을 수행한다면 toString, equals, hashCode를 만드시 오버라이드해야 한다. 다행히 이런 메서드를 정의하는 것은 어렵지 않으며 IDE에서도 이를 지원하는 경우가 많다.

Kotlin은 더 편리하다. 이런 메서드를 IDE를 통해 생성할 필요 없이 컴파일러에서 만들어준다. data라는 변경자를 클래스 앞에 붙이기만 하면 컴파일러가 자동으로 만들어준다. data 변경자가 붙은 클래스를 데이터 클래스라고 부른다.

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

Client 클래스는 자바에서 요구하는 모든 메서드를 포함한다.

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

equalshashCode주 생성자에 나열된 모든 프로퍼티를 고려해 만들어진다. 생성된 equals 메서드는 모든 프로퍼티 값의 동등성을 확인한다. hashCode 메서드는 모든 프로퍼티의 해시 값을 바탕으로 계산한 해시 값을 반환한다. 이때 주 생성자 밖에 정의된 프로퍼티는 equalshashCode를 계산할 때 고려 대상이 아니라는 사실에 유의하자. Kotlin 컴파일러는 이 외에도 몇 가지 유용한 메서드를 더 만들어주는데 차후에 알아보도록 한다.

데이터 클래스와 불변성: copy() 메서드

데이터 클래스의 프로퍼티가 꼭 val일 필요는 없다. 원한다면 var 프로퍼티를 써도 된다. 하지만 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 데이터 클래스를 불변 클래스로 만들라고 권장한다. 해시 기반 컬렉션에 데이터 클래스 객체를 담는 경우엔 불변성이 필수적이다.

데이터 클래스 인스턴스를 불변 객체로 쉽게 활용할 수 있게 Kotlin 컴파일러는 copy 메서드를 지원한다. 이 메서드는 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해준다. 객체는 메모리에서 변경하는 것보다 복사하는 편이 낫다. 복사본은 원본과 다른 생명주기를 가지며, 복사를 하면서 일부 프로퍼티 값을 바꾸거나 복사본을 제거해도 원본에 영향이 없다. Client의 copy를 직접 구현하면 다음과 같을 것이다.

class Client(val name: String, val postalCode: Int) {
    fun copy(name: String = this.name,
                        postalCode: Int = this.postalCode) =
            Client(name, postalCode)
}
val lee = Client("이시영", 4122)
println(lee.copy(postalCode = 4000)
// Client(name=이시영, postalCode=4000)

3. 클래스 위임: by 키워드 사용

대규모 객체지향 시스템을 설계할 때 시스템을 취약하게 만드는 문제는 보통 구현 상속에 의해 발생한다. 기반 클래스를 설계할 때의 조건을 충분히 이해하지 않거나 문서화가 되어 있지 않은 상태에서 오버라이드하게 되면 문제가 발생한다. 이러한 문제를 해결하기 위해 코틀린에서는 클래스를 기본적으로 final로 만들고 필요한 경우에만 open으로 상속이 가능하도록 했다.

하지만 종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이런 경우는 일반적으로 데코레이터 패턴으로 해결한다. 하지만 이 또한 준비해야 하는 코드가 상당히 많은 것이 단점이다. 다음 코드를 보자.

class DelegatingCollection<T>: Collection<T> {
    private val innerList = arrayListOf<T>()

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

Kotlin에서는 이런 위임을 일급 시민 기능으로 지원한다. 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다. 위 코드는 아래와 같이 줄일 수 있다.

class DelegatingCollection<T> (
    innerList: Collection<T> = ArrayList<T>()
): Collection<T> by innerList {}

메서드 중 일부의 동작을 변경하고 싶은 경우 메서드를 오버라이드하면 컴파일러가 생성한 메서드 대신 오버라이드한 메서드가 사용된다. 기존 클래스의 메서드에 위임하는 기본 구현으로 충분한 메서드는 따로 오버라이드하지 않아도 된다.

이 기법을 이용해서 원소를 추가하려고 시도한 횟수를 기록하는 컬렉션을 구현해보자.

class CountingSet<T> (
    private val innerSet: MutableCollection<T> = HashSet<T>()
): MutableCollection<T> by innerSet {
    var objectsAdded = 0
    private set

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