Kotlin

[Kotlin] 모든 클래스가 정의해야 하는 메서드 toString, equals, hashCode

sh1mj1 2024. 1. 9. 20:11

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처            https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

자바와 마찬가지로 코틀린 클래스도 `toString`, `equlas`, `hashCode` 등을 오버라이드 할 수 있다.

 

이런 메서드들을 알아보면서 아래 `Client` 라는 클래스를 만들어서 예제에 사용하자.

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

toString(): 문자열 표현

자바처럼 코틀린의 모든 클래스도 인스턴스의 문자열 표현을 얻을 방법을 제공한다.

주로 디버깅과 로깅 시 이 메서드를 사용한다.

기본 제공되는 객체의 문자열 표현은 `Client@5e9f23b4` 같은 방식이다.

이는 디버깅, 로깅 시 유용하지 않으며, `toString()` 메서드를 오버라이드해서 구현을 바꿀 수 있다.

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

@Test
fun testToString() {
    val client = Client("sh1mj1", 4122)
    assert(client.toString() == "Client(name='sh1mj1', postalCode=4122)")
}

`toString` 에 대해 더 알아보자.

oracle 문서 에서 java 의 `toString` 에 대한 설명이다.

`클래스명@16진수_hashCode`를 반환한다. 그래서 기본 구현된 `toString` 을 호출하면 `Client@5e9f23b4` 와 같은 형태가 나오는 것이다.

`toString` 메서드는 오브젝트를 텍스트로 나타내고, 사람이 읽기 좋은 좋은 결과를 반환해야 한다.

모든 클래스가 이 메서드를 오버라이딩 하는 것을 권장하고 있다.

 

JVM  호출에서 stable 하게 유지될 필요도 없는 메시지이기 때문에, 실제로 로직에 사용하는 게 아닌 개발자가 읽기 위한 용도라는 것이다.

예를 들어 어떤 프로그램이 간단한 MVC 패턴으로 구현되어 있다면, View 에서 Model 의 출력을 위해 Model 에 해당하는 클래스의 `toString` 을 호출하는 방법이 권장되지 않는다.

이에 대해서는 우테코 `toString` 이라는 키워드로 구글링하면 많은 자료가 나온다.

 

hashCode 는 더 아래에서 설명하겠다.

equals(): 객체의 동등성

만약 `Client` 의 두 인스턴스를 아래처럼 두 개 만들었다고 하자. 

@Test
fun testEquals() {
    val client1 = Client("sh1mj1", 4122)
    val client2 = Client("sh1mj1", 4122)
    assert(client1 != client2)
}

`client1` 와 `client2` 는 서로 다른 객체이다.

그래서 당연히 두 객체가 동일하지 않다.

 

그런데 `name` 과 `postalCode` 는 동일하다.

우리가 `name` 과 `postalCode` 가 동일하다면, 같은 객체라고 판단하고 싶다면?

우리는 `equals` 를 오버라이드하여 이를 구현할 수 있다.

코틀린과 자바의 동등성 연산

먼저 `equals` 를 새로 구현해보기 전에 코틀린과 자바의 동등성 연산에 대해 간단한 표를 통해 알아보고 넘어가자.

참조 타입(원시 타입이 아닌)을 비교할 때  Java Kotlin
equality(동등성) 비교

두 객체의 동등성을 비교한다
structural equality(구조적 동등성)이라고도 함
`equals` `==`
내부적으로 equals 를 호출해서 객체를 비교
`equals` 를 `override` 하지 않으면 기본은 참조 비교임!
reference comparision (참조 비교)

두 피연산자의 주소가 같은지 비교한다.
referential equality(래퍼런스적 동등성) 이라고도 함
`==` `===`

client 에 equals() 구현

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(): String = "Client(name='$name', postalCode=$postalCode)"
}

`Any` 는 java 의 `Object` 에 대응하는 클래스로, 모든 클래스의 최상위 클래스이다.

위에서 `(other == null || other !is Client)` 는  `(other !is Client)` 로 간단히 써도 된다. 이는 나중에 다른 글에서 설명하겠다.

 

