시배's Android

Effective Kotlin | 5장. 객체 생성 본문

Book/Effective Kotlin

Effective Kotlin | 5장. 객체 생성

si8ae 2024. 3. 11. 21:15

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

클라이언트가 클래스의 인스턴스를 만들게 하는 가장 일반적인 방법은 기본 생성자(Primary constructor)를 사용하는 방법이다.

class MyLinkedList<T>{
    val head : T
    val tail : MyLinkedList<T>?
}

val list = MyLinkedList(1, MyLinkedList(2, null))

하지만 생성자가 객체를 만들수 있는 유일한 방법은 아니다.

헬퍼 클래스를 생각해보자.

fun <T> myLinkedListOf(

): MyLinkedList<T>? {
 if(elements.isEmpty()) return null
 val head = elements.first()
 val elementsTail = elements.
 copyOfRange(1, elements.size)
 val tail = myLinkedListOf(*elementsTail)
 return MyLinkedList(head, tail)
}

val list = myLinkedListOf(1, 2)

생성자의 역할을 대신 해주는 함수를 팩토리 함수라고 부른다.

생성자 대신 팩토리 함수를 사용하면 생기는 장점들이다.

  • 생성자와 다르게 함수에 이름을 붙일 수 있다.
    예를들어 ArrayList(3) 일때, 무엇을 의미하는지 알수 없는데 ArrayList.withSize(3)이라는 이름이 붙어있다면 훨씬 유용할 것이다.
  • 함수가 원하는 형태의 타입을 리턴할 수 있다.
    인터페이스 뒤에 실체 객체 구현을 숨길때 유용하게 사용할 수 있다.
    listOf는 List 인터페이스를 리턴하는데, 플랫폼에 따라 다른 빌트인 컬렉션으로 만들어진다.
    또한 인터페이스를 리턴하기때문에, 인터페이스를 지켜서 만들어진다면 어떤 클래스라도 잘동작할 수 있다.
  • 새 객체를 만들 필요가 없다.
    싱글턴 패턴처럼 객체를 하나만 생성하게 강제하거나, 캐싱 메커니즘을 사용할수도 있다.
    또한 객체를 만들수 없을 경우, null을 리턴하게 만들수도 있다.
    예를 들어 Connction.createOrNull()은 연결이 생성될수 없을때 null을 리턴한다.
  • 아직 존재하지 않는 객체를 리턴할 수 있다.
    프로젝트를 빌드하지않고도, 앞으로 만들어질 객체를 사용하거나 프록시를 통해 만들어지는 객체를 사용할 수도 있다.
  • 객체 외부에 팩토리 함수를 만들면, 그 가시성을 원하는대로 제어할 수 있다.
    같은 모듈에서만 접근하게 할수 있다던가..
  • 팩토리 함수는 인라인으로 만들 수 있으며, 파라미터들을 refied로 만들수 있다.
  • 팩토리 함수는 생성자로 복잡한 객체도 만들어 낼수 있다.
  • 생성자는 즉시 슈퍼클래스나, 기본생성자를 호출해야하지만, 팩토리 함수는 원하는때에 생성자를 호출할 수 있다.

fun makeListView(config : Config) : ListView{
val items = ... // config로부터 요소를 읽어 들인다.

return ListView(items) // 진짜 생성자를 호출한다.

}

다만 서브 클래스 생성에는 슈퍼클래스의 생성자가 필요하기 때문에, 서브 클래스를 만들어 낼수 없다.


class IntLinkedList :MyLinkedList<Int>(){

 construce(vararg ints : Int) : myLinkedListOf(*ints)
 //오류 발생 

}

하지만, 팩토리 함수로 슈퍼 클래스를 만들기로 햇다면, 서브클래스에도 팩토리 함수를 만들면된다.

class MyLinkedIntList(head : Int , tail : MyLinkedIntList?) : MyLinkedList<Int>(head, tail)

fun myLinkedIntListOf(vararg elements : Int) : MyLinkedIntList? {
if(elements.is Empty()) return null

val head = elements.first()

val elemenstTail = elemnts.copyOfRange(1, elements.size)

val tail = myLinkedIntListOf(*elementsTail)

return MyLinkedIntList(head, tail)


}

이전 생성자보다 길지만, 유연성, 클래스 독립성, nullable을 리턴하는 다양한 특징 갖는다.

