[Kotlin] data class 파헤치기 - copy, toString, equals, hashcode, componentN

Doyeon Lim·2021년 10월 3일
1

Kotlin

목록 보기
2/2

> Tech-I-Learned Github 바로가기

코틀린에서 자주 사용하는 data class
잘 알고, 잘 쓰자!

🤷‍♀️data class란?

[참고] kotlinlang.org : Data classes

data class는 데이터 보관을 주 목적으로 하는 클래스이다.

아래와 같아 data라는 키워드를 사용해 선언할 수 있다.

data class User(val name: String, val age: Int)

data class는 특이하게도 컴파일러가 몇 가지 함수를 제공해준다.

다만 여기서 중요한 점은,

기본적으로 이 함수들은 주 생성자에 선언된 프로퍼티에 한정되어있다.

이 사실을 명심하고 data class에 기본적으로 제공되는 함수들에 대해 자세히 알아보자.

copy()

copy() 메서드는 이름 그대로 클래스 주 생성자의 데이터를 그대로 복사해서 data class로 반환해준다.

data class User(val name: String, val age: Int)

fun main() {
    val user1 = User("minsu", 10)
    val user2 = user1.copy()
    
    println(user1) // User(name=minsu, age=10)
    println(user2) // User(name=minsu, age=10)
    println(user1 === user2) // false
}

위와 같이 copy 메서드를 사용해서 user2에 user1의 프로퍼티를 복사한 새로운 인스턴스를 반환해준다.

copy와 동시에 프로퍼티 값을 변경해주고 싶다면 아래와 같이 사용해주면 된다.

val user2 = user1.copy(name="sumin")
print(user2) // User(name=sumin, age=10)

copy를 쓰는 이유?

[참고 : Kotlin in Action 책]

data class의 프로퍼티는 var, val 둘 중 무엇으로 선언하든 상관없다. 다만, 모든 프로퍼티를 읽기 전용(val)로 만들어서 data class를 불변 클래스로 만드는 것을 권장한다고 한다.

그 이유는 다음과 같은 예시가 있다.

  1. HashMap 등의 컨테이너에 객체를 담는 경우 불변성이 필수적이다.
  2. 불변 객체는 추론이 쉽다.
  3. 다중스레드 프로그램의 경우, 객체의 값이 변하면 스레드간 동기화를 해야한다.
  4. 객체를 메모리상에서 직접 바꾸는 것보다 복사본을 만드는 편이 더 낫다.

문제1. 주생성자가 아닌 프로퍼티

data class의 copy는 주생성자의 프로퍼티만 복사한다.

data class User(val name: String, val age: Int) {
    var isSuperStar : Boolean = false
}

fun main() {
    val user1 = User("minsu", 10)
    user1.isSuperStar = true
    
    val user2 = user1.copy()

    println(user1.isSuperStar) // true
    println(user2.isSuperStar) // false
}

위와 같이 isSuperStar라는 프로퍼티에 값을 지정하고 copy를 해도 user2의 isSuperStar는 기본값이 false로 복사된다. 따라서 copy는 주생성자만 복사함을 알고 copy 메서드를 커스텀하는 등의 작업을 따로 해주어야한다.

copy를 커스텀하는 방법은 아래의 문제2에서 알아보자.

문제2. 얕은 복사, 깊은 복사

data class의 copy는 deep copy하지 않기 때문에 아래와 같은 상황이 발생할 수 있다.

data class User(val name: String, val age: Int, var friends: MutableList<String>) {
    var isSuperStar : Boolean = false
}

fun main() {
    val friends = mutableListOf("a1", "a2", "a3")
    
    val user1 = User("minsu", 10, friends)
    user1.isSuperStar = true
    
    val user2 = user1.copy()
    user2.friends.add("a4")

    println(user1) // User(name=minsu, age=10, friends=[a1, a2, a3, a4])
    println(user2) // User(name=minsu, age=10, friends=[a1, a2, a3, a4])
}

user2에 user1을 copy하고 user2의 friends에 a4를 추가했는데 user1의 friends 목록까지 변경되었다. 얕은 복사를 하기 때문에 생기는 문제로, 이러한 경우에는 copy 메서드를 직접 구현해주어야한다.

직접 구현하면서 주생성자가 아닌 프로퍼티도 같이 복사해줄 수 있다.

data class User(val name: String, val age: Int, var friends: MutableList<String>) {
    var isSuperStar : Boolean = false
    
    fun copy(): User {
        val copyUser = User(name, age, friends.toMutableList())
        copyUser.isSuperStar = isSuperStar
        
        return copyUser
    }
}

fun main() {
    val friends = mutableListOf("a1", "a2", "a3")
    
    val user1 = User("minsu", 10, friends)
    user1.isSuperStar = true
    
    val user2 = user1.copy()
    user2.friends.add("a4")

    println(user1) // User(name=minsu, age=10, friends=[a1, a2, a3])
    println(user2) // User(name=minsu, age=10, friends=[a1, a2, a3, a4])
    println(user1.isSuperStar) // true
    println(user2.isSuperStar) // true
}

