Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.
자바와 마찬가지로 코틀린 클래스도 `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 의 컨벤션
`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` 를 기반으로 만들어진 컬렉션은 탐색 성능이 나쁘다.
원소가 포함되어 있는지 확인할 때마다 모든 원소와 비교해야 하기 때문이다.
해시 테이블을 사용한다면 성능이 더 좋아질 수 있다.
해시 테이블은 각 원소에 숫자를 할당하는 해시 함수를 사용하며 이 함수는 같은 원소라면 항상 같은 숫자를 리턴한다.
해시 함수는 각 요소에 특정한 숫자를 할당하고, 이를 기반으로 요소를 다른 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` 와 같이 일관성 있는 동작을 해야 한다.
즉, 같은 원소는 반드시 같은 해시 코드를 가져야 한다.
- `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
'Kotlin' 카테고리의 다른 글
[Kotlin] 클래스 위임과 by, Decorator(데코레이터) 패턴 (1) | 2024.01.10 |
---|---|
[Kotlin] data class: toString, equals, hashCode, copy, componentN (0) | 2024.01.10 |
[Kotlin] 인터페이스의 프로퍼티 & 접근자에서 backing field 에 접근, 접근자의 가시성 (1) | 2024.01.09 |
[Kotlin] 주 생성자 & 부 생성자 & 초기화 블록 (1) | 2024.01.06 |
[Kotlin] sealed 로 클래스 계층 확장을 제한하기 + 태그 클래스 VS 상태 패턴 (1) | 2024.01.06 |