팩토리 함수는 굉장히 강력한 객체 생성방법중 하나이지만, 기본생성자를 사용하지 말라는 얘기가 아니다.

팩토리 함수는 기본생성자가 아닌 추가적인 생성자(secondary constructor)와 경쟁관계이다.

팩토리 함수에는 어떤것들이 있는지 알아보자.

Compaion 객체 팩토리 함수

팩토리 함수를 정의하는 가장 일반적인 방법은 Companion 객체를 사용하는것이다.

 class MyLinkedList<T>(
     val head : T,
    val tail : MyLinkedList<T>?
 ){
     companion object{
     fun <T> of(vararg elements : T) : MyLinkedList<T>? {

     }
    }
 }

 val list = MyLinkedList.of(1,2)

다른 프로그래밍은 이와같은 방식은 이름을 가진 생성자 라고 표현한다.

코틀린에서는 인터페이스에서도 이러반 접근방법을 구현할수 있다.

 class MyLinkedList<T>(
     val head : T,
    val tail : MyLinkedList<T>?
 ){

 }
interface MyList<T>{

     companion object{
     fun <T> of(vararg elements : T) : MyLinkedList<T>? {}
   }
 }

 val list = MyLinkedList.of(1,2)

이외에도 다음과 같은 이름을 많이 사용한다.

  • from
    파라미터를 하나 받고, 같은 타입의 인스턴스를 리턴하는 타입변환 함수
    val data: Data = Date.from(instant)
  • of
    파라미터를 여러개 받고, 인스턴스를 만들어 주는 함수
    val faceCards : Set<Rank> = EnumSet.of(JACK , QUEEN , KING)
  • valueOf
    of와 비슷한 기능을 하면서도 의미를 쉽게 읽을수 있게 이름을 붙인 함수
    val prime :BigInteger = BigInteger.valueOf(Integer.MAX_VALUE)
  • instacne 또는 getInstance
    싱글턴으로 인스턴스 하나를 리턴하는 함수이다.
    val StackWalker = StackWalker.getInsetance(option)
  • createInstance 또는 newInstance
    함수를 호출할때마다, 새로운 인스턴스를 반환한다.
    val newArray.newInstance(classObject , arrayLeen)
  • getType
    getInstance처럼 작동하지만, 팩토리함수가 다른 클래스에 있을때 사용한다.
    타입은 팩토리 함수에서 리턴하는 타입이다.
    val fs : FileStore = File.getFilesStore(path)
  • newType
    newInstance처럼 작동하지만, 팩토리함수가 다른 클래스에 있을때 사용한다.
    타입은 팩토리 함수에서 리턴하는 타입이다.
    val br : BuffedReader = File.newBufferedReader(path)

경험이 없는 코틀린 개발들은 companion 개체를 단순한 정적 멤버처리 처럼 다루는경우가 많으나,
companion 객체는 인터페이스를 구현할 수 있고, 클래스를 상속받을 수 있다.

abstract class ActivityFactor{
    abstract fun getIntent(context: Cotnext) : Intent

    fun start(context : Context)[
        val intent = getIntent(context)
        context.startActivity(intent)
    }

}

