관리 메뉴

나만을 위한 블로그

[이펙티브 코틀린] 아이템 37. 데이터 집합 표현에 class 한정자를 사용하라 본문

책/Effective Kotlin

[이펙티브 코틀린] 아이템 37. 데이터 집합 표현에 class 한정자를 사용하라

참깨빵위에참깨빵_ 2023. 2. 26. 16:16
728x90
반응형

때로는 데이터들을 한꺼번에 전달해야 할 때가 있다. 일반적으로 이런 상황에 아래와 같은 data 한정자가 붙은 클래스를 사용한다.

 

fun main() {
    val player = Player(0, "철수", 100)
}

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

 

data 한정자를 붙이면 몇 가지 함수가 자동 생성된다.

 

  • toString
  • equals, hashCode
  • copy
  • componentN(1, 2, ...)

 

toString()은 클래스명과 기본 생성자 형태로 모든 프로퍼티와 값을 출력해준다. 로그 출력, 디버그 시 유용하게 쓸 수 있다.

equals()는 기본 생성자의 프로퍼티가 같은지 확인한다. hashCode()는 equals()와 같은 효과를 낸다.

 

fun main() {
    val player = Player(0, "철수", 100)
    println(player == Player(0, "철수", 100)) // true
    println(player == Player(0, "영희", 100)) // false
}

 

copy()는 불변 데이터 클래스를 만들 때 편리하다. copy()는 기본 생성자 프로퍼티가 같은 새로운 객체를 복제한다. 새로 만들어진 객체의 값은 이름 있는 아규먼트를 활용해서 바꿀 수 있다.

 

fun main() {
    val player = Player(0, "철수", 100)

    val newObj = player.copy(name = "영수")
    println(newObj) // Player(id=0, name=영수, points=100)
}

 

이런 copy()는 data 한정자를 붙이기만 하면 자동으로 만들어지므로 구현을 볼 수도 없고 볼 필요도 없다. 또한 copy()는 객체를 얕은 복사하지만 이것은 객체가 불변이라면 아무 상관이 없다. 불변 객체는 깊은 복사한 객체가 필요없기 때문이다.

componentN()는 위치를 기반으로 객체를 해제할 수 있게 해준다.

 

fun main() {
    val player = Player(0, "철수", 100)

    val (id, name, point) = player
}

 

이렇게 객체를 해제하는 코드를 작성하면 코틀린은 내부적으로 componentN()를 쓰는 코드로 변환한다.

 

fun main() {
    val player = Player(0, "철수", 100)

//    val (id, name, point) = player
    val id: Int = player.component1()
    val name: String = player.component2()
    val point: Int = player.component3()
}

 

위치를 기반으로 객체를 해제하는 건 장점도 있고 단점도 있다. 가장 큰 장점은 변수명을 원하는 대로 지정할 수 있다는 거다. 또한 componentN()만 있다면 List, Map.Entry 등의 형태로도 객체를 해제할 수 있다.

 

fun main() {
    val visited = listOf("China", "Russia", "India")
    val (first, second, third) = visited
    println("$first $second $third")
    // China Russia India

    val trip = mapOf(
        "China" to "Tianjin",
        "Russia" to "Petersburg",
        "India" to "Rishikesh"
    )

    for ((country, city) in trip) {
        println("We loved $city in $country")
        // We loved Tianjin in China
        // We loved Petersburg in Russia
        // We loved Rishikesh in India
    }
}

 

위치를 잘못 지정하면 다양한 문제가 발생할 수 있어 위험하다. 위치 순서를 혼동해서 객체를 잘못 해제하는 문제는 자주 발생한다.

 

fun main() {
    val elon = FullName("Elon", "Reeve", "Musk")
    val (name, surname) = elon
    println("It is $name $surname") // It is Elon Reeve
}

data class FullName(
    val firstName: String,
    val secondName: String,
    val thirdName: String
)

 

객체를 해제할 때는 주의해야 하므로 데이터 클래스의 기본 생성자에 붙어 있는 프로퍼티 이름과 같은 이름을 쓰는 게 좋다. 그렇게 하면 순서 등을 잘못 지정했을 때 인텔리제이, 안드로이드 스튜디오가 관련된 경고를 준다. 이런 경고는 유용하므로 경고 대신 오류로 업그레이드해도 좋다.

값을 하나만 갖는 데이터 클래스는 해제하지 않는 게 좋다. 간단한 코드지만 읽는 사람에게 혼동을 줄 수 있다. 특히 람다 표현식과 같이 활용될 때 문제가 된다.

 

