프로그래밍 노트/Effective 시리즈

[Effective Kotlin] 3장. 재사용성(Reusability)

깡냉쓰 2023. 11. 23. 19:00
728x90
반응형

재사용(reusability)성은 프로그래밍 언어의 핵심이라고 할 수 있음

  • System.out.println, 각종 정렬 함수, http client 구현이 안되어 있다면? T_T

재사용성은 힘이 있는 만큼 굉장히 위험하기도 함

  • A, B의 공통 부분을 추출한다면, 이후 공통 부분을 수정할 일이 있을 경우 한꺼번에 수정 가능
  • 그러나, 세상일은 우리가 원하는대로 일어나지 않음
    • A를 대상으로 수정한 것이 B에서 문제가될 수 있고, B를 대상으로 수정한 것이 A에서 문제가될 수 있음
  • 따라서 재사용성을 고려하는 일은 생각보다 어렵고, 다양한 오류를 발생시킬 수 있음

[Effective Kotlin. 19] knowledge를 반복하여 사용하지 말라

프로젝트에서 이미 있던 코드를 복사해서 붙여넣고 있다면, 무언가가 잘못된 것이다.

  • knwledge를 반복하여 사용하지 말라
  • <실용주의 프로그래머> Don't Repeat Yourself 'DRY 규칙'

knowledge?

  • 넓은 의미로 의도적인 정보를 뜻함
  • 프로젝트를 진행할 때 정의한 모든 것이 knowledge
    • 알고리즘 작동 방식, UI 형태, 우리가 원하는 결과 등등
  • 프로그램에서 중요한 knowledge 두 가지
    • 로직(logic) : 프로그램이 어떠한 식으로 동작하는지와 프로그램이 어떻게 보이는지
      • 시간이 지나면서 계속 변해감
    • 공통 알고리즘(common algorithm) : 원하는 동작을 하기 위한 알고리즘
      • 한 번 정의된 이후에는 크게 변하지 않음 (최적화 또는 더 빠른 알고리즘으로 교체될 수 있지만 동작이 변하지는 않음)

모든 것은 변한다. 소프트웨어도 마찬가지다.

  • 모든 것은 변화하고, 우리는 이에 대비해야 함
  • 변화할 때 가장 큰 적은 knowlege가 반복되어 있는 부분
    • 확장성(scalable)을 막고, 쉽게 깨지게(fragile) 만듬
  • 개발자는 도구와 기능들을 활용해서 knowlege의 반복을 최소화 시켜야함

언제 코드를 반복해도 될까?

  • 반대로 knowlege 반복을 줄이면 안되는 상황을 살펴보자
    • 결론부터 말하면 knowlege 반복처럼 보이지만, 실질적으로 다른 knowlege를 나타내므로 추출하면 안됨
    • 어떤 프로젝트에서 독립적인 2개의 안드로이드 애플리케이션을 만들고 있음
    • 빌드 도구 설정이 비슷할 것이므로, 이를 추출해서 knowlege 반복을 줄일 수 있다고 생각
    • but 두 애플리케이션은 독립적이므로 구성 변경이 일부 필요할 수 있음. 한 애플리케이션 쪽의 구성만 변경해야 한다면 문제가 됨\
    • 이처럼 신중하지 못한 추출은 변경을 더 어렵게 만듬
  • 함께 변경될 가능성이 높은가? 따로 변경될 가능성이 높은가? 라는 질문으로 어느 정도 결정이 가능
    • 코드를 추출하는 이유는 변경을 쉽게 만들기 위함이기 때문이다.
  • 유용한 휴리스틱 -> 비즈니스 규칙이 다른 곳(source)에서 왔는지 확인. 다른 곳에서 왔다면, 독립적으로 변경될 가능성이 높음

