Effective Kotlin #6 클래스 설계

yeji·2022년 12월 8일
0

Effective Kotlin

목록 보기
6/7

item 36. 상속보다는 컴포지션을 사용하라

상속

  • is-a 관계의 객체 계층 구조를 만들기 위해 설계
    • is-a
      • (is a relationship, inheritance)는 일반적인 개념과 구체적인 개념의 관계
      • ex. 사람은 동물이다. 소는 동물이다.
      • 일반 클래스를 구체화 하는 상황에서 상속을 사용
    • has-a
      • (has a relationship, association)는 일반적인 포함 개념의 관계. 클래스 안에 객체를 소유하고 있는 관계
      • 상속 사용 x

상속은 관계가 명확하지 않을 때 사용하면, 여러 가지 문제가 발생할 수 있다.
따라서 단순하게 코드 추출 또는 재사용을 위해 상속하려고 한다면 좋지 않다.
-> 이러한 상황에서는 컴포지션을 사용하는 것이 더 좋다.

행위 재사용

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() {
        
    }
}

공통되는 행위를 추출할 때 슈퍼클래스를 만드는데 이는 단점이 존재한다.

  • 상속은 하나의 클래스만을 대상으로 할 수 있다. 상속을 통해 행위를 추출하다보면 많은 함수를 갖는 거대한 BaseXXX 클래스를 만들게 되고 깊고 복잡한 계층 구조가 만들어지게 된다.
  • 상속은 클래스의 모든 것을 가져오므로 불필요한 함수를 갖는 클래스가 될 수 있다. 이는 인터페이스 분리 원칙(ISP: Interface Segrega-tion Principle)을 위반하게 된다.
    • 클라이언트는 사용하지 않는 인터페이스에 강제로 의존해서는 안된다. (꼭 필요한 메서드만 이용하자)
  • 상속은 이해하기 어렵다. 메서드의 동작 방식을 이해하기 위해 슈퍼클래스를 여러번 확인해야 할 수도 있다.

