시배's Android

Effective Kotlin | 6장. 클래스 설계 본문

Book/Effective Kotlin

Effective Kotlin | 6장. 클래스 설계

si8ae 2024. 3. 14. 21:15

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

상속은 관계가 명확하지 않을 때 사용하면, 여러 가지 문제가 발생할 수 있습니다. (‘is-a’ 관계에서 사용)

class ProfilerLoader {
    fun load() {
        // 프로그래스 바를 보여 줌
        // 프로파일을 읽어 들임
        // 프로그레스 바를 숨김
    }
}

class ImageLoader {
    fun load() {
        // 프로그래스 바를 보여 줌
        // 이미지를 읽어 들임
        // 프로그레스 바를 숨김
    }
}

상속을 이용하면 위의 코드를 아래와 같이 시용할 수 있습니다.

abstract class LoaderWithProgress {
    fun load() {
        // 프로그래스 바를 보여 줌
        innerLoad()
        // 프로그래스 바를 숨김
    }

    abstract fun innerLoad()
}


class ProfilerLoader: LoaderWithProgress() {
    override fun innerLoad() {
        // 프로파일을 읽어 들임
    }
}

class ImageLoader: LoaderWithProgress() {
    override fun innerLoad() {
        // 이미지를 읽어 들임
    }
}

하지만 몇 가지 단점이 있습니다. 그렇기 때문에 명확한 ‘is-a’ 관계일때 사용하는것이 좋습니다.

  • 상속은 하나의 클래스만을 대상으로 할 수 있습니다.
  • 상속을 사용해서 행위를 추출하다 보면, 많은 함수를 갖는 거대한 BaseXXX 클래스가 만들어지고 복잡해집니다.
  • 상속은 클래스의 모든 것을 가져오게 됩니다.
  • 따라서 불필요한 함수를 갖는 클래스가 만들어질 수 있습니다. (인터페이스 분리 원칙(ISP) 위반)
  • 상속은 어렵습니다. 슈퍼클래스를 여러 번 확인해야 합니다.
    이런한 이유로 컴포지션을 사용하면 대안이 가능합니다. 컴포지션을 사용한다는 것은 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하기 때문에 추가 코드만을 통해서 해결이 가능합니다.
class Progress {

    fun showProgress() {
        // ...
    }

    fun hideProgress() {
        // ...
    }
}

class ProfilerLoader {
    private val progress = Progress()

    fun load() {
        progress.showProgress()
        // 프로파일을 읽어 들임        
        progress.hideProgress()
    }
}

class ImageLoader {
    private val progress = Progress()

    fun load() {
        progress.showProgress()
        // 이미지를 읽어 들임        
        progress.hideProgress()
    }
}

컴포지션을 사용하면 다음과 같은 장점을 같습니다.

  • 컴포지션은 다른 클래스의 내부 구현에 의존하지 않기 때문에 더 안전합니다.
  • 컴포지션은 여러 클래스를 대상으로 할 수 있기 때문에 더 유연합니다.
  • 컴포지션은 this 리시버를 사용할 수 없기 때문에 리시버를 명시적으로 활용해야 해야 더 명시적입니다.

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

코틀린에는 data class가 있기 때문에 데이터 집합을 생성할때에는 data class를 이용하는게 좋습니다.

data class Player(
    val id: Int,
    val name: String,
    val points: Int,
)

data class는 자동으로 아래 함수들이 생성이 되는 강점이 있습니다.

  • toString()
  • equals()와 hashCode()
  • copy()
  • componentN()

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

대부분의 프로그래밍 언어에는 함수 타입이라는 개념이 없기 때문에 메서드가 하나만 있는 인터페이스(=SAM)을 이용합니다.

안드로이드 개발자들은 클릭 리스너때문에 아래와 같은 코드를 많이 봤을 겁니다.

//  하나의 메서드만 가지는 인터페이스 SAM(Single Abstract Method)
interface OnClick {
    fun clicked(view: View)
}

fun setOnClickListener(listener: OnClick) {
    // ...
}

setOnClickListener(object : OnClick {
    override fun clicked(view: View) {
        // ...
    }
})

이를 함수 타입으로 변경하면 다음과 같이 사용할 수 있습니다.

fun setOnClickListener(listener: (View) -> Unit) {
  // ...
}

그리고 함수 타입으로 변경 시 다음과 같은 기능들을 사용할 수 있습니다.

  • 람다 표현식 또는 익명 함수로 전달