단일 책임 원칙(SRP: Single Responsibility Principle)

  • 자세한 내용은 좋은 아키텍처리를 정의하는 원칙 SOLID 참고
  • 클래스를 변경하는 이유는 단 한 가지여야 한다. (A class sohuld have only one reason to change)
  • 두 액터(actor)가 같은 클래스를 변경하는 일은 없어야 한다.
    • 액터 : 변화를 만들어내는 존재(source of change), 서로의 업무와 분야에 대해 잘 모르는 개발자
  • Actor가 2개인 Student class
class Studnet {
  // ..

  // 인증 관련 부서에서 사용
  fun isPassing(): Boolean
      calculatePointsFromPassedCourses() > 15

  // 장학금 관련 부서에서 사용
  fun qualifiesForScholarship(): Boolean = 
      calculatePointsFromPassedCourses > 30

  private fun calculatePointsFromPassedCourses(): Int {
    // ..
  }
}
  • 코틀린의 확장 함수를 활용하면, 함수는 Student 클래스 아래에 두면서 각각의 부서가 관리하는 서로 다른 모듈 파일에 배치할 수 있음
// 인증 모듈
fun Student.qualifiesForScholarship(): Boolean {
  /*...*/
}


// 장학금 모듈
fun Student.calculatePointsFromPassedCourses(): Boolean {
  /*...*/
}

정리

  • 서로 다른곳에서 사용하는 knowledge 독립적으로 변경할 가능성이 높음. 따라서 비슷한 처리를 하더라도, 완전히 다른 knowledge로 취급하는 것이 좋음
  • 다른 knowlege는 분리해 두는 것이 좋음. 그렇지 않으면 재사용해서는 안 되는 부분을 재사용하려는 유혹이 발생함
  • 모든 것은 변화하기 때문에 공통 knowledge는 추출해서 변화에 대비 해야함
    • 여러 요소에 비슷한 부분이 있으면, 변경이 필요할 때 실수가 발생할 수 있음. 이런 부분은 추출해야함
    • 추가적으로 의도하지 않은 수정을 피하려면 또 다른 곳에서 조작하는 부분이 있다면, 분리해서 사용하는 것이 좋음
  • Don't Repeat Yourself 문장을 엄격하게 지키려고 비슷해 보이는 코드를 모두 추출하려는 경향이 있지만 극단적인 것은 언제나 좋지 않음

[Effective Kotlin. 21] 일반적인 프로퍼티 패턴은 프로퍼티 위임으로 만들어라

  • 코드 재사용과 관련해서 프로퍼티 위임이라는 새로운 기능을 제공함
    • 프로퍼티 위임을 사용하면 일반적인 프로퍼티 행위를 추출해서 재사용할 수 있음
    • 대표적인 예로 지연 프로퍼티(lazy)가 있음
  • java에서는 구현이 어렵지만, 코틀린에서는 stdlib를 통하여 lazy 프로퍼티 패턴을 쉽게 구현할 수 있음
val value by lazy { createValue() }

// observarble 패턴 구현
// observable 델리게이트 기반으로 간단하게 구현 가능
var items: List<Item> by
  Delegates.observable(listOf()) { _, _, _ ->
    notifyDataSetChanged() 
}

var key: String? by
  Delegates.observable(null) {_, old, new ->
    Log.e("key changed from $old to $new")  
}
  • 뷰, 리소스 바인딩, 의존성 주입, 데이터 바인딩 등 프로퍼티 위임 메커니즘을 활용하여 다양한 패턴을 만들 수 있음
    • 자바에서는 어노테이션을 많이 활용해야 하지만, 코틀린은 프로퍼티 위임을 사용해서 간단하고 type-safe하게 구현할 수 있음
// 안드로이드 리소스 뷰/리소스 바인딩
private val button: Button by bindView(R.id.button)
private val doctor: Doctor by argExtra(DOCTOR_ARG)

// 의존성 주입
private val presenter: MainPresenter by inject()

// 데이터 바인딩
private val port by bindConfiguration("port")

