ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [ Effective Kotlin ] Chapter 6 - Class design
    카테고리 없음 2024. 3. 14. 19:27

    Item 36 : Prefer composition over inheritance

    상속은 객체의 계층 구조가 'is-a' 관계를 갖도록 설계되었다.

    단순히 코드를 재사용하기 위해 상속을 사용하는 경우에는 적절하지 않으며, 더 가벼운 방법인 'Composition'을 사용하는 것이 좋다.

    Simple behavior reuse

    예를 들어, 프로그래스바 표시와 숨김을 처리하는 비슷한 로직이 있는 두 클래스가 있고, 이를 상속을 통해 공통된 로직을 추출하면 다음과 같을 것이다.

    abstract class LoaderWithProgress {
        fun load() {
            // show progress
            innerLoad()
            // hide progress
        }
    
        abstract fun innerLoad()
    }
    
    class ProfileLoader: LoaderWithProgress() {
        override fun innerLoad() {
            // load profile
        }
    }
    
    class ImageLoader: LoaderWithProgress() {
        override fun innerLoad() {
            // load image
        }
    }

     

    하지만, 이런 방식은 몇 가지 단점이 존재한다.

    • 클래스는 단 하나의 상위 클래스만 가질 수 있기에, 함수 추출이 누적되어 거대한 'BaseXXX' 클래스를 만들거나, 복잡하고 깊은 타입 계층을 만들 수 있다.
    • 하위 클래스는 상위 클래스의 모든 메서드와 필드를 상속받게 되어, 필요하지 않은 기능까지 상속받을 수 있어 '인터페이스 분리 원칙(ISP)'를 위반하는 문제가 발생할 수 있다.
    • 상위 클래스의 기능이 명시적이지 않을 때, 이를 확인하기 위해 상위 클래스의 코드를 참조해야 하는 추가적인 노력이 필요하다.

    이러한 단점을 해결하기 위해, 'Composition'을 사용하는 것이 좋다.

     

    'Composition'은 객체를 프로퍼티로 보유하고 필요한 동작만 선택적으로 재사용할 수 있다.

    또한, 'Composition'은 여러 기능을 추출하여 사용하기에 더 유연한 작업이 가능하다.

    class Progress {
        fun showProgress() { /* show progress */ }
        fun hideProgress() { /* hide progress */ }
    }
    
    class FinishedAlert {
        fun show() { /* show finished alert */ }
    }
    
    class ImageLoader {
        private val progress = Progress()
        private val finishedAlert = FinishedAlert()
    
        fun load() {
            progress.showProgress()
            // load image
            progress.hideProgress()
            finishedAlert.show()
        }
    }

    Talking the whole package

    위에서 설명한 것과 같이 상속은 객체의 계층 구조를 나타내는데 좋은 방법이지만, 단지 몇몇 공통부분을 재사용하기 위해 사용하는 것은 적절하지 않다. 이런 몇몇 공통 부분을 재사용할 때는 필요한 기능만 선택하여 사용할 수 있는 'Composition'을 사용하는 것이 좋다.

     

    예를 들어, 짖을 수 있고 냄새를 맡을 수 있는 'Dog' 클래스를 만든다고 가정해 보자.

    abstract class Dog {
    
        open fun bark() {
            // ...
        }
    
        open fun sniff() { 
            // ...
        }
    }

     

    이때, 냄새를 맡을 수 없는 'RobotDog'라는 타입을 만들려면 다음과 같은 문제가 발생한다.

    abstract class Robot {
    
        open fun calculate() {
            // ...
        }
    }
    
    // Error: Only one class may appear in a supertype list
    class RobotDog : Dog(), Robot() {  
    
        override fun sniff() {
            throw Error("Operation not supported")
        }
    }
    1. 'RobotDog'에 불필요한 메서드 'sniff'가 존재하기에 '인터페이스 분리 원칙(ISP)'을 위반한다.
    2. 'Dog' 타입의 객체를 'RobotDog' 타입의 객체로 대체했을 때, 프로그램이 예외를 던질 수 있기에 '리스코프 치환 원칙(LSP)'을 위반할 수 있다.
    3. Kotlin에서는 다중 상속을 지원하지 않기에 'RobotDog'는 'Dog'와 'Robot'을 동시에 상속받을 수 없다.

    'Composition'은 개발자가 필요한 기능만을 선택하여 재사용할 수 있고, 클래스가 불필요한 의존성을 갖지 않도록 할 수 있다.

    또한, 타입 계층 구조를 표현할 때 인터페이스를 사용하면, 다중 상속의 이점을 얻을 수 있다.

    Inheritance breaks encapsulation

    클래스에 상속 관계가 생기면 상위 클래스의 내부 구현이 하위 클래스에 영향을 줄 수 있다.

    이는 곧 '상속'이 '캡슐화'를 깨뜨릴 수 있음을 의미한다.

     

    예를 들어, 요소의 수를 계산하는 'CounterSet'이 필요하다고 가정하고, 'HashSet'을 상속받아 구현하면 다음과 같을 것이다.

    class CounterSet<T> : HashSet<T>() {
    
        var counter = 0
            private set
    
        override fun add(element: T): Boolean {
            counter++
            return super.add(element)
        }
    
        override fun addAll(elements: Collection<T>): Boolean {
            counter += elements.size
            return super.addAll(elements)
        }
    
        override fun remove(element: T): Boolean {
            counter--
            return super.remove(element)
        }
    }

     

    하지만 위 구현은 다음과 같이 의도했던 방식과 다르게 동작할 수 있다.

    val counterList = CounterSet<String>()
    counterList.addAll(listOf("A", "B", "C"))
    print(counterList.elementsAdded)            // 6

     

    이유는 'HashSet' 클래스의 'addAll' 메서드가 'add' 메서드를 내부적으로 사용하기 때문에 'addAll'을 통해 요소를 추가할 때마다 카운터가 2번 증가하기 때문이다. 이러한 이유로 'HashSet'을 상속하는 대신 'Composition'을 사용하는 것이 좋다.

    class CounterSet<T> {
    
        private val set = HashSet<T>()
    
        var counter = 0
            private set
    
        fun add(element: T): Boolean {
            counter++
            return set.add(element)
        }
    
        fun addAll(elements: Collection<T>): Boolean {
            counter += elements.size
            return set.addAll(elements)
        }
    
        fun remove(element: T): Boolean {
            counter--
            return set.remove(element)
        }
    }
    
    // usage
    val counterList = CounterSet<String>()
    counterList.addAll(listOf("A", "B", "C"))
    print(counterList.elementsAdded)            // 3

     

    또 다른 문제가 발생할 수 있는데, 바로 'CounterSet'이 'Set'의 성질을 잃게 된다는 점이다.
    즉, 'CounterSet'은 'Set'이 아니기에 다형성을 잃게 된다.

     

    이를 'Delegation pattern'으로 해결할 수 있다.

    'Delegation pattern'은 클래스가 어떤 인터페이스를 구현하고, 동일한 인터페이스를 구현하는 객체를 내부에 구성한 뒤, 인터페이스에 정의된 메서드를 구성된 객체에 전달하는 패턴이다. 이렇게 전달된 메서드를 'Forwarding methods'라고 한다.

    class CounterSet<T>: MutableSet<T> {
    
        private val innerSet = HashSet<T>()         // Composition
        var elementsAdded = 0
            private set
    
        override fun add(element: T): Boolean {
            elementsAdded++
            return innerSet.add(element)            // Forwarding method
        }
    
        override fun addAll(elements: Collection<T>): Boolean {
            elementsAdded += elements.size
            return innerSet.addAll(elements)        // Forwarding method
        }
    
        override val size: Int 
            get() = innerSet.size
    
        override fun contains(element: T): Boolean =
            innerSet.contains(element)
    
        override fun containsAll(elements: Collection<T>): Boolean =
            innerSet.containsAll(elements)
    
        override fun isEmpty(): Boolean =
            innerSet.isEmpty()
    
        override fun iterator(): MutableIterator<T> = 
            innerSet.iterator()
    
        override fun clear() = 
            innerSet.clear()
    
        override fun remove(element: T): Boolean = 
            innerSet.remove(element)
    
        override fun removeAll(elements: Collection<T>): Boolean =
            innerSet.removeAll(elements)
    
        override fun retainAll(elements: Collection<T>): Boolean =
            innerSet.retainAll(elements)
    
    }

     

    Kotlin은 위와 같이 많은 수의 'Forwarding methods'를 구현해야 하는 문제를 알고, 다음과 같이 간편하게 사용할 수 있도록 'Interface delegation'을 지원한다.

    class CounterSet<T>(
        private val innerSet: MutableSet<T> = mutableSetOf()
    ) : MutableSet<T> by innerSet {                 // Interface delegation
    
        var elementsAdded = 0                       // Composition
            private set
    
        override fun add(element: T): Boolean {
            elementsAdded++
            return innerSet.add(element)            // Forwarding method
        }
    
        override fun addAll(elements: Collection<T>): Boolean {
            elementsAdded += elements.size
            return innerSet.addAll(elements)        // Forwarding method
        }
    }

     

    이처럼 다형성이 필요함과 동시에 상속을 사용하는 것에 리스크가 있을 때 'Delegation pattern'을 사용하는 것이 좋다. 하지만, 대부분의 경우 다형성이 필요하지 않기에, 'Composition'을 사용하는 것이 코드를 더 쉽게 이해할 수 있고 유연하게 만들 수 있다. 또한, 상속이 캡슐화를 깨뜨리는 점은 보안상의 문제를 일으킬 수 있지만, 대부분의 클래스는 'contract'가 명시되어 있거나 상속을 염두에 두고 메서드를 설계한 경우가 많기에 하위 클래스에서 특정 구현에 의존하지 않도록 설계할 수 있다.

     

    그럼에도 불구하고, 상속 대신 'Composition'을 사용하는 것은 더 많은 유연성과 재사용성을 제공하기에 'Composition'을 사용하는 것이 좋다.

    Restricting overriding

    상속을 염두하지 않고 설계된 클래스는 내부 메서드나 필드를 'final'로 선언하면 확장을 방지할 수 있다. 기본적으로 모든 메서드들은 'final'로 설정되어 있기에, 특별한 이유로 상속을 허용해야 하는 경우 'override'하고 싶은 메서드들만 'open'으로 설정하면 된다.

    open class Parent {
        fun a() {}
        open fun b() {}
    }
    
    class Child: Parent() {
        override fun a() {} // Error : final a cannot be overridden
        override fun b() {}
    }

     

    또한, 메서드를 'override'하면 해당 메서드를 'final'로 다시 설정할 수 있다는 점을 기억해야 한다.

     

    다음과 같이 선언하면 하위 클래스에서 'override' 할 수 있는 메서의 수를 제한할 수 있다.

    open class ProfileLoader: InternetLoader() {
    
        final override fun loadFromInternet() {
            // load profile
        }
    }

    Item 37 : Use the data modifier to represent a bundle of data

    여러 데이터를 묶어서 전달해야 하는 상황에서는 'data class'를 사용하는 것이 좋으며, 다음과 같은 유용한 함수들을 자동으로 지원한다.

    • 'toString'
    • 'equals and hashCode'
    • 'copy'
    • 'componentN'
    data class Player(
        val id: Int,
        val name: String,
        val points: Int
    )
    
    val player = Player(1, "Player 1", 1000)
    
    // toString() - 클래스 이름, 모든 프로퍼티 값과 이름을 표시
    print(player.toString()) // Player(id=1, name=Player 1, points=1000)
    
    // equals() - 모든 프로퍼티가 동일한지, hashCode()가 일관성이 있는지 확인
    player == Player(1, "Player 1", 1000)    // true
    player == Player(1, "Player 2", 1000)    // false
    
    // copy() - 원본 객체의 모든 프로퍼티를 기본 값으로 가진 새로운 객체 생성, 'named arguments'를 사용하여 각각의 값을 변경 가능
    val newPlayer: Player = player.copy(name = "Player 22")
    
    // componentN() - 프로퍼티에 순차적으로 접근할 수 있는 기능을 제공, 구조를 분해(destructuring)하여 선언할 때 유용
    val (id, name, points) = player
    
    // 위 예시의 componentN의 컴파일된 코드
    val id = player.component1()
    val name = player.component2()
    val points = player.component3()

    Prefer data classes instead of tuples

    'tuple'은 'Serializable'을 구현하고, 커스텀 된 'toString'을 갖는 'generic data class'이다.

    public data class Pair<out A, out B>(
        public val first: A,
        public val second: B
    ) : Serializable {
    
        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 {
    
        override fun toString(): String = 
            "($first, $second, $third)"
    }

     

    하지만, 'tuple' 대신 'data class'를 사용하는 이유는 (Int, String, String, Long)의 타입을 'tuple'로 정의하였을 때, 각 타입의 구체적인 의미나 사용 목적을 직관적으로 이해하기 어려운 문제가 있어 'tuple'을 잘 사용하지 않는다.

     

    그럼에도, 'tuple'은 다음과 같이 'local purpose'로 유용하게 사용될 수 있다.

    // 1. 값에 이름을 즉시 지정해야 하는 경우
    val (description, color) = when {
        degrees < 5 -> "cold" to Color.BLUE
        degrees < 23 -> "mild" to Color.YELLOW
        else -> "hot" to Color.RED
    }
    
    // 2. 예측 할 수 없는 집계를 나타내는 경우
    val (odd, even) = numbers.partition { it % 2 == 1 }

     

    위와 같은 경우를 제외하면, 'data class'를 사용하여 각 타입의 구체적인 의미나 사용 목적을 명시적으로 표현하는 것이 좋다.


    Item 38 : Use function types instead of interface to pass operations and actions

    대다수의 프로그래밍 언어에서는 '함수 타입'의 개념이 없기에, 단일 메서드를 가진 인터페이스를 사용한다. 이러한 단일 메서드를 갖는 인터페이스를 'Single Abstract Method'라고 한다. Kotlin에서 함수가 'Single Abstract Method'를 요구하면, 이를 구현하는 'object' 인스턴스를 전달할 수 있다.

    interface OnClick {
        fun clicked(view: View)
    }
    
    fun setOnClick(onClick: OnClick) { /* ... */ }
    
    // usage
    setOnClick(object: OnClick {
        override fun clicked(view: View) {
            // ...
        }
    })

     

    하지만, Kotlin에서는 '함수 타입'이 존재하기에, 'Single Abstract Method' 대신에 함수 타입으로 선언하면 여러 방법으로 파라미터를 전달할 수 있는 유연성을 얻을 수 있다.

    fun setOnClick(onClick: (View) -> Unit) { /* ... */ }
    
    // usage
    setOnClick { view -> /* ... */ }                // lambda expression
    setOnClick(fun(view: View) { /* ... */ })       // anonymous function
    setOnClick(::handleClick)                       // function reference
    setOnClick(this::showUsers)                     // bound function reference
    
    class ClickListener: (View) -> Unit {
        override fun invoke(view: View) { /* ... */ }
    }
    
    setOnClick(ClickListener())                     // declared function type

     

    'Single Abstract Method'는 메서드 자체와 해당 메서드의 파라미터 이름이 명확하게 지정되어 있다는 장점이 있다. 하지만, 이 역시 '함수 타입'은 'typealias'를 통해 함수 타입에 이름을 부여할 수 있으며, 파라미터에 이름을 지정하면 IDE에서 기본적으로 제안받을 수 있다.

    typealias OnClick = (view: View) -> Unit
    fun setOnClick(listener: OnClick) { /* ... */ }

     

    또한, 람다 표현식은 코드 내 인자들을 간편하게 'destructuring' 할 수 있어, 각 인자를 명확하고 간결하게 다룰 수 있다.
    이는 전통적인 'Single Abstract Method'를 사용하는 것보다 더 간결하고 많은 유연성을 제공한다.

    // Single Abstract Method
    class CalendarView {
        var listener: Listener? = null
    
        interface Listener {
            fun onDateSelected(date: LocalDate)
            fun onPageChanged(date: LocalDate)
        }
    }
    
    // Function Type
    class CalendarView {
        var onDateSelected: ((LocalDate) -> Unit)? = null
        var onPageChanged: ((LocalDate) -> Unit)? = null
    }

    When should we prefer a SAM?

    Kotlin이 아닌 다른 언어(e.g : Java)에서 사용될 클래스를 설계할 때에는 'typealias' or IDE의 제안을 볼 수 없기에 'Single Abstract Method'을 사용하는 것이 좋다. 또한, 일부 언어에서 Kotlin의 함수 타입을 사용하면, 함수가 명시적으로 'Unit'을 반환하도록 요구하기에 'Single Abstract Method'를 사용하는 것이 좋다.

    // Kotlin
    class CalendarView {
        var onDateSelected: ((LocalDate) -> Unit)? = null
        var onPageChanged: OnDateSelected? = null
    }
    
    interface OnDateSelected {
        fun onClick(date: Date)
    }
    
    // Java
    CalendarView c = new CalendarView();
    c.setOnDateSelected(date -> Unit.INSTANCE);
    c.setOnPageChanged(date -> {});

    Item 39 : Prefer class hierarchies to tagged classes

    규모가 큰 프로젝트의 클래스에서는 해당 클래스가 어떻게 동작하는지를 지정하는 'constant mode'를 포함하는 클래스를 볼 수 있으며,  이 'constant mode'를 'tag'라고 부르며, 'tag'를 가진 클래스를 'tagged class'라고 부른다.

     

    아래 예시는, 어떤 값이 특정 기준을 만족하는지 테스트하기 위해 사용되는 클래스이다.

    class ValueMatcher<T> private constructor(
        private val value: T? = null,
        private val matcher: Matcher
    ) {
        fun match(value: T?) = when(matcher) {
            Matcher.EQUAL -> value == this.value
            Matcher.NOT_EQUAL -> value != this.value
            Matcher.LIST_EMPTY -> value is List<*> && value.isEmpty()
            Matcher.LIST_NOT_EMPTY -> value is List<*> && value.isNotEmpty()
        }
    
        enum class Matcher { 
            EQUAL,
            NOT_EQUAL,
            LIST_EMPTY,
            LIST_NOT_EMPTY 
        }
    
        companion object {
            fun <T> equal(value: T) = ValueMatcher(value = value, matcher = Matcher.EQUAL)
            fun <T> notEqual(value: T) = ValueMatcher(value = value, matcher = Matcher.NOT_EQUAL)
            fun <T> listEmpty() = ValueMatcher<T>(matcher = Matcher.LIST_EMPTY)
            fun <T> listNotEmpty() = ValueMatcher<T>(matcher = Matcher.LIST_NOT_EMPTY)
        }
    }

     

    위 예시를 보면, 'tagged class'는 대부분 하나의 클래스 안에서 서로 다른 'constant mode'가 경쟁하고 있는 것을 알 수 있다.

     

    이런 방식은 여러 가지 단점이 있다.

    1. 하나의 클래스 안에서 여러 'constant mode'를 관리위해, 각 'mode'를 확인하고 적절히 처리하는 로직이 필요하다. 이는 코드에 추가적인 보일러플레이트를 도입하며, 결과적으로 코드 복잡성이 증가한다.
    2. 클래스가 다양한 상태를 표현하기 위해 여러 프로퍼티를 사용하면, 일부 프로퍼티는 특정 상태에서 사용되지 않는 경우가 발생할 수 있으며, 이는 객체의 일관성을 저하시킨다. 예를 들어, 'LIST_EMPTY' 또는 'LIST_NOT_EMPTY'의 'mode'일 때 'value'가 사용되지 않는다.
    3. 객체의 프로퍼티가 여러 목적으로 사용되고 다양한 방법으로 설정될 수 있기에, 객체 상태를 일관되고 정확하게 유지하는 것이 어려워진다.
    4. 객체 상태가 복잡해지고, 정확한 상태로의 초기화가 어려워지기에, 대부분의 경우 팩토리 함수를 사용해야 하는 추가적인 노력이 필요하다.

    Kotlin에서는 하나의 클래스 안에 여러 'constant mode'를 모으는 것이 아닌, 각 'mode' 마다 별도의 클래스를 정의하고 다형적으로 사용하는 것이 좋다. 이럴 때, 'tagged class' 대신 'sealed class'를 사용하면 다음과 같은 이점을 얻을 수 있다.

    1. 서로 다른 책임들이 복잡하게 얽히지 않게 할 수 있어 코드가 더 깔끔해진다.
    2. 각 객체는 자신에게 필요한 데이터만 보유하고, 자신이 필요로 하는 파라미터를 스스로 정의할 수 있다.
    sealed class ValueMatcher<T> {
    
        abstract fun match(value: T): Boolean 
    
        class Equal<T>(val value: T): ValueMatcher<T>() {
            override fun match(value: T) = 
                this.value == value
        }
    
        class NotEqual<T>(val value: T): ValueMatcher<T>() {
            override fun match(value: T) = 
                this.value != value
        }
    
        class ListEmpty<T>: ValueMatcher<List<T>>() {
            override fun match(value: List<T>) = 
                value is List<*> && value.isEmpty()
        }
    
        class ListNotEmpty<T>: ValueMatcher<List<T>>() {
            override fun match(value: List<T>) = 
                value is List<*> && value.isNotEmpty()
        }
    }

    Sealed modifier

    'sealed modifier'는 파일 외부에서 하위 클래스를 정의하는 것을 금지해 주는 특징이 있다.

    이 덕분에 'when'문에서 모든 경우를 다룰 때 다음 장점을 가질 수 있다.

    • 모든 경우가 고려되어 있어 'else' 분기를 추가할 필요가 없다.
    • 새로운 기능을 쉽게 추가할 수 있고, 이를 놓치는 일이 없도록 도와준다.

    이런 장점은 서로 다른 'mode'에 따라 다르게 동작하는 연산을 쉽게 정의할 수 있다.

    예를 들어, 각 하위 클래스에 별도로 동작을 정의하는 것이 아닌, 다음과 같이 새로운 함수들을 확장 함수 형태로 정의할 수 있다.

    fun <T> ValueMatcher<T>.reversed(): ValueMatcher<T> = 
        when(this) {
            is ValueMatcher.Equal -> ValueMatcher.NotEqual(value)
            is ValueMatcher.NotEqual -> ValueMatcher.Equal(value)
            is ValueMatcher.ListEmpty -> ValueMatcher.ListNotEmpty()
            is ValueMatcher.ListNotEmpty -> ValueMatcher.ListEmpty()
        }

    Tagged classes are not the same as state pattern

    'tagged class'와 'state pattern'은 서로 다른 목적을 가지고 있으며, 'state pattern'은 객체의 상태에 따라 다르게 동작하는 연산을 정의하는 패턴이다.

     

    이 둘의 주된 차이점은 다음과 같다.

    • 'state'는 보다 많은 책임을 지닌 더 큰 클래스의 일부로 존재한다.
    • 'state'는 변경되어야 한다.

    'state pattern'을 사용하면, 서로 다른 상태들을 대표하는 'class hierarchies'를 구성하게 된다. 또한, 현재 어떤 상태인지 나타내는 'read-write property'가 존재한다.

    sealed class WorkoutState { 
        class PreParerState(val exercies: Exercise): WorkoutState()
        class ExerciseState(val exercise: Exercise): WorkoutState()
        data object DoneState : WorkoutState()
    }
    
    fun List<Exercise>.toState(): List<WorkoutState> = 
        flatMap { exercise ->
            listOf(
              PreParerState(exercise),
              ExerciseState(exercise)
            )
        } + DoneState
    
    class WorkoutPresenter(/* ... */) {
    
        private var state: WorkoutState = states.first()    // read-write property
        // ...
    }

    Item 40 : Respect the contract of equals

    Kotlin 모든 객체는 'Any' 클래스를 확장하며, 'Any'는 'equals', 'hashCode', 'toString' 메서드와 이에 따른 명확한 'contract'를 가진다. 위 메서드들은 Java에서부터 오랫동안 정의되어 왔기에, 많은 객체와 함수가 이들의 'contract'에 의존하고 있다. 그렇기에, 해당 'contract'를 위반하면, 일부 객체나 함수가 제대로 동작하지 않을 수 있다.


    만약, 해당 메서드들을 'overriding' 하고 싶다면, 해당 'contract'를 준수하는 것이 중요하며, 이에 대해 명확히 이해하고 있어야 한다.

    Equality

    Kotlin에서는 두 가지 타입의 'equality'가 존재한다.

    • structural equality
      • 'equals' 메서드와 '==' 연산자를 통해, 객체의 내용이 서로 같은지 비교한다.
      • 'a == b'는 'a'가 'null'이 아닐 경우, 'a.equals(b)'로 해석된다.
      • 'a == b'는 'a'가 'null'일 경우, 'a?.equals(b) ?: (b === null)'로 해석된다.
    • referential equality
      • '===' 연산자를 통해, 두 객체가 메모리 상에서 같은 위치를 가리키고 있을 때 'true'를 반환한다.
      • 즉, 두 변수가 정확히 동일한 객체를 참조하고 있는 경우에만 'true'를 반환한다.
    open class Animal
    class Book
    class Cat : Animal()
    
    Animal() == Book()      // Error : Operator ==  cannot be applied to Animal and Book
    Animal() === Book()     // Error : Operator === cannot be applied to Animal and Book
    
    Animal() == Cat()       // OK, because Cat is a subclass of Animal
    Animal() === Cat()      // OK, because Cat is a subclass of Animal

    Why do we need equals?

    'Any' 클래스에서 제공되는 'equals' 메서드는, 주어진 다른 객체가 현재 객체와 동일한 인스턴스인지 확인한다.
    즉, 'referential equality'를 확인하는 것이며, 이는 기본적으로 모든 객체는 고유함을 의미한다.

    class Name(val name: String)
    
    val name1 = Name("John")
    val name2 = Name("John")
    val name1Ref = name1
    
    name1 == name1      // true
    name1 == name2      // false
    name1 == name1Ref   // true
    
    name1 === name1     // true
    name1 === name2     // false
    name1 === name1Ref  // true

     

    만약, 'primary constructor'의 모든 프로퍼티들이 서로 같은지 확인해야 하는 경우, 'data class'를 사용하는 것이 좋다.

    data class FullName(val name: String, val surname: String)
    
    val fullName1 = FullName("John", "Smith")
    val fullName2 = FullName("John", "Smith")
    val fullName3 = FullName("John", "Doe")
    
    fullName1 == fullName1      // true
    fullName1 == fullName2      // true, because data are the same
    fullName1 == fullName3      // false
    
    fullName1 === fullName1     // true
    fullName1 === fullName2     // false
    fullName1 === fullName3     // false

     

    또한, 불필요한 중복 프로퍼티의 'equality check'가 필요 없는 경우에도 'data class'를 사용하는 것이 좋다.

    class DateTime(
        private var millis: Long = 0L,
        private var timeZone: TimeZone? = null
    ) {
        private var asStringCache: String = ""          // unnecessary  
        private var changed: Boolean = false            // unnecessary
    
        override fun equals(other: Any?): Boolean =
            other is DateTime &&
                    millis == other.millis &&
                    timeZone == other.timeZone
    }
    
    // The same can be achieved by using data modifier
    data class DateTime(
      private var millis: Long = 0L,
      private var timeZone: TimeZone? = null
    ) {
      private var asStringCache: String = ""            // unnecessary
      private var changed: Boolean = false              // unnecessary
    
      // ...
    }

     

    이처럼, Kotlin에서는 대부분의 경우에 별도로 'equality'에 대한 구현이 필요하지 않다. 하지만, 특정 상황에서는 직접 'equals' 메서드를 구현해야 하는 경우가 있다.

     

    예를 들어, 두 객체가 동일한지 결정하는 데 있어, 특정 프로퍼티를 의존하는 경우에 'equality'를 구현해야 한다.

    class User(
        val id: Int,            // unique identifier
        val name: String
    ) {
        override fun equals(other: Any?): Boolean =
            other is User && id == other.id
    
        override fun hashCode(): Int = id
    }

     

    이처럼, 'equals'를 직접 구현해야 하는 시나리오는 다음과 같다.

    • 기본적으로 제공되는 'equals' 로직이 요구사항과 일치하지 않는 경우
    • 객체의 모든 프로퍼티가 아닌, 일부 프로퍼티만을 기반으로 'equality'를 결정해야 하는 경우
    • 해당 객체를 'data class'로 정의하고 싶지 않거나, 비교해야 할 특정 프로퍼티가 'primary constructor'에 포함되어 있지 않은 경우

    The contract of equals

    'equals' 메서드는 객체가 다른 객체와 동등한 지를 나타내며, 이를 직접 구현할 때는 다음과 같은 'contract'를 준수해야 한다.

    • 반사성(Reflexivity) : 'null'이 아닌 어떤 값 'x'에 대해, 'x.equals(x)'는 항상 'true'
    • 대칭성(Symmetry) : 'null'이 아닌 어떤 값 'x'와 'y'에 대해, 'x.equals(y)'가 'true'를 반환하면, 'y.equals(x)' 또한 'true'
    • 추이성(Transitivity) : 'null'이 아닌 어떤 값 'x', 'y', 'z'에 대해, 'x.equals(y)'와 'y.equals(z)'가 'true'를 반환하면, 'x.equals(z)' 또한 'true'
    • 일관성(Consistency) : 'null'이 아닌 어떤 값 'x'와 'y'에 대해, 'equals' 비교에 사용되는 정보에 변동이 없다면 'x.equals(y)'는 항상 일관된 결과를 반환
    • 'null'이 아닌 어떤 값 'x'에 대해, 'x.equals(null)'은 항상 'false'

    추가적으로, 공식적인 'contract'에 포함된 사항은 아니지만 'equals', 'toString', 'hashCode' 메서드들은 항상 빠르게 동작해야 한다. 위 메서드들이 실행 시간이 오래 걸리는 경우는, 일반적으로 예상치 못한 상황으로 간주된다.

    Reflexive

    객체의 'equality'는 반사성을 가져야 한다. ('x.equals(x)'는 항상 'true')

     

    이는 당연한 규칙으로 보이지만, 경우에 따라 위반될 수 있다.

    예를 들어, 현재 시간을 표현하는 'Time' 클래스에서 'ms' 단위로 객체를 비교하는 시도는 반사성을 위반할 수 있다.

    // Don't do this, violates reflexivity
    class Time(
        val millisArg: Long = -1,
        val isNow: Boolean = false
    ) {
        val millis: Long 
            get() = if (isNow) System.currentTimeMillis() else millisArg
    
        override fun equals(other: Any?): Boolean =
            other is Time && (other.millis == millis)
    }
    
    val now = Time(isNow = true)
    now == now                                  // Sometimes true, sometimes false
    List(100000) { now }.all { it == now }      // Most likely false

     

    이는 다음과 같이 단위 테스트의 'assertion' 검사에도 제대로 동작하지 않을 것이다.

    val now1 = Time(isNow = true)
    val now2 = Time(isNow = true)
    
    assertEquals(now1, now2)        // Sometimes passes, sometimes fails

     

    이처럼 결과가 일관되지 않음으로, 'equality'의 원칙 중 하나인 일관성도 위반된다. 이는 결과가 정확하게 나온 것인지, 아니면 일관성 없는 동작 때문에 나온 것인지 혼동이 생겨, 그 결과에 대한 신뢰를 잃게 된다. 이를 개선하는 방법 중 하나는, 'Time' 객체가 현재 시간을 표현하는지 확인한 뒤, 그렇지 않다면 두 객체가 같은 'TimeStamp'를 갖는지 확인하는 것이다.

    sealed class Time {
        data class TimePoint(val ms: Long) : Time()
        data object Now : Time()
    }

    Symmetric

    객체 간의 'equality'는 대칭성을 가져야 한다. ('x.equals(y)'가 'true'를 반환하면, 'y.equals(x)' 또한 'true')

     

    하지만, 만약 'equality check'에서 다른 타입의 객체를 허용하는 경우에는 이 원칙이 쉽게 위반 될 수 있다.

    예를 들어, 복소수를 나타내는 클래스를 만들고, 'Double'을 허용하는 'equality'를 구현하는 경우는 아래와 같을 것이다.

    class Complex(
        val real: Double,
        val imaginary: Double
    ) {
        // Don't do this, violates symmetry
        override fun equals(other: Any?): Boolean {
            if (other is Double) {
                return imaginary == 0.0 && real == other
            }
    
            return other is Complex && (other.real == real) && (other.imaginary == imaginary)
        }
    }

     

    이는, 'Complex'타입과 'Double' 타입이 서로 다른 타입이기에, 결과가 비교하는 요소의 순서에 영향을 받게 된다.

    Complex(1.0, 0.0).equals(1.0)   // true
    1.0.equals(Complex(1.0, 0.0))   // false

     

    만약, 대칭성이 위반되면, 컬렉션에서 'contains' 메서드를 사용할 때나, 단위 테스트에서 'assertion' 검사 시 문제가 발생할 수 있다.

    val list = listOf<Any>(Complex(1.0, 0.0))
    list.contains(1.0)      // Currently on the JVM this is false, but it depends on the collection’s implementation and might change

     

    위와 같이 비교하는 요소의 순서에 따라 결과가 달라지는 내용은 공식 문서에 명시되어 있지 않다. 왜냐하면 객체의 생성자 대부분은 비교하는 요소의 순서가 변경되어도 동일하게 동작할 것이라고 가정하기 때문이다. 하지만, 리팩토링 과정에서 비교 순서가 언제든지 변경될 수 있기에, 대칭성이 위반되는 경우 예상치 못한 오류를 발생시킬 수 있다. 이러한 이유로, 'equals'를 직접 구현할 때는 항상 'equality'를 심도 있게 고려해야 한다.

     

    일반적인 해결책은 서로 다른 타입 간의 'equality'를 허용하지 않는 것이다.

    Kotlin에서는 'Any'를 제외한 공통된 상위 타입이 없는 경우, 두 타입 간에 '==' 연산자를 사용할 수 없다.

    Complecx(1.0, 0.0) == 1.0   // Error

    Transitive

    객체 간의 'equality'는 추이성을 가져야 한다. ('x.equals(y)'와 'y.equals(z)'가 'true'를 반환하면, 'x.equals(z)' 또한 'true')

     

    추이성과 관련된 가장 큰 문제는 하위 타입이 상위 타입의 프로퍼티를 검사할 때, 서로 다른 종류의 'equality'를 구현할 때 발생한다.

    예를 들어, 'Date'를 확장하는 'DateTime'이 있다고 가정해 보자.

    open class Date(
        val year: Int,
        val month: Int,
        val day: Int
    ) {
    
        // Don't do this, symmetric but not transitive
        override fun equals(other: Any?): Boolean =
            when (other) {
                is DateTime -> this == other.date
                is Date -> (year == other.year) && (month == other.month) && (day == other.day)
                else -> false
            }
    
        // ...
    }
    
    class DateTime(
        val date: Date,
        val hour: Int,
        val minute: Int,
        val second: Int
    ): Date(date.year, date.month, date.day) {
    
        // Don't do this, symmetric but not transitive
        override fun equals(other: Any?): Boolean =
            when (other) {
                is DateTime -> (date == other.date) && (hour == other.hour) && (minute == other.minute) && (second == other.second)
                is Date -> date == other
                else -> false
            }
    
        // ...
    }

     

    위 구현은 'DateTime' 끼리 비교할 때와 'Date'과 'DateTime'를 비교할 때 고려해야 할 프로퍼티의 차이에 있다.

     

    같은 날짜를 가지고 있어도 서로 다른 시간을 가진 'DateTime' 끼리는 서로 같지 않다고 하지만, 아래와 같이 'Date'와 비교했을 때는 동일하다고 판단한다. 이러한 'equality'는 추이성을 위반한다.

    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)
    
    o1 == o2    // true
    o2 == o3    // true
    o1 == o3    // false <- So equality is not transitive

     

    앞서 말한, 'Any'를 제외한 공통된 상위 타입이 없는 경우, 두 타입 간에 '==' 연산자를 사용할 수 없다.

    즉, 같은 타입의 객체끼리만 비교하는 제한 규칙이 발휘하지 못한 이유는 '상속'이 사용되었기 때문이다.

    위 예시에서는 하위 클래스가 상위 클래스의 자리를 대체할 수 없기에, '리스코프 치환 원칙'을 위반하고 있다.

     

    따라서 상속 대신 'Composition' 사용이 더 적절하다.

    data class Date(
        val year: Int,
        val month: Int,
        val day: Int
    )
    
    data class DateTime(
        val date: Date,
        val hour: Int,
        val minute: Int,
        val second: Int
    )
    
    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)
    
    o1.equals(o2)       // false
    o2.equals(o3)       // false
    o1 == o3            // false
    
    o1.date.equals(o2)  // true
    o2.equals(o3.date)  // true
    o1.date == o3.date  // true

    Consistent

    객체 간의 'equality'는 일관성을 가져야 한다. ('equals' 비교에 사용되는 정보에 변동이 없다면, 'x.equals(y)'는 항상 일관된 결과를 반환)

     

    'equals' 메서드는 객체의 상태를 변경하지 않는 순수함수로 동작해야 하며, 그 결과가 오직 입력 값과 그 객체의 상태에만 의존해야 한다. 앞서 'Reflexive' 예시의 'Time' 클래스에서 이 원칙을 위반하는 예시를 보았기에 이 원칙의 설명은 넘어가자.

    Implementing equals

    'equals' 메서드를 직접 구현하는 것은 특별한 이유가 있다면 권장되지 않는다.
    즉, 기본적으로 제공되는 'equality' 구현들과 'data class'를 활용하는 것이 좋다.

     

    앞서 말한 것처럼 'equals'를 직접 구현해야 하는 경우에는 해당 구현이 'Reflexive', 'Symmetric', 'Transitive', 'Consistent'를 준수하는지 확인해야 한다. 또한, 상속받는 클래스에서 'equality'의 방식을 변경하지 않도록 주의해야 한다.


    Item 41 : Respect the contract of hashCode

    'Any' 클래스에서 'override' 가능한 또 다른 메서드는 'hashCode'이며, 이는 'HashTable' 자료구조에서 사용된다.

    'HashTable'은 다양한 컬렉션이나 알고리즘의 내부에서 사용되며, 'HashTable'이 등장한 배경을 살펴보자.

    HashTable

    요소를 신속하게 추가하는 컬렉션은 보통 중복된 요소를 허용하지 않는 'Set'과 'Map'이 있다.

    즉, 요소를 추가할 때 해당 요소와 동일한 요소가 있는지 확인한다.

     

    배열이나 연결 리스트 기반의 컬렉션은 크기가 커질수록 이런 요소를 확인하는 과정이 느리기에, 이 문제를 해결하기 위해 'HashTable'이 등장했다.

     

    'HashTable'은 '해시 함수'를 통해 요소마다 번호를 부여하고, 요구 사항에 따라 요소들을 다양한 '버킷'으로 분류할 수 있다. 이후 해당 버킷들은 'HashTable'에 저장된다. 즉, 'HashTable'은 버킷의 수와 동일한 크기의 '배열'이다.

     

    'HashTable'에서 요소를 추가할 때마다, 해시 함수의 결과를 배열의 인덱스로 사용하여 버킷을 찾는다.
    요소를 찾을 때는 같은 방식으로 버킷을 찾고, 버킷 안의 요소 중 하나와 동일한지 확인한다.
    여기서 해시 함수는 동일한 요소에 대해서 항상 같은 값을 반환해야 하므로, 다른 버킷을 확인할 필요가 없다.
    또한, 요소를 찾는데 필요한 연산 횟수를 버킷의 수로 나누어 적은 비용으로 요소를 찾을 수 있다.

     

    아래 예시는 문자열과 4개의 버킷으로 분할되는 해시 함수가 있다고 가정한 것이다.

    Text Hash code
    "How much wood would a woodchuck chuck" 3
    "Peter Piper picked a peck of pickled peppers" 2
    "Betty bought a bit of butter" 1
    "She sells seashells by the seashore" 2

     

    이를 '해시 코드'를 기반으로 다음과 같은 'HashTable'을 구성할 수 있다.

    Index Object to which hash table points
    0 []
    1 ["Betty bought a bit of butter"]
    2 ["Peter Piper picked a peck of pickled peppers", "She sells seashells by the seashore"]
    3 ["How much wood would a woodchuck chuck"]

     

    이후, 'HashTable'에 새로운 텍스트의 존재 여부를 확인할 때, '해시 코드'를 계산하게 된다.

    • 해시 코드가 '0'인 경우, 해당 텍스트는 목록에 없다.
    • 해시 코드가 '1' 또는 '3'인 경우, 단 하나의 텍스트와 비교한다.
    • 해시 코드가 '2'인 경우, 두 개의 텍스트와 비교한다.

     

    'Kotlin/JVM'에서 기본적으로 제공되는 'Set(LinkedHashSet)'과 'Map(LinkedHashMap)'도 이 원리를 사용한다.

    '해시 코드'를 생성하기 위해서는 'hashCode' 메서드를 사용한다.

    Problem with mutability

    요소에 대한 해시는 요소가 추가될 때만 계산되며, 요소가 변경되어도 그 위치는 변경되지 않는다. 이런 특징으로 'Set' 또는 'Map'에 객체가 추가된 후 변경될 경우, 해당 객체는 제대로 동작하지 않게 되므로 주의해야 한다.

    data class FullName(
        var name: String,
        var surname: String
    )
    
    val person = FullName("John", "Smith")
    val muSet = mutableSetOf<FullName>()
    muSet.add(person)
    person.surname = "Doe"
    
    print(person)               // FullName(name=John, surname=Doe)
    print(person in muSet)      // false
    print(muSet)                // [FullName(name=John, surname=Doe)]

    The contract of hashCode

    'hashCode'의 'contract'는 다음과 같다.

    1. 동일한 객체에 대해 'hashCode' 메서드를 여러 번 호출해도, 'equals' 비교에 사용되는 정보가 변경되지 않는 한, 일관성 있는 정수 값을 반환해야 한다.
    2. 'equals'를 통해 두 객체가 동등함을 확인했다면, 각 객체에서 'hashCode'를 호출했을 때 동일한 정수 값을 반환해야 한다.

    첫 번째 'contract'는 'hashCode'가 일관성을 가져야 함을 의미한다.

     

    두 번째 'contract'는 개발자들이 간과할 수 있는 중요한 사항이다. 'hashCode'는 항상 'equals'와 일관성을 가져야 하며, 동등한 객체는 동일한 '해시 코드'를 가져야 한다.

     

    만약, 두 번째 'contract'가 위반되면, 'HashTable' 기반 컬렉션에서 객체들이 분실될 위험이 있다.

    class FullName(
        var name: String,
        var surname: String
    ) {
        override fun equals(other: Any?): Boolean =
            other is FullName && (other.name == name) && (other.surname == surname)
    
        // where is hashCode?
    }
    
    val muSet = mutableSetOf<FullName>()
    muSet.add(FullName("John", "Smith"))
    print(FullName("John", "Smith") in muSet)                       // false
    print(FullName("John", "Smith") == FullName("John", "Smith"))   // true

     

    위와 같이 예상치 못한 결과가 나오기에, 'equals'를 직접 구현한 경우에는 반드시 'hashCode'도 함께 'overriding' 해야 한다.

     

    추가로, 요구되지 않지만, 'HashTable'을 효율적으로 만들 수 있는 방법이 있다. 바로, 요소들의 '해시 코드'를 가능한 넓게 분산시키는 것이다. 이는, 서로 다른 객체들이 서로 다른 해시 코드를 가지도록 하여, 해시 충돌을 최소화하고 데이터 구조의 성능을 극대화할 수 있다.

     

    만약, 'hashCode'가 항상 같은 값을 반환한다면, 모든 요소가 동일한 버킷에 할당될 것이다. 이는 기술적으로 'hashCode'의 'contract'에 만족할지 몰라도, 실질적으로 무용지물일 것이다. 아래의 각 클래스의 'equalsCounter'를 비교해 보면, 이 내용을 이해할 수 있을 것이다.

    class Proper(val name: String) {
    
        override fun equals(other: Any?): Boolean {
            equalsCounter++
            return other is Proper && name == other.name
        }
    
        override fun hashCode(): Int = name.hashCode()      // spread widely
    
        companion object {
            var equalsCounter = 0
        }
    }
    
    class Terrible(val name: String) {
    
        override fun equals(other: Any?): Boolean {
            equalsCounter++
            return other is Terrible && name == other.name
        }
    
        override fun hashCode(): Int = 0        // Do not do that, always returns the same value 0
    
        companion object {
            var equalsCounter = 0
        }
    }
    
    val properSet = List(10000) { Proper("$it") }.toSet()
    print(Proper.equalsCounter)                 // 0
    
    val terribleSet = List(10000) { Terrible("$it") }.toSet()
    print(Terrible.equalsCounter)               // 50117047
    
    Proper.equalsCounter = 0
    println(Proper("9999") in properSet)        // true
    println(Proper.equalsCounter)               // 1
    
    Proper.equalsCounter = 0
    println(Proper("A") in properSet)           // false
    println(Proper.equalsCounter)               // 0
    
    Terrible.equalsCounter = 0
    println(Terrible("9999") in terribleSet)    // true
    println(Terrible.equalsCounter)             // 2077
    
    Terrible.equalsCounter = 0
    println(Terrible("A") in terribleSet)       // false
    println(Terrible.equalsCounter)             // 10001

    Implementing hashCode

    앞서 설명한 것처럼 'equals'를 직접 구현할 때에만 'hashCode'도 함께 'overriding' 한다.

     

    즉, 'equals'를 'overriding' 하지 않는다면, 'hashCode'를 'overriding' 할 필요가 없다.

    'equals'를 'overriding' 한다면, 동일한 요소에 대해서 항상 같은 값을 반환하는 'hashCode'를 'overriding' 해야 한다.

     

    만약 객체의 중요한 프로퍼티들을 기반으로 'equality check'가 필요한 경우, 해당 프로퍼티들의 '해시 코드'를 기반으로 'hashCode'를 구현해야 한다. 이처럼, '다수의 해시 코드'를 '하나의 해시 코드'로 합치는 일반적인 방법은 모든 해시 코드를 하나의 결과 값에 누적시키는 것이다. 이를 위해 다음 해시 코드를 추가할 때마다 현재 결과에 '31'을 곱하고, 그 결과에 새로운 해시 코드를 더한다.

     

    이런 관례는 'data class'에 의해 자동으로 생성된 'hashCode'도 동일하게 따른다.

    class DateTime(
        private var millis: Long = 0L,
        private var timezone: TimeZone? = null
    ) {
        private var asStringCache = ""
        private var changed = false
    
        override fun equals(other: Any?): Boolean =
            other is DateTime && (other.millis == millis) && (other.timezone == timezone)
    
        override fun hashCode(): Int {
            var result = millis.hashCode()
            result = result * 31 + timezone.hashCode()      // accumulate hash code
            return result
        }
    }

     

    실제로는 'hashCode'를 'overriding' 하는 경우는 드물기에, 'data modifier'를 통해 'hashCode'를 자동으로 생성하는 것이 가장 좋다.


    Item 42 : Respect the contract of compareTo

    'compareTo'는 'Any' 클래스 안에 포함되어 있지 않지만, Kotlin에서는 '연산자로 취급'하여 수학적 부등호와 같은 역할을 한다.

    obj1 > obj2     // Translates to obj1.compareTo(obj2) > 0
    obj1 <= obj2    // Translates to obj1.compareTo(obj2) <= 0

     

    'compareTo'는 Comparable<T> 인터페이스에 포함되어 있다.

    즉, 어떤 객체가 Comparable<T>를 구현하거나, 'compareTo' 메서드를 가지고 있다면, 해당 객체는 '자연스러운 순서'가 존재함을 의미한다.

     

    이처럼 'compareTo'를 통해 자연스러운 순서를 정의할 때 고려해야 할 사항은 다음과 같다.

    • Antisymmetric, 대칭적이지 않아야 한다.
      • 만약, 'a >= b'이고, 'b >= a'라면 'a == b' 이다.
      • 따라서, 'comparison'과 'equality' 사이에는 관계가 있으며, 'compareTo'는 'equals'와 일관성을 가져야 한다.
    • Transitive, 추이적이어야 한다.
      • 만약, 'a > b'이고, 'b > c'라면 'a > c' 이다.
      • 추이성이 없다면, 정렬 알고리즘 중 'QuickSort'와 'MergeSort'는 무한히 반복되는 루프에 빠질 수 있다.
    • Connex, 연결적이어야 한다.
      • 두 요소 'a'와 'b'에 대해 'a >= b' 또는 'b >= a'가 되어야 한다.
      • 'compareTo'는 비교 결과를 'Int'(양수, 음수, 0 중 하나)를 반환하기에, 서로 비교될 수 있음을 보장한다.
      • 연결성이 없다면, 정렬 알고리즘 중 'QuickSort'와 'InsertionSort'를 사용할 수 없다.

    Do we need a compareTo?

    Kotlin에서 'compareTo'를 직접 구현하는 일은 거의 없으며, 각 상황에 맞게 순서를 정하는 것이 더 많은 유연성을 얻는다.

    아래 예시는 간단한 비교를 통한 정렬과, 복잡한 비교를 통한 정렬이다.

    class User(val name: String, val surName: String)
    val names = listOf<User>(/* ... */)
    
    val simpleSorted = names.sortedBy { it.surName }                                // Simple comparison
    val complexSorted = names.sortedWith(compareBy({ it.surName }, { it.name }))    // Complex comparison

     

    날짜, 시간, 'String'과 같이 자연스러운 순서를 가진 객체들도 있기에 'compareTo'를 직접 구현할 필요가 없다.

    하지만, 객체에 자연스러운 순서가 있는지 확실하지 않은 경우 'comparator'를 사용을 고려하는 것이 좋다.

    또한, 자주 사용되는 'comparator'가 있다면, 이를 'companion object'에 배치하여 관리하는 것이 좋다.

    class User(val name: String, val surName: String) {
        // ...
    
        companion object {
            val DISPLAY_ORDER = compareBy(User::surname, User::name)
        }
    }
    
    val sorted = names.sortedWith(User.DISPLAY_ORDER)

    Implementing compareTo

    'compareTo'를 직접 구현하는 경우, 이 작업을 보다 쉽게 만드는 여러 Top-level 함수가 존재한다.

    class User(
        val name: String,
        val surName: String
    ): Comparable<User> {
    
        // compare two values
        override fun compareTo(other: User): Int =
            compareValues(surName, other.surName)
    
        // compare more values 
    //    override fun compareTo(other: User): Int =
    //        compareValuesBy(this, other, {it.surName}, {it.name})
    
    }

     

    만약 특별한 로직을 적용하는 'compareTo'를 구현해야 한다면, 아래 조건을 만족해야 한다.

    • 비교하는 두 객체가 동일한 경우 '0'을 반환
    • receiver 객체가 other 객체보다 크다면 양수를 반환
    • receiver 객체가 other 객체보다 작다면 음수를 반환
    • 'Antisymmetric', 'Transitive', 'Connex'를 만족

    Item 43 : Consider extracting non-essential parts of your API into extensions

    클래스 내에서 'final method' 정의 시, 다음 정의 방식 중 결정해야 한다.

    • define them as members
    • define them as extension functions
    // 1. Defining methods as members
    class Workshop(/* ... */) {
        // ...
    
        fun makeEvent(date: DateTime): Event = // ...
    
        val permalink 
            get() = "/workshop/$name"
    }
    
    // 2. Defining methods as extensions
    class Workshop(/* ... */) {
        // ...
    }
    
    fun Workshop.makeEvent(date: DateTime): Event = // ...
    
    val Workshop.permalink
        get() = "/workshop/$name"
    
    
    // usage
    fun useWorkshop(workshop: WorkShop) {
        val event = workshop.makeEvent(DateTime.now())
        val permalink = workshop.permalink
    
        val makeEventRef = Workshop::makeEvent
        val permalinkPropRef = Workshop::permalink
    }

     

    두 방식은 사용하는 방법, 리플렉션 참조 방법 등이 유사함을 알 수 있다. 그럼에도 불구하고, 두 방식은 명확한 차이점과 각각 장단점을 가지고 있다. 'extension'과 'member'의 사용법에 있어 가장 큰 차이점은 'extension'은 별도의 'import'가 필요하다.

     

    이로 인해 'extenstion'은 다음과 같은 장점을 가질 수 있다.

    • 다른 패키지에 배치될 수 있는 유연성을 가지고 있어, 직접 클래스에 추가할 수 없는 상황 또는 데이터와 행동을 분리할 때 유용하다.
    • 동일한 이름으로 같은 타입의 여러 'extension'을 정의할 수 있다.

    그러나, 동일한 이름으로 다른 동작을 하는 'extension'이 존재할 수 있는 가능성을 가지고 있어 혼란을 줄 수 있다.
    이러한 경우에는 'member'로 생성하여 컴파일러가 'member'를 우선적으로 선택하도록 하여 혼란을 줄일 수 있다.

     

    또 다른 중요한 차이점은 'extension'은 'virtual'이 아니라는 점에서 'derived class'에서 'override' 할 수 없다.
    반면, 'member'는 'virtual' 속성이라는 점에서 '상속'을 통해 'override' 할 수 있다.
    따라서, 상속을 염두에 둔 요소를 'extension'으로 정의하는 것은 적절하지 않다.

     

    또 다른 중요한 특징은 'extension'을 클래스가 아닌 타입에 정의할 수 있다는 점이다.

    inline fun CharSequence?.isNullOrBlank(): Boolean {
        contract { 
            returns(false) implies (this@isNullOrBlank != null) 
        }
    
        return this == null || this.isBlank()
    }
    
    public fun Iterable<Int>.sum(): Int {
        var sum: Int = 0
        for (element in this) {
            sum += element
        }
    
        return sum
    }

     

    마지막으로, 'extension'은 클래스 참조에서 'member'로 표시되지 않는다. 이러한 특성으로 인해, 'annotation processor'는 'extension'을 인식하지 못하며, 결과적으로 'annotation processing' 과정에서 'extension'으로 전환해야 할 요소들을 추출하는 것이 불가능하다. 반대로, 중요하지 않은 요소들을 'extension'으로 옮기면, 'annotation processosing' 과정에서 불필요하게 감지되거나 영향을 받지 않게 할 수 있다.


    Item 44 : Avoid member extensions

    어떤 클래스에 대한 'extension 함수'를 정의하더라도, 이는 클래스의 'member'로 추가되지 않는다.

    실제로, 'extension 함수'는 일반 함수로 컴파일되며, 'receiver'는 함수의 첫 번째 인자로 전달된다.

    fun String.isPhoneNumber(): Boolean = 
        this.length == 7 && this.all { it.isDigit() }
    
    // Compiled
    fun isPhoneNumber(`$this`: String): Boolean =
        $this.length == 7 && $this.all { it.isDigit() }

     

    위와 같은 구현 방식을 통해 실제 클래스의 구조를 변경하지 않으면서, 마치 해당 클래스의 일부인 것처럼 동작할 수 있음을 알 수 있다. 이는 해당 클래스의 'member extension'이 될 수 있고, 심지어 인터페이스 내에서 특정 타입에 대한 'extension'을 정의할 수도 있음을 의미한다.

    interface PhoneBook {
        fun String.isPhoneNumber(): Boolean
    }
    
    class Fizz: PhoneBook {
        override fun String.isPhoneNumber(): Boolean =
            this.length == 7 && this.all { it.isDigit() }
    }

     

    'member extension'은 정의될 수 있지만, 이를 사용하지 않는 이유가 있다.

    1. 클래스 안에 선언하여 '특정 member'의 'visibility'를 제한하고자 할 때 실제로 'visibility'가 제한되지 않는다.
    // Bad practice, Don't do this
    class PhoneBookIncorrect {
        // ...
    
        fun String.isPhoneNumber(): Boolean =
            length == 7 && all { it.isDigit() }
    }
    
    // not really restrict visibility
    PhoneBookIncorrect().apply { "1234567890".isPhoneNumber() }

     

    'extension'의 'visibility'를 제한하고자 하는 경우에는 'local'로 배치하는 것이 아닌, 'visibility modifier'를 사용하는 것이 바람직하다.

    // This is how we limit extension functions visibility
    class PhoneBookCorrect {
      // ...
    }
    
    private fun String.isPhoneNumber(): Boolean =
      length == 7 && all { it.isDigit() }
    1. 참조가 지원되지 않는다.
    val ref = String::isPhoneNumber
    val str = "1234567890"
    val boundedRef = str::isPhoneNumber
    
    val refX = PhoneBookIncorrect::isPhoneNumber            // Error
    val boundedRefX = PhoneBookIncorrect()::isPhoneNumber   // Error
    1. 'extension receiver'와 'dispatch receiver'에 대한 암시적 접근이 혼란스러울 수 있다.
    class A { 
        val a = 10
    }
    
    class B { 
    	val a = 11
        val b = 20
    
        fun A.test() = a + b
    }
    
    // Result 30
    1. 'extension'이 'receiver'를 변경하거나 참조할 때, 'extension receiver'를 수정하는지, 'dispatch receiver'를 수정하는지 명확하지 않다.
    class A {
        // ...
        var count = 10
    }
    
    class B {
        // ...
    	var count = 20
        
        fun A.update() = count + 1      // Shell is update A or B?
    }
    
    // Result : 11
    1. 경험이 부족한 개발자들에게는 멤버 확장의 사용이 직관적이지 않거나 이해하기 어려울 수 있다.
Designed by Tistory.