이제 다른 `Client` 객체와 비교할 때 프로퍼티의 값이 서로 (`name` 과 `postalCode` 의 값이) 같으면 같다고 판단한다.

물론 일부 프로퍼티만 서로 같을 때 같다고 판단하도록 구현할 수도 있다.

 

이렇게 구현해주면 이제 테스트 코드에서 `client1 == client2`  가 된다.

@Test
fun testEquals() {
    val client1 = Client("sh1mj1", 4122)
    val client2 = Client("sh1mj1", 4122)
    assert(client1 == client2)
}

참고로, `equals` 는 기본적으로 `Any` 에 구현되어 있다.

그래서 모든 객체에서 사용할 수 있다.

하지만 연산자를 사용해서 다른 타입의 두 객체를 비교하는 것은 할 수 없다.

물론 두 타입이 상속 관계를 갖는 경우에는 비교할 수  있다.

@Test
fun `equals 사용과 == 사용`() {
    assertFalse { Animal().equals(Book()) } // 가능
    assertFalse { Animal() == Cat() } // 가능
//  assertFalse { Animal() == (Book()) } // ERROR Operator '==' cannot be applied to 'Animal' and 'Book'
}

 

그런데 `Client` 클래스로 더 복잡한 작업을 하다보면 제대로 동작하지 않는 경우가 생긴다.

 

왜 그럴까? 바로 `hashCode` 가 없기 때문이다.

`hachCode` 를 알아보기 전에 먼저 `equals`  컨벤션에 대해 더 자세히 알아보자.

equals 의 컨벤션

Any 에 선언되어 있는 equals 메서드

`equals` 에는 위처럼 주석이 달려있다. 

  • 어떤 다른 객체가 이 객체와 같은지(equal to) 확인할 때 사용한다. 구현은 아래 요구사항을 충족해야 한다.
    • Reflexive(반사적) 동작: `x` 가 non-null 이면 `x.equals(x)` 는 `true` 이다.
    • Symmetric(대칭적) 동작: `x`, `y` 가 non-null 이면, `x.equals(y)` 와 `y.equals(x)` 는 같다.
    • Transitive(연속적) 동작: `x`, `y`, `z` 가 non-null 이고, `x.equals(y)` 와 `y.equals(z)` 가 `true` 이면, `x.equals(z)` 도 `true` 이다.
    • Consistent(일관적): `x`, `y` 가 non-null 이면, `x.equals(y)` 는 항상 같은 결과를 리턴한다. 
    • null 과 다름: `x` 가 non-null 이면 `x.equals(null)` 은 `false` 이다.'

위 요구사항은 자바에서부터 정의된 내용이다.

Reflexive(반사성) 컨벤션 위반

/**
 * Reflexive 하지 않은 equals 구현의 예
 */
class Time(
    val millisArg: Long = -1,
    val isNow: Boolean = false,
) {
    val milis: Long
        get() =
            if (isNow) System.currentTimeMillis()
            else millisArg

    override fun equals(other: Any?): Boolean =
        other is Time && milis == other.milis
}

@RepeatedTest(10000)
fun `Time 의 equals 를 10000 번 테스트`() {
    val now = Time(isNow = true)
    assertTrue { now == now } // 가끔 false 일 때도 있음.
}

위 코드에서는 `now == now` 즉, `now.equals(now)` 이 `true` 가 아닐 때도 생긴다.

 

그렇다면 `Time` 을 어떻게 수정해야 할까?

'객체가 현재 시간을 나타내는가?' 를 확인하고, 그렇지 않다면, '같은 타임스탬프를 갖고 있는가?' 로 동등성을 확인하면 된다.

위는 태그 클래스의 고전적인 예이므로 태그 클래스보다는 클래스 계층 구조를 사용해서 해결하는 것이 좋다.

 

`sealed class FixedTime` 를 상속하는 `TimePoint` 와 `FixedTime`

