프로그래밍/Kotlin

이펙티브 코틀린 3장 재사용성

seungdols 2022. 10. 23. 21:47

3장 재사용성

아이템 19 knowledge(의도적인 정보)를 반복하여 사용하지 말라

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

프로젝트를 진행할 때 정의한 모든 것이 knowledge 이다. 해당 정보의 종류는 다양한데, 알고리즘의 작동방식, UI의 형태, 우리가 원하는 결과등이 모두 의도적인 정보이며, knowledge이다.

대표적인 knowledge의 두 가지

  1. Logic
  2. Common algorithm

모든 것은 변화하고, 변화에 대응을 할 수 있어야 한다. knowledge의 반복은 프로젝트의 확장성을 막고, 쉽게 깨지게 만든다. 그렇기 때문에 knowledge 반복을 줄여주는 도구의 사용이 필요하다.

그런 도구의 예로는 ORM, DAO를 활용 하는 방법이 될 수 있다.

반복 해도 되는 knowledge의 경우

두 코드가 같은 knowledge를 나타내는지? 다른 knowledge를 나타내는지는 "함께 변경 될 가능성이 높은가?", "따로 변경될 가능성이 높은가?"라는 질문으로 어느 정도 결정할 수 있다.

단일 책임 원칙

단일 책임 원칙이란, 클래스는 단 한가지의 변경점만 가지고 있어야 한다.

  • 서로 다른 곳에서 사용하는 knowledge는 독립적으로 변경할 가능성이 많다. 그렇기 때문에 완전히 다른 knowledge로 취급하는 것이 좋다.
  • 다른 knowledge는 분리해 두는 것이 좋다. 그렇지 않으면, 재사용해서는 안 되는 부분을 재사용 하려는 유혹이 발생할 수 있다.

아이템 20 일반적인 알고리즘을 반복해서 구현하지 말라.

val percent = when {
    numberFromUser > 100 -> 100
    numberFromUser < 0 -> 0
    else -> numberFromUser
}

위와 같은 알고리즘은 숫자를 특정 범위에 맞추는 것인데, 이런 알고리즘은 이미 내장 라이브러리에 있다.

val percent = numberFromUser.coerceIn(0, 100)

이미 있는 함수를 활용하면, 코드가 짧아지고, 함수 이름만으로도 무엇을 하는지 확실하게 알 수 있다.

stdlib은 확장 함수를 활용해서 만들어진 거대한 유틸리티 라이브러리이다. 그런데, 해당 함수들을 하나하나 살펴보는 것은 굉장히 어려울 수 있지만, 그럴만한 가치가 있는 일이다.

나만의 유틸리티 구현하기

표준 라이브러리에 없는 알고리즘이 필요할 수 있다. 예를 들어 컬렉션에 있는 모든 숫자의 곱을 계산하는 라이브러리가 필요하다면, 어떻게 해야 할까?

널리 알려진 추상화이므로 범용 유틸리티 함수로 정의하는 것이 좋다.

fun Iterable<Int>.product() = fold(1) { acc, i -> acc * i}

많이 사용되는 알고리즘 추출하는 방법으로는 top level function, 프로퍼티 위임, 클래스등이 있으나, 확장함수는 이런 방법들과 비교해 다음과 장점을 갖고 있다.

  • 함수는 상태를 유지 하지 않고, 행위를 나타내기 좋다.
  • 탑 레벨 함수와 비교해 확장 함수는 구체적인 타입이 있는 객체에만 사용을 제한 가능
  • 수정할 객체를 argument로 전달받아 사용하는 것보다는 확장 리시버로 사용하는 것이 가독성 측면에서 좋다.
  • 확장 함수는 객체에 정의한 함수보다 객체를 사용할때, 자동 완성 기능등으로 제안이 이루어지므로 쉽게 찾을 수 있다.

아이템 21 일반적인 프로퍼티 패턴은 프로퍼티 위임으로 만들어라.

코틀린은 코드 재사용과 관련하여 프로퍼티 위임이라는 새로운 기능을 제공한다. 프로퍼티 위임을 사용하면 일반적인 프로퍼티의 행위를 추출해서 재사용할 수 있다.

val value by lazy { createValue() }

프로퍼티 위임을 사용하면, 이외에도 변화가 있을때 이를 감지하는 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")}
private val presenter: MainPresenter by inject()
private val repository: NetworkRepository by inject()
private val vm: MainViewModel by viewModel()
var token: String? = null
    get() {
        print("token returned value $field")
        return field
    }
    set(value) {
        print("token changed from $field to $value")
        field = value
    }

var attempts: Int = 0
    get() {
        print("attempts returned value $field")
        return field
    }
    set(value) {
        print("attempts changed from $field to $value")
        field = value
    }