간단한 프로퍼티 델리게이트 만들어 보기

  • 일부 프로퍼티가 변경될 때 간단한 로그를 출력할 수 있는 기능을 만들고 싶음
    • 자주 반복될 것 같은 패턴. 프로퍼티 위임을 활용해서 추출해보자
    • 프로퍼티 위임 : 다른 객체의 메서드를 활용하여 프로퍼티 접근자(getter/setter)를 만드는 방식
val token: String? by LoggingProperty(null)
val attempts: Int by LoggingProperty(0)

private class LoggingProperty<T>(var value: T) {
  operator fun getValue(
    thisRef: Any?,
    prop: KProperty<*>
  ): T {
    print("${prop.name} returned value $value")
    return value
  }

  operator fun setValue(
    thisRef: Any?,
    prop: KProperty<*>,
    newValue: T
  ) {
    val name = prop.name
    print("$name changed from $value to $newValue)
    value = newValue
  }
}
  • by는 아래와 같이 컴파일 됨
@JvmField
private val 'token$delegate' = LoggingProperty<String?>(null)
var token: String?
    get() = 'token$delegate'.getValue(this, ::token)
    set(value) {
        'token$delegate'.setValue(this, ::token, value)
    }
  • 단순한게 값만 처리하는 것이 아닌 컨텍스트(this)와 프로퍼티 레퍼런스의 경계도 함께 사용하는 형태로 바뀜
  • 프로퍼티에 대한 레퍼런스는 이름, 어노테이션과 관련된 정보 등을 얻을 때 사용됨

stdlib에서 알아두면 좋은 프로퍼티 델리게이터

  • lazy
  • Delegates.observable
  • Delegates.vetoable
  • Delegates.notNull

[Effective Kotlin. 22] 일반적인 알고리즘을 구현할 때 제네릭을 사용하라

  • 타입 아규먼트를 사용하면 함수에 타입을 전달할 수 있음
  • 타입 아규먼트를 사용하는 함수를 제네릭 함수(generic function)이라고 부름
inline fun <T> Iterable<T>.filter(
  predicate: (T) -> Boolean
): List<T> {
  val destination = ArrayList<T>()
  for (element in this) {
    if (predicate(element)) {
      destination.add(element)
    }
  }
  return destination
}
  • 타입 파라미터는 컴파일러에 타입관련된 정보를 제공하여 프로그램이 조금 더 안전해지고, 개발자는 프로그래밍이 편해짐

제네릭 제한

  • 구체적인 타입의 서브타입만 사용하게 타입을 제한할 수 있음
    • 콜론 뒤에 슈퍼타입을 설정해서 제한을 건다.
// 서브타입 제한
fun <T: Comprable<T>> Iterable<T>.sorted(): List<T> {
  /*...*/
}

// Any로 nullable이 아닌 타입만 받도록 설정
inline fun<T, R: Any> Iterable<T>.mapNotNull(
  transform: (T) -> R?
): List<R> {
  return mapNotNullTo(ArrayList<R>(), transform)
}

// 둘 이상의 제한을 걸 때
fun <T: Animal> pet(animal: T) where T: GoodTempered {
  /*...*/
}
// 또는
fun <T> pet(animal: T) where T: Animal, T: GoodTempered {
  /*...*/
}
  • 타입 파라미터는 구체 자료형(concrete type)의 서브사팅븡 제한할 수 있음
  • 특정 자료형이 제공하는 메서드를 안전하게 사용할 수 있음
반응형

[Effective Kotlin. 24] 제네릭 타입과 variance 한정자를 활용하라

class Cup<T>
  • 위 클래스는 타입 파라미터 T는 variance 한정자(out 또는 in)가 없으므로, 기본적으로 invariant(불공변성, 무공변성)임
  • invariant는 제네릭 타입으로 만들어지는 타입들이 서로 관련성이 없다는 것을 의미
    • Cup<Number>, Cup<Any>, Cup<Noting>은 아떠한 관련성도 갖지 않음
fun main() {
  val anys: Cup<Any> = Cup<Int>() // Type mismatch
  val nothigs: Cup<Nothing> = Cup<Int>() // 오류
}
  • 만약 어떤 관련성을 원하면, out 또는 in이라는 variance 한정자를 붙임
  • out은 타입 파라미터를 covariant(공변성)로 만듬
    • 이는 A가 B의 서브타입일 때, Cup<A>는 Cup<B>의 서브타입이라는 의미
class Cup<out T>
open class Dog
class Puppy: Dog()

fun main() {
  val a: Cup<Dog> = Cup<Puppy>() // OK
  val b: Cup<Puppy> = Cup<Dog>() // 오류
  val anys: Cup<Any> = Cup<Int>() // OK
  val nothigs: Cup<Nothing> = Cup<Int>() // 오류
}
  • in은 타입 파라미터를 contravariant(반공변성)로 만듬
    • 이는 A가 B의 서브타입일 때, Cup<A>는 Cup<B>의 슈퍼타입이라는 것을 의미
class Cup<in T>
open class Dog
class Puppy: Dog()

fun main() {
  val a: Cup<Dog> = Cup<Puppy>() // 오류
  val b: Cup<Puppy> = Cup<Dog>() // OK
  val anys: Cup<Any> = Cup<Int>() // 오류
  val nothigs: Cup<Nothing> = Cup<Int>() // OK
}
  • variance 한정자를 그림으로 나타내면 아래와 같음

함수 타입

