클래스는 객체 지향 프로그래밍(OOP) 패러다임에서 가장 중요한 추상화입니다.
아이템 36 - 상속보다는 컴포지션을 사용하라
상속은 'is-a' 관계의 객체 계층 구조를 만들기 위해 설계되었습니다. 상속은 관계가 명확하지 않을 때 사용하면, 여러 가지 문제가 발생할 수 있습니다. 그렇기 때문에 일반적으로는 상속보다 컴포지션을 사용하는 것이 좋습니다.
간단한 행위 재사용
공통되는 행위는 슈퍼클래스를 만들어서 많이 추출합니다.
abstract class LoaderWithProgress {
fun load() {
innerLoad()
}
abstract fun innerLoad()
}
class ProfileLoader: LoaderWithProgress() {
override fun innerLoad() {
}
}
class ImageLoader: LoaderWithProgress() {
override fun innerLoad() {
}
}
이러한 코드는 몇 가지 단점이 있습니다.
- 상속은 하나의 클래스만을 대상으로 할 수 있습니다.
- 상속은 클래스의 모든것을 가져오게 됩니다.
- 상속은 이해하기 어렵습니다. 메서드를 읽고 슈퍼클래스를 여러 번 확인해야 합니다.
이러한 이유 때문에 컴포지션을 대안으로 사용합니다. 컴포지션은 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하는 것을 의미합니다.
class Progress {
fun showProgress() { }
fun hideProgress() { }
}
class ProfileLoader {
val progress = Progress()
fun load() {
progress.showProgress()
progress.hideProgress()
}
}
class ImageLoader {
val progress = Progress()
fun load() {
progress.showProgress()
progress.hideProgress()
}
}
위의 코드는 프로그래스 바를 관리하는 객체를 다른 모든 객체에서 갖고 활용하는 코드가 필요합니다.
컴포지션을 활용하면 클래스 내부에서 여러 기능을 재사용할 수 있고, 다양하게 활용할 수 있습니다.
모든 것을 가져올 수밖에 없는 상속
상속은 모든 것을 가져옵니다. 그렇기 때문에 일부분을 재사용하기 위한 목적으로는 적합하지 않습니다. 반면에 컴포지션은 우리가 원하는 행위만 가져올 수 있습니다.
아래의 Dog 클래스는 로봇 강아지와 같은 케이스에서 필요가 없는 메서드를 갖기 때문에, 인터페이스 분리 원칙에 위배됩니다. 또한 슈퍼클래스의 동작을 서브클래스에서 깨버리므로, 리스코프 치환 원칙에도 위반됩니다.
abstract class Dog {
open fun bark() { /*...*/ }
open fun sniff() { /*...*/ }
}
캡슐화를 깨는 상속
상속을 활용할 때는 내부적으로 이를 어떻게 활용하는지도 중요합니다. 내부적인 구현 방법에 의해서 클래스의 캡슐화가 깨질 수 있기 때문입니다.
class CounterSet<T>: HashSet<T>() {
var elementsAdded: Int = 0
private set
override fun add(element: T): Boolean {
elementsAdded++
return super.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
elementsAdded += elements.size
return super.addAll(elements)
}
}
위의 클래스는 문제가 없어보이지만, 제대로 동작하지 않습니다.
fun main() {
val counterList = CounterSet<String>()
counterList.addAll(listOf("A", "B", "C"))
print(counterList.elementsAdded) // 6
}
문제는 HashSet의 addAll 내부에서 add를 사용했기 때문입니다. addAll과 add에서 추가한 요소 개수를 중복해서 세므로, 요소 3개를 추가했는데 6이 출력됩니다.
이런 문제는 또다시 컴포지션을 사용하면 해결할 수 있습니다.
class CounterSet<T> {
private var innerSet = HashSet<T>()
var elementsAdded: Int = 0
private set
fun add(element: T) {
elementsAdded++
innerSet.add(element)
}
fun addAll(elements: Collection<T>) {
elementsAdded += elements.size
innerSet.addAll(elements)
}
}
하지만 CounterSet은 이제 Set이 아니게 되어버렸습니다. 이를 유지하고 싶다면, 위임 패턴을 사용하면 됩니다.
위임 패턴이란 클래스가 인터페이스를 상속받게하고, 포함한 객체의 메서드들을 활용해서, 인터페이스에서 정의한 메서드를 구현하는 패턴입니다. 이렇게 구현된 메서드를 포워딩 메서드라고 부릅니다.
class CounterSet<T>(
private val innerSet: MutableSet<T> = mutableSetOf()
) : MutableSet<T> by innerSet {
var elementsAdded: Int = 0
private set
override fun add(element: T): Boolean {
elementsAdded++
return innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
elementsAdded += elements.size
return innerSet.addAll(elements)
}
}
오버라이딩 제한하기
개발자가 상속을 제한하고 싶다면 final을 사용하면 됩니다. 만약 상속은 허용하지만, 메서드는 오버라이드하지 못하게 만들고 싶다면 open을 사용합니다.
정리
- 컴포지션은 다른 클래스의 내부적인 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존하므로 안전합니다.
- 컴포지션은 여러 클래스를 대상으로 할 수 있기 때문에 더 유연합니다. 원하는 것만 받을 수 있기도 합니다.
- 컴포지션은 더 명시적입니다. 슈퍼클래스의 메서드를 사용할 때는 리시버를 따로 지정하지 않아도 되지만, 어디에서 왔는지 혼동될 수 있습니다.
- 컴포지션은 객체를 명시적으로 사용해야 하므로 번거롭습니다.
상속은 명확한 'is-a' 관계일 때 사용하는 것이 좋습니다.
아이템 37 - 데이터 집합 표현에 data 한정자를 사용하라
데이터를 한꺼번에 전달해야 할 때, data 한정자를 붙은 클래스를 사용합니다.
data class Player(
val id: Int,
val name: String,
val points: Int,
)
val player = Player(0, "Gecko", 9999)
data 한정자를 붙이면 다음 함수가 자동으로 생성됩니다.
toString
toString은 클래스의 이름과 기본 생성자 형태로 모든 프로퍼티의 값을 출력해줍니다. 이는 로그를 출력할 때나 디버그할 때 유용하게 활용할 수 있습니다.
print(player) // Player(id=0, name=Gecko, points=9999)
equals
equals는 기본 생성자의 프로퍼티가 같은지 확인해줍니다. 그리고 hashCode는 equals와 같은 결과를 냅니다.
player == Player(0, "Gecko", 9999) // true
player == Player(0, "Ross", 9999) // false
copy
copy는 immtable 데이터 클래스를 만들 때 편리합니다. copy는 기본 생성자 프로퍼티가 같은 새로운 객체를 복제합니다. 새로 만들어진 객체의 값은 이름 있는 아규먼트를 활용해서 변경할 수 있습니다.
val newObj = player.copy(name = "Thor")
print(newObj) // // Player(id=0, name=Thor, points=9999)
또한 copy 메서드는 객체를 얕은 복사하지만, 이것은 객체가 immutable 이라면 아무런 상관이 없습니다. immutable 객체는 깊은 복사한 객체가 필요 없기 때문입니다.
componentN 함수
componentN 함수는 위치를 기반으로 객체를 해체할 수 있게 해줍니다.
val (id, name, pts) = player
아래와 같이 위치를 기반으로 객체를 해체할 수 있습니다.
val id: Int = player.component1()
val name: String = player.component2()
val pts: Int = player.component3()
객체를 해체할 때에는 데이터 클래스의 기본 생성자에 붙어있는 프로퍼티 이름과 같은 이름을 사용하는 것이 좋습니다. 그렇게 하면 순서 등을 잘못 지정했을 때, IDE에서 경고를 줍니다.
추가적으로 생성자가 하나인 데이터 클래스는 해체하지 않는 것이 좋습니다. 읽는 사람에게 혼동을 줄 수 있습니다. 특히 람다 표현식과 함께 사용할 때 문제가 됩니다.
user.let { (a) -> print(a) }
Tuple(튜플) 대신 데이터 클래스 사용하기
튜플이란? by GPT
튜플(Tuple)은변경할 수 없는(Immutable) 자료형으로, 여러 개의 값을순서대로 저장할 때 사용합니다. 파이썬에서 주로 사용되며,리스트와 비슷하지만 값의 수정, 추가, 삭제가 불가능합니다.
튜플은 Serializable을 기반으로 만들어지며. toString을 사용할 수 있는 제네릭 데이터 클래스입니다.
코틀린에는 `Pair`와 `Triple`이 남아있습니다.
값에 간단하게 이름을 붙일 때와
val (description, color) = when {
degrees < 5 -> "cold" to Color.BLUE
degrees < 23 -> "mild" to Color.YELLOW
else -> "hot" to Color.RED
}
표준 라이브러리에서 볼 수 있는 것처럼 미리 알 수 없는 집합을 표현할 때를 제외하면 무조건 데이터 클래스를 사용하는 것이 좋습니다.
val (odd, even) = numbers.partition { it % 2 == 1 }
val map = mapOf(1 to "San Francisco", 2 to "Amsterdam")
튜플을 사용한다면 Pair<String, String>이 각각 무엇을 나타낸다는 것을 인지하기 어렵습니다. 데이터 클래스는 이런 문제를 해결합니다.
아이템 38 - 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라
대부분의 언어에서는 함수 타입이라는 개념이 없기에 연산 또는 액션을 전달할 때 메서드가 하나만 있는 인터페이스를 활용합니다. 이러한 인터페이스를 SAM(Single-Abstract Method)라 부릅니다.
interface OnClick{
fun clicked(view: View)
}
함수가 SAM을 받는다면, 이러한 인터페이스를 구현한 객체를 전달받는다는 의미입니다.
fun setOnClickListener(listener: View) {
}
이런 코드를 함수 타입을 사용하는 코드로 변경하면, 더 좋습니다.
fun setOnClickListener(listener: (View) -> Unit) {
}
언제 SAM을 사용해야 할까?
필자는 코틀린이 아닌 다른 언어에서 사용할 클래스를 설계할 때 SAM을 사용하라 말합니다.
함수 타입으로 만들어진 클래스는 자바에서 IDE의 지원을 제대로 받을 수 없습니다. 또한 코틀린의 함수 타입을 사용하려면 Unit을 명시적으로 리턴하는 함수가 필요합니다.
아이템 39 - 태그 클래스보다는 클래스 계층을 사용하라
상수를 모은 클래스를 태그 클래스라고 부릅니다. 다만, 태크 클래스는 서로 다른 책임을 한 클래스에 넣기에 문제가 발생합니다.
여기서 태그 클래스는 이넘 클래스입니다.
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)
}
}
이런 코드는 한 클래스에 여러 모드를 처리하기 위한 보일러 플레이트 코드가 많이 추가됩니다.
코틀린에서는 이런 단점 때문에 enum보다 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>(val value: T) : ValueMatcher<T>() {
override fun match(value: T): Boolean = value is List<*> && value.isEmpty()
}
class NotEmptyList<T>(val value: T) : ValueMatcher<T>() {
override fun match(value: T): Boolean = value is List<*> && value.isNotEmpty()
}
}
이렇게하면 책임이 분산되므로 훨씬 깔끔합니다. 각각의 객체들은 자신에게 필요한 데이터만 있으며, 적절한 파라미터만 갖습니다.
Sealed 한정자
abstract 한정자를 대신 사용할 수도 있지만, sealed 한정자는 외부 파일에서 서브 클래스를 만드는 행위를 모두 제한합니다.
그렇기 때문에 타입이 추가되지 않을 거라는 게 보장됩니다. 따라서 else를 사용하지 않아도 됩니다.
아이템 40 - equals의 규약을 지켜라
코틀린의 Any는 다음 메서드가 있습니다.
- equals
- hashCode
- toString
이런 규약은 Any 클래스를 상속받는 모든 클래스가 잘 지켜야 합니다.
동등성
코틀린에는 2가지의 동등성이 있습니다.
- 구조적 동등성: equals 메서드와 이를 기반으로 만들어진 == 연산자로 확인하는 동등성입니다.
- 레퍼런스적 동등성: === 연산자로 확인하는 동등성입니다. 두 피연산자가 같은 객체인지 확인합니다.
다른 타입의 두 객체를 === 연산자로 비교하는 것은 허용되지 않습니다. 물론 상속 관계를 갖는 경우에는 비교할 수 있습니다.
equals가 필요한 이유
Any 클래스에 구현되어 있는 equals 메서드는 두 인스턴스가 완전히 같은 객체인지를 비교합니다. 이는 모든 객체는 기본적으로 유일한 객체라는 것을 의미합니다.
다만, 두 객체의 프로퍼티가 같다면 같은 객체로 볼 수 있습니다.
data class FullName(val name: String, val surname: String)
val name1 = FullName("Marcin", "Moskala")
val name2 = FullName("Marcin", "Moskala")
name1 == name1 // true
name1 == name2 // true 데이터가 같기 때문
데이터 클래스의 경우에는 내부에 어떤 값을 갖고 있는지가 중요하므로, 이와 같이 동작하는 것이 좋습니다.
equals의 규약
equals는 다음과 같은 규약을 따라야 합니다.
- 반사적 동작: x가 널이 아니라면, x.equals(x)는 true를 리턴해야 한다.
- 대칭적 동작: x와 y가 널이 아닌 값이라면, x.equals(y)는 y.equals(x)와 같은 결과를 출력해야 한다.
- 일관적 동작: 널이 아니라면 항상 같은 결과를 반환해야 한다.
- 널과 관련된 동작: x가 널이 아니라면, x.equals(null)은 항상 false를 반환해야 한다.
특별한 이유가 없는 경우, 직접 equals를 구현하지 않아야 합니다.
아이템 41 - hashCode의 규약을 지켜라
hashCode 함수는 수많은 컬렉션과 알고리즘에 사용되는 해시 테이블을 구축할 때 사용됩니다.
해시 테이블
해시 테이블은 컬렉션에 요소를 빠르게 추가하고, 빠르게 추출할 때 사용하기 좋습니다.
해시 함수는 데이터를 추가할 때 특정한 숫자(해시 값)를 만들어서 특정한 버킷에 넣습니다. 이때 같은 데이터는 항상 같은 버킷에 들어갑니다.
데이터를 찾을 때는 해시 값을 게산해서 해당 버킷을 찾은 뒤, 버킷 내부에서 원하는 요소를 찾습니다. 해시 함수는 같은 요소라면 같은 값을 리턴하므로, 다른 버킷을 확인할 필요 없이 바로 원하는 것이 들어 있는 버킷을 찾을 수 있습니다.
가변성과 관련된 문제
해시 테이블을 사용하는 경우에는 요소가 변경되어도 해시 코드는 다시 계산되지 않으며, 버킷 재배치도 이루어지지 않습니다. 그래서 mutable한 객체가 변경되면 기존 해시 코드와 일치하지 않을 수가 있습니다.
data class Person(var name: String, var age: Int)
fun main() {
val set = HashSet<Person>()
val person = Person("Alice", 25)
set.add(person) // 해시 값이 계산되고 버킷에 저장됨
println(set.contains(person)) // ✅ true (정상)
// 🔥 객체의 값을 변경 (해시 값이 바뀔 가능성이 있음)
person.name = "Bob"
println(set.contains(person)) // false (찾을 수 없음!)
}
그래서 해시 등의 자료 구조구조에서는 mutable 객체가 사용되지 않습니다.
hashCode의 규약
hashCode에는 명확한 규약이 있습니다.
- 어떤 객체를 변경하지 않았다면, hashCode는 여러 번 호출해도 그 결과가 항상 같아야 합니다.
- equals 메서드의 실행 결과로 두 객체가 같다고 나온다면, hashCode 메서드의 호출 결과도 같다고 나와야 합니다.
첫번째 요구 사항은 일관성 유지를 위해 hashCode가 필요하다는 것입니다.
두번째 요구 사항을 지키지 않는다면 컬렉션 내부에 요소가 들어 있는지 제대로 확인하지 못하는 문제가 발생할 수 있습니다. 아래의 예시에서는 hashCode를 오버라이드 하지 않았습니다.
class FullName(
var name: String,
var surname: String,
) {
override fun equals(other: Any?): Boolean {
return other is FullName
&& other.name == name
&& other.surname == surname
}
}
fun main() {
val set = mutableSetOf<FullName>()
set.add(FullName("Marcin","Moskala"))
val fullName = FullName("Marcin","Moskala")
println(fullName in set) // false
println(fullName == set.first()) // true
}
그래서 코틀린은 equals 구현을 오버라이드 할 때, hashCode도 함께 오버라이드 하는 것을 추천합니다.
그리고 이전에 hashCode 함수를 통해 어떤 버킷에 배치할지를 결정한다고 말했습니다. 그렇기 때문에 만약 동일한 hashCode를 가진 요소가 많아진다면, 동일한 버킷에 많은 요소를 배치할 것이기 때문에 해시 테이블을 사용하는 의미 자체가 없어질 수 있습니다. 그렇기 때문에 규약을 위반하는 것은 아니지만, 이를 지양하는 것이 좋습니다.
hashCode 구현하기
hashCode는 일반적으로 모든 해시 코드의 값을 더하는 과정마다 31을 곱하는 방식으로 구해집니다. 31을 꼭 사용해야 하는것은 아니지만 관례적으로 31을 많이 사용합니다.
data class FullName(val name: String, val surname: String) {
// hashCode 구현 시 31을 사용한 예시
override fun hashCode(): Int {
var result = 17 // 기본값을 17로 설정 (보통 임의의 홀수로 시작)
result = 31 * result + name.hashCode() // name을 기반으로 hashCode 계산
result = 31 * result + surname.hashCode() // surname을 기반으로 hashCode 계산
return result
}
override fun equals(other: Any?): Boolean {
return other is FullName && other.name == name && other.surname == surname
}
}
hashCode를 일반적으로 직접 구현할 일은 없습니다. 하지만 만약 구현해야 한다면 꼭 equals로 같이 구현해야 합니다.
아이템 42 - compareTo의 규약을 지켜라
코툴린에서 compareTo를 따로 정의해야 하는 상황은 거의 없습니다. 일반적으로 어떤 프로퍼티 하나를 기반으로 순서를 지정하는 것으로 충분하기 때문입니다.
아이템 43 - API의 필수적이지 않는 부분을 확장 함수로 추출하라
클래스의 메서드를 정의할 때는 메서드를 멤버로 정의할 것인지 확장 함수로 정의할 것인지 결정해야 합니다.
두 가지 방법은 거의 비슷합니다. 호출하는 방법도 비슷하고, 리플렉션으로 레퍼런싱하는 방법도 비슷합니다. 먼저 알아둬야 할 것은 두 방식 중에 어떤 방식이 우월하다고 할 수 없다는 것입니다.
1. 확장 함수는 다른 패치지에 위치
먼저 멤버와 확장의 가장 큰 차이점은 확장은 따로 가져와서 사용해야 한다는 것입니다. 그래서 일반적으로 확장 함수는 다른 패키지에 위치합니다. 우리가 직접 멤버를 추가할 수 없는 경우, 데이터와 행위를 분리하도록 설계된 프로젝트에서 사용됩니다.
2. 확장 함수는 가상 함수가 아니다.
가상(virual) 함수는 호출 시점에 실제 인스턴스의 타입에 따라(런타임에) 어떤 구현을 실행할지 말하는 결정합니다.
확장 함수는 이러한 가상 함수가 아니기 떄문에 런타임 다형성을 지원하지 않아 override가 불가합니다.
왜 “상속을 목적으로 설계된 요소”에는 확장 함수를 쓰면 안 되는가?
- 상속을 통해 서브클래스마다 다르게 동작하도록 설계하고자 하는 기능이라면, 호출 시점에 실제 인스턴스를 기준으로 구현을 선택해 주어야 합니다.
- 확장 함수는 “어느 타입의 변수”로 호출했느냐에 따라 결정되므로, 서브클래스 고유의 동작을 보장할 수 없습니다.
- 따라서 다형성이 요구되는 기능은 반드시 open fun + override fun 형태의 멤버 함수로 설계해야 합니다.
정리
확장 함수는 더 많은 자유와 유연성을 줍니다. API의 필수적인 부분은 멤버로 두는 것이 좋지만, 필수적이지 않은 부분은 확장 함수로 만드는 것이 여러모로 좋습니다.
아이템 44 - 멤버 확장 함수의 사용을 피하라
어떤 클래스에 대한 확장 함수를 정의할 때, 이를 멤버로 추가하는 것은 좋지 않습니다. 이에 대한 이유는 아래와 같습니다.
- 함수 레퍼런스를 지원하지 않습니다.
- 확장 함수가 외부에 있는 다른 클래스를 리시버로 받을 때, 어떤 동작을 하는지 명확하지 않습니다.
- 직관적이지 않거나, 어렵습니다.
'스터디 > 이펙티브 코틀린' 카테고리의 다른 글
아이템 48 - 더 이상 사용하지 않는 객체의 레퍼런스를 제거하라 (0) | 2025.02.26 |
---|---|
[이펙티브 코틀린] 5장 - 객체 생성 (0) | 2025.02.04 |
[이펙티브 코틀린] 4장 - 추상화 설계 (0) | 2025.01.21 |
[이펙티브 코틀린] 3장 - 재사용성 (0) | 2025.01.13 |
[이펙티브 코틀린] 1장 - 안정성 (0) | 2023.12.04 |