class MainActivity : xxx{

companion object : ActivityFactory(){
    overide fun getIntext(cotnext: Context) Intent =
    Intent(context ,MainActivity::class.java
    }
}

//사용

val intent = MainActivity.getIntent(context)
MainActivity.start(context)
MainAcitivity.startForResult(activity, requestCode)

또한 compainon 객체 팩토리는 값을 가질수 있어서 캐싱을 구현하거나, 가짜 객체를 생성할 수 있다.

확장 팩토리 함수

compainon 객체가 존재할때, 다른 파일에서 해당 객체의 companion 객체를 만들면 어떻게 해야할까?

다음과 같이 Tool 인터페이스가 있다.

interface Tool{
    compainon obejct{}
} 

아래와 같이 companion 객체를 활용해서 확장함수를 정의할 수 있다.

fun Tool.Compainon.createBigTool : BigTool{

}

다만 compainon객체를 확장하려면, 적어도 비어있는 컴패니언 객체가 필요하다.

가짜 생성자

코틀린의 생성자는 톱레벨 함수와 같은 형태로 사용된다.

class A
val a = A()

그래서 다음과 같이 톱 레벨 함수처럼 참조될 수 있다.

일반적으로 대문자로 시작하는지 아닌지는 생성자와 함수를 구분하는 기준이다.

만약 쓰인다면 다른 용도로 사용된다.

List와 MutableList는 인터페이스이기에 생성자를 가질수 없지만, List를 생성자처럼 사용하는 코드를 보았을것이다.

public inline fun<T> MutableList(
 size: Int,
 init: (index: Int) -> T
) : MutavleList<T> {
    val list = ArrayList<T>(size)
    repeat(size) {index -> list.add(init(index)}
    retirm list
}

이러한 톱레벨 함수는 생성자처럼 보이고 생성자 처럼 작동한다.
하지만 팩토리함수와 같은 모든 장점을 갖는다.

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

객체를 정의하고 생성하는 가장 기본적인 방법은 기본 생성자(primary constructor)를 사용하는것이다.

class User(var name : String , var surname : String)
val user = User("Monkey", "D 루파")

기본생성자를 활용해서 객체를 만드는것이 좋다.

데이터 클래스의 객체를 생성하거나, 객체의 종속성을 주입할수도 있다.

//데이터 모델을 데터 클래스로 사용하면, 프로젝트 내부의 데이터로 사용된다는것을 명확하게 알수있고,
//데이터로 활용할때, 유용한 몇가지 함수를 제공한다.
data class Student(
val name : String,
val surname : String,
val age : Int
)
class QuotationPresenter(
 private val view: QuotationView,
 private val repo: QutationRepository
){
// 기본생성자 선언된거 외에도 더많은 프로퍼티를 갖고있지만 생성자로 OK.
    private var nextQuoteId = -1

    fun onStart(){
         onNext()
    }

    fun onNext() {
         nextQuoteId = (nextQuoteId + 1) % repo.quotoNumber
         val quoto = repo.getQuote(nextQuoteId)
        view.showQuoto(quote)
    }
}

기본생성자가 좋은 방식인 이유를 이해하려면, 아래와 관련된 자바패턴을 이해하는것이 좋다.

  • 점층적 생성자 패턴
  • 빌더 패턴

점층적 생성자 패턴

점층적 생성자 패턴이란 '여러가지 종류의 생성자를 사용하는' 아주 간단한 패턴이다.

class Pizza{
    val name : String
    val cheese : Int
    val olview : 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, 0)
constructor(size: String , cheese : Int): this (size, 0 )

위와같이 사용하는것은 좋은코드가 아니다.

코틀린에서는 디폴트 아규먼트를 사용한다.

class Pizza(
    val size: String,
    val chees : Int = 0
    val olives : Int = 0
    val bacon :Int = 0

)

디폴트 아규먼트를 사용할경우, 장점이 생기는데

  • 파타미터들의 값을 원하는대로 지정가능
  • 아규먼트를 원하는 순서로 지정 가능
  • 명시적으로 이름을 붙일수 있다.
val villagePizza = Pizza(
 size = "L"
 cheese = 1,
 olives = 2,
 bacon = 3
)

빌더패턴

자바는 코틀린과 달리 디폴트 아규먼트나 이름있는 파라미터를 사용할수 없다.
그래서 자바에서는 빌더 패턴을 사용한다.

  • 디폴트값을 지정가능
  • 아규먼트를 원하는 순서로 지정 가능
  • 파라미터에 명시적으로 이름을 붙일수 있다.
class Pizza private constructor(
    val name : String
    val cheese : Int
    val olives : Int
    val bacon : Int
){
    class Builder(private size : String){
        private var cheese : Int = 0
        private var olives : Int = 0

    fun setChesse(value :Int) : Builder = apply {chees = value}
    fun setOlvies(value :Int) : Builder = apply {chees = value}

       fun build () = Pizza(size, chees, olives, bacon)
    }


}

빌더 패턴보다 디폴트 파라미터와 이름있는 파라미터를 사용하는것이 좋은이유는 무엇일까?

  • 더 짧다.
    읽는입장에서도 읽기 쉽고, 구현하는입장에서 빌더패턴은 구현하는데 시간이 많이걸린다.
  • 더 명확하다.
    거대하게 빌더패턴으로 만들어진 객체는 어떤 추가적인 처리를 하는지 이해하기 어렵다.
  • 사용하기 쉽다.
    빌더패턴은 언어위체 추가로 구현한 개념이므로 추가적인 공부가 필요하다.
  • 동시성과 관련한 문제가 없다.
    빌더 패턴은 일반적인 mutable이므로 thread Safety 하게 구현하기 어렵다.

그렇다면 빌더패턴이 좋은 경우를 간단하게 실펴보자.
빌더 패턴은 값의 의미를 묶어서 지정할수도 있고, 특정값을 누적시킬수도 있다.

//값의 의미를 묶을수 있다.
val dialog = AlertDialog.Builder(context)
.setMassage(R.string.Fire_missiale)
.setPositiveButton(R.string.fire , {d, id ->
    //미사일 발사
})

//addRoute를 누적
val router = Router.Builder()
.addRoute(path = "/home" , ::showHome)
.addRoute(path = "/user" , ::showUser)

빌더패턴을 사용하지 않을경우, 추가적인 타입을 만들어야하기때문에 코드가 복잡해진다.

일반적으로 이런코드는 DSL 빌더를 사용한다.

val dialog = context.alret(R.string.fire_missiles){
 positveButton(R.stirng.fire){

 }
 negativeButton(){

 }
}

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

}

이렇게 DSL 빌더를 활용하면, 빌더패턴보다 훨씬 유연하고 명확하다.

또 빌더패턴의 장점으로는 팩토리를 사용할 수 있다.

fun Context.makeDefaultDialogBuilder = AlertDialog.Builder(this)
    .setIcon(R.drawable.ic_Dialog)
    .setTitle(R.string.dialog_title)
    .setOnCancelListenr{it.cancel}

팩토리를 사용하려면, 커링(currying)을 사용해야하나, 코틀린은 커링을 지원하지 않는다.

대신 객체를 데이터클래스로 만들고, 이를 copy로 복제해서 필요한 설정을 수정해서 사용한다.

data class DialogConfig(
    val icon: Int = -1,
    val title: Int = -1,
    val onCancelListenr: (() -> Unit)? =null
)

fun makeDefualtDialogCOnfig() = DialogConfig(
    icon = R.drawble.ic_dialog,
    title = R.string.dialog_title,
    onCancelListener = {it.cancel() }
)

이는 실무에서 보기어 어려운 코드로, 대화상자를 정의하려는 경우, 함수를 사용해서
만들고 정의 요소를 옵션 아규먼트로 전달하는 것이 좋다.

결론적으로 코틀린에서는 빌더 패턴을 거의 사용하지 않습니다.

빌더패턴은 다음과 같은 경우에만 사용한다.