sealed class FixedTime
class TimePoint(val millis: Long) : FixedTime() {
    override fun equals(other: Any?): Boolean {
        if (other !is TimePoint) {
            return false
        }
        return millis == other.millis
    }
}

class Now : FixedTime() {
    val millis = System.currentTimeMillis()
    override fun equals(other: Any?): Boolean {
        if (other !is Now) {
            return false
        }
        return millis == other.millis
    }
}

@RepeatedTest(100_000) 
fun `FixedTime 의 equals 를 100_000 번 테스트`() { // 항상 통과: Reflexive 하다.
    val now1 = TimePoint(1300)
    assertTrue { now1 == now1 }

    val now2 = Now()
    assertTrue { now2 == now2 }
}

Symmetric(대칭성) 컨벤션 위반

class Complex(val real: Double, val imaginary: Double) {
    override fun equals(other: Any?): Boolean {
        if (other is Double) {
            return imaginary == 0.0 && real == other
        }
        return other is Complex &&
                real == other.real &&
                imaginary == other.imaginary
    }
}

@Test
fun testComplex() {
    val complex = Complex(1.0, 0.0)
    assertTrue(complex.equals(1.0))
    assertTrue((1.0).equals(complex)) // FAIL
}

위 코드대로라면 `Double` 은 `Complex` 와 비교할 수 없다.

그래서 `equals` 에 대해 순서에 따라서 결과가 달라진다.

다른 클래스는 아예 동등하지 않게 만들어버리는 것이 좋다.

Transitive(연속성) 컨벤션 위반

open class Date(val year: Int, val month: Int, val day: Int) {
    override fun equals(other: Any?): Boolean = when (other) {
        is DateTime -> this == other.date
        is Date -> other.year == year && other.month == month && other.day == day
        else -> false
    }
    // ...
}

class DateTime(val date: Date, val hour: Int, val minute: Int, val second: Int) :
    Date(date.year, date.month, date.day) {
    override fun equals(other: Any?): Boolean = when (other) {
        is DateTime -> other.date == date && other.hour == hour && other.minute == minute && other.second == second
        is Date -> date == other
        else -> false
    }
    // ...
}

@Test
fun testDateAndDateTime() {
    val o1 = DateTime(Date(1992, 10, 20), 12, 30, 0)
    val o2 = Date(1992, 10, 20)
    val o3 = DateTime(Date(1992, 10, 20), 14, 45, 30)

    assertTrue { o1 == o2 }
    assertTrue { o2 == o3 }
    assertFalse { o1 == o3 } // transitive(연속적으로) 동작하지 않음
}

`DateTime` 과 `Date` 를 비교할 때 보다 `DateTime` 과 `DateTime` 을 비교할 때 더 많은 프로퍼티를 확인한다는 문제점이 있다. 

그래서 날짜가 같지만 시간이 다른 두 `DateTime` 객체를 비교하면 `false` 이지만, 이것들을 날짜가 같은 `Date` 객체를 비교하면 `true` 가 나온다.

 

위 코드에서는 `DateTime` 이 `Date` 를 상속하고 있다. 

처음부터 상속 대신 합성을 사용하고, 두 객체를 아예 비교하지 못하게 만드는 것이 좋다.

 

상속 대신 합성을 사용하는 `FixedDate`, `FixedDateTime`

class FixedDate(val year: Int, val month: Int, val day: Int) {
    override fun equals(other: Any?): Boolean {
        if (other !is FixedDate) {
            return false
        }
        return year == other.year && month == other.month && day == other.day

    }
}

class FixedDateTime(val date: FixedDate, val hour: Int, val minute: Int, val second: Int) {
    override fun equals(other: Any?): Boolean {
        if (other !is FixedDateTime) {
            return false
        }
        return date == other.date && hour == other.hour && minute == other.minute
    }
}

