상속
상속은 관계가 명확하지 않을 때 사용하면, 여러 가지 문제가 발생할 수 있다.
따라서 단순하게 코드 추출 또는 재사용을 위해 상속하려고 한다면 좋지 않다.
-> 이러한 상황에서는 컴포지션을 사용하는 것이 더 좋다.
class ProfileLoader {
fun load() {}
}
class ImageLoader {
fun load() {}
}
// 상속 구조
abstract class LoaderWithProgress {
fun load() {
innerLoad()
}
abstract fun innerLoad()
}
class ProfileLoader: LoaderWithProgress() {
override fun innerLoad() {
}
}
class ImageLoader: LoaderWithProgress() {
override fun innerLoad() {
}
}
공통되는 행위를 추출할 때 슈퍼클래스를 만드는데 이는 단점이 존재한다.
-> 이러한 단점을 가지는 슈퍼클래스 사용을 대신하는 방법이 컴포지션(Composition)이다.
Composition
컴포지션은 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용 하는 것을 의미한다.
// 컴포지션
class Progress {
fun show() {}
fun hide() {}
}
class ProfileLoader {
val progress = Progress() // 객체
override fun innerLoad() {
progress.show() // 객체를 함수 내에서 사용
// 프로파일 읽기
progress.hide()
}
}
class ImageLoader: Loader() {
val progress = Progress()
override fun innerLoad() {
progress.show()
// 이미지 읽기
progress.hide()
}
}
컴포지션 사용 장점
상속은 슈퍼클래스의 methods, expectations (contract), behavior 등 모든 것을 가져온다.
하지만, 일부분만 재사용하고 싶다면 컴포지션을 사용하는 것이 좋다.
abstract class Dog {
open fun bark() { ... }
open fun sniff() { ... }
}
class Labrador: Dog()
class RobotDog: Dog() {
override fun sniff(){
// 로봇개는 냄새 맡는 기능(행위)이 없으므로, 이 함수는 불필요한 함수다.
// 필요없는 메서드 가짐 (인터페이스 분리 원칙위반), 슈퍼클래스의 동작을 서브클래스에서 깨버림 (리스코프 치환원칙 위반)
}
}
코틀린은 클래스의 다중 상속을 허용하지 않기에 두 가지 방법으로 구현해야 한다.
1. 상속 (RobotDog의 경우 인터페이스로 개와 로봇이 갖는 특성을 추상화한 뒤 이를 모두 상속하여 RobotDog을 구현)
2. 컴포지션
내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수도 있다.
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() = runBlocking {
val counterList = CounterSet<String>()
counterList.addAll(listOf("A","B","C"))
print(counterList.elementsAdded) // 결과는 6이 나오게 된다.
}
3개를 넣었는데 결과가 6이 나온다.
실제로는 제대로 동작하지 않는 코드다. addAll과 add에서 추가한 요소 개수를 중복해서 세었기에 문제가 생겼다.
이럴 경우 내부 구현 방식을 변경할 수 있을 것 같은데 변경된 CounterSet을 활용해 무언가를 구현했다면, 이를 구현한 부분들도 연쇄적으로 중단될 위험이 있다.
컴포지션을 사용해서 해결한다면?
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)
}
}
컴포지션을 사용하여 innerSet을 만들어 hashSet 객체를 관리하여 문제를 해결할 수 있다.
하지만, 그렇게 하면 더이상 CounterSet이 Set이 아니게 되기에 다형성이 사라진다.
다형성을 유지하기 위해 Delegate 패턴을 사용할 수 있다.
by
키워드를 사용하여 보일러 플레이트 코드를 줄일 수 있다. 또한, 관리 포인트를 일원화 시켜 코드 유지보수를 용이하게 해준다. (한 곳에서 구현하면 다른 곳에서 위임받아 사용할 수 있기 때문)서브 클래스의 상속을 막고 싶은 경우 final
키워드 사용
코틀린은 기본적으로 클래스의 상속과 메서드의 오버라이드를 막아두었다. 따라서 오버라이드하기 위해서는 클래스 이름 또는 메서드 앞에 open
키워드를 붙여야 한다.
컴포지션과 상속의 차이 및 특징
일반적으로 OOP에서는 상속보다 컴포지션을 사용하는 것이 좋다.
데이터들을 한꺼번에 전달할 때 data class 사용
data
modifier를 붙이면 다음과 같은 함수가 자동 생성된다.
코틀린의 튜플은 Pair, Triple이 있으며 Serializable을 기반으로 만들어졌다.
일반적인 상황에서는 가독성 때문에 튜플 보다 data 클래스를 사용하는 것이 좋다.
Pair나 Triple은 프로퍼티 이름이 없기 때문에 타입만 보고 의미를 인지하기 어려울 수 있기 때문이다.
data class를 사용하는 것은 추가 비용은 거의 들지 않으나 함수를 더 명확하게 만들어준다.
연산 또는 액션을 전달할 때 메서드가 하나만 있는 인터페이스를 활용하여 함수 타입(ex. (Int) -> Unit)을 전달한다.
이러한 인터페이스를 SAM(Single-Abstract Method)라고 부른다.
interface OnClick {
fun onClick(view: View) // SAM
}
fun setOnClickListener(listener: OnClick){...}
setOnClickListener(object : OnClick {
override fun onClick(view:View){...} // -> 인터페이스를 구현한 객체를 전달받음
})
인터페이스를 구현한 객체를 전달받음
인터페이스 대한 함수 타입을 사용하는 코드
fun setOnClickListener(listener: (View) -> Unit) { ... }
setOnClickListener { ... } // trailing 람다 표현식
setOnClickListener(fun(view) {...}) // 익명 함수
setOnClickListener(::println) // 함수 레퍼런스
setOnClickListener(this::showUsers) // 제한된 함수 레퍼런스
// 선언된 함수 타입을 구현한 객체로 전달
class ClickListener : (View) -> Unit {
override fun invoke(view:View) { ... }
}
setOnClickListener(ClickListener())
SAM의 장점
kotlin에서의 SAM fun
https://kotlinlang.org/docs/fun-interfaces.html#functional-interfaces-vs-type-aliases
딱 한가지 경우에는 SAM을 사용하는 것이 좋다.
-> 코틀린이 아닌 다른 언어세서 사용할 클래스를 설계할 때
자바에서는 인터페이스가 더 명확하다. 함수 타입으로 만들어진 클래스는 자바에서 typealias와 ide의 지원 등을 제대로 받을 수 없다.
자바에서 사용하기 위한 API를 설계할 때는 함수 타입보다 SAM을 사용하는 것이 합리적이다. 하지만 이외의 일반적인 경우에는 함수타입을 사용하는 것이 좋다.
Tagged class란 하나의 클래스 내에 Enum 삽입, 상수, 각 태그별 필드, 분기 코드 등을 포함한 클래스를 일컫는다.
Tagged class 단점
tagged class의 문제점을 해결하는 방법은 클래스 계층 구조로 변경하는 것이며, 코틀린에서는 sealed class를 많이 사용한다.
sealed class를 사용하여 구현하면 책임이 분산된다. 또한, 각각의 객체들은 자신에게 필요한 데이터만 있으며, 적절한 파라미터만 갖는다.
sealed class는 외부에서 확장되지 않는 밀봉된 구조를 가진다. 외부의 다른 class들은 sealed class로부터 상속을 받을 수 없도록 봉인되어 있다.
컴파일 타임에 코드를 작성하면서 서브클래스들에 접근해서 제한된 사용이 가능
*sealed interface
반드시 sealed를 사용해야 하는 것은 아니다. abstract를 사용할 수도 있지만, sealed 한정자는 외부 파일에서 서브클래스를 만드는 행위 자체를 모두 제한한다.
sealed는 외부 파일에서 서브 클래스를 만드는 행위를 제한한다. 그러므로 when을 사용할 때 서브클래스별 분기를 만드는 경우 else를 사용할 필요가 없어진다. -> 외부에서 서브 클래스 타입이 추가되지 않을 것이라는게 보장되기 때문
when문을 사용하여 경우의 수를 코딩
tagged class와 state pattern을 혼동하면 안된다.
상태패턴은 객체의 내부 상태가 변화할 때, 객체의 동작이 변하는 소프트웨어 디자인 패턴이다. MVC, MVP, MVVM 아키텍처 모델 사용 시 사용
sealed class를 사용하면 필요한 값만 받으면 되기에 상태를 더 상세하게 정의할 수 있다. -> sealed class는 상태패턴 O
tagged class는 상태를 처리하려면 constructor를 다 수정해야 한다. -> tagged class는 상태패턴X!!
tagged class와 state pattern의 차이점