  • 빌더패턴을 사용하는 다른언어로 작성된 라이브러리를 그대로 올믹ㄹ때
  • 디폴트 아규먼트와 DSL을 지우너하지 않는 다른언어에서 쉽게 사용할 수 있게 API를 설계 할때

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

DSL은 복잡한 객체, 계층구조를 갖고있는 객체를 정의할 때 굉장히 유용하다.

코틀린 테스트를 활용해 테스트 케이스를 정의했다.

class MyTest : StringSpec({
    "length sould return size of string" {
           "hello".length shouldBe 5
    }
    "strtsWith should test for a prefix" {
         "world" should startWith("wor")
    }

})

Gradle 설정을 정의할때에도 Gradle Dsl이 사용된다.

DSL을 활용하면 복잡하고 계층적인 자료구조를 쉽게 만들수 있다.
이미 존해나는것도 좋지만, 사용자 정의 DSL을 만드는것도 알아두는것이 좋다.

사용자 정의 DSL 만들기

사용자 정의 DSL을 만드는 방법을 이해하려면 리시버를 사용하는 함수 타입에 대한 개념을 이해해야한다.


inline fun <T> Iterable<T>.filter(
 predicate: (T) -> Boolean

) : List<T>{
    val list = arrayListOf<T>()
    for (elem in this) {
        if(predicate(elem)){
        list.add(elem)
        }
    }
}

함수타입의 몇가지 예를 살펴보자.

  • () -> Unit : No argument Unit(no) retrun
  • (Int)-> Unit : Int argument Unit(no) Return
  • (Int)-> () -> Unit : Int를 아규먼트로 받고, 다른 함수를 리턴한다.
    이때의 다른함수는 Unit을 리턴한다.
  • (()->Unit) -> Unit : 다른함수를 아규먼트로 받고 Unit를 리턴한다.
    이때의 다른함수는 아규먼트로 아무것도 받지않고 Unit을 리턴한다.

함수타입을 만드는 기본적인 방법은 다음과 같다.