@Test
fun testFixedDate() {
    val o1 = FixedDateTime(FixedDate(1992, 10, 20), 12, 30, 0)
    val o2 = FixedDate(1992, 10, 20)
    val o3 = FixedDateTime(FixedDate(1992, 10, 20), 14, 45, 30)

    assertTrue { o1.date == o2 }
    assertTrue { o2 == o3.date }
    assertFalse { o1 == o3 }
    assertTrue { o1.date == o3.date }
}

Consistent(일관성) 컨벤션 위반

원칙을 위반하는 대표적인 예로 `java.net.URL.equals()` 가 있다.

`java.net.URL` 객체 2개를 비교하면 동일한 IP 주소로 해석될 때는 `true`, 아닐 때는 `false` 가 나온다.

이 결과는 네트워크 상태에 따라서 달라진다는 것이 문제이다.

@Test
fun testUrl() {
    val enWiki = URL("https://en.wikipedia.org/")
    val wiki = URL("https://wikipedia.org/")
    assertTrue { enWiki == wiki }
}

위의 `enWiki` 와 `wiki` 는 두 주소가 같은 IP 주소를 나타내므로 테스트가 통과된다.

하지만 인터넷 연결이 끊겨 있으면 테스트에 실패한다.

동등성이 네트워크 상태에 의존한다는 것은 잘못된 것이다.

  • 설계의 문제점
    • 동작이 일관되지 않음. 네트워크 상태에 따라 결과가 달라짐.
    • 일반적으로 `equals` 는 빠를 것이라 예상하지만, 네트워크 처리는 굉장히 느림.
    • 동작 자체에 문제가 있음. 동일한 IP 주소를 갖는다고 동일한 컨텐츠를 나타내는 것은 아님.
      virtual hosting(가상 호스팅)을 한다면, 관련 없는 사이트가 같은 IP 주소를 공유할 수도 있음.
      이러한 경우에도 `java.net.URL` 의 `equals` 는 `true` 를 리턴함.

코틀린/JVM 또는 다른 플랫폼을 사용할 때는 `java.net.URL` 이 아닌 `java.net.URI` 를 사용해서 이런 문제를 해결한다.

 

특별한 이유가 없는 이상, 직접 `equals` 를 구현하는 것은 좋지 않다. 

만약 직접 구현해야 한다면, Relexitive(반사적), Symmentric(대칭적), Transtive(연속적), Consistent(일관적) 인 동작을 하는지 확인하자.

이러한 클래스는 `final`(not `open`) 로 만들어서 사용하는 것이 좋으며, 만약 `final` 로 하지 않고 상속을 한다면, 그 하위 클래스에서 `equals` 가 동작하는 방식을 `override` 하면 안된다!

  • equals 를 직접 구현해야 하는 경우
    • 기본적으로 제공되는 동작과 다른 동작을 해야 할 때
    • 일부 프로퍼티만으로 비교해야 할 때
    • `data` 키워드를 붙이고 싶지 않거나, 비교해야 할는 프로퍼티가 기본 생성자에 없을 때 (data class 에 대한 설명 참고)

hashCode(): 해시 컨테이너

이제 `hashCode` 에 대해 살펴보자.

자바에서는 `equals` 를 오버라이드 할 때 반드시 `hashCode` 도 오버라이드 해야 한다.

실제로 위에서 만들었던 `Client` 코드를 보면 컴파일러가 아래와 같은 warning 을 표시해준다.

Class has 'equals()' defined but does not define 'hashCode()'
클래스가 equals 를 정의했는데 hashCode 는 정의하지 않아요..

먼저 아래와 같은 테스트를 돌려보자.

@Test
fun testContains() {
    val set = hashSetOf(Client("sh1mj1", 4122))
    assertTrue { set.contains(Client("sh1mj1", 4122)) } // FAIL
}

이상하게 테스트가 실패한다. 

이는 `Client` 클래스가 `hashCode` 메서드를 정의하지 않았기 때문이다. 

 

JVM 언어에서는 "`equals()` 가 `true` 를 리턴하는 두 객체는 반드시 같은 `hashCode()` 를 리턴해야 한다." 라는 제약이 있다. 

위에서 `set` 은 `HashSet` 이며, 이는 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 hash code 를 비교하고, 만약 같다면 실제 값을 비교한다.