  • 함수타입은 파라미터 유형과 리턴 타입에 따라서 서로 어떤 관계를 갖음
  • Int를 받고, Any를 리턴하는 함수를 파라미터로 받는 함수를 생각해본다면
    • (Int) -> Any 타입의 함수는 (Int) -> Number, (Number) -> Any, (Number) -> Number 등으로 작동함
fun printProcessedNumber(transition: (Int)->Any) {
  print(transition(42))
}

val intToDouble: (Int) -> Number = { it.toDouble() }
val numberAsText: (Number) -> Any = { it.toShort() }
val numberHash: (Any) -> Number = { it.hashCode() }
printProcessedNumber(intToDouble)
printProcessedNumber(numberAsText)
printProcessedNumber(numberHash)
  • 이러한 타입은 아래와 같은 관계가 있음

  • 계층 구조 아래로 가면, 타이핑 시스템 계층에서 파라미터 타입이 더 높은 타입으로 이동 (Int -> Number -> Any)
  • 리턴 타입은 계층 구조의 더 낮은 타입으로 이동 (Any -> Number -> Int)

  • 코틀린 함수 타입의 모든 파라미터 타입은 contravariant(반공변성)임 (in)
    • T'가 T의 서브타입이면 C<T>는 C<in T'>의 서브타입이다.
    • Number가 Any의 서브타입이므로, C<Any>는 C<Number>의 서브타입이다.
    • 자기 자신과 부모 객체만을 허용
  • 모든 리턴 타입은 convariant(공변성)임 (out)
    • T'가 T의 서브타입이면 C<T'>는 C<out T>의 서브타입이다.
    • Number가 Any의 서브타입이므로, C<Number>는 C<Any>의 서브타입이다.
    • 자기 자신과 자식 객체만 허용

  • 파라미터 타입은 반공변성이므로 Number로 정의하면 Any가 들어갈 수 있다.
  • 리턴 타입은 공변성이므로 Number로 정의하면 Any가 들어갈 수 없다.
  • 함수 타입을 사용할 때는 자동으로 variance 한정자가 사용됨

variance 한정자의 안전성

