Kotlin

[Kotlin] data class: toString, equals, hashCode, copy, componentN

sh1mj1 2024. 1. 10. 11:00

 

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

 

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

 

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

다행히, 여러 IDE 에서는 자동으로 그런 메서드를 정의해주는 기능이 있다.

IntelliJ 에서 자동으로 메서드를 만들어준다

그런데 코틀린에서는 더 편하다.

data class(데이터 클래스)

`data` 라는 변경자를 클래스 앞에 붙이면 필요한 메서드를 컴파일러가 자동으로 만들어준다.

`data` 변경자가 붙은 클래스를 데이터 클래스라고 한다.

 

`Client` 를 데이터 클래스로

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

이제 `Client` 클래스는 자바에서부터 요구하는 모든 메서드를 가진다.

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

이 때 data class 의 주 생성자 바깥에 정의된 프로퍼티는 `equals` 나 `hashCode` 를 계산할 때 고려의 대상이 아니다. 

또한 아래 두 메서드도 자동으로 생성된다.

  • `copy` 메서드
  • `componentN` 메서드 (`component1()`, `component2()` 등)

copy() 메서드 : data class 와 immutability(불변성)

데이터 클래스의 프로퍼티가 꼭 `val` 일 필요는 없다.

`var` 프로퍼티도 사용할 수 있다.

하지만 데이터 클래스의 모든 프로퍼티를 읽기 전용으로 만들어서 data class 를 immutable class 로 만들라고 권장한다.

 

해시 컨테이너(HashMap 등)에 데이터 클래스 객체를 담는 경우에는 불변성이 필수적이다. 

만약 데이터 클래스 객체를 키로 하는 값을 해시 컨테이너에 담은 후, 키로 쓰인 데이터 객체의 프로퍼티를 변경하면??

컨테이너 상태가 잘못될 수 있다. 아래 코드에서 실제로 어떻게 되는지 살펴보자.

`postalCode` 를 `var` 로 설정한 후 테스트

@Test
fun testMutableProperty() {
    val client = Client("sh1mj1", 4122)
    val hashSet = hashSetOf(client)
    assertTrue { hashSet.contains(client) }
    client.postalCode = 1000
    println(hashSet) // print [Client(name=sh1mj1, postalCode=1000)]
    assertFalse { hashSet.contains(client) } // client 를 포함하지 않는다
    assertFalse { hashSet.contains(Client("sh1mj1", 1000)) } // 포함하지 않는다
    assertFalse { hashSet.contains(Client("sh1mj1", 4122)) } // 포함하지 않는다
}

`hashSet` 에 `client` 를 담은 후, `client` 의 `postalCode` 를 변경했다.

`postalCode` 는 `Client` 의 `hashCode` 메서드에서 해시 코드를 만드는 데 사용되는 프로퍼티이다.

변경 후, `hashSet` 이 변경 전 객체와 변경 후 객체를 포함하지 않는 것으로 나온다.

즉, 해시 컨테이너(hashSet) 의 상태가 이상해졌다.

 

그렇다면, 어떻게 만약 `client` 를 변경하고 싶다면 어떻게 해야 할까?

불변 객체를 사용하면 많은 이점을 얻을 수 있다.
Effective Koltin 의 "item 1: 가변성을 제한하라" 에서는 다양한 이점을 설명한다.

 

데이터 클래스 인스턴스를 불변 객체로 더 쉽게 활용할 수 있게 코틀린 컴파일러는 한가지 편의 메서드를 제공한다.

객체를 copy(복사)하면서 일부 프로퍼티를 바꿀 수 있게 해주는 `copy` 메서드이다.

메모리 상에서 객체를 직접 바꾸는 것보다 복사본을 만드는 것이 훨씬 더 낫다.

복사본은 원본과 다른 생명주기를 갖고, 복사본을 변경하거나 제거해도 원본에는 영향을 끼치지 않는다.

 

만약 `copy` 메서드를 직접 구현한다면 아래와 같을 것이다.

fun copy(name: String = this.name, postalCode: Int = this.postalCode) = Client(name, postalCode)

 

`copy` 메서드 테스트(`postalCode` 를 `val` 로 설정한 후)

@Test
fun testCopy() {
    val client = Client("sh1mj1", 4122)
    val hashSet = hashSetOf(client)
    val client2 = client.copy(postalCode = 1000)
    assertTrue { hashSet.contains(client) }
    assertTrue { hashSet.contains(Client("sh1mj1", 4122)) }
    assertFalse { hashSet.contains(client2) }
}

`client` 를 `copy` 하여 `client2` 를 만들어도, 기존 해시 컨테이너에 들어있던 객체에 대해서는 `contains` 메서드가 정상적으로 동작하는 것을 볼 수 있다.

위처럼 `copy` 로 새로 만든 객체의 값은 이름 있는 `argument` 를 활용해서 변경할 수 있다.

이렇게 `copy` 는 immutable 데이터 클래스를 만들 때 편리하다. 

data class 객체의 destructuring declaration(구조 분해 선언)

data class 객체에는 구조 분해를 사용할 수 있다.

복합적인 값을 분해해서 여러 다른 변수를 한꺼번에 초기화할 수 있다.

@Test
fun testDestructuringDeclaration() {
    val client = Client("sh1mj1", 4122)
    val (name, postalCode) = client
    assertTrue { name == "sh1mj1" }
    assertTrue { postalCode == 4122 }
}

이렇게 구조 분해 선언의 형식은 어렵지 않다.

 