-> 이러한 단점을 가지는 슈퍼클래스 사용을 대신하는 방법이 컴포지션(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 패턴을 사용할 수 있다.

  • Delegate pattern
    • composition을 이용하는 일반적인 패턴
    • 객체의 구현을 다른 객체에게 위임한다.
    • 클래스가 인터페이스를 상속받게 하고, 포함한 객체의 메서드들을 활용해서 인터페이스에서 정의한 메서드를 구현하는 패턴
    • composition 객체의 함수가 많아지면 보일러플레이트 코드를 많이 작성해야 한다. -> 이렇게 구현된 코드가 forwarding method이다.
    • 코틀린의 by 키워드를 사용하여 보일러 플레이트 코드를 줄일 수 있다. 또한, 관리 포인트를 일원화 시켜 코드 유지보수를 용이하게 해준다. (한 곳에서 구현하면 다른 곳에서 위임받아 사용할 수 있기 때문)
      • by를 사용하면 컴파일 시점에 포워딩 메서드들이 자동으로 만들어진다.

오버라이딩(재정의) 제한하기

서브 클래스의 상속을 막고 싶은 경우 final 키워드 사용

  • 메서드를 오버라이드할 때, 서브 클래스에서 해당 메서드에 final을 붙일 수도 있다.

코틀린은 기본적으로 클래스의 상속과 메서드의 오버라이드를 막아두었다. 따라서 오버라이드하기 위해서는 클래스 이름 또는 메서드 앞에 open 키워드를 붙여야 한다.

  • open 클래스는 open 메서드만 오버라이드할 수 있다.

컴포지션과 상속의 차이 및 특징

  • 컴포지션은 안전하다. 다른 클래스의 내부적인 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존한다.
  • 컴포지션은 유연하다. 코틀린 클래스는 다중 상속이 불가하며, 컴포지션은 여러 클래스를 대상으로 사용 가능하다.
  • 컴포지션은 명시적이다. 리시버를 명시적으로 활용할 수 밖에 없으므로 메서드가 어디에 있는지 확실히 알 수 있다.
  • 컴포지션은 번거롭다. 상속을 사용할 때보다 코드를 수정해야 하는 경우가 더 많다.
  • 상속은 다형성을 활용한다. 이는 양날의 검이며, 상속을 할경우 수퍼클래스와 서브클래스의 규약을 잘 지켜야 한다.

일반적으로 OOP에서는 상속보다 컴포지션을 사용하는 것이 좋다.

  • 상속은 언제 사용?
    - 명확한 is-a 관계일 때 사용하면 좋다.
    - Labrado is a Dog -> true
    - RobotDog is Dog -> ambiguous
    • 슈퍼클래스를 상속하는 모든 서브클래스는 슈퍼클래스로도 동작할 수 있어야 한다.
    • 슈퍼클래스의 모든 단위 테스트는 서브클래스로도 통과할 수 있어야 한다.(리스코프 치환 원칙)

item 37. 데이터 집합 표현에 data 한정자를 사용하라

데이터들을 한꺼번에 전달할 때 data class 사용

data modifier를 붙이면 다음과 같은 함수가 자동 생성된다.

  • toString : 클래스 이름, 기본 생성자 형태로 모든 프로퍼티 와 값을 출력
  • equals : 기본 생성자의 프로퍼티가 같은지 확인. 객체의 값의 일치여부(boolean)
  • hashCode : 비교한 두 객체가 동등하다면 동일한 버킷아이디 반환. 객체의 주소값(int)을 이용하여 객체 고유의 해시코드(int)
  • copy : 기본 생성자 프로퍼티가 같은 새로운 객체로 얕은 복사. 프로퍼티 변경도 가능 (원본 객체가 저장된 주소 자체를 넘겨도 안전하다. 원본 값을 직접 읽을 순 있으나 조작할 순 없기 때문)
    componentN(component1, component2 ...) : 위치를 기반으로 프로퍼티에 접근하여 객체를 destructing함 (객체가 가지고 있는 여러 값을 분해해서 여러 변수에 한꺼번에 초기화)

튜플 대신 데이터 클래스 사용하기

코틀린의 튜플은 Pair, Triple이 있으며 Serializable을 기반으로 만들어졌다.
일반적인 상황에서는 가독성 때문에 튜플 보다 data 클래스를 사용하는 것이 좋다.
Pair나 Triple은 프로퍼티 이름이 없기 때문에 타입만 보고 의미를 인지하기 어려울 수 있기 때문이다.

data class를 사용하는 것은 추가 비용은 거의 들지 않으나 함수를 더 명확하게 만들어준다.

  • 함수의 리턴 타입이 더 명확
  • 리턴 타입이 더 짧아지며 전달하기 쉬워진다.
  • 사용자가 데이터 클래스에 적혀 있는 것과 다른 이름을 활용해 변수를 해제하면, ide에서 경고가 출력된다.
    data class를 좁은 스코프를 가지게하고 싶다면 일반적인 가시성을 제한할 수 있다. 또한, 로컬 처리에서만 이를 활용하고 싶다면 private을 붙이면된다.

item 38. 연산 또는 액션을 전달할 때는 인터페이스 대신 함수 타입을 사용하라

연산 또는 액션을 전달할 때 메서드가 하나만 있는 인터페이스를 활용하여 함수 타입(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의 장점

  • argument에 이름이 붙어있는 것?!
    - SAM만의 장점은 아니다.
    - typealias을 사용하면 함수타입도 이름을 붙일 수 있기에
    - ex. typealias OnClick = (View) -> Unit
    • 파라미터도 이름을 가질 수 있다.
    • 람다표현식을 사용할 때는 아큐먼트 분해도 사용할 수 있다.

kotlin에서의 SAM fun
https://kotlinlang.org/docs/fun-interfaces.html#functional-interfaces-vs-type-aliases

언제 SAM을 사용해야 할까?

딱 한가지 경우에는 SAM을 사용하는 것이 좋다.
-> 코틀린이 아닌 다른 언어세서 사용할 클래스를 설계할 때
자바에서는 인터페이스가 더 명확하다. 함수 타입으로 만들어진 클래스는 자바에서 typealias와 ide의 지원 등을 제대로 받을 수 없다.
자바에서 사용하기 위한 API를 설계할 때는 함수 타입보다 SAM을 사용하는 것이 합리적이다. 하지만 이외의 일반적인 경우에는 함수타입을 사용하는 것이 좋다.

item 39. 태그 클래스보다는 클래스 계층을 사용하라

Tagged class란 하나의 클래스 내에 Enum 삽입, 상수, 각 태그별 필드, 분기 코드 등을 포함한 클래스를 일컫는다.
Tagged class 단점

  • 한 클래스에 여러 보일러플레이트 코드가 추가된다.
  • 여러 목적으로 사용해야 마르모 프로퍼티가 일관적이지 않을 수 있다.
  • 객체가 제대로 생성되는지 보장받기 위해 팩토리 메서드를 사용해야 한다.

tagged class의 문제점을 해결하는 방법은 클래스 계층 구조로 변경하는 것이며, 코틀린에서는 sealed class를 많이 사용한다.
sealed class를 사용하여 구현하면 책임이 분산된다. 또한, 각각의 객체들은 자신에게 필요한 데이터만 있으며, 적절한 파라미터만 갖는다.

sealed modifier

sealed class는 외부에서 확장되지 않는 밀봉된 구조를 가진다. 외부의 다른 class들은 sealed class로부터 상속을 받을 수 없도록 봉인되어 있다.
컴파일 타임에 코드를 작성하면서 서브클래스들에 접근해서 제한된 사용이 가능

*sealed interface

  • sealed class의 생성자를 가지고 있지 않은 interface 버전. 생성자를 필요로 하지 않을 때 사용
  • 코틀린에서 허용되지 않는 클래스 다중 상속으로 인한 sealed class의 단점을 보완할 수 있음

반드시 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의 차이점

  • 상태는 더 많은 책임을 가진 큰 클래스이다.
  • 상태는 변경할 수 있다.

item 40. equals의 규약을 지켜라

profile
🐥

0개의 댓글