위의 프로퍼티는 타입은 다르지만, get,set의 형태가 유사하다. 이런 경우, 프로퍼티 위임을 활용하면 좋다. 단, 메소드의 이름을 getter의 경우 getValue, setter의 경우 setValue로 명명해야 한다.

var token: String? by LoggingProperty(null)
var 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
    }
}
val map: Map<String, Any> = mapOf(
    "name" to "Marcin",
    "koltin" to true
)
val name by map
println(name) // Marcin

코틀린 stdlib에서 다음과 같은 프로퍼티 델리게이터를 알아두면 좋다.

아이템 22 일반적인 알고리즘을 구현할 때 제네릭을 사용하라.

  • generic in/out
    • T (read/write) 무공변(invariance)
    • in (only write) 반공변(contravariance)
      • ? super T (java) - Lower Bound Wildcards
        • T와 T의 조상만 가능
    • out (only read) 공변(covariance)
      • ? extends T (java) - Upper Bound Wildcards
        • T와 T의 자손만 가능
    • start projection *

타입 파라미터는 컴파일러에 타입과 관련된 정보를 제공하여 컴파일러가 타입을 조금이라도 더 정확하게 추측할 수 있도록 한다.

inline fun <T> Iterable<T>.filter(
    predicate: (T) -> Boolean
): List<T> {
    val destination = ArrayList<T>()
    for (ele in this) {
        if (predicate(ele)) {
            destination.add(ele)
        }
    }
    return destination
}

제네릭 타입 제한

ref.

fun <T : Comparable<T>> Iterable<T>.sorted(): List<T> {

}

fun <T, C: MutableCollection<in T>> Iterable<T>.toCollection(destination: C):C {

}

class ListAdapter<T: ItemAdapter>(){}

Any 타입 제한 (nullable이 아닌 것을 명시)

inline fun <T, R: Any> Iterable<T>.mapNotNull(
    transform: (T) -> R?
): List<R> {
    return mapNotNullTo(ArrayList<R>(), transform) 
}

where를 통한 여러 파라미터의 타입 제한

fun <T: Animal> pet(animal: T) where T: GoodTempered {

}

fun <T> pet(animal: T) where T: Animal, T: GoodTempered {

}

하나 이상의 파라미터의 타입을 제한하고자 하면, where를 사용하여 두 개이상의 파라미터의 타입 제한을 명시할 수 있다.

아이템 23 타입 파라미터의 쉐도잉을 피하라

class Forest(val name: String) {
    // Don't
    fun addTree(name: String ) {

    }
}

위와 같은 코드가 있을때, 지역 파라미터가 외부 스코프에 있는 프로퍼티를 가린다. 이를 shadowing이라고 부른다.

해당 현상은 클래스 타입 파라미터와 함수 타입 파라미터 사이에서도 발생한다.

interface Tree
class Birch: Tree
class Spruce: Tree
class Forest<T: Tree> {
    fun <T: Tree> addTree(tree: T) {}
}

위와 같이 작성하면, Forest와 addTree의 타입 파라미터가 독립적으로 작동된다.

val forest = Forest<Birch>() // Forest<Tree>()이 아니니, 
forest.addTree(Birch())
forest.addTree(Spruce()) // 정상처리 

위 경우 이런 상황을 의도 했는지 아닌지 이해하기가 어렵다.

class Forest<T: Tree> {
    fun addTree(tree: T) {}
}

val forest = Forest<Birch>()
forest.addTree(BirCh())
forest.addTree(Spruce()) // Error 

만약, 독립적인 타입 파라미터를 의도했다면, 이름을 아예 다르게 지정하는 것이 좋다.

class Forest<T: Tree> {
    fun <ST: Tree> addTree(tree: ST) {}
}

아이템 24 제네릭 타입과 variance 한정자를 활용하라

class Cup<T>

위 코드에서 타입 파라미터 T는 variance 한정자(in / out)가 없으므로 기본적으로 invariance (불공변성)이다. invariance라는 것은 제네릭 타입으로 만들어지는 타입들이 서로 관련성이 없다는 의미다. Cup<Int>, Cup<Number>, Cup<Any>Cup<Nothing>은 어떠한 관련성도 갖지 않는다.

어떤 관련성을 원한다면, out 또는 in 이라는 variance 한정자를 붙인다. out은 타입 파라미터는 covariant (공변성)로 만든다.

이는 A가 B의 서브 타입일때, Cup<A>Cup<B>의 서브 타입이라는 의미이다.

공변

class Cup<out T>
open class Dog
class Puppy: Dog()