  • 람다 식
  • 익명함수
  • 함수 레퍼런스

fun plus(a: Int, b: Int) = a+b 같은 함수가 있다고 생각해보자.

val plus1 : (Int, Int) -> Int = {a , b -> a+b}
val plus2 : (Int, Int) -> Int = fun(a, b) = a+b
val plus3 : (Int, Int) -> Int = ::plus

프로퍼티 타입이 지정되어있기때문에 람다와 익명함수의 아규먼트 타입을 추론할수 있다.
반대로 아규먼트 타입을 지정해서 함수의 형태를 추론할수 있다.

val plus4 = {a : Int , b : Int -> a+b}
val plus5 = fun(a: Int, b : Int) = a+b

함수 타입은 '함수를 나타내는 객체'를 표현하는 타입이다.
익명은 이름을 갖고있지 않은 함수이며, 람다는 익명함수를 짧게 작성할 수 있는 표기 방법입니다.

함수를 나타내는 타입이 있다면 확장함수는 어떻게 표현할 수 있을까?

fun Int.myPlus(other : Int) = this + other

익명확장 함수 또한 가능하다.

val myPlus = fun Int.(other : Int) = this + other

이 함수의 타입은 학장함수를 나타내는 특별한 타입, 리시버를 가진 함수 타입이라고 부른다.

일반저긴 함수 타입과 비슷하지만, 파라티머 앞에 리시버 타입이 추가되어 있다.

val myPlus : Int. (Int)-> Int = fun Int.(other : Int) = this + other

이렇게 만들면, 스코프 내부의 this 키워드가 확장 리시버를 참조하게 만든다.

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

리시버를 가진 익명 확장 함수와 람다 표현식은 아래와 같이 호출이 가능하다.

  • invoke메서드 사용
  • 확장함수가 아닌 함수처럼 사용
  • 일반적인 확장 함수처럼 사용

 myPlus.invoke(1,2)
 myPlus(1,2)
 1.myPlus(2)

리시버를 가진 함수타입의 중요한 특징은 this 참조 대상을 변경할수 있다는 것이다.
this는 apply 함수에서 리시버 객체의 메서드와 프로퍼티를 간다하게 참조할 수 있게 해준다.

inline fun<T> T.apply(block : T.() -> Unit) : T {
 this.block
 return this
}

class User{
    var name : String = ""
    var surname : String = ""

}

val user = User.apply{
    name = "Marcin"
    surname = "Moskala"
}

리시버를 가진 함수타입은 DSL을 구성하는 가장 기본적인 블록이다.

fun createTable() : TableDsl = table{
    tr{
        for(i in 1..2){
            td{
                +"This is column $i"
            }
        }
    }
}

함수가 텝레벨에 위치하고 별도의 리시버를 갖지 않으므로 table 함수도 톱레벨에 있어야 한다.

tr 함수는 table 내부에서만 허용되어야 하며, 따라서 table 함수의 아규먼트는 tr함수를 갖는 리시버를 가져야한다.

fun table(init : TableBuilder.() -> Unit) : TableBuilder{

}

class TableBuilder{
 fun tr(init : TrBuilder.() -> Unit) {}
}

class TrBuilder{
 fun td(init : TdBuilder.() -> Unit) {}
}

"This is column" 은 단순하게 문자열에 적용된 단항 +연산자이기 때문에 아래와 같이 정의한다.

class TdBuildr{
    var text = ""

    operater fun Stirng.unaryPlus(){
        text += this
    }

}

이렇게 DSL을 모두 정의했고, 파라미터를 활용하여 적절하게 값들을 초기화 하면 된다.

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

이전에 언급한것 처럼 apply 함수를 사용하면 더욱 간단하게 만들 수 있다.

fun table(init : TableBuidler.() -> Unit) = TableBuilder().apply(init)

언제 사용하면 좋을까?

DSL을 정의하는것은 개발자의 인지적 혼란과 성능이라는 비용이 모두 발생할 수 있다.

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

DSL없이 빌더나 단순하게 생성자만 활용해도 원하는 모든것을 표현할수 있으나,
코드를 읽는 사람에게 필요없는 반복적인 코드가 있을경우 DSL을 고려해보자.