Effective Kotlin #5 객체 생성

yeji·2022년 12월 2일
0

Effective Kotlin

목록 보기
5/7

코틀린의 코드는 순수 함수형 스타일로 작성할 수도 있지만, 자바처럼 객체 지향 프로그래밍(OOP) 스타일로도 작성할 수 있다.
OOP는 객체를 생성해서 사용하므로, 객체를 생성하는 방법을 정의해야 한다.
객체를 생성하는 방법에 따라서 여러 가지 다른 특징이 생긴다. 따라서 어떤 생성 방법들이 있는지 알아야 한다.

  • 기본 생성자 사용(primary constructor)
  • factory function
  • DSL

kotlin은 java와 굉장히 비슷하지만, 세부적인 부분에서 큰 차이가 있다.

  • kotlin은 정적 메서드를 사용할 수 없다.
    • 그래서 일반적으로 톱레벨 함수와 companion 객체 함수 등을 대신 활용한다.

item 33. 생성자 대신 팩토리 함수를 사용하라

클래스의 인스턴스를 만드는 가장 일반적인 방법은 기본 생성자(primary constructor)를 사용하는 것이다.

class MyLinkedList<T>(
  val head: T, val tail: MyLinkedList<T>?
)
val list = MyLinkedList(1, MyLinkedList(2, null))

팩토리 함수

  • 생성자의 역할을 대신 해주는 함수를 팩토리 함수

팩토리 함수 장점

  • 함수에 이름을 붙일 수 있다.
  • 함수가 원하는 형태의 타입을 리턴할 수 있다. (ex listOf 함수)
  • 호출될 때마다 새 객체를 만들 필요가 없다. (ex 싱글턴 패턴)
    존재하지 않는 객체를 리턴할 수도 있다.
  • 인라인으로 만들 수 있으며, 그 파라미터들을 reified로 만들 수 있다.
    - reified는 inline에서만 함수를 정의가능 (정의 방법 : Generics의 타입 T 앞에 reified 키워드를 써주면 된다)
    - reified 키워드를 사용하는 이유 : 타입 T는 컴파일 타임에는 존재하지만 런타임에는 Type erasure 때문에 접근할 수 없다. 따라서 일반적인 클래스에 작성된 함수 body에서 제네릭 타입에 접근하고 싶다면 genericFunc 처럼 명시적으로 타입을 파라미터로 전달해주어야 한다.
    생성자는 즉시 슈퍼클래스 또는 기본 생성자를 호출해야 하지만 팩토리 함수는 원하는 때에 생성자를 호출 할 수 있다.

팩토리 함수 종류
1. companion 객체 팩토리 함수
자바 에서는 정적 팩토리 함수와 같다.
경험 없는 코틀린 개발자들은 companion 객체 멤버를 단순히 정적 멤버처럼 다루지만 인터페이스 구현, 클래스를 상속받을 수도 있다.
2. 확장 팩토리 함수
companion 객체가 존재할 때, 객체의 함수처럼 사용할 수 있는 팩토리 함수를 만들어야 할 때 사용.
3. 톱레벨 팩토리 함수
대표적인 예 listOf, setOf, mapOf
안드로이드에서는 액티비티를 시작하기 위해서, 인텐트를 만드는 함수를 정의해서 사용.
4. 가짜 생성자
진짜 생성자 대신에 가짜 생성자를 만드는 이유
인터페이스를 위한 생성자를 만들고 싶을 때
reified 타입 아규먼트를 갖게 하고 싶을 때
위를 제외하면, 가짜 생성자는 진짜 생성자처럼 동작해야 한다.
5. 팩토리 클래스의 메서드
팩토리 클래스는 프로퍼티를 가질 수 있다. 이를 활용하면 다양한 종류로 최적화하고, 다양한 기능을 도입할 수 있다.

item 34. 기본 생성자에 이름있는 옵션 아규먼트를 사용하라

기본생성자로는 다양한 설정을 하기 어렵습니다. 그래서 자바에서는 아래와 같은 방법을 생성자 대신 사용해서 구현하곤 합니다.

생성자를 오버로딩하는 Telescoping constructor pattern
내부에 Builder 객체를 만들어, 메서드 체이닝을 구현하는 Buidler pattern
사실 코틀린에서는 둘 다 필요 없습니다. 그 이유에 대해 알아봅시다.

Java - Telescoping constructor pattern (점층적 생성자 패턴)
생성자를 여러 개 만들어서 재사용(오버로딩)하는 방법입니다.

class Pizza{
val size: String
val cheese: Int
val olives: Int
val bacon: Int

constructor(size: String, cheese: Int, olives: Int, bacon: Int){
    this.size = size
    this.cheese = cheese
    this.olives = olives
    this.bacon = bacon
}

constructor(size: String, cheese: Int, olives: int) : this(size, cheese, olives, 0)
constructor(size: String, cheese: Int): this(size, cheese, 0)
constructor(size: String): this(size, 0)

}

Java - Builder Pattern
내부 클래스 Builder 를 이용하여 메서드 체이닝으로 만드는 방법입니다.