fun main() {
    val user = User("John")
    user.let { a -> println(a) }    // User(name=John)
    // 이렇게 하지 마라
    user.let { (a) -> println(a) }  // John
}

data class User(val name: String)

 

일부 프로그래밍 언어에선 람다 표현식의 아규먼트 주변에 감싸는 괄호를 입력해도 되고 않아도 되므로 문제가 된다.

 

튜플 대신 데이터 클래스 사용하기

 

데이터 클래스는 튜플보다 많은 걸 제공한다. 구체적으로 코틀린의 튜플은 Serializable을 기반으로 만들어지며 toString을 쓸 수 있는 제네릭 데이터 클래스다.

 

public data class Pair<out A, out B>(
    public val first: A,
    public val second: B
) : Serializable {

    public override fun toString(): String = "($first, $second)"
}

public data class Triple<out A, out B, out C>(
    public val first: A,
    public val second: B,
    public val third: C
) : Serializable {

    public override fun toString(): String = "($first, $second, $third)"
}

 

Pair, Triple만 예시로 든 이유는 이것이 코틀린에 남아 있는 마지막 튜플이기 때문이다. 과거엔 (Int, String, String, Long)처럼 괄호와 타입 지정을 통해 원하는 형태의 튜플을 정의할 수 있었다. 튜플은 데이터 클래스와 같은 역할을 하지만 훨씬 가독성이 나빴다. 튜플만 보고는 어떤 타입을 나타내는지 예측할 수 없다. 튜플은 좋아 보였지만 언제나 데이터 클래스를 쓰는 게 더 좋았기 때문에 점차 없어진 것이다. Pair, Triple은 몇 가지 지역적인 목적으로 인해 남아있을 뿐이다.

 

  • 값에 간단하게 이름붙일 때

 

fun main() {
    val (description, color) = when {
        degress < 5 -> "cold" to Color.BLUE
        degress < 23 -> "mild" to Color.YELLOW
        else -> "hot" to Color.RED
    }
}

 

  • 표준 라이브러리에서 볼 수 없는 것처럼 미리 알 수 없는 aggregate(집합)를 표현할 때

 

fun main() {
    val numbers = listOf(1, 2, 3, 4, 5)
    val (odd, even) = numbers.partition { it % 2 == 1 }
    val map = mapOf(1 to "샌프란시스코", 2 to "암스테르담")
}

 

이 경우들을 제외하면 무조건 데이터 클래스를 쓰는 게 좋다. 아래는 전체 이름을 이름, 성(surname)으로 분할하는 코드다. 이름과 성을 Pair<String, String>으로 나타냈다.

 

fun main() {
    val fullName = "김 철수"
    val (firstName, lastName) = fullName.parseName() ?: return
    println("그의 이름은${lastName}(이)다")    // 그의 이름은 철수(이)다
}

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

    return Pair(firstName, lastName)
}

 

문제는 다른 사람이 이 코드를 읽을 때 Pair<String, String>이 전체 이름을 나타낸다는 걸 인지하기 어렵단 것이다. 뭣보다 성과 이름 중에 뭐가 앞에 있을지 예측하기 어렵다. 성이 앞에 있을 수도 있고 이름이 앞에 있을 수도 있다.

이걸 좀 더 쓰기 쉽고 함수를 읽기 쉽게 만들려고 한다면 데이터 클래스를 쓰면 된다.

 

fun main() {
    val fullName = "김 철수"
    val (firstName, lastName) = fullName.parseName() ?: return
    println("firstName : $firstName, lastName : $lastName")
}

data class FullName(
    val firstName: String,
    val lastName: String
)

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

    return FullName(firstName, lastName)
}

 

이렇게 해도 추가비용은 거의 들지 않는다. 오히려 아래처럼 함수를 더 명확하게 만들어 준다.

 

  • 함수의 리턴 타입이 더 명확해진다
  • 리턴 타입이 더 짧아지며 전달하기 쉬워진다
  • 사용자가 데이터 클래스에 적혀 있는 것과 다른 이름을 활용해 변수를 해제하면 경고가 출력된다

 

이 클래스에 좁은 스코프를 갖게 하고 싶다면 일반적인 클래스와 같은 형태로 가시성에 제한을 걸어 두면 된다. 로컬 처리에서만 이를 활용하고 싶다면 private을 붙여주면 된다. 이렇게 데이터 클래스를 활용하면 튜플을 활용할 때보다 더 많은 장점이 있다.

반응형
Comments