setOnClickListener { //... }
setOnClickListener(fun(view) { // ... } )
  • 함수 레퍼런스 또는 제한된 함수 레퍼런스로 전달
setOnClickListener(::println)
setOnClickListener(this::showUsers)
  • 선언된 함수 타입을 구현한 객체로 전달
class ClickListener: (View) -> Unit {
    override fun invoke(view: View) {
        // ...
    }
}

setOnClickListener(ClickListener())

SAM을 사용할 때에는 코틀린이 다른 언어에서 사용할 클래스를 설계할 때 입니다.

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

상수 모드를 태그라고 부르며 태그를 가진 클래스를 태그 클래스라고 합니다. 하지만 태그 클래스는 서로 다른 책임을 한 클래스에 태그로 구분해서 넣음으로써 다양한 문제를 내포하게 됩니다.

  • 한 클래스에 여러 모드를 처리하기 위한 보일러 플레이트 코드가 추가 됩니다.
  • 여러 목적으로 사용해야 하므로 프로퍼티가 일관적이지 않게 사용될 수 있습니다.
  • 여러 목적을 가지고 요소를 여러 방법으로 설정할 수 있는 경우, 상태의 일관성과 정확성을 지키기 어렵습니다.
  • 팩토리 메서드를 사용하지 않으면 객체가 제대로 생성되었는지 확인하기 어렵습니다.

아래는 태그 클래스의 예제입니다.

class ValueMatcher<T> private constructor(
    private val value: T? = null,
    private val matcher: Matcher
) {

    companion object {
        fun <T> equal(value: T)
            = ValueMatcher<T>(value = value, matcher = Matcher.EQUAL)

        fun <T> notEqual(value: T)
                = ValueMatcher<T>(value = value, matcher = Matcher.NOT_EQUAL)

        fun <T> emptyList(value: T)
                = ValueMatcher<T>(value = value, matcher = Matcher.LIST_EMPTY)

        fun <T> notEmptyList(value: T)
                = ValueMatcher<T>(value = value, matcher = Matcher.LIST_NOT_EMPTY)
    }

    enum class Matcher {
        EQUAL,
        NOT_EQUAL,
        LIST_EMPTY,
        LIST_NOT_EMPTY
    }

    fun match(value: T?) = when(matcher) {
        Matcher.EQUAL -> value == this.value
        Matcher.NOT_EQUAL -> value != this.value
        Matcher.LIST_EMPTY -> value is List<*> && value.isEmpty()
        Matcher.LIST_NOT_EMPTY -> value is List<*> && value.isNotEmpty()
    }

}

태그 클래스는 sealed 클래스로 변경이 가능하고 이를 통해 한 클래스에 여러 모드를 만드는 방법 대신에, 각각의 모드를 여러 클래스로 만들고 타입 시스템과 다형성을 활용하는 것입니다.

이를 sealed class로 변경하면 다음과 같습니다.

sealed class ValueMatcher<T> {
   abstract fun match(value: T): Boolean

   class Equal<T>(private val value: T) : ValueMatcher<T>() {
       override fun match(value: T): Boolean {
           return value == this.value
       }
   }

    class NotEqual<T>(private val value: T) : ValueMatcher<T>() {
        override fun match(value: T): Boolean {
            return value != this.value
        }
    }

    class EmptyList<T>(private val value: T) : ValueMatcher<T>() {
        override fun match(value: T): Boolean {
            return value is List<*> && value.isEmpty()
        }
    }

    class NotEmptyList<T>(private val value: T) : ValueMatcher<T>() {
        override fun match(value: T): Boolean {
            return value is List<*> && value.isNotEmpty()
        }
    }
}

sealed 한정자를 사용하면 아래와 같은 장점이 있습니다.

  • 외부 파일에서 서브클래스를 만드는 행위 자체를 모두 제한
  • 외부에서 추가적인 서브클래스를 만들 수 없으므로, 타입이 추가되지 않을 거라는게 보장이라 when()절에서 else 브랜치를 만들 필요가 없습니다.

43. API의 필수적이지 않는 부분을 확장 함수로 추출하라

클래스의 메서드를 정의할 때에는 메서드를 멤버로 정의할 건지 확장으로 정의할건지 결정해야 하는데, 아래와 같은 특징이 있기 때문에 상황에 맞춰서 잘 선택하여야 합니다.

  • 확장 함수는 읽어 들여야 합니다. (클래스의 public API로 import)
  • 확장 함수는 일반적으로 다른 패키지에 위치
  • 데이터와 행위를 분리하도록 설계된 프로젝트에 사용할 수 있습니다.
  • 확장함수는 virtual이 아닙니다.
  • 파생 클래스에서 오버라이드 할 수 없습니다. 그래서 상속을 목적으로 하는 설계에서는 확장 함수로 만들면 안됩니다.
  • 멤버는 높은 우선 순위를 갖습니다.
  • 확장 함수는 클래스 위가 아니라 타입 위에 만들어집니다.
  • 확장 함수는 클래스 레퍼런스에 나오지 않습니다.
  • 확장은 클래스 레퍼런스에서 멤버로 표시되지 않기 때문에 어노테이션 프로세서가 따로 처리하지 않습니다.
  • 필수적이지 않은 요소를 확장 함수로 추출하면, 어노테이션 프로세스로부터 숨겨집니다.

44. 멤버 확장 함수의 사용을 피하라

확장 함수는 다음과 같이 클래스 멤버로 정의할 수 있습니다.

interface PhoneBook {
    fun String.isPhoneNumber(): Boolean
}

class Fizz: PhoneBook {
    override fun String.isPhoneNumber(): Boolean {
        return length == 7 && all { it.isDigit() }
    }
}

위와 같은 코드는 DSL을 만들 때를 제외하면 피하는게 좋습니다. 이유는 아래와 같습니다

  • 가시성을 제한하지 못합니다.
  • 레퍼런스를 지원하지 않습니다.
  • 암묵적 접근을 할 때, 두 리시버 중 어떤 리시버를 선택할지 혼동됩니다.
  • 확장 함수가 외부에 있느 다른 클래스를 리시버로 받을 때, 해당 함수가 어떤 동작을 하는지 명확하지 않습니다.
  • 경험이 적은 개발자의 경우 확장 함수를 보면, 직관적이지 않을 수 있습니다.