위에서는 hash code 가 서로 다르기 때문에 집합에 들어있지 않다고 판단한 것이다.

Hash Table(해시 테이블)

`hashCode` 함수는 수많은 컬렉션과 알고리즘에 사용되는 자료 구조인 hash table(해시 테이블)을 구축할 때 사용된다.

 

어떤 컬렉션에 요소를 빠르게 추가, 탐색해야 한다고 하자.

이 때 `Set` 과 `Map` 을 사용할 수 있다.

이 둘은 중복을 허용하지 않기 때문에 동일한 원소가 이미 들어있는지 확인해야 한다. 

배열이나 `LinkedList` 를 기반으로 만들어진 컬렉션은 탐색 성능이 나쁘다.

원소가 포함되어 있는지 확인할 때마다 모든 원소와 비교해야 하기 때문이다.

 

해시 테이블을 사용한다면 성능이 더 좋아질 수 있다.

해시 테이블은 각 원소에 숫자를 할당하는 해시 함수를 사용하며 이 함수는 같은 원소라면 항상 같은 숫자를 리턴한다.

 

이미지 출처: dEpayse 님의 meduim 글 https://medium.com/depayse/kotlin-data-structure-hashtable-ebb9f949e936

해시 함수는 각 요소에 특정한 숫자를 할당하고, 이를 기반으로 요소를 다른 bucket(버킷, 통) 에 넣는다.

같은 요소는 항상 동일한 버킷에 넣는다.

버킷은 버킷 수와 같은 크기의 배열인 해시 테이블에 보관된다. 

즉, 요소를 추가할 때는, 해시 함수로 버킷을 계산하고, 이 버킷 안에 요소를 추가한다. (버킷은 배열처럼 구현됨)

요소 탐색 시에는, 해시 함수로 만들어지는 숫자를 사용하여 버킷을 찾고, 버킷 내부에서 원하는 요소를 찾는다.

이 때 다른 버킷을 확인할 필요가 없어 탐색이 빨라진다. 

 

코틀린/JVM 의 기본 `Set` 인 `LinkedHashSet` 와 기본 `Map` 인 `LinkedHashMap` 도 이를 사용한다.

코틀린은 hash code 를 만들 때 `hashCode` 함수를 사용한다. 

 

해시 테이블 개념은 컴퓨터 과학에서 매우 많이 사용된다.

이 자료 구조에 대해서도 내용이 많기 때문에 나중에 따로 정리하겠다. 

더 자세히는 dEpayse 님, 망나니개발자님의 글을 참조하면 좋을 것이다.

Hash Code 와 가변성

hash code 는 요소가 추가될 때만 계산되고, 요소가 변경되어도 hash code 를 새로 계산하지 않으며, 버킷도 재배치되지 않는다.

그래서 `LinkedHashSet` 과 `LinkedHashMap` 의 키는 한 번 추가한 요소에 대해 그 요소 객체를 수정하면, 해시 컨테이너가 제대로 동작하지 않게 된다.

 

따라서 `Set` 의 요소나, `Map` 의 키로 mutable 요소를 사용하면 안되며, 사용하더라도 요소를 변경하면 안된다.

그래서 immutable 한 객체를 많이 사용한다.

hashCode 의 컨벤션

코틀린 1.3.11 기준으로 `hashCode` 의 공식 컨벤션을 정리해보면

  • 어떤 객체를 변경하지 않았다면 `hashCode` 는 여러 번 호출해도 그 결과가 항상 같아야 한다.
    • 일관성 유지를 위해 `hashCode` 가 필요함
  • `equals` 메서드의 실행 결과로 두 객체가 같다고 나온다면, `hashCode` 메서드의 호출 결과도 같다고 나와야 한다. 
    • `hashCode` 는 `equals` 와 같이 일관성 있는 동작을 해야 한다.
      즉, 같은 원소는 반드시 같은 해시 코드를 가져야 한다.

