클래스는 객체 지향 프로그래밍(OOP) 패러다임에서 가장 중요한 추상화입니다. 이번 장에서는 코틀린에서 자주 볼 수 있는 클래스 사용 패턴에 대해 알아보고자 합니다. 이런 패턴을 어떻게 활용하고, 어떤 점을 기대하고 사용하는 지 등 규약에 대해서 알아보겠습니다. 해당 규약들을 잘 따르면, 안전하고 깔끔한 코드를 만드는 데 도움이 됩니다.
아이템 36 - 상속보다는 컴포지션을 사용하라
상속은 굉장히 강력한 기능으로, IS-A 관계의 객체 계층 구조를 만들기 위해 사용됩니다. 상속은 관계가 명확하지 않을 때 사용하면, 여러 문제가 발생할 수 있으므로 신중하게 사용해야 합니다.
일반적으로, 일부 코드 추출 또는 재사용이 목적이라면 상속보다는 컴포지션을 사용하는 것이 좋습니다.
간단한 행위 재사용
프로그레스 바를 특정 로직 처리 전에 출력하고, 처리 후에 숨기는 유사한 동작을 하는 두 개의 클래스가 있다고 가정합니다.
abstract class LoaderWithProgress {
fun load() {
// 프로그레스 바 보여줌
innerLoad()
// 프로그레스 바 숨김
}
abstract fun innerLoad()
}
class ProfileLoader: LoaderWithProgress() {
override fun innerLoad() {
// profile 읽음
}
}
class ImageLoader: LoaderWithProgress() {
override fun innerLoad() {
// image 읽음
}
}
이때, 유사한 동작을 하는 내용을 상속을 이용해 추출한다면, 위와 같이 코드가 작성됩니다. 이러한 코드는 간단한 경우 문제 없이 동작하지만, 몇 가지 단점이 있습니다.
- 상속은 하나의 클래스만을 대상으로 할 수 있습니다.
- 하나의 클래스만 상속이 가능하다보니, 상속을 이용해 행위를 추출하다보면 많은 함수를 갖는 거대한
BaseXXX
,CommonXXX
클래스를 만들게 되어 깊고 복잡한 계층 구조가 만들어질 수 있습니다.
- 하나의 클래스만 상속이 가능하다보니, 상속을 이용해 행위를 추출하다보면 많은 함수를 갖는 거대한
- 상속은 클래스의 모든 것을 가져오게 됩니다.
- 상속하고자 하는 클래스의 일부 함수만 가져올 수 없어, 인터페이스 분리 원칙(Interface Segregation Principle)에 위배됩니다.
- 상속은 이해하기 어렵습니다.
- 메소드 작동을 확인하기 위해 슈퍼 클래스를 여러 번 확인하고 있다면, 문제가 있는 코드입니다.
인터페이스 분리 원칙(Interface Segregation Principle)이란?
인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메소드에 의존하지 않아야 한다는 원칙입니다.
인터페이스 분리 원칙은, 큰 덩어리의 인터페이스들을 구체적이고 작은 단위로 분리함으로써 클라이언트들이 꼭 필요한 메소드들만 이용할 수 있게 합니다. 인터페이스 분리 원칙은, 거대한 인터페이스를 사용함으로써 생길 수 있는 사이드 이펙을 줄일 수 있도록 인터페이스를 작게 분리하는 것이 목표입니다.
예를 들어, 아래 왼쪽 사진과 같이 큰 인터페이스를 참조함에 따라 불필요한 메소드도 구현해야 하는 상황일 때, 오른쪽 사진처럼 실제로 필요한 항목들만 분리하여 인터페이스를 분리하는 것을 의미합니다.
위와 같은 단점들 때문에 상속보다는 컴포지션(composition)을 사용하는 것을 권장합니다. 컴포지션을 사용한다는 의미는 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하는 것을 의미합니다.
앞선 예시를 컴포지션을 활용한다면, 아래와 같이 작성될 수 있습니다.
interface Progress {
fun showProgress() { /* 프로그레스 바 보여줌 */ }
fun hideProgress() { /* 프로그레스 바 숨김 */ }
}
class ProfileLoader: Progress {
fun load() {
showProgress()
// profile 읽음
hideProgress()
}
}
class ImageLoader: Progress {
fun load() {
showProgress()
// image 읽음
hideProgress()
}
}
앞선 상속을 사용했을 때보다 작성해야 할 코드의 양은 늘어날 수 있지만, 실제 동작을 좀 더 명확하게 예측할 수 있고 컴포지션 대상을 좀 더 자유롭게 사용할 수 있습니다.
예를 들어, ProfileLoader
나 ImageLoader
와 다르게 VideoLoader
클래스에서는 프로그레스 바를 숨긴 뒤에 다른 행위를 추가적으로 해야 한다면 상속을 사용하게 되면 하나 이상의 클래스를 상속할 수 없어 두 기능을 하나의 슈퍼클래스에 배치해야 합니다.
- 이렇게 작성하게 되면, 계층 구조를 이해하기도 어렵고 매번 수정이 필요할 때마다 어떻게 진행할지에 대해 고민이 많이 필요합니다.
상속은 하나의 클래스만 상속할 수 있고, 슈퍼클래스 내용 중 필요한 부분만 떼어내 상속 받을 수 없습니다. 따라서, 일부분만 재사용하기 위한 목적이라면 상속은 적합하지 않습니다.
반면에, 컴포지션은 우리가 원하는 행위만 가져올 수 있습니다.
하지만 위 케이스에서 컴포지션을 사용한다면, 아래와 같이 VideoLoader
에서 load()
함수만 수정해주면 문제될 부분이 없습니다.
class VideoLoader: Progress {
fun load() {
showProgress()
// video 읽음
hideProgress()
// video 재생 (신규로 추가한 로직)
}
}
캡슐화를 깨는 상속
상속을 활용할 때는 외부에서 이를 어떻게 사용하는지도 중요하지만, 내부적으로 어떻게 활용하는지도 중요합니다. 내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수 있기 때문입니다.
아래 예시를 통해 어떤 상황인지 알아보도록 하겠습니다. 먼저, CounterSet
라는 클래스는 자신에게 추가된 요소의 개수를 반환하는 counter
라는 프로퍼티를 가지며 HashSet
기반으로 구현되어있습니다.
위 클래스는 문제 없어보이지만, 실제로 테스트를 해보면 생각한대로 counter
값이 나오지 않습니다. 이렇게 잘못된 결과가 나오는 이유는, HashSet
의 addAll
내부에서 add
를 사용하고 있기 때문입니다.
내부적으로 호출한 add()
함수는, CounterSet
클래스가 HashSet
의 add()
함수를 override하였기 때문에, CounterSet
클래스의 add()
함수 호출을 유발하여 생각했던 것과 다른 결과가 나오는 것입니다.
이를 해결하고자 아래와 같이 addAll()
함수를 제거할 수도 있지만, 자바 업데이트 등에 의해 HashSet
의 addAll()
함수가 add()
함수를 호출하지 않는 것으로 구현이 변경된다면 예상치 못한 형태로 동작할 수 있습니다.
라이브러리의 구현은 해당 라이브러리를 사용하는 사람이 강제할 수 있는 것이 아니기 때문에, 위처럼 사용하는 것은 위험합니다. 이럴 때에는 상속 대신 컴포지션을 사용하는 것이 좋습니다.
다만, 위처럼 변경할 경우 다형성이 사라지게 되어, CounterSet
은 더 이상 Set
이 아니게 됩니다.
CounterSet
클래스는Set
을 상속받지 않기 때문에,add()
,addAll()
외의Set
이 제공해주는 메소드를 사용할 수 없기 때문입니다.
다형성을 유지할 필요가 있다면, 위임 패턴을 사용할 수 있습니다. 위임 패턴은 클래스가 인터페이스를 상속 받고, 포함된 객체의 메소드를 활용해서 인터페이스에 정의된 메소드를 구현하는 패턴입니다. 이렇게 구현된 메소드들을 포워딩 메소드(forwarding method)라고 부릅니다.
class CounterSet<T>: MutableSet<T> {
private val innerSet = HashSet<T>()
var counter = 0
private set
override fun add(element: T): Boolean {
counter++
return innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
counter += elements.size
return innerSet.addAll(elements)
}
// 포워딩 메소드들
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 clear() = innerSet.clear()
override fun iterator(): MutableIterator<T> = innerSet.iterator()
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)
}
위처럼 위임 패턴을 사용하면, 작성해야 하는 포워딩 메소드가 너무 많아진다고 느껴질 수 있습니다. 코틀린은 위임 패턴을 쉽게 구현할 수 있는 문법을 제공하여 아래와 같이 간단하게 작성할 수 있습니다.
- 아래와 같이 작성하면, 컴파일 시점에 포워딩 메소드들이 자동으로 만들어집니다.
class CounterSet<T>(
private val innerSet: MutableSet<T> = mutableSetOf()
): MutableSet<T> by innerSet{ // by 키워드를 통한 위임
var counter = 0
private set
override fun add(element: T): Boolean {
counter++
return innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
counter += elements.size
return innerSet.addAll(elements)
}
}
다형성이 필요할 경우에는, 위처럼 위임 패턴을 사용할 수 있습니다.
하지만, 보통 다형성이 그렇게 필요한 경우는 많지 않아서 단순히 컴포지션만 활용하면 되는 케이스가 더 많습니다.
아이템 37 - 데이터 집합 표현에 data 한정자를 사용하라
코틀린은 data class를 제공하며, 클래스에 data 한정자를 붙이면 아래와 같은 함수가 자동으로 생성됩니다.
data class 사용시 자동 생성되는 함수들
toString
- 클래스 이름과 기본 생성자 형태로 모든 프로퍼티와 값을 출력해줍니다.
- 로그 출력이나 디버깅 시 유용합니다.
equals와 hashCode
equals
는 기본 생성자의 프로퍼티가 같은지 확인해줍니다.hashCode
는equals
와 같은 결과를 냅니다.
copy
immutable
데이터 클래스를 만들 때 편리하며, 기본 생성자 프로퍼티가 같은 새로운 객체를 복제합니다.- 새로 만들어진 객체의 값은 이름 있는 아규먼트를 활용해 변경하여 생성할 수 있습니다.
copy
메소드는 객체를 얕은 복사를 합니다.
componentN(component1, component2 등)
- 위치를 기반으로 객체를 해제할 수 있게 해줍니다.
- 위치 기반 객체 해제는 장점과 단점이 모두 있습니다.
- 장점: 변수의 이름을 원하는 대로 지정할 수 있습니다.
- 단점: 위치를 잘못 지정하면 다양한 문제가 발생할 수 있습니다(위치 혼동으로 잘못된 값을 매핑할 수 있음).
- 객체를 해제할 때에는 데이터 클래스의 기본 생성자에 붙어 있는 프로퍼티 이름과 같은 이름을 사용하는 것이 좋습니다.
- 같은 이름을 사용하면, 순서를 잘못 사용했을 때 인텔리제이가 경고를 해줍니다.
- 만약, 값을 하나만 갖는 데이터 클래스라면 해제하지 않는 것이 좋습니다. 람다와 함께 사용할 때 읽는 사람에게 혼란을 줄 수 있습니다.
튜플 대신 데이터 클래스 사용하기
데이터 클래스는 튜플보다 많은 것을 제공합니다. 코틀린의 튜플은 Serializable
을 기반으로 만들어지며, toString
을 사용할 수 있는 제네릭 데이터 클래스입니다. 튜플은 데이터 클래스와 같은 역할을 하지만, 가독성이 나쁘기 때문에 몇 가지 목적으로만 사용하는 게 좋습니다.
val (description, color) = when {
degrees < 5 -> "cold" to Color.BLUE
degrees < 23 -> "mild" to Color.ORANGE
else -> "hot" to Color.RED
}
- 값에 간단하게 이름을 붙일 때
val (odd, even) = numbers.partition { it % 2 == 1 }
val map = mapOf(1 to "A", 2 to "B")
- 표준 라이브러리에서 볼 수 있는 것처럼 미리 알 수 없는 aggregate를 표현할 때
코틀린에서 데이터 클래스를 사용하는 것은 추가 비용이 거의 들지 않기 때문에, 함수를 더 명확히 하기 위해 사용하면 좋습니다.
- 함수의 리턴 타입이 명확해집니다.
- 리턴 타입이 더 짧아지며, 전달하기 쉬워집니다.
- 사용자가 데이터 클래스에 적혀 있는 것과 다른 이름을 활용해 변수를 해제하면 경고가 출력됩니다.
아이템 38 - 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라
대부분의 프로그래밍 언어에서는 함수 타입이라는 개념이 없습니다. 그래서 연산 또는 액션을 전달할 때, 메소드가 하나만 있는 인터페이스를 활용합니다. 이러한 인터페이스는 SAM(Single-Abstract Method)라고 부릅니다.
interface OnClick {
fun clicked(view: View)
}
// 함수가 SAM을 파라미터로 받는다는 것은, 인터페이스를 구현한 객체를 전달받는다는 의미
fun setOnClickListener(listener: OnClick) {
//...
}
// 아래와 같이 사용됨
setOnClickListener(object: OnClick {
override fun clicked(view: View) {
//... 인터페이스의 메소드를 구현하여 전달
}
}
- 위 코드는 예시로, 코틀린에서는 위처럼 작성해도 내부적으로 함수 타입으로 변환되어 사용됩니다.
코틀린은 함수 타입을 지원하고 있기 때문에, 위처럼 사용하기 보다는 함수 타입으로 사용하는 것이 더 편리합니다.
- 람다 표현식 또는 익명 함수로 전달
setOnClickListener { /* ... */ }
setOnClickListener(fun(view) { /* ... */ })
- 함수 레퍼런스 또는 제한된 함수 레퍼런스로 전달
setOnClickListener(::println)
setOnClickListener(this::showUsers)
- 선언된 함수 타입을 구현한 객체로 전달
class ClickListener: (View) -> Unit {
override fun invoke(view: View) {
//...
}
}
setOnClickListener(ClickListener())
코틀린의 함수 타입은 typealias
를 통해 이름을 붙일 수 있으며, 아래 예시처럼 적용할 수 있습니다.
fun setOnClickListener(listener: OnClickListener) { // 함수 타입에 별칭 적용
listener("click", 123)
}
typealias OnClickListener = (String, Int) -> Unit
언제 SAM을 사용해야 할까?
대부분 코틀린 함수 타입을 사용하는 것이 좋지만, 코틀린이 아닌 다른 언어에서 사용할 클래스를 설계할 때에는 SAM을 사용하는 것이 좋습니다. 함수 타입으로 만들어진 클래스는 자바에서 타입 별칭과 IDE 지원 등을 받을 수 없기 때문에 자바에서 사용할 함수라면, 함수 타입보다는 SAM이 유리합니다.
아이템 39 - 태그 클래스보다는 클래스 계층을 사용하라
상수(constant
) 모드는 태그(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<T>(value = value, matcher = Matcher.EQUAL)
fun <T> notEqual(value: T) = ValueMatcher<T>(value = value, matcher = Matcher.NOT_EQUAL)
fun <T> emptyList() = ValueMatcher<T>(matcher = Matcher.LIST_EMPTY)
fun <T> notEmptyList() = ValueMatcher<T>(matcher = Matcher.LIST_NOT_EMPTY)
}
}
예를 들어, 위와 같은 클래스는 태그 클래스로 볼 수 있으며, 이러한 클래스에는 아래와 같은 단점이 있습니다.
- 한 클래스에 여러 모드를 처리하기 위한 상용구(
boilerplate
)가 추가됩니다. - 여러 목적으로 사용해야 하므로, 프로퍼티가 일관적이지 않게 사용될 수 있고 더 많은 프로퍼티가 필요합니다.
- 위 예시를 보면,
emptyList
나notEmptyList
는value
가 필요하지 않음에도, 생성자에value
프로퍼티를 갖습니다.
- 위 예시를 보면,
- 요소가 여러 목적을 가지고, 각 요소를 여러 방법으로 설정할 수 있다면, 상태의 일관성과 정확성을 지키기 어렵습니다.
- 팩토리 메소드를 사용해야 하는 경우가 많습니다. 사용하지 않으면, 객체가 제대로 생성되었는지 확인하기 어려울 수 있습니다.
코틀린은 태그 클래스보다 sealed
클래스를 많이 사용합니다. sealed 클래스를 사용하면, 하나의 클래스 내 여러 모드를 만드는 대신에 각 모드를 여러 클래스로 만들고 타입 시스템과 다형성을 활용합니다.
sealed class ValueMatcher<T> {
abstract fun match(value: T): Boolean
class Equal<T>(val value: T): ValueMatcher<T>() {
override fun match(value: T): Boolean = value != this.value
}
class NotEqual<T>(val value: T): ValueMatcher<T>() {
override fun match(value: T): Boolean = value == this.value
}
class EmptyList<T>(): ValueMatcher<T>() {
override fun match(value: T): Boolean = value is List<*> && value.isEmpty()
}
class NotEmptyList<T>(): ValueMatcher<T>() {
override fun match(value: T): Boolean = value is List<*> && value.isNotEmpty()
}
- 위처럼
sealed
클래스로 구성하면, 책임이 분산되어 훨씬 깔끔합니다. - 각 클래스는 자신에게 필요한 데이터만 있으면 되기 때문에 적절한 파라미터만 갖습니다.
- 태그 클래스 예시와 다르게 emptyList나
notEmptyList
를 구현할 때 불필요한value
프로퍼티를 갖지 않습니다.
- 태그 클래스 예시와 다르게 emptyList나
// ValueMatcher는 sealed 클래스라서, when절에 else가 불필요함
fun <T> ValueMatcher<T>.reversed(): ValueMatcher<T> = when (this) {
is ValueMatcher.EmptyList -> ValueMatcher.NotEmptyList<T>()
is ValueMatcher.NotEmptyList -> ValueMatcher.EmptyList<T>()
is ValueMatcher.Equal -> ValueMatcher.NotEqual<T>(value)
is ValueMatcher.NotEqual -> ValueMatcher.Equal<T>(value)
}
sealed
한정자 대신 abstract
한정자를 사용할 수도 있지만, sealed
한정자는 외부 파일에서 서브 클래스를 만드는 행위 자체를 모두 제한할 수 있어 타입이 추가되지 않음을 보장할 수 있습니다. 이로 인해, when
에서 사용할 때 else
브랜치를 따로 만들지 않아도 되어 when
구문에서 누락된 항목이 발생할 수 없어 관리가 용이합니다.
sealed 클래스(봉인된 클래스)
sealed 클래스는 클래스 계층 구조의 제어된 상속 관계를 제공합니다. sealed 클래스의 직접적인 파생 클래스들은 컴파일 시점에 미리 알 수 있으며, sealed 클래스가 정의된 패키지와 모듈 내에서만 서브 클래스가 생성될 수 있습니다.
sealed 클래스를 when 표현식과 함께 사용하면, 모든 서브 클래스에 대한 행동을 커버할 수 있으며, 새로운 서브 클래스가 생성될 수 없음을 보장할 수 있습니다.
// Function to log errors
fun log(e: Error) = when(e) {
is Error.FileReadError -> println("Error while reading file ${e.file}")
is Error.DatabaseError -> println("Error while reading from database ${e.source}")
Error.RuntimeError -> println("Runtime error")
// No `else` clause is required because all the cases are covered
}
sealed 클래스는 아래 상황일 때 유용합니다.
- 제한된 클래스 상속이 필요한 경우
- 타입 안전 설계가 필요한 경우
- 폐쇄형 API 작업인 경우
sealed 클래스는 직접 인스턴스를 만들 수 없다는 점에서 추상 클래스이기도 합니다. 다만, 일반적인 추상 클래스는 새로운 하위 클래스 추가를 막을 수 없기 때문에 sealed 클래스가 유용합니다.
태그 클래스와 상태 패턴의 차이
태그 클래스와 상태 패턴(state pattern)을 같은 것으로 오해하면 안됩니다. 상태 패턴은 객체의 내부 상태가 변화할 때, 객체의 동작이 변하는 소프트웨어 디자인 패턴입니다. 상태 패턴을 사용하면, 서로 다른 상태를 나타내는 클래스 계층 구조를 만들게 되며, 현재 상태를 나타내기 위한 읽고 쓸 수 있는 프로퍼티도 만들게 됩니다.
sealed class WorkoutState
class PrepareState(val exercise: Exercise): WorkoutState()
class ExerciseState(val exercise: Exercise): WorkoutState()
object DoneState: WorkoutState()
fun List<Exercise>.toStates(): List<WorkoutState> =
flatMap { exercise -> listOf(PrepareState(exercise), ExerciseState(exercise))
} + DoneState
class WorkoutPresenter( /* ... */ ) {
private var state: WorkoutState = states.first()
// ...
}
- 상태는 더 많은 책임을 가진 큰 클래스이며, 상태는 변경할 수 있습니다.
구체 상태(concreate state)는 객체를 활용해 표현하는 것이 일반적이며, 태그 클래스보다는 sealed
클래스 계층으로 만듭니다. 현재의 state
값은 보통 아래처럼 immutable
객체로 만들고 변경해야 할 때 state
프로퍼티를 변경하게 하며, 뷰에서는 state
의 변화를 관찰합니다.
private var state: WorkoutState by Delegates.obeservable(states.first()) { _, _, _ ->
updateView()
}
타입 계층과 상태 패턴은 실질적으로 함께 사용하는 협력 관계라고 할 수 있습니다.
아이템 40 - equals의 규약을 지켜라
코틀린의 Any
에는 아래와 같은 규약들을 가진 메소드들이 있습니다.
equals
hashCode
toString
이 메소드들은 자바 때부터 정의되어 있던 메소드이며, 코틀린에서도 중요한 역할을 하고 많은 객체와 함수들이 이 규칙들에 의존하고 있습니다. 따라서, 규약을 위반하면 일부 객체 또는 기능이 제대로 동작하지 않을 수 있습니다.
동등성
코틀린에는 두 가지 동등성이 있습니다.
- 구조적 동등성(structurual equality)
equals
메소드와 이를 기반으로 만들어진==
연산자(!=
포함)로 확인되는 동등성입니다.
- 레퍼런스적 동등성(referential equality)
- === 연산자(!== 포함)로 확인하는 동등성으로, 두 피연산자가 같은 객체를 가리키면
true
를 리턴합니다.
- === 연산자(!== 포함)로 확인하는 동등성으로, 두 피연산자가 같은 객체를 가리키면
equals
는 모든 클래스의 슈퍼 클래스인 Any
에 구현되어 있기 때문에, 모든 객체에서 사용할 수 있습니다. 다만, 다른 타입의 두 객체를 비교하는 것은 큰 의미가 없기 때문에 연산자를 활용해서 다른 타입의 두 객체를 비교하는 것은 허용하지 않습니다.
equals
가 필요한 이유
Any
에 구현된 equals
메소드는 기본적으로 ===
(레퍼런스적 동등성)처럼 두 인스턴스가 같은 객체인지 비교합니다.
equals
를 따로 오버라이딩 하지 않았기 때문에==
호출 시equals()
함수가 호출되고,equals()
함수는===
로 동작합니다.- 이로 인해,
==
나===
가 모두 같은 결과가 나옴을 볼 수 있습니다.
만약, 두 객체가 기본 생성자의 프로퍼티가 같다면 같은 객체로 봐야 한다면 data 한정자를 붙여 데이터 클래스로 정의하면 됩니다. 데이터 클래스의 equals
는 기본적으로 구조적 동등성(structural equality)로 동작합니다.
데이터 클래스는 내부에 어떤 값을 갖고 있는지가 중요하기 때문에 이렇게 동작합니다.
- 일반 class 예시와 다르게,
==
와===
간에 상이한 결과가 있음을 확인할 수 있습니다. - 데이터 클래스의 동등성은 구조적 동등성이라, name1과 name2의 기본 생성자 프로퍼티 항목이 모두 같은 값이라
name1 == name2
가 true로 반환됩니다.
만약, 데이터 클래스의 동등성을 일부 프로퍼티만 비교하고자 한다면 비교 대상에서 빼고 싶은 프로퍼티를 기본 생성자에 넣지 않으면 됩니다. 이 경우, copy
메소드를 통해 복제될 대상에서도 빠짐을 참고해야 합니다.
equals
를 직접 구현해야 하는 경우
- 기본적으로 제공되는 동작과 다른 동작을 해야 하는 경우
- 일부 프로퍼티만 비교해야 하는 경우
- data 한정자를 붙이는 것을 원하지 않거나, 비교해야 하는 프로퍼티가 기본 생성자에 없는 경우
equals
규약
위 규약 외에도equals
,toString
,hashCode
의 동작은 매우 빠를 것이라 예측되므로 빠르게 동작되어야 합니다. 이는 공식 문서에는 없는 규약이지만, 두 요소가 같은지 확인하는 동작이 오래 걸리는 것은 일반적으로 예측하지 못하는 동작입니다.
equals
규약에 대해 하나씩 알아보도록 하겠습니다.
반사적(reflexive) 동작
x
가 널(null
)이 아닌 값이라면,x.equals(x)
는 true를 리턴해야 한다.
실수로 equals
를 잘못 오버라이딩하여서, 실행할 때마다 결과가 달라지는 등 문제가 있으면 안됩니다. 결과에 일관성이 없으면, 실행 결과가 제대로 된 것인지 알 수 없어 코드를 신뢰할 수 없습니다.
equals
규약이 잘못되면, 컬렉션 내부에 해당 객체가 포함되어 있는지 확인하는contains
메소드 등도 제대로 동작하지 않습니다.
대칭적(symmetric) 동작
x
와y
가 널이 아니라면,x.equals(y)
는y.equals(x)
와 같은 결과를 출력해야 한다.
일반적으로 모든 사람들은 equals
에 대해 대칭적 동작을 가정하고 있기 때문에, 대칭적인 동작을 하지 않으면 예상치 못한 오류가 발생할 수 있고, 디버깅하면서 원인을 파악하기 어렵습니다.
연속적(transitive) 동작
x
,y
,z
가 널이 아닌 값이고x.equals(y)
와y.equals(z)
가 true라면,x.equals(z)
도 true여야 한다.
연속적인 동작을 설계할 때 가장 큰 문제는 타입이 다른 경우입니다.
val o1 = DateTime(Date(2024, 7, 28), 0, 0, 0)
val o2 = Date(2024, 7, 28)
val o3 = DateTime(Date(2024, 7, 28), 12, 12, 12)
o1 == o2 // true
o2 == o3 // true
o1 == o3 // false // 연속적으로 동작하지 않음
Date
와DateTime
의 동등성 비교시 확인하는 프로퍼티들과DateTime
과DateTime
의 동등성 비교시 확인하는 프로퍼티가 달라 연속적인 동작으로 이루어지지 않았습니다.- 서로 다른 클래스에 대해 비교할 수 없도록 하기 위해서는
Date
와DateTime
간의 상속 관계를 끊는 것이 좋습니다.
일관적(consistent) 동작
x
와y
가 널이 아니라면,x.equals(y)
는 여러 번 실행해도 항상 같은 결과를 리턴해야 한다.
두 객체를 비교한 결과는 한 객체를 수정하지 않는 한 항상 같은 결과를 내야 합니다.
- 따라서,
immutable
객체들간의 비교라면 결과가 언제나 같아야 합니다.
equals
는 비교 대상이 되는 객체 두 개에만 의존하는 순수 함수(pure function)여야 합니다.
널과 관련된 동작
x
가 널이 아니라면,x.equals(null)
은 항상 false여야 한다.
null
은 유일한 객체이기 때문에, null이 아닌 값을 가진 객체가 null
과 같을 수 없습니다.
URL과 관련된 equals
문제
equals
를 잘못 설계한 예로는 java.net.URL
이 있습니다. java.net.URL
객체 2개를 비교할 때는, 동일한 IP 주소로 해석되면 true를 반환하고 그 외에는 false를 리턴합니다. 아래 예시를 통해 URL과 관련된 equals
문제를 소개하도록 하겠습니다.
import java.net.URL
fun main() {
val enWiki = URL("https://en.wikipedia.org/")
val wiki = URL("https://wikipedia.org/")
println(enWiki == wiki)
}
- 위 코드는 네트워크 상태에 따라 결과가 달라지기 때문에, 동작이 일관성이 없습니다.
equals
동작에 비교 대상이 되는 두 객체 뿐 아니라 네트워크 상태라는 다른 항목도 의존하게 되어equals
규약을 어긴 사례입니다.
- 일반적으로
equals
와hashCode
는 빠른 동작을 할 것이라 예상하지만, 네트워크 처리이기 때문에 생각보다 빠르지 않습니다. - 동작 자체에 문제가 있습니다.
- 기본적으로 동일한 IP를 가진다고 해서 동일한 컨텐츠를 갖는 것은 아니기 때문에 NAT를 이용하는 등 공통된 IP를 사용하는 서비스들이 있다면
equals
가 제대로된 정보를 반환해주지 못합니다.
- 기본적으로 동일한 IP를 가진다고 해서 동일한 컨텐츠를 갖는 것은 아니기 때문에 NAT를 이용하는 등 공통된 IP를 사용하는 서비스들이 있다면
equals
는 오버라이딩하는게 좋을까?
특별한 이유가 없는 이상, 직접 equals
를 구현하는 것은 좋지 않습니다. 앞서 설명한 equals
와 관련된 규약을 지키면서 구현을 해야 하기 때문에 반드시 필요한게 아니라면 따로 만들지 않는 것이 좋습니다.
만약, 만들게 된다면 final
클래스로 만드는 것을 권장합니다. 만약 상속을 한다면, 서브클래스에서 equals
가 작동하는 방식을 변경하면 안됩니다. 상속을 지원하면서 완벽한 사용자 정의 equals
를 만드는 것은 거의 불가능에 가깝습니다. 참고로 데이터 클래스는 항상 final
클래스입니다.
아이템 41 - hashCode의 규약을 지켜라
Any
가 제공하는 오버라이드할 수 있는 함수에는 equals
외에 hashCode
도 있습니다. hashCode
는 수많은 컬렉션과 알고리즘에 사용되는 자료 구조인 해시 테이블(hash table)을 구축할 때 사용됩니다.
해시 테이블
컬렉션에 요소를 추가하고 조회하는 작업을 빠르게 해야 하는 상황일 때, 보통 Set
이나 Map
을 활용합니다. 이 두 컬렉션은 중복을 허용하지 않기 때문에 요소 추가 시 같은 요소가 이미 들어있는지 확인합니다.
Array
나 LinkedList
를 기반으로 만들어진 컬렉션이라면 중복된 요소가 있는지 확인하려면 컬렉션 내부를 모두 확인해야 하기 때문에 성능이 좋지 않습니다. Set
이나 Map
은 이러한 중복 조회 성능을 높이기 위해 해시 테이블을 활용합니다. 해시 테이블은 각 요소에 숫자를 할당하는 함수가 필요하며, 이를 해시 함수라고 합니다. 해시 함수의 특징은 아래와 같습니다.
- 빠르다
- 충돌이 적다(다른 값이라면 최대한 다른 숫자를 리턴한다)
해시 함수는 각각의 요소에 특정한 숫자를 할당하고, 이를 기반으로 요소를 다른 버킷(bucket)에 넣습니다. 기본적으로 해시 함수는 같은 요소라면 같은 숫자를 리턴한다는 규약을 갖기 때문에, 같은 요소는 항상 동일한 버킷에 넣게 됩니다. 이러한 버킷들은 버킷 수와 같은 크기인 배열인 해시 테이블에 보관되며 버킷은 배열처럼 구현된다고 볼 수 있습니다. 해시 함수는 컬렉션에 요소 추가 및 조회 작업을 빠르게 하기 위한 로직이기 때문에, 해시 함수의 속도는 빨라야 합니다.
해시 테이블을 이용하면, 하나의 큰 배열에 데이터를 넣고 그 중에 원하는 값을 찾는 것보다 빠릅니다. 예를 들어, 1,000,000개의 요소와 1,000개의 버킷이 있는 경우, 해시 함수를 통해 어떤 버킷에 있을지를 먼저 확인하면 최대 1,000,000개의 요소를 확인하지 않고 최대 1,000개의 요소 확인만으로 원하는 결과를 반환할 수 있습니다.
해시 테이블 개념은 컴퓨터 과학에서 많이 사용되며, 코틀린/JVM에 있는 LinkedHashSet
과 LinkedHashMap
에서도 해시 테이블을 이용하며, 코틀린에서 해시 코드를 만드는 작업은 hashCode
함수가 진행합니다.
해시 코드와 가변성 문제
해시 코드는 요소가 추가될 때에만 계산됩니다. 요소가 변경되어도 해시 코드는 재계산되지 않기 때문에, 버킷 재배치도 일어나지 않습니다. 그래서 기본적으로 LinkedHashSet
과 LinkedHashMap
의 키는 한 번 추가한 요소를 변경할 수 없습니다.
따라서, Set
이나 Map
의 키로 mutable
요소를 사용하면 안되며, 혹 사용하더라도 요소를 변경하면 안 됩니다.
hashCode
규약
hashCode
는 앞서 봤던 equals
처럼 규약이 있으며, 각 항목들은 아래와 같습니다.
참고로hashCode
를 구현할 때 가장 중요한 규칙은, '언제나equals
와 일관된 결과가 나와야 한다'이며, 같은 객체라면 언제나 같은 값을 리턴해야 합니다.
- 어떤 객체를 변경하지 않았다면(
equals
에서 비교에 사용된 정보가 수정되지 않는 이상),hashCode
는 여러 번 호출해도 그 결과가 항상 같아야 합니다. equals
메소드의 실행 결과로 두 객체가 같다고 나온다면,hashCode
메소드의 호출 결과도 같다고 나와야 합니다.- 같은 요소에는 같은 해시 코드를 가져야 컬렉션 내부에 요소가 들어 있는지 확인할 수 있습니다.
위와 같은 규약이 있어, 어떠한 이유에 의해 equals
를 오버라이딩해야 할 때에는 hashCode
도 함께 오버라이딩하는 것을 추천합니다.
Any
클래스에는 작성되지 않은 규약이지만, hashCode
를 사용할 때에는 최대한 요소를 넓게 퍼뜨려 각 요소별로 최대한 다른 해시 값을 갖는 것이 좋습니다. 많은 요소가 같은 버킷에 배치된다면, 그만큼 특정 버킷에서 원소를 찾을 때 조회할 대상이 많아진다는 것이기 때문에 해시값은 최대한 요소별로 다르게 하는 게 좋습니다.
class Proper(val name: String) {
override fun hashCode(): Int {
return name.hashCode()
}
override fun equals(other: Any?): Boolean {
equalsCounter++
return other is Proper && name == other.name
}
companion object {
var equalsCounter: Int = 0
}
}
class Terrible(val name: String) {
override fun hashCode(): Int = 0
override fun equals(other: Any?): Boolean {
equalsCounter++
return other is Proper && name == other.name
}
companion object {
var equalsCounter: Int = 0
}
}
- 극단적으로, 모든 요소가 같은 해시값을 가진다면 위처럼 요소를 찾을 때 조회해야 하는 양이 크게 늘어납니다.
hashCode 구현하기
data 한정자가 붙은 데이터 클래스는 기본적으로 equals
와 hashCode
함수를 만들어주기 때문에, 직접 hashCode
를 정의할 일은 거의 없습니다. 다만, equals
를 재정의할 경우에는 hashCode
를 항상 같이 재정의해주어야 합니다.
hashCode
는 기본적으로 equals
에서 비교에 사용되는 프로퍼티를 기반으로 해시 코드를 만들며, 일반적으로 위와 같이 구현되어 있습니다.
- 기본적으로, 각 요소별 해시 코드 값을 더하며, 해시 코드 값을 더할 때는 앞선 결과에 31을 곱한 뒤 더하게 됩니다.
- 반드시 31을 곱해야 하는 것은 아니지만, 관례적으로 31을 많이 사용합니다.
- 위 예시는
Arrays
에 대한 예시이며,Arrays
가 아니더라도 유사하게 구현되어 있습니다. - 데이터 클래스에서 호출하는
equals
역시 위와 같습니다.- 데이터 클래스에서 따로
equals
를 재정의하지 않았다면, 위 사진 왼쪽의Objects.java
에 있는 hash 함수가 호출되어 오른쪽 사진의hashCode
함수가 동작하게 됩니다.
- 데이터 클래스에서 따로
코틀린/JVM의 Objects 클래스에는 hashCode
함수가 있으며, 해당 함수는 해시를 계산해줍니다. 코틀린 stdlib에서는 따로 hashCode
용 함수를 제공하지 않으며, 이는 보통 hashCode
를 직접 구현하는 일이 거의 없기 때문에 제공하지 않는다고 보면 됩니다.
아이템 42 - compareTo의 규약을 지켜라
compareTo
메소드는 Any
클래스에 있는 메소드는 아니며, 수학적인 부등식으로 변환되는 연산자이며, 아래와 같이 동작해야 합니다.
- 비대칭적 동작
a >= b
이고b >= a
이면,a == b
여야 합니다. 비교와 동등성 비교에 어떠한 관계가 있어야 하며 일관성이 있어야 합니다.
- 연속적 동작
a >= b
이고,b >= c
이면,a >= c
여야 합니다.
- 코넥스적 동작
- 두 요소는 어떤 확실한 관계가 있어야 합니다. 예를 들어,
a >= b
또는b >= a
중 적어도 하나는 true를 반환해야 합니다.
- 두 요소는 어떤 확실한 관계가 있어야 합니다. 예를 들어,
compareTo
는 아래와 같이 수학적인 부등식으로 쓰입니다.
obj1 > obj2 // obj1.compareTo(obj2) > 0으로 내부적으로 변환됨
obj1 < obj2 // obj1.compareTo(obj2) < 0으로 내부적으로 변환됨
obj1 >= obj2 // obj1.compareTo(obj2) >= 0으로 내부적으로 변환됨
obj1 <= obj2 // obj1.compareTo(obj2) <= 0으로 내부적으로 변환됨
compareTo
를 따로 정의해야 할까?
일반적으로 코틀린에서 compareTo
를 따로 정의해야 하는 상황은 없습니다. 보통 특정 프로퍼티를 기반으로 순서를 지정하는 것으로 충분하기 때문에 sortedBy
를 사용하면 원하는 순서대로 정렬할 수 있습니다.
class User(val name: String, val surname: String)
val names = listOf<User>( /* ... */ )
val sorted = names.sortedBy { it.name }
만약, 여러 프로퍼티를 기준으로 순서를 지정해야 한다면, sortedWith
함수와 compareBy
를 활용해서 비교기(comparator)를 만들면 됩니다. 비교기를 만들면, 우선순위에 맞게 순서대로 정렬이 됩니다.
// surname을 기준으로 먼저 정렬하며, surname이 같다면 name까지 비교해서 정렬함
val sorted = names.sortedWith(compareBy({ it.surname }, { it.name }))
자연스러운 순서를 갖는 객체들(예: 단위, 날짜, 시간 등)에 대해서는 주어진 비교기를 활용하면 되지만, 객체가 자연스러운 순서인지 확실하지 않다면 앞선 예시처럼 비교기를 만들어서 사용하는 것이 좋습니다.
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)
compareTo
구현하기
만약, 앞서서 설명했던 정렬 외로 로직상에서 특정 객체에 대해 compareTo
를 사용해야 하고, 이 compareTo
를 원하는대로 구현이 필요하다면, 코틀린에서 제공하는 compareValues
나 compareValuesBy
톱레벨 함수를 활용할 수 있습니다.
class User(val name: String, val surname: String): Comparable<User> {
override fun compareTo(other: User): Int {
return compareValues(surname, other.surname)
}
}
class User(val name: String, val surname: String): Comparable<User> {
override fun compareTo(other: User): Int {
// 여러 값을 compareTo에서 활용할 경우
return compareValuesBy(this, other, User::name, User::surname)
}
}
이렇게 비교기를 만들 때, compareTo
함수는 아래와 같은 값을 리턴해야 하는 것을 유의해야 합니다.
- 0: 리시버와
other
가 같은 경우 - 양수: 리시버가
other
보다 큰 경우 - 음수: 리시버가
other
보다 작은 경우
아이템 43 - API의 필수적이지 않은 부분을 확장 함수로 추출하라
확장 함수는 우리에게 더 많은 자유와 유연성을 줍니다. 확장 함수는 상속, 어노테이션 처리 등을 지원하지 않고 클래스 내부에 없으므로 약간의 혼동을 줄 수 있습니다. API의 필수적인 부분은 멤버로 두는 것이 좋지만, 필수적이지 않은 부분은 확장 함수로 만드는 것이 좋습니다.
클래스의 메소드를 정의할 때에는 멤버 함수와 확장 함수 중 어떤 것으로 정의할 지 결정해야 합니다. 두 방법은 호출하는 방법이나 리플렉션으로 레퍼런싱하는 방법 모두 비슷합니다. 두 방식 중 어떤 것이 더 우월하다고 표현할 수는 없고 장단점을 모두 갖고 있어 상황에 맞게 사용해야 합니다.
확장 함수는 클래스 내에 직접 추가하는 형태가 아니다보니 데이터와 행위를 분리하도록 설계된 프로젝트에서 사용됩니다. 임포트해서 사용한다는 특징 덕분에 확장은 같은 타입에 같은 이름으로 여러 개 만들 수 있습니다. 다만, 같은 이름으로 다른 동작을 하는 확장이 있을 수 있다는 점에서 위험할 수 있기 때문에 이러한 함수는 확장 함수보다는 멤버 함수로 구현하는 것이 좋습니다.
- 멤버 함수와 확장 함수가 있을 경우, 멤버 함수가 더 높은 우선 순위를 갖습니다.
확장 함수는 가상이 아니기 때문에, 멤버 함수와 달리 하위 클래스에서 오버라이드를 할 수 없습니다. 확장 함수는 컴파일 시점에 임포팅한 대상으로 정적 선택됩니다.
또한, 확장 함수는 클래스가 아닌 타입에 정의하는 것으로, 아래 예시처럼 nullable
또는 구체적인 제네릭 타입에 대해서도 확장 함수를 만들 수 있습니다.
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
}
아이템 44 - 멤버 확장 함수의 사용을 피하라
어떤 클래스에 대한 확장 함수를 정의할 때, 이를 멤버로 추가하는 것은 좋지 않습니다. 확장 함수는 첫 번째 아규먼트로 리시버를 받는 단순한 일반 함수로 컴파일됩니다.
fun String.isPhoneNumber(): Boolean = length == 7 && all { it.isDigit() }
// 컴파일 후
fun isPhoneNumber('$this': String): Boolean = '$this'.length == 7 && '$this'.all { it.isDigit() }
단순하게 변환되는 것이기 때문에, 확장 함수를 클래스 멤버로 정의할 수도 있지만, DSL을 만들 때를 제외하면 클래스 멤버로 확장 함수를 사용하지 않는 것이 좋습니다. 클래스 내에 확장 함수를 작성하는 것은 단순한 확장 함수의 형태를 더 복잡하게 만들게 됩니다.
// 이렇게 구현하면 안됨!
class PhoneBookIncorrect {
// ...
fun String.isPhoneNumber() = length == 7 && all { it.isDigit() }
}
// 클래스 내 구현된 확장 함수는 더 복잡하게 사용하게 됨
fun main() {
PhoneBookIncorrect().apply { "1234567789".isPhoneNumber() }
}
멤버 확장을 피해야 하는 이유는 아래와 같습니다.
- 레퍼런스를 지원하지 않습니다.
- 암묵적 접근을 할 때, 두 리시버 중 어떤 리시버가 선택될지 혼동됩니다.
class A { val a: Int = 10 }
class B {
val a: Int = 20
val b: Int = 30
fun A.test(): Int {
return a + b
} // 10 + 30일까요? 20 + 30일까요?
}
- 확장 함수가 외부에 있는 다른 클래스를 리시버로 받을 때, 해당 함수가 어떤 동작을 하는지 명확하지 않습니다.
- 경험이 적은 개발자의 경우 확장 함수를 보면 직관적으로 느끼지 못할 수 있습니다.
스터디 준비
궁금한 점
- 아이템 39 이해하기..
- 아이템 42
- 언제 compareTo 오버라이딩이 필요할까? 특정 정렬에서만 사용하는게 아닌, 아예 해당 객체 비교할 때 항상 사용하기 위해 오버라이딩이 필요한걸까?
고찰
참고자료
- 이펙티브 코틀린(마르친 모스칼라 저)
- baeldung - hash tables
- kotlin docs - sealed class
- 위키백과 - 인터페이스 분리 원칙
- baeldung - interface segregation principle in java
'PROGRAMMING LANGUAGE > KOTLIN' 카테고리의 다른 글
[Effective Kotlin] 8장 효율적인 컬렉션 처리 (0) | 2024.08.10 |
---|---|
[Effective Kotlin] 7장 비용 줄이기 (0) | 2024.08.03 |
[Kotlin Coroutine] 코루틴 단위 테스트 (0) | 2024.07.27 |
[Kotlin Coroutine] Asynchronous Flow (5) | 2024.07.20 |
[Effective Kotlin] 5장 객체 생성 (0) | 2024.07.17 |