상속보다는 컴포지션을 사용하라
상속은 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()
}
}
또한 여기서 기능을 추가하고자 한다면 private val finishedAlert = FinishedAlert()
프로퍼티를 추가해서 각각 상황에 맞게 함수를 구현하도록 하면 된다.
상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져온다. 즉, 일부분을 재사용하기 위한 목적으로는 적합하지 않다.
abstract class Dog {
open fun bark() { /*...*/ }
open fun sniff() { /*...*/ }
}
class RobotDog: Dog() {
override fun sniff() {
throw Error("지원되지 않는 기능입니다")
// 인터페이스 분리 원칙에 위반됨
}
}
만약 상속으로 작성했다가 지원하지 않는 함수가 존재한다면 위와 같이 작성할 것이고, 이는 인터페이스 분리 원칙에 위반된다.
위 코드를 인터페이스로 잘 쪼개서 나타내면 아래와 같다.
//인터페이스 구현
interface Barkable {
fun bark()
}
interface Sniffable {
fun sniff()
}
abstract class Dog : Barkable, Sniffable {
override fun bark() { /*...*/ }
override fun sniff() { /*...*/ }
}
// RobotDog 클래스는 필요한 인터페이스만 구현
class RobotDog : Barkable {
override fun bark() {
//..
}
}
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)
}
}
val counterList = CounterSet<String>()
counterList.addAll(listOf("A", "B", "C"))
print(counterList.elementsAdded) // 6, 예상치 못한 동작
HashSet의 addAll은 내부에서 add를 호출하므로 3이 아닌 6이 출력된다.
HashSet의 addAll이 add를 호출할 것이라고 생각하고 코드를 짰다가, HashSet이 추후 add를 호출하지 않는 방향으로 수정된다면 영향을 받게 된다. (캡슐화 깨짐)
이때 컴포지션을 사용하는 것이 좋다.
//컴포지션 사용
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)
}
}
하지만 이는 다형성을 잃게 되는데, 만약 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)
}
}
open 클래스의 open 메서드만 오버라이드할 수 있으므로, open을 달지말자!