-
36장 - 상속보다는 컴포지션을 사용하라Kotlin/Effective Kotlin 요약 2023. 3. 25. 00:01반응형
개요
상속은 is-a 관계의 객체 계층 구조를 만들기 위해 설계되었습니다.
코드의 재사용성을 위해 상속을 사용할 때 관계가 명확하지 않을 때 신중하게 고려해야 합니다.
간단한 행위 재사용
class ProfileLoader{ fun load(){ //프로그래스 바 노출 //프로필 읽기 //프로그래스 바 숨김 } } class ImageLoader{ fun load(){ //프로그래스 바 노출 //이미지 읽기 //프로그래스 바 숨김 } }
이런 경우 보통 슈퍼클래스를 만들어 공통되는 행위를 뽑아냅니다.
하지만 몇 가지 단점이 존재합니다.
- 상속을 사용해서 행위를 추출하다 보면, 많은 함수를 갖는 거대한 BaseXXX 클래스가 만들어지며 깊고 복잡한 계층 구조가 만들어집니다.
- 불필요한 함수를 갖는 클래스가 만들어질 수 있습니다.
- 코드를 이해할 때 슈퍼클래스를 여러 번 확인해야 할 수 있습니다.
컴포지션을 활용하기
상속의 대안으로 컴포지션을 활용할 수 있습니다.
컴포지션이란 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하는 것을 의미합니다.
class Progress{ fun showProgress() {} fun hideProgress() {} } class ProfileLoader{ val progress = Progress() fun load(){ progress.showPregress() //프로필 읽어 들임 progress.hideProgress() } }
객체를 선언하는 추가 코드가 발생하지만 읽는 사람은 코드의 실행을 더 명확하게 예측할 수 있으며 프로그레스 바를 자유롭게 사용할 수 있습니다.
모든 것을 가져올 수 밖에 없는 상속
상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져옵니다.
만약 짖기와 냄새 맡기라는 함수를 갖는 Dog 클래스가 존재합니다.
abstract class Dog{ open fun bark() {} open fun sniff() {} }
로봇 강아지를 만들고 싶으며 로봇 강아지는 짖기만 가능하게 하려면 어떻게 해야 할까요?
class RobotDog: Dog(){ override fun sniff(){ throw Error("Operation not supported") } }
인터페이스 분리 원칙에 위반되고, 리스코프 치환 원칙에도 위반됩니다.
또한 코틀린에서는 다중 상속을 지원하지 않습니다.
캡슐화를 깨는 상속
내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수 있습니다.
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")) println(counterList.elementsAdded) //출력 : 6 }
요소는 3개인데 결과는 6이 나옵니다.
바로 HashSet의 addAll 내부에서 add를 사용하기 때문에 elementsAdded가 중복으로 추가됩니다.
addAll메서드를 제거하면서 해결할 수 있습니다.
하지만 어느 날 자바가 HashSet addAll을 최적화하고 내부적으로 add를 호출하지 않는 방식으로 구현한다면? 예상하지 못한 형태로 동작하게 됩니다.
이런 문제 또한 컴포지션을 통해 해결할 수 있습니다.
class CounterSet<T>{ private val 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) } } fun main() { val counterList = CounterSet<String>() counterList.addAll(listOf("A","B","C")) println(counterList.elementsAdded) }
일반적으로 다형성이 그렇게까지 필요한 이유가 없기 때문에 대부분 컴포지션을 통해 해결할 수 있습니다.
정리
- 컴포지션이 더 유연하고 안전하다
- 컴포지션이 더 명시적이다
- 컴포지션은 생각보다 번거로울 수 있다(클래스에 일부 기능을 추가할 때 이를 포함하는 객체의 코드를 변경해야 한다)
- 상속은 다형성을 활용할 수 있다
- 상속은 명확한 IS-A관계일 때 사용하면 좋다
- 상속을 위해 설계되지 않았다면 final을 활용하자
'Kotlin > Effective Kotlin 요약' 카테고리의 다른 글
38장 - 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라 (0) 2023.03.27 37장 - 데이터 집합 표현에 data 한정자를 사용하라 (0) 2023.03.26 35장 - 복잡한 객체를 생성하기 위한 DSL을 정의하라 (0) 2023.03.21 34장 - 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라 (0) 2023.03.20 33장 - 생성자 대신 팩토리 함수를 사용하라 (0) 2023.03.19