  • 자바의 배열은 convaraint인데, covariant라는 속성을 갖기 때문에 문제가 발생하는 경우가 있음
    • 컴파일시에는 문제가 없지만, 런타임 오류가 발생
Integer[] numbers = {1, 4, 2, 1};
Object[] objects = numbers;
objects[2] = "B"; // 런타임 오류: ArrayStoreException
  • numbers를 Object[]로 캐스팅해도 내부에서 사용되는 실질적인 타입이 바뀌는 것은 아님(여전히 Intger)
  • 따라서 이러한 배열네 String 타입을 할당하면 오류가 발생
  • 코틀린은 이러한 결함을 해결하기 위해 Array(IntArray, CharArray 등)를 invaraint(불변)로 만듬
    • Array<Int>를 Array<Any> 등으로 바꿀 수 없음
  • 파라미터 타입을 예측할 수 있다면, 어떤 서브타입이라도 전달할 수 있음 (아규먼트를 전달할 때, 암묵적으로 업캐스팅할 수 있음)
open class Dog
class Puppy: Dog()
class Hound: Dog()

fun takeDog(dog: Dog) {}

takeDog(Dog())
takeDog(Puppy()) // 업캐스팅
takeDog(Hound())
class Box<out T>
  private var value: T? = null

  // 코틀린에서 실제 사용할 수 없는 코드
  fun set(value: T) {
    this.value = value
  }

  fun get(): T = value ?: error("Value not set")
}

val puppyBox = Box<Puppy>()
val dogBox: Box<Dog> = puppyBox
dogBox.set(Hound()) // Puppy를 위한 공간

val dogHouse = Box<Dog>()
val box: Box<Any> = dogHouse
box.set("Some string") // Dog을 위한 공간
box.set(42) // Dog을 위한 공간
  • 이러한 상황은 안전하지 않음
  • 캐스팅 후 실질적인 객체가 그대로 유지되고, 타이핑 시스템에서만 다르게 처리되기 때문
    • Int를 설정하려고 하는데, 해당 위치는 Dog만을 위한 자리 -> 오류 발생
  • 코틀린은 public in 한정자 위치에 covariant 타입 파라미터(out 한정자)가 오는 것을 금지하여 이러한 상황을 막는다.
class Box<out T>
  var value: T? = null // 오류

  fun set(value: T) { // 오류
    this.value = value
  }

  fun get(): T = value ?: error("Value not set")
}
  • covariant(out 한정자)는 public out 한정자 위치에서도 안전하므로 따로 제한되지 않음
    • 이러한 안정성의 이유로 생성되거나 노출되는 타입에만 covariant(out 한정자)를 사용
    • 이러한 프로퍼티는 일반적으로 producer 또는 immutable 데이터 홀더에 많이 사용됨

  • 좋은 예로 T는 covariant인 List<T>가 있음
  • 함수의 파라미터가 List<Any?>로 예측된다면, 별도 변환 없이 모든 종류의 파라미터로 전달할 수 있음
  • MutableList<T>에서 T는 in 한정자 위치에서 사용되며, 안전하지 않으므로 invariant(무공변)임
fun append(list: MutableList<Any>) {
  list.add(42)
}

val strs = mutableListOf<String>("A", "B", "C")
append(strs) // 코틀린에서는 사용할 수 없는 코드
val str: String = strs[3]
print(str)
class Box<out T, in K>(val value: T) {
  fun set(value: K) {

  }
}

val boxStr: Box<String, Any> = Box("Str")
val boxAny: Box<Any, String> = boxStr

variance 한정자의 위치

  • 크게 두 위치에서 사용 가능
    1. 선언 부분
      • 일반적으로 이 위치에서 사용
      • 클래스와 인터페이스가 사용되는 모든 곳에 영향을 줌
// 선언 쪽의 variance 한정자
class Box<out T>(val value: T)
val boxStr: Box<String> = Box("Str")
val boxAny: Box<Any> = boxStr
    1. 클래스와 인터페이스를 활용하는 위치
    • 특정한 변수에만 variance 한정자가 적용됨
class Box<T>(val value: T)
val boxStr: Box<String> = Box("Str")
// 사용하는 쪽의 variance 한정자
val boxAny: Box<out Any> = boxStr
  • 모든 인스턴스에 variance 한정자를 적용하면 안 되고, 특정 인스턴스에만 적용해야 할 때 이런 코드를 사용
728x90
반응형