fun main(args: Array<String>) {
    val b: Cup<Dog> = Cup<Puppy>() // OK
    val a: Cup<Puppy> = Cup<Dog>() // 오류 -> 왜 오류남?
    val anys: Cup<Any> = Cup<Int>() // OK
    val nothings: Cup<Nothing> = Cup<Int> // 오류 -> 왜 오류남?
}

in 한정자는 반대 의미이다. in 한정자는 타입 파라미터를 contravariant(반변성)으로 만든다.
이는 A가 B의 서브타입일 때, Cup<A>Cup<B>의 슈퍼타입이라는 것을 의미한다.

반공변

class Cup<in T>
open class Dog
class Puppy: Dog()

fun main(args: Array<String>) {
    val b: Cup<Dog> = Cup<Puppy>() // 오류 -> Puppy 조상이 Dog은 맞음, 하지만, 타입에서 Dog의 조상은 Any만 가능..
    val a: Cup<Puppy> = Cup<Dog>() // OK
    val anys: Cup<Any> = Cup<Int>() // 오류
    val nothings: Cup<Nothing> = Cup<Int> // OK
}

함수 타입

함수 타입은 파라미터 유형과 리턴 타입에 따라서 서로 어떤 관계를 갖는다.

fun printProcessedNumber(transition: (Int) -> Any) {
    print(transition(42))
}

(Int) -> Any 타입의 함수는 (Int) -> Number, (Number) -> Any, (Number) -> Number, (Number) -> Int 등으로도 작동 한다.

val intToDouble: (Int) -> Number = { it.toDouble() }
printProcessedNumber(intToDouble)

val numberAsText: (Number) -> Any = { it.toShort() }
printProcessedNumber(numberAsText)

val identity: (Number) -> Number = { it }
printProcessedNumber(indentity)

val numberToInt: (Number) -> Int = { it.toInt() }
printProcessedNumber(numberToInt)

val numberHash: (Any) -> Number = { it.hashCode() }
printProcessedNumber(numberHash)

type hierarchy

계층 구조의 아래로 가면, 타이핑 시스템 계층에서 파라미터 타입이 더 높은 타입으로 이동하고, 리턴 타입은 계층 구조의 더 낮은 타입으로 이동한다.

코틀린 함수 타입의 모든 파라미터 타입은 contravariant (반공변-out 한정자) 이다. 또한 모든 리턴 타입은 covariant (공변-in 한정자) 이다.

(T[1in], T[2in]) -> T(out)

함수 타입을 사용할 때는 이처럼 자동으로 variance 한정자가 사용된다.

variance 한정자의 안정성

Java의 배열은 covariant이다. 이유는 많지만, 이 문제로 인하여 큰 문제가 발생한다.

Integer[] numbers = {1, 4, 2, 1};
Object[] objects = numbers;
objects[2] = "B" // ArrayStoreException

위 코드가 문제가 되는 이유는 Object로 캐스팅해도 구조 내부에서 사용되고 있는 실질적인 타입이 바뀌지 않는다.

코틀린은 이런 결함을 해결하기 위해 Array를 invariant로 만들었다.

Array<Int> -> Array<Any>로 바꿀 수 없다.

open class Dog
class Puppy: Dog()
class Hound: Dog()

fun takeDog(dog: Dog) {}

takeDog(Dog())
takeDog(Puppy())
takeDog(Hound())

파라미터 타입을 예측할 수 있다면, 어떤 서브타입이라도 전달할 수 있다. argument를 전달할때, 암묵적으로 업캐스팅 할 수 있다.
이는 covariant 하지 않고, covariant 타입 파라미터 (out 한정자)가 in 한정자 위치에 있다면, covariant와 업캐스팅을 연결해서 우리가 원하는 타입을 아무것이나 전달 할 수 있다.

위 설명이 도대체 무엇을 말하는지 이해가 어렵다... 나중에 다시 보자.

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()) // Hound()를 넣을 수 없음.

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")  
}
class Box<out T> {  
    // private로 제한하면 오류가 발생하지 않게 되며, 객체 내부에서는 업캐스트 객체에 covariant를 사용할 수 없기 때문이다.
    private 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 한정자)를 사용하는 것이다.

sealed class Response<out R, out E>
class Failure<out E>(val error: E): Response<Nothing, E>()
class Success<out R>(val value: R): Response<R, Nothing>()

covariant와 public in 위치와 같은 문제는 contravariant 타입 파라미터와 public out 위치에서도 발생한다. out 위치는 암묵적인 업캐스팅을 허용한다.

open class Car
interface Boat
class Amphibious: Car(), Boat

fun getAmphibious(): Amphibious() // Error

val car: Car = getAmphibious()
val boat: Boat = getAmphibious()

사실 이는 contravariant에 맞는 동작이 아니다.