그래서 코틀린은 `equals` 구현을 오버라이드할 때 `hashCode` 도 함께 오바리이드 하는 것을 추천한다.

 

필수 요구사항은 아니지만 제대로 사용하려면 지켜야 하는 요구사항도 있다.

  • `hashCode` 는 최대한 원소를 넓게 퍼뜨려야 한다.

만약 많은 원소가 같은 버킷에 배치된다면 해시 테이블을 사용하는 이유 자체가 없어진다.

아래와 같은 극단적인 예를 살펴보자.

 

`Proper` 과 `Terrible`

class Proper(val name: String) {
    override fun equals(other: Any?): Boolean {
        equalsCounter++
        return other is Proper && name == other.name
    }
    override fun hashCode(): Int {
        return name.hashCode()
    }
    companion object {
        var equalsCounter = 0 // equals 가 호출되는 횟수 확인하기
    }
}

class Terrible(val name: String) {
    override fun equals(other: Any?): Boolean {
        equalsCounter++
        return other is Terrible && name == other.name
    }
    override fun hashCode(): Int = 0 // BAD CODE
    companion object {
        var equalsCounter = 0 // equals 가 호출되는 횟수 확인하기
    }
}

테스트 코드

@Test
fun testProper() {
    val properSet = List(10000) { Proper("$it") }.toSet()
    println(Proper.equalsCounter) // 0. 
    // 모두 다른 bucket 에 들어가서 equals 를 호출할 필요가 없었다.
    
    val terribleSet = List(10000) { Terrible("$it") }.toSet()
    println(Terrible.equalsCounter) // 50116443.
    // 모두 같은 bucket 에 들어가서 equals 를 많이 호출한다.
    
    println()

    Proper.equalsCounter = 0
    println(Proper("9999") in properSet) // true
    println(Proper.equalsCounter) // 1 // 9999 로 만들어진 해시코드에 매칭되는 bucket 을 찾고, equals 를 호출
    println()

    Proper.equalsCounter = 0
    println(Proper("A") in properSet) // false // "A" 로 만들어진 해시코드가 없기 때문에 equals 호출하지 않아도 false 임을 안다
    println(Proper.equalsCounter) // 0
    println()

    Terrible.equalsCounter = 0
    println(Terrible("9999") in terribleSet) // true
    println(Terrible.equalsCounter) // 7535 // 모든 해시코드가 같아 모든 데이터가 한 bucket 에 있어서 equals 를 많이 호출
    println()

    Terrible.equalsCounter = 0
    println(Terrible("A") in terribleSet) // false
    println(Terrible.equalsCounter) // 10001
}

위 테스트에서 각 `equalsCounter` 를 비교해보면 굉장히 큰 차이가 난다. 

버킷 충돌 등의 문제로 `Terrible` 의 `equalsCounter` 에는 굉장히 큰 값이 된다.

hashCode 구현하기

이렇게 간단히 `hashCode` 와 hash table 을 알아보았다. 

그렇다면 `hashCode` 를 어떻게 구현해야 할까?

기본적으로 `equals` 에서 비교에 사용되는 프로퍼티를 기반으로 hash code 를 만들어야 한다.

일반적으로 모든 해시 코드의 값을 더하며, 더하는 과정마다 이전까지의 결과에 31을 곱한 뒤에 더해준다. (관례적으로 31을 자주 사용. 꼭 31 일 필요는 없음)

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

이렇게 되면 `testContains` 테스트가 예상대로 통과된다.


이렇게 클래스에 정의해야 하는 메서드 `toString`, `equals`, `hashCode` 를 알아보았다.

그런데 이렇게 하나하나 직접 메서드를 만들어주는 것은 꽤 피곤한 것 같다.

다음 글에서 이를 해결할 수 있는 코틀린의 좋은 기능을 알아본다.

 

 

참조

https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#toString--

https://yeonyeon.tistory.com/188

https://be-student.tistory.com/42

https://medium.com/depayse/kotlin-data-structure-hashtable-ebb9f949e936

https://mangkyu.tistory.com/102