상속은 관계가 명확하지 않을 때 사용하면 여러 가지 문제가 발생할 수 있다. 그래서 단순하게 코드 추출 또는 재사용을 위해 사용할 때는 컴포지션을 사용하자.
그럼 상속으로 어떤 문제들이 발생할 수 있을까?
만약 프로그레스바를 로직 전 후로 출력하고 숨기는 동작을 한다고 해보자
class ProfileLoader {
fun load() {
// show progress bar
// read file
// hide progress bar
}
}
class ImageLoader {
fun load() {
// show progress bar
// read image
// hide progress bar
}
}
이럴 때 많은 경우 슈퍼클래스를 만들어 공통된 행위를 추출한다.
abstract class LoaderWithProgress {
fun load() {
// show progress bar
innerLoad()
// hide ..
}
abstract fun innerLoad()
}
간단한 동작이라면 문제가 없지만 몇 가지 단점이 있다.
상속은 하나의 클래스만을 대상으로 할 수 있다. 행위를 추출하다보면 많은 함수를 갖는 거대한 BaseXXX
가 탄생하고 깊고 복잡한 계층 구조가 된다.
상속은 클래스의 모든 것을 가져온다. 불필요한 함수까지 갖게되어 인터페이스 분리 원칙에 위반된다.
상속은 이해하기 어렵다. 슈퍼클래스까지 여러 번 확인해야 한다.
그래서 다른 대안인 컴포지션
을 사용하는 것이 좋다.
위 코드를 컴포지션으로 사용한다면 아래와 같다.
class Progress {
fun showProgress() { */ show progress */ }
fun hideProgress() { */ hide progress */ }
}
class ProfileLoader {
val progress = Progress()
fun load() {
progress.showProgress()
// read file
progress.hideProgress()
}
}
ImageLoader 이하 동문
프로그레스바를 관리하는 객체를 다른 모든 객체에서 갖고 활용하는 추가 코드가 필요하다. 그래서 이 추가 코드를 적절하게 활용하는 것이 어려워서 상속을 선호하는 경우도 많다. 그러나 이런 추가 코드로 인해 코드를 읽는 사람들이 코드의 실행을 더 명확하게 예측할 수 있다는 장점도 있고, 프로그레스 바를 훨씬 자유롭게 사용가능하다.
또한, 컴포지션은 하나의 클래스 내부에서 여러 기능을 재사용할 수 있게되는 장점도 가지고 있다.
class ProfileLoader {
private val progress = Progress()
private val finishedAlert = FinishedAlert()
fun load() {
progress.showProgress()
// read file
progress.hideProgress()
finsihedAlert.show()
}
}
게다가 하나 이상의 클래스를 상속할 수는 없기 때문에 두 기능을 하나의 슈퍼클래스에 배치해야하는데 컴포지션을 사용하면 여러 기능을 유연하게 사용할 수 있다.
만약 꿋꿋하게 상속을 쓰고 싶다고 하면 슈퍼클래스에 파라미터를 받아서 컨트롤 해야한다
ex)
abstract class InternetLoader(val showAlert: Boolean) {
}
class ProfileLoader : InternetLoader(showAlert = true) {}
class ImageLoader : InternetLoader(showAlert = false) {}
서브클래스가 필요하지도 않은 기능을 갖고 단순하게 이를 차단할 뿐인 나쁜 해결 방법이다.
이렇듯,
상속은 슈퍼클래스의 모든 것을 가져온다. 그렇기에 필요한 것만 가져올 수는 없다.
위에서 말했듯이 상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져온다. 따라서 계층 구조를 나타낼 때 굉장히 좋은 도구가 될 수 있지만, 일부분을 재사용하기 위한 목적으로는 적합하지 않다. 이럴땐 컴포지션을 활용하는게 좋다.
만약 로봇 강아지를 만드는데 bark
만 가능하고 sniff
는 못하게 한다고 해보자
class RobotDog : Dog() {
override fun sniff() {
throw Error("Operation not supported")
//인터페이스 분리 원칙에 위반됨
}
}
// 그럼 다중 상속을하면 되지 않아? -> X, 코틀린은 다중상속을 지원하지 않는다.
abstract class Robot {
open fun calculate() { */...*/ }
}
class RobotDog : Dog(), Robot() // Error
위와 같은 상황들이 벌어지기 때문에 상속을 사용했을 때 문제가 발생한다.
상속을 활용할 때는 외부에서 어떻게 활용하는지도 중요하지만, 내부적으로 이를 어떻게 활용하는지도 중요하다. 왜냐하면 내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수 있기 때문이다.
만약 CounterSet
클래스가 있다고 해보자
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)
}
}
막상 addAll을 호출 후 elementsAdded
를 읽어보면 오동작되는 걸 확인할 수 있다
ex) addAll(listOf("1", "2", "3))
print(counterList.elementsAddede) = 6
바로 HastSet인 addAll
함수 내부에서 add
를 호출하고 있기 때문이다. 그래서 이에 맞춰서 서브클래스도 수정을 해놓았는데 추후에 자바가 내부적으로 add
를 호출하지 않는 방식으로 변경된다면 예상하지 못한 형태로 또 동작하게 된다.
또한, 다른 라이브러에서 현재 만든 CounterSet을 활용해서 구현했다면 그 구현들도 연쇄적으로 중단될 수 밖에 없다.
라이브러리 구현이 변경되는 것은 자주 접할 수 있는 문제다. 그럼 이를 어떻게 하면 좋을까? 바로 컴포지션을 활용하는 것이다.
class CounterSet<T> {
private val innerSet = HashSet<T>()
...
}
그러나 이렇게 구현하면 다형성이 사라져서 CounterSet은 더이상 Set
이 아니게 된다. 이를 유지하고 싶다면 위임패턴을 사용하자
위임 패턴은 클래스가 인터페이스를 상속받게 하고 포함한 객체의 메서들을 활용해서 인터페이스에서 정의한 메서드를 구현하는 패턴
이렇게 구현된 메서드를 포워딩 메서드라고 부른다.
class CounterSet<T>: MutableSet<T> {
private val innerSet = HashSet<T>()
// ...
override val size: Int
get() = innerSet.size
override fun contains(element: T): Boolean = innerSet.contains(element)
//...
}
// 위와 같이 다 override해서 구현한다면 너무 많아진다.
// 코틀린은 위임 패턴을 쉽게 구현할 수 있는 문법을 제공하므로 다음과 같이 구현해보자
class CounterSet<T>(
private val innerSet: MutableSet<T> = mutableSetOf()
): MutableSet<T> by innerSet {
//...
}
그래서 이처럼 다형성이 필요한데, 상속된 메서드를 직접 활용하는 것이 위험할 때는 이와 같은 위임 패턴을 사용하는 것이 좋다.
(물론 일반적으로 다형성이 그렇게까지 필요한 경우는 없어서 단순한 컴포지션으로 해결되는 경우가 많다)
개발자가 상속용으로 설계되지 않은 클래스를 상속하지 못하게 하려면, final
을 사용한다. 만약 어떤 이유로 상속은 허용하지만 메서드는 오버라이드하지 못하게 만들고 싶다면 다음과 같이 해보자.
open class Parent {
fun a() {}
open fun b() {}
}
class Child: Parent() {
ovveride fun a() {} //Error
ovveride fun b() {}
}
상속용인 메서드에만 open
키워드를 붙이자. 또한 서브클래스에서 오버라이드된 메서드에 final
을 붙일 수도 있다.
컴포지션과 상속은 다음과 같은 차이가 있다
컴포지션이 더 안전하다. 외부에서 관찰되는 동작에만 의존하므로
컴포지션이 더 유연하다. 컴포지션은 여러 클래스를 대상으로 필요한 것만 받을 수 있으므로
컴포지션이 더 명시적이다. 리시버를 명시적으로 활용하므로 메서드가 어디에 있는지 확실하게 알 수 있다.
컴포지션은 생각보다 번거롭다. 객체를 명시적으로 사용해야해서 일부 기능이추가되면 이를 포함하는 객체의 코드를 다 변경해야 하므로 코드를 수정해야 하는 경우가 더 많다.
상속은 다형성을 활용할 수 있다. 그러나 이를 쓰면 슈퍼클래스의 모든 것을 동작해야하기 때문에 규약을 잘 지켜서 코드를 잘 작성해야 한다.
그렇다고 상속을 아예 사용하지 말라는 것은 아니다. 명확한 is-a
관계일 때 상속을 사용하는 것이 좋다.
슈퍼클래스를 상속하는 모든 서브클래스는 슈퍼클래스로도 동작할 수 있어야 한다.
슈퍼클래스의 모든 단위 테스트는 서브클래스로도 통과할 수 있어야한다.
뷰를 출력하기 위해 사용되는 Android의 Acitivity, iOS의 UIViewController, React의 React.Component 등이 대표적이다.
또한, 상속을 위해 설계되지 않은 메서드는 final
로 만들어두는 것이 좋다!