open class Car
interface Boat
class Amphibious: Car(), Boat
class Box<in T>(
    val value: T // Error
)

val garage: Box<Car> = Box(Car())
val amphibiouseSpot: Box<Amphibious> = garage
val boat: Boat = garage.value 

val noSpot: Box<Nothing> = Box<Car>(Car())
val boat: Nothing = noSpot.value

코틀린은 contravariant 타입 파라미터를 public out 한정자 위치에 사용하는 것을 금지하고 있다.

class Box<in T> {
    var value: T? = null

    fun set(value: T) {
        this.value = value
    }
    fun get(): T = value ?: error("value not set")
}
class Box<in T> {  
    private var value: T? = null  

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

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

타입 파라미터에 contravariant(in 한정자)를 사용한다.

가장 많이 사용 되는 예시)

public interface Continuation<in T> {
    public val context: CoroutineContext
    public fun resumeWith(result: Result<T>)
}

variance 한정자의 위치

  • 클래스와 인터페이스 선언 부분
  • 클래스와 인터페이스를 활용하는 부분
class Box<out T>(val value: T)

class Box<T>(val value: T)
val boxString: Box<String> = Box("str")
val boxAny: Box<out Any> = boxString

모든 인스턴스에 variance 한정자를 적용하면 안되고, 특정 인스턴스에만 적용해야 할 때 위와 같은 코드를 사용한다.

  • MutableList는 in 한정자를 포함하면, 요소를 리턴할 수 없으므로 in 한정자를 붙이지 않음
  • 단일 파라미터 타입에 in 한정자를 붙여서 contravariant를 가지게 하는 것은 가능
    interface Dog
    interface Cutie
    data class Puppy(val name: String): Dog, Cutie
    data class Hound(val name: String): Dog
    data class Cat(val name: String): Cutie
    

fun fillWithPuppies(list: MutableList) {
list.add(Puppy("Jim"))
list.add(Puppy("Beam"))
}

val dogs = mutableListOf(Hound("pluto"))
fillWithPuppies(dogs)
println(dogs)

val animals = mutableListOf(Cat("Felix"))
fillWithPuppies(animals)
println(animals)


variance 한정자 사용을 하면, 위치가 제한 될 수 있다. 

- `MutableList<out T>`는 get 사용가능, set은 Nothing 타입의 argument가 전달될거라고 추측 되어 사용할 수 없다. 모든 타입의 서브 타입을 가진 리스트(Nothing 리스트)가 존재할 가능성이 있기 때문이다. 
- `MutableList<in T>`는 get/set 모두 사용 가능, get의 경우 argument의 타입은 Any?가 된다. 모든 타입의 슈퍼 타입을 가진 리스트(Any 리스트)가 존재할 가능성이 있기 때문이다.
-  이 말은 이해한 것은 결국 위에서 타입 시스템의 계층을 기억해야 이해가 쉽다. 갑자기 Nothing이나, Any?가 나온 이유는 타입 시스템의 계층 구조에 따라 제한을 했으면, 해당 제한의 최상위, 최하위 타입도 가능하기 때문이다.

### 결론 

- 타입 파라미터의 기본적인 variance의 동작은 invariant 이다. 
- out 한정자(covariant) - 공변
    - `A -> B, Cup<out T> = Cup<A>는 Cup<B>의 서브타입`
- in 한정자 (contravariant) - 반공변
    - `A -> B, Cup<B>는 Cup<A>의 슈퍼타입` 
- List/Set, Map의 값 타입 파라미터 covariant (out 한정자-공변)
- Array, MutableList, MutableSet, MutableMap의 타입 파라미터는 invariant(불공변)
- 함수 타입의 파라미터 타입은 contravariant(in 한정자), 리턴 파라미터 타입은 covariant(out 한정자)
- 리턴만 되는 타입에는 covariant(out) 사용
- 허용만 되는 타입에는 contravariant(in) 사용

## 아이템 25 공통 모듈을 추출해서 여러 플랫폼에서 재사용하라 

코틀린 코드 
- 서버 코틀린/JVM
- 브라우저 코틀린/JS
- 안드로이드
- ios

코틀린 코드는 브라우저와 서버에서 사용할 수 있다는 장점이 있다. 최근에는 ktor 라는 백엔드 서버 프레임워크를 많이 사용하는 추세이다. 

### 함께 사용하기 

- kotlin/JVM을 사용한 백엔드 개발 - spring, ktor
- kotlin/JS를 사용한 웹사이트 개발 - react
- kotlin/android - android sdk
- kotlin/native - object-c/swift로 변환 
- kotlin/JVM desktop - tornadoFX
- kotlin/native - 라즈베리파이, 리눅스, macos 프로그램 개발 
반응형