내부적으로는 구조 분해 선언의 각 변수를 초기화하기 위해 `componentN` 이라는 함수를 호출한다.(N 은 변수의 위치에 따라 붙는 번호)

구조 분해 선언은 componentN 함수 호출로 변환된다.

data class 의 구조 분해 선언 중 주의할 점

위 `testDestructuringDeclaration` 테스트 함수에서는 `client` 에 대해 `(name, postalCode)` 로 구조 분해 선언을 하고 있다. 

이 순서는 `Client` 데이터 클래스의 주 생성자에 프로퍼티가 선언된 순서이다.

그런데 이 위치 순서를 잘못하면, 문제가 발생할 수 있다.

객체를 해제할 때는 주의해야 하므로, 데이터 클래스의 주 생성자에 붙어있는 프로퍼티 이름과 같은 이름을 사용하는 것이 좋다.

이렇게 하면, 순서를 잘못 지정했을 때 IDE 에서 관련 경고를 보여준다.

data class Player(val id: Int, val name: String, val points: Int)

@Test
fun testDestructuringDeclaration2() {
    val player = Player(10, "sh1mj1", 100)
    val (name, id, points) = player
    // 정상 작동은 되지만 경고를 보여준다.
    // Variable name 'name' matches the name of a different component
    // Variable name 'id' matches the name of a different component
    assertTrue { name == 10 && id == "sh1mj1" && points == 100 }
}

 

아래처럼 값을 하나만 갖는 데이터 클래스는 해제하지 않는 것이 좋다.

간단한 코드지만, 읽는 사람에게 혼동을 줄 수 있다.

data class User(val name: String)

@Test
fun testUser() {
    val user = User("sh1mj1")
    val (name) = user
    assertTrue { name == "sh1mj1" }

    user.let { a -> println(a) } // User(name=sh1mj1)
    user.let { (a) -> println(a) } // sh1mj1
}

참고로 data 클래스의 주 생성자에 들어있는 프로퍼티에 대해 컴파일러가 자동으로 `componentN` 함수를 만들어준다.

즉, `componentN` 함수를 우리가 직접 사용하는 것도 가능하다.

componentN 함수 직접 사용

@Test
fun testComponentN(){
    val client = Client("sh1mj1", 4122)
    assertTrue { client.component1() == "sh1mj1" }
    assertTrue { client.component2() == 4122}
}

구조 분해 선언은 함수에서 여러 값을 리턴할 때 유용하다. 

배열이나 컬렉션에도 `componentN` 함수가 있다. 

물론 배열과 컬렉션에 무한히 `componentN` 을 선언할 수는 없으므로 `component200` 처럼은 사용할 수는 없다. 

코틀린 표준 라이브러리에서는 맨 앞의 다섯 원소에 대한 `componentN` 을 제공한다.

튜플 대신 data class 사용하기

튜플(`Pair` 와 `Triple`)에도 구조 분해 선언을 사용하여 함수에서 여러 값을 더 간단하게 리턴할 수 있다.

코틀린의 튜플은 `Serializable` 을 기반으로 만들어지며 `toString` 을 사용할 수 있는 제네릭 데이터 클래스이다. 

 

튜플은 값에 간단히 이름을 붙일 때, 표준 라이브러리에서 볼 수 있는 것처럼 미리 알수 없는 집합을 표현할 때 사용하는 게 좋지만,

이외의 경우에는 데이터 클래스를 사용하는 것이 더 좋다.

튜플만 보고는 어떤 타입을 나타내는지 예측할 수 없기 때문이다.

 

아래 코드에서 튜플(여기선 `Pair` 사용)을 사용한 경우와, 데이터 클래스를 사용한 코드를 비교하여 보자.

`Pair` 를 사용한 코드

fun String.parseName1(): Pair<String, String>? {
    val indexOfLastSpace = this.trim().lastIndexOf(' ')
    if (indexOfLastSpace < 0) return null
    val firstName = this.take(indexOfLastSpace)
    val lastName = this.drop(indexOfLastSpace).trim()
    return Pair(firstName, lastName)
}

@Test
fun testUsingPair() {
    val fullName = "Tom Jackson"
    val (firstName, lastName) = fullName.parseName1() ?: return
    assertTrue { firstName == "Tom" }
    assertTrue { lastName == "Jackson" }
}

`parseName1` 이라는 코드를 읽을 때 `Pair<String, String>` 이 전체 이름을 나타낸다는 것을 인지하기 어렵다. 

그리고 `lastName` 과 `firstName` 중 어떤 것이 앞에 있을지 예측하기도 어렵다.

 

data class 를 사용한 코드

fun String.parseName2(): FullName? {
    val indexOfLastSpace = this.trim().lastIndexOf(' ')
    if (indexOfLastSpace < 0) return null
    val firstName = this.take(indexOfLastSpace)
    val lastName = this.drop(indexOfLastSpace).trim()
    return FullName(firstName, lastName)
}

@Test
fun testUsingFullName() {
    val fullName = "Tom Jackson"
    val (firstName, lastName) = fullName.parseName2() ?: return
    assertTrue { firstName == "Tom" }
    assertTrue { lastName == "Jackson" }
}

반면에 이렇게 data class 를 사용하면 이점을 얻을 수 있다.

  • 함수의 리턴 타입이 더 명확해짐
  • 리턴 타입이 더 짧아지며, 전달하기 쉬워짐.
  • 사용자가 data class 에 적혀있는 것과 다른 이름을 활용해 변수를 구조 분해하면 컴파일러가 경고해준다.

이렇게 data 변경자를 통해 값 객체를 더 편리하게 사용하는 방법을 살펴보았다.