** 참고로 copy의 경우 Any의 메서드가 아니기 때문에 override가 아니라 직접 구현해주는 형태이다.

** deep copy에 대해 더 자세히 알고 싶다면?

[Kotlin] 깊은 복사(Deep Copy) 하는 3가지 방법

toString()

위의 copy 예시에서 우리는 data class를 출력할 때 별도의 구현 없이 코드는 println(user1), 출력 형태는 User(name=minsu, age=10)와 같은 형태였다.

그 이유는 바로 data class가 toString()을 기본적으로 제공해주기 때문이다.

더 자세히 알기 위해서 먼저 일반 User class를 생성하고 출력해보면 아래와 같이 data class와 전혀 다르게 class 정보를 출력해주지 않음을 확인할 수 있다.

class User(val name: String, val age: Int)

fun main() {
    val user = User("minsu", 10)
    print(user) // User@71be98f5

문제1. 주생성자가 아닌 프로퍼티

아래와 같이 주생성자의 프로퍼티만 toString을 통해 출력되고 있다.

data class User(val name: String, val age: Int) {
    val isSuperStar: Boolean = false
}

fun main() {
    val user1 = User("minsu", 10)
    println(user1) // User(name=minsu, age=10)
}

모든 프로퍼티를 출력해주고 싶다거나 원하는 출력 형태가 있다면 아래처럼 toString을 오버라이딩해서 직접 구현해주면 된다.

data class User(val name: String, val age: Int) {
    val isSuperStar: Boolean = false

    override fun toString(): String {
        return "[User](name=$name, age=$age, isSuperStar=$isSuperStar)"
    }
}

fun main() {
    val user1 = User("minsu", 10)
    println(user1) // [User](name=minsu, age=10, isSuperStar=false)
}

equals()와 hashCode()

equalshashcode에 대해 잘 알기 위해서는 먼저 class에서 같은 프로퍼티를 갖고 있는 두 인스턴스를 비교할 때 어떻게 동작하는지 알아야한다.

class User(val name: String, val age: Int)

fun main() {
    val user1 = User("minsu", 10)
    val user2 = User("minsu", 10)

    print(user1 == user2) // false
}

이렇게 class로 선언한 User 인스턴스를 같은 값을 같도록 생성하고 ==으로 비교해주면 false가 출력된다.

코틀린에서는 ==을 자동으로 equals 연산자로 호출한다.
class에서는 equals 메서드를 오버라이딩 하지 않았기때문에 프로퍼티 값을 비교하는 것이 아니라 인스턴스 자체가 같은지를 판단하게 된다.

그러나 data class의 경우 컴파일러가 값을 비교하도록 구현해놓았기 때문에 아래와 같이 동작하게 된다.

data class User(val name: String, val age: Int)

fun main() {
    val user1 = User("minsu", 10)
    val user2 = User("minsu", 10)

    print(user1 == user2) // true
}

문제1. 주생성자가 아닌 프로퍼티

주 생성자가 아닌 프로퍼티의 값은 다름에도 불구하고 동등한지 판단할 경우 생성자의 값만 비교해 같다는 결과가 출력된다.

data class User(val name: String, val age: Int) {
    var isSuperStar: Boolean = false
}

fun main() {
    val user1 = User("minsu", 10)
    user1.isSuperStar = true
    
    val user2 = User("minsu", 10)

    print(user1 == user2) // true
}

따라서 필요하다면 아래와 같이 equals와 hashCode를 재정의해서 모든 프로퍼티를 비교할 수 있도록 해주어야한다.

import java.util.*

data class User(val name: String, val age: Int) {
    var isSuperStar: Boolean = false

    override fun equals(other: Any?): Boolean {
        if (other == null || other !is User) return false
        return this.name == other.name && this.age == other.age && this.isSuperStar == other.isSuperStar
    }

    override fun hashCode(): Int {
        return Objects.hash(name, age, isSuperStar)
    }
}

fun main() {
    val user1 = User("minsu", 10)
    user1.isSuperStar = true

    val user2 = User("minsu", 10)

    print(user1 == user2) // false
}

Q) equals와 hashcode()를 같이 재정의해야하는 이유
둘 중 하나만 재정의하게 되면 같은 값 객체라도 해시값이 다른 문제가 발생할 수 있다.

componentN()

data class는 구조 분해 선언을 사용해 변수를 쉽게 할당할 수 있다.
그 방식은 아래와 같다.

data class User(val name: String, val age: Int)

fun main() {
    val user1 = User("minsu", 10)
    val (name, age) = user1
    
    println(name) // minsu
    println(age) // 10
    println(user1.component1()) // minsu
    println(user1.component2()) // 10
}
profile
🙇‍♀️ Android

1개의 댓글

comment-user-thumbnail
2023년 4월 25일

잘 보고 갑니다!

답글 달기