728x90
반응형
- 상속은 굉장히 강력한 기능인 만큼 여러 가지 문제를 발생시킬 수 있다.
- 따라서 단순하게 코드 추출 또는 재사용을 위해 상속을 한다면 신중하게 생각해봐야 한다.
간단한 행위 재사용
- 슈퍼 클래스를 만들어서 공통 행위를 추출한 경우
abstract class LoaderWithProgess {
fun load() {
// 프로그레스 바 보여줌
innerLoad()
// 프로그레스 바 숨김
}
abstract fun innerLoad()
}
class ProfileLoader: LoaderWithProgress() {
override fun innerLoad() {
// 프로파일 읽어 들임
}
}
class ImageLoader: LoaderWithProgress() {
override fun innerLoad() {
// 이미지 읽어 들임
}
}
몇 가지 단점 존재
- 상속은 하나의 클래스만 할 수 있음
- 상속으로 행위를 추출하다 보면, 많음 함수를 갖는 거대한 BaseXXX 클래스를 만들게 됨. 깊고 복잡한 계층 구조
- 상속은 클래스 모든 것을 가져오게 됨
- 필요 없는 함수에 의존하게 됨
- 일반적으로 필요 이상으로 많은걸 포함하는 모듈에 의존하는 것은
해롭다.
- ISP(인터페이스 분리 원칙)와 연관되어 있음
- 이해하기 어려움
- 메소드를 읽고, 작동 방식을 이해하기 위해 슈퍼클래스를 여러번 확인해야 한다면 문제가 있음
상속 대신 컴포지션(composition)을 사용하자
- 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하자
class Progress {
fun showProgress() { /* show progress */ }
fun hideProgress() { /* hide progress */ }
}
class ProfileLoader {
val progress = Progress()
fun load() {
progress.showProgress()
// 프로파일을 읽어 들임
progress.hideProgress()
}
}
class ImageLoader {
val progress = Progress()
fun load() {
progress.showProgress()
// 이미지를 읽어 들임
progress.hideProgress()
}
}
- 추가 코드가 필요하지만
코드를 읽은 사람들이 코드의 실행을 더 명확하게 예측할 수 있다는 장점
존재 - 프로그레스 바를 훨씬 자유롭게 사용할 수 있다는 장점 또한 존재
문제점1 - 모든 것을 가져올 수 밖에 없는 상속
- 상속은 객체의 계층 구조를 나타낼 때 굉장히 좋은 도구이지만,
일부분을 재사용하기 위한 목적
으로는 적합하지 않음 - 일부분만 재사용하고 싶다면
컴포지션
을 이용하자.- 원하는 행위만 가져올 수 있기 때문
상속 이용 케이스
abstract class Dog {
open fun bark() { /*...*/ }
open fun sniff() { /*...*/ }
}
class Labardog: Dog()
// 로봇 강아지를 만들고 싶은데, bark만 가능하다면?
class RobotDog: Dog() {
override fun sniff() {
throw Error("Operation not supported")
// ISP(Interface Segregation Principle) 위배
}
}
- 인터페이스 분리 원칙(ISP) 위반
- 리스코프 치환 원칙(LSP) 위반
- 슈퍼클래스의 동작을 서브클래스에서 깨버림
- 참고) SOLID 원칙
- 컴포지션보다 인터페이스를 활용한 다중 상속이 좋을 수도 있음~ (상황에 따라 다름)
문제점2 - 캡슐화를 깨는 상속
class CounterSet<T>: HashSet<T>() {
var elementAdded: Int = 0
private set
override fun add(element: T): Boolean {
elementAdded++
return super.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
elementAdded += 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이 출력됨
- 간단하게 addAll 함수를 제거해 버리면 되지 않을까? 당장에 해결은 되겠으나..
- 자바 업데이트로 인하여 addAll이 add를 호출하지 않는 방식으로 구현된다면? CounterSet를 활용한 구현들 연쇄 중단
컴포지션(composition)을 사용하면 될까?
class CounterSet<T> {
private val innerSet: HashSet<T>()
var elementAdded: Int = 0
private set
fun add(element: T) {
elementAdded++
innerSet.add(element)
}
fun addAll(elements: Collection<T>) {
elementAdded += elements.size
innerSet.addAll(elements)
}
}
- 구현상 문제가 없겠으나,
다형성
이 사라지게 됨- CounterSet은 더 이상 Set이 아니다..
위임 패턴을 사용해도 된다.
- 위임 패턴은 클래스가 인터페이스를 상속받게 하고, 포함한 객체의 메서드들을 활용하여 인터페이스에서 정의한 메서드를 구현하는 패턴
- 이렇게 구현된 메서드를
포워딩 메서드(forwarding method)
라고 부름
class CounterSet<T> (
private val innerSet: MutableSet<T> = mutableOf()
): MutableSet<T> by innerSet {
var elementAdded: Int = 0
private set
override fun add(element: T): Boolean {
elementAdded++
return innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
elementAdded += elements.size
return innerSet.addAll(elements)
}
}
- kotlin 에서는 위임 패턴을 쉽게 구현할 수 있는 문법을 제공하므로 위와 같이 짧게 구현이 가능하다. kotlin 클래스 위임
- 컴파일 시점에 포워딩 메서드들이 자동으로 만들어짐
다형성이 필요한데, 상속 메소드를 직접 활용하는 것이 위험할 때 위임 패턴을 사용
- 하지만 컴포지션을 활용하면 해결되는 경우가 많다.
오버라이딩 제한하기
- 상속은 허용하지만, 메서드 오버라이드하지 못하게 만들고 싶을 때 메서드에 open 키워드 사용
정리
- 컴포지션은 더 안전하다.
- 내부 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존하므로
- 컴포지션은 더 유연하다.
- 컴포지션은 필요한 것만 받을 수 있음
- 컴포지션은 더 명시적이다.
- 컴포지션은 생각보다 번거롭다.
- 객체를 명시적으로 사용해야 하므로, 대상 클래스에 일부 기능을 추가할 때 이를 포함하는 객체 코드도 변경해야 한다.
일반적으로 OOP에서는 상속보다 컴포지션을 사용하는 것이 좋다. 상속은 명확한 is-a 관계
일 때만 사용하자
728x90
반응형
'프로그래밍 노트 > Effective 시리즈' 카테고리의 다른 글
[Effective Kotlin. 11] 가독성을 목표로 설계하라 (1) | 2023.11.01 |
---|---|
[Effective Kotlin. 26] 함수 내부의 추상화 레벨을 통일하라 (0) | 2023.10.25 |
생성자 대신 정적 팩토리 메서드를 고려해보자. (0) | 2020.01.18 |
자원을 직접 명시하지 말고 의존 객체 주입을 사용하자 (0) | 2020.01.18 |
옵셔널(Optional)을 반환하는 메서드 설계 (0) | 2020.01.15 |