// 생성자를 private로 잠궈 Builder 사용을 강제할 수 있다.
class Pizza private constructor(
val size: String,
val cheese: Int = 0,
val olive: Int = 0,
val bacon: Int = 0
) {
class Builder(private val size: String){ // 1. 필수 값은 생성자로 받는다.
private var cheese: Int = 0
private var olive: Int = 0
private var bacon: Int = 0

    // 2. 각 함수는 Builder 를 반환해서, 메서드를 체이닝 할수있게 한다.
    fun setCheese(value: Int): Builder = apply{
        cheese = value
    }

    fun setOlive(value: Int): Builder = apply{
        olive = value
    }

    fun setBacon(value: Int): Builder = apply{
        bacon = value
    }
    
    // 3. 마지막 Builder.build()를 호출하면, 기본 생성자로 해당 객체를 만든다. 
    fun build() = Pizza(size, cheese, olive, bacon)
}

}
val villagePizza = Pizza.Builder("L")
.setCheese(1)
.setOlive(2)
.setBacon(3)
.build()

Kotlin - 너무나 간단한 해결책, default arguments
네, 위의 2가지는 기본 인자를 이용하면 언어 문법차원에서 해결이 가능합니다.

코드가 짧다
더 다양한 생성자를 제공해준다.
언어 문법적인 기능이라, 다양한 곳에서 활용할 수 있다.
동시성 문제에서도 안전하다. (코틀린의 함수 파라미터는 항상 immutable 이다)
class Pizza(
val size: String,
val cheese: Int = 0,
val olives: Int = 0,
val bacon: Int = 0
)

val myPizza = Pizza("L", bacon=3)

Builder 패턴은 종종 쓰이지 않나요?
물론, Builder 패턴을 사용하면 메서드 체이닝을 이용할 수 있기에, 아래와 같이 사용할 수 있습니다.

val dialog = AlertDialog.Builder(context)
.setMessage(R.string.fire_missiles)
.setFireButton(R.string.fire, {id -> /../})
.setCancelButton(R.string.cancel, {id -> /../})
.create()

val router = Router.Builder()
.addRoute(path= "/home", ::showHome)
.addRoute(path= "/users", ::showUsers)
.build()

이러한 코드는 코틀린에서 권장하지 않습니다.
코틀린 문법을 이용한 DSL(Domain Specific Language)로 대체할 수 있기 때문입니다.

물론 DSL 구현이 더 번거롭지만, 더 유연하고 가독성이 좋은 코드를 만들 수 있습니다.
var dialog = context.alert(R.string.fire_missiles) {
fireButton(R.string.fire) { /../ }
cancleButton { /../ }
}

val route = router {
"/home" directTo :: showHome
"/users" directTo :: showUsers
}

결론적으로 아래와 같은 특별한 경우를 제외하고는 코틀린에서 빌더 패턴을 사용할 일은 없습니다.

빌더 패턴을 사용하는 다른 언어로 작성된 라이브러리를 그대로 옮길 때
Default arguments, DSL 을 지원하지 않는 언어에서 사용하는 코틀린 API를 설계할 때

item 35. 복잡한 객체를 생성하기 위한 DSL을 정의하라

코틀린 문법을 이용한 DSL(Domain Specific Language)로 대체할 수 있기 때문입니다.

var dialog = context.alert(R.string.fire_missiles) {
fireButton(R.string.fire) { /../ }
cancleButton { /../ }
}

val route = router {
"/home" directTo :: showHome
"/users" directTo :: showUsers
}

Andriod View DSL (Anko 라이브러리)
verticalLayout{
val name = editText()
button("Say Hello"){
onClick { toast("Hello, ${name.text}!")} // toast popup view
}
}

Test DSL
class MyTests: StringSpec({
"반환되는 길이는 String의 크기이어야 합니다." {
"test string".length shouldBe 5
}
"startsWith 함수는 prefix를 반환해야 합니다."{
"world" should startWith("wor")
}
})

Gralde DSL
plugins {
'java-library'
}

dependencies{
api("junit:junit:4.12")
implementation("junit:junit:4.12")
testImplementation("junit:junit:4.12")
}

configurations{
implementation{
resolutionStrategy.failOnVersionConflict()
}
}
코틀린 DSL은 타입에 안전하므로 type-safe 컴파일 타임이나 IDE에서 여러가지 힌트를 얻을 수 있습니다. 대부분의 경우 이미 누군가 만들어둔 DSL을 사용할테지만, 필요한 경우 직접 만들어도 됩니다.

사용자 정의 DSL 만들기
사용자 정의 DSL를 만드려면, 먼저 리시버를 사용하는 함수를 이해해야 합니다.
코틀린에 익숙하지 않다면 이해하기 어려울 수 있으니, 하나씩 차근차근 알아봅시다.

inline fun Iterable.filter(predicate: (T) -> Boolean): List {
val list = arrayListOf()

for (elem in this){
    if (predicate(elem)){ list.add(elem) }
}
return list

}

  1. 함수의 타입
    코틀린에서는 함수를 인자로 받고, 반환할 수 있습니다. 그래서 아래와 같은 함수 타입이 존재합니다

()->Unit : 인자가 없고, Unit을 리턴합니다. (반환값 없음)
(Int)->Unit : Int 인자를 받고, Unit을 리턴합니다. (반환값 없음)
(Int)->Int : Int 인자를 받고, Int를 리턴합니다.
(Int,Int)->Int : Int, Int 총 2개를 받고, Int를 리턴합니다
(Int)->()->Unit : Int 인자를 받고, ()->Unit 타입인 다른 함수를 리턴합니다.
(()->Unit)->Unit : ()->Unit 타입인 함수를 인자로 받고, Unit을 리턴합니다. (반환값 없음)

  1. 함수를 만드는 법
    함수 타입은 람다 표현식 익명 함수 함수 레퍼런스를 이용해서 쉽게 만들 수 있습니다.

fun plus(a: Int, b: Int) = a + b // 함수
// val name: type = ...
val plusLambda: (Int, Int)->Int = {a,b -> a + b}
val plusAnonymous: (Int, Int)->Int = fun(a,b) = a + b
val plusReference: (Int, Int)->Int = ::plus
물론 타입 정보는 코틀린의 타입추론을 이용해 생략할 수 있습니다.

val plusLambda = {a: Int, b: Int -> a + b}
val plusAnonymous = fun(a: Int, b: Int) = a + b

  1. 확장함수의 타입 (리시버를 가진 함수타입)
    코틀린에는 확장함수가 존재합니다. 확장함수는 아래와 같이 만들 수 있습니다.

fun Int.myPlus(other: Int) = this + other // 확장함수
val myPlus: Int.(Int)->Int = fun Int.(other: Int) = thie + other
val myPlus = fun Int.(other: Int) = thie + other // 타입 생략
이러한 확장함수의 타입을 리시버를 가진 함수 타입이라고 부릅니다. 확장함수에서 리시버는 this 키워드를 이용해서 참조할 수 있습니다.

val myPlus: Int.(Int)->Int = { this + it } // { a -> this + a }

  1. (리시버를 가진 함수타입)을 활용하는 방법
    영어로는 Lambda with Receiver 라고 부릅니다.

inline fun T.apply(block: T.() -> Unit): T {

this.block() // 인자로 받은 확장함수를 적용시킵니다.

return this // 리시버 (ex User.apply() 라면 User)를 반환합니다.

}
// 확장함수를 Lambda로 구현하여 인자로 전달합니다.
val userLambda = User().apply({ this.name = "Marcin"; this.surname = "Jiwon"})

// 코틀린에서는 마지막 인자가 함수타입이면, 이렇게 따로 표기할 수 있습니다.
val user = User().apply {
this.name = "Marcin"
this.surname = "jiwon"
}

// 리시버(this)는 생략할 수 있습니다.
val user2 = User().apply {
name = "Marcin"
surname = "Jiwon"
}

  1. DSL 만들기

fun createTable(): TableDsl = table { // table(...) -> TableBuilder 함수
tr{ // TableBuilder.tr(...) 함수
for(i in 1..2){
td{ // TrBuilder.td(...) 함수
+"This is column $i"
}
}
}
}
이는 톱 레벨에 table() 함수를 정의하고, 아래와 같은 클래스 구조를 가지게 하면 됩니다.

fun table(init: TableBuilder.()->Unit): TableBuilder {
val tableBuilder = TableBuilder()
init.invoke(tableBuilder)

return tableBuilder

}

class TableBuilder {
var trs = listOf()
fun tr(init: TrBuilder.()->Unit) { /../ }
}

class TrBuilder {
var tds = listOf()
fun td(init: TdBuilder.()->Unit) { /../ }
}

// + "This is Column" 는 이렇게 구현하면 됩니다.
class TdBuilder{
var text = ""
operator fun String.unaryPlus() {// "+" 연산자 오버로딩
text += this
}
}
그리고 원하는 동작을 구현하면 됩니다.

// apply 함수를 이용하면, 간단하게 만들 수 있습니다.
fun table(init: TableBuilder.()->Unit): TableBuilder = TableBuilder().apply(init)

class TableBuilder {
var trs = listOf()

fun td(init: TrBuilder.()->Unit){
    trs = trs + TrBuilder().apply(init)
}

}

class TrBuilder {
var tds = listOf()

fun td(init: TdBuilder.()->Unit) {
    tds = tds + TdBuilder().apply(init)    
}

}

class TdBuilder{
var text = ""

operator fun String.unaryPlus() {// "+" 연산자 오버로딩
    text += this
}

}

DSL의 단점
DSL을 구현하면 사용법이 간단해지고, 코드 가독성이 올라갑니다. 하지만 개발자 입장에서 내부적으로 어떻게 동작하는지 파악하기는 매우 어렵습니다.

그래서 단순한 기능에 굳이 DSL을 사용하는건 닭 잡는 데 소 잡는 칼을 쓰는 격 입니다.
아래와 같은 상황에 적절하게 활용하는 것을 권장합니다.

복잡한 자료 구조
계층적인 구조
거대한 량의 데이터

profile
🐥

0개의 댓글