ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Effective Kotlin Chapter 5 - Object creation
    Kotlin 2024. 3. 7. 21:12

    Item 33 : Consider factory functions instead of constructors

    일반적으로 Kotlin에서 클라이언트에게 클래스 인스턴스를 제공하는 방법은 'primary constructor'를 제공하는 것이다.

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

     

    이 외에도 객체를 인스턴스화시키는 다양한 'creational 패턴'이 존재하며, 이런 패턴 대부분은 객체 생성을 함수가 대신하는 아이디어를 중심으로 돌아간다.

    fun <T> myLinkedListOf(vararg element: T): MyLinkedList<T>? {
        if (element.isEmpty()) return null
    
        val head = element.first()
        val elementTail = element.copyOfRange(1, element.size)
        val tail = myLinkedListOf(*elementTail)
        return MyLinkedList(head, tail)
    }
    
    val list = myLinkedListOf(1, 2)

     

    이처럼, 생성자의 역할을 대신하는 함수를 팩토리 함수라고 하며, 생성자 대신 팩토리 함수를 사용하면 다음과 같은 장점을 얻는다.

    1. 생성자와 달리 함수에는 이름이 있기에, 이를 통해 객체의 생성 과정이나 특성을 명확하게 설명할 수 있다.
    2. 생성자와 달리 함수는 반환 타입의 어떠한 하위 타입도 반환할 수 있어 더 유연하게 객체를 제공할 수 있다.
      이런 특징은 실제 구현 객체를 인터페이스 뒤에 숨길 때 유용하다.
    3. 생성자와 달리 함수는 객체 생성 시, 캐싱 메커니즘을 적용하거나 특정 조건에서만 생성할 수 있도록 최적화할 수 있다.
      또한, 객체 생성에 실패했을 때 'null'을 반환하여 유연한 대처가 가능하다.
    4. 생성자와 달리 함수는 컴파일 전에 DI 프레임워크나 프록시를 통해 존재하지 않는 객체를 제공할 수 있다.
    5. 함수를 'inline'으로 정의하고 타입 파라미터를 '실체화(reified)'할 수 있어, 런타임에 타입 정보를 사용할 수 있다.
    6. 함수는 여러 단계의 초기화가 필요하거나, 다양한 파라미터의 조합이 필요하는 등 복잡한 객체를 생성할 수 있다.
    7. 생성자는 상위 클래스가 있으면 해당 생성자를 즉시 호출하지만, 팩토리 함수를 사용하면 생성자 호출을 지연시킬 수 있다.

    팩토리 함수의 제한점은 상속 구조에서 하위 클래스의 생성자를 직접적으로 호출할 수 없다.

    class IntLinkedList : MyLinkedList<Int>() {                 // Supposing that MyLinkedList is open
        constructor(vararg ints: Int) : myLinkedListOf(*ints)   // Error
    }

     

    이런 제한점은, 그냥 하위 클래스의 팩토리 함수를 하나 더 정의하면 쉽게 해결할 수 있다.

    class MyLinkedIntList(head: Int, tail: MyLinkedIntList?) : MyLinkedList<Int>(head, tail)
    
    fun myLinkedIntListOf(vararg elements: Int): MyLinkedIntList? {
        if (elements.isEmpty()) return null
        val head = elements.first()
        val tail = myLinkedIntListOf(*elements.copyOfRange(1, elements.size))
        return MyLinkedIntList(head, tail)
    }

     

    이처럼 팩토리 함수는 많은 장점이 있어 생성자 대신 사용 할 수 있지만, 팩토리 함수 본문에서도 생성자를 사용해야 하므로, 생성자는 반드시 존재해야 한다. 즉, 팩토리 함수는 'primary constructor'와 경쟁하는 것이 아니고, 'secondary constructor'와 경쟁하는 것임을 이해해야 한다.

     

    하지만, 대부분의 Kotlin 프로젝트에서는 'secondary constructor'를 사용하지 않기에, 팩토리 함수는 다양한 종류의 팩토리 함수들과 함께 경쟁 상대가 된다.

     

    아래는 다양한 종류의 팩토리 함수들 이다.

    1. companion object factory function

    팩토리 함수를 정의하는 가장 일반적인 방법은 'companion object' 안에 정의하는 것이다.

    class MyLinkedList<T>(val head: T, val tail: MyLinkedList<T>?) {
    
        companion object {
            fun <T> of(vararg elements: T): MyLinkedList<T>? {
                // ... 
            }
        }
    }
    
    // usage
    val list = MyLinkedList.of(1, 2)

     

    또는, 인터페이스에서도 동작하기에 다음과 같이 사용할 수 있다.

    class MyLinkedList<T>(val head: T, val tail: MyLinkedList<T>?) : MyList<T> {
        // ...
    }
    
    interface MyList<T> {
    
        companion object {
            fun <T> of(vararg elements: T): MyList<T>? {
                // ...
            }
        }
    }
    
    // usage
    val list = MyList.of(1, 2)

     

    위와 같이 'of'라는 이름의 함수는 Java에서 유래된 'convention'이기에 별도의 설명이 없음에도, 대부분의 개발자들이 인자가 무엇을 의미하는지 쉽게 이해할 수 있다.

     

    아래는 Java에서 유래된 몇 가지 'convention'들에 대한 설명이다.

    함수명 설명
    from - 단일 타입의 특정 인스턴스를 다른 타입의 인스턴스로 변환
    - val date: Date = Date.from(instant)
    of - 동일한 타입의 여러 인스턴스를 결합하여 하나의 컬렉션으로 집계
    - val faceCards: Set<Rank> = EnumSet.of(JACK, QUEEN, KING)
    valueOf - 기본 타입의 값을 받아 해당 타입을 갖는 래퍼 클래스의 인스턴스로 변환
    - val prime: BigInteger = BigInteger.valueOf(Integer.MAX_VALUE)
    instance / getInstance - 'singleton 패턴'에서 유일한 인스턴스를 얻는데 사용
    - 파라미터화가 된 경우, 제공된 인자에 따라 파라미터화된 인스턴스를 반환하며, 인자가 같을때는 반환되는 인스턴스가 항상 같다고 예상할 수 있음
    - val luke: StackWalker = StackWalker.getInstance(options)
    create / newInstance - 호출 할 때 마다 새로운 인스턴스를 생성하고, 이를 반환
    - val newArray: Array = Array.newInstance(classObject, arrayLen)
    getType - 유일한 인스턴스를 얻는 팩토리 함수가 클래스 내부에 존재하지 않고, 다른 클래스에 정의되어 있을 때 사용
    - val fs: FileStore = Files.getFileStore(path)
    newType - 새로운 인스턴스를 얻는 팩토리 함수가 클래스 내부에 존재하지 않고, 다른 클래스에 정의되어 있을 떄 사용
    - val br: BufferedReader = Files.newBufferedReader(path)

     

    추가로, 클래스 내부에 'companion object'가 존재하면, 해당 클래스의 인스턴스 없이도 접근할 수 있는 멤버를 제공한다. 또한, 'companion object'는 인터페이스를 구현하거나 다른 클래스를 상속할 수 있어서 단순히 메서드만 가지는 것이 아니라, 데이터를 저장하고 이 데이터를 기반으로 로직을 수행할 수 있다.

    abstract class ActivityFactory {
        abstract fun getIntent(context: Context): Intent
    
        fun start(context: Context) {
            val intent = getIntent(context)
            context.startActivity(intent)
        }
    }
    
    class MainActivity : AppCompatActivity() {
        // ...
    
        companion object : ActivityFactory() {
            override fun getIntent(context: Context): Intent =
                Intent(context, MainActivity::class.java)
        }
    }
    
    // usage
    val intent = MainActivity.getIntent(context)
    MainActivity.start(context)

    2. Extension factory functions

    별도의 파일에 'companion object'를 함수처럼 동작하는 팩토리 함수로 만들고 싶지만,
    해당 'companion object'를 수정할 수 없는 경우에는 확장 함수로 정의하여 사용할 수 있다.

    // Tool.kt
    interface Tool {
        companion object { /* ... */ }
    }
    
    // CreateTool.kt
    fun Tool.Companion.createBigTool(/* ... */): BigTool { /* ... */ }
    
    // usage
    val bigTool = Tool.createBigTool(/* ... */)

     

    단, 'companion object'의 확장 함수를 만들려면, 비어 있더라도 반드시 'companion object'가 선언되어 있어야 한다는 전제 조건이 있다.

    3. Top-level functions

    객체를 생성하는 가장 인기 있는 방법 중 하나는 'listOf', 'setOf', 'mapOf'와 같은 Top-level 팩토리 함수를 사용하는 것이다.
    이처럼 자주 생성되는 객체를 Top-level 팩토리 함수로 생성하면 코드가 더 간결해지고 읽기 쉬워진다.

    public fun <T> listOf(
       vararg elements: T
    ): List<T> = 
        if (elements.size > 0) elements.asList() 
        else emptyList()
    
    // usage
    val list1 = List.Of(1, 2, 3)     // Not recommended (Java convention)
    val list2 = listOf(1, 2, 3)      // Clean and readable

     

    주의할 점으로는, Public Top-level 함수는 클래스의 메서드처럼 불러올 수 있기에 사용자들에게 혼동을 줄 수 있는 문제가 있다.
    때문에 Public Top-level 함수의 이름을 정할 때는 신중하게 정해야 한다.

    4. Fake constructors

    Kotlin에서 생성자는 Top-level 함수와 사용법이 유사하며, 생성자 참조는 함수 인터페이스(Function0<A>)를 구현한다.

    class A
    
    val a: A = A()                          // constructor, but looks like a function
    val reference: () -> A = ::A            // constructor reference
    // or val reference: Function<A> = ::A    

     

    생성자와 함수 사이의 유일한 차이점을 사용자 측면에서 볼 때, 클래스는 대문자로 시작하고, 함수는 소문자로 시작한다고 볼 수 있다.
    하지만, 기술적으로 함수는 대문자로도 시작될 수도 있다. 예를 들어, Kotlin 표준 라이브러리의 경우 'List' 인터페이스의 경우 생성자를 가질 수 없지만, 다음과 같이 생성자를 가진 것처럼 사용할 수 있다.

    // Kotlin stblib
    public inline fun <T> List(
        size: Int,
        init: (index: Int) -> T
    ): List<T> = MutableList(size, init)
    
    public inline fun <T> MutableList(
        size: Int,
        init: (index: Int) -> T
    ): MutableList<T> {
        val list = ArrayList<T>(size)
        repeat(size) { index -> list.add(init(index)) }
        return list
    }
    
    // usage
    List(4) { "User$it" } // [User0, User1, User2, User3]

     

    위 Top-level 함수 'List'는 생성자처럼 보이고 동작하기에 많은 개발자들이 함수라는 사실을 모른다. 때문에 위와 같은 함수를 'Fake constructor'라고 부른다. 실제로 생성자 대신 'Fake constructor'를 사용하는 이유는 다음과 같다.

    1. 인터페이스에 대한 생성자를 가질 수 있어, 실제 코드와 결합도를 낮추고 유연성을 높일 수 있다.
    2. 'reified' 타입 파라미터를 가질 수 있어, 런타임에 타입 정보를 사용할 수 있다.

    이 외에 'Fake constructor'는 일반 생성자처럼 보이고 동작해야 하며, 캐싱 메커니즘, nullable 타입 반환, 서브 클래스의 반환 등을 하고 싶은 경우 'companion object' 안에 팩토리 함수로 정의하는 것이 더 적절하다.

     

    때때로 'companion object + invoke'를 통해 'Fake constructor'를 정의하는 경우가 있다.

    // Don't do this
    class Tree<T> {
        companion object {
            operator fun invoke(size: Int, generator: (Int) -> T): Tree<T> {
                // ...
            }
        }
    }
    
    Tree(10) { "$it" }

     

    하지만 이는 적절한 방법이 아니다. 왜냐하면, 'invoke' 연산자는 객체를 생성하는 연산자가 아닌, 객체를 호출하는 연산자이기 때문이다.

     

    또한, 이런 접근방식은 일반적인 Top-level 함수를 사용하는 것보다 더 복잡하며, 다음 'Constructor', 'Fake constructor', 'companion object + invoke'를 참조할 때, 어떻게 차이 나는지 비교해 보면 이 복잡성을 이해할 수 있다.

    타입 참조
    Constructor val f: () -> Tree = ::Tree
    Fake constructor val f: () -> Tree = ::Tree
    Invoke in companion object val f: () -> Tree = Tree.Companion::invoke

    5. Methods on a factory class

    팩토리 클래스는 프로퍼티를 가질 수 있어 객체 생성 과정을 보다 최적화하는 데 사용될 수 있다.
    또한, 이런 능력 덕분에 개발자는 캐싱 메커니즘 또는 이전에 생성된 객체를 복제하여 객체 생성 속도를 높이는 등의 다양한 종류의 최적화를 도입할 수 있게 된다.

    data class Student(
        val id: Int,
        val name: String,
        val surname: String
    )
    
    class StudentsFactory {
        var nextId = 0              // property
        fun next(name: String, surname: String) =
            Student(nextId++, name, surname)
    }
    
    val factory = StudentsFactory()
    val student = factory.next("Alice", "Smith")
    println(student)                // Student(id=0, name=Alice, surname=Smith)
    
    val anotherStudent = factory.next("Bob", "Johnson")
    println(anotherStudent)         // Student(id=1, name=Bob, surname=Johnson)

    Item 34 : Consider a primary constructor with named optional arguments

    'primary constructor'는 인자를 전달하여 초기 상태를 결정하고 객체를 생성하는 데 주로 사용된다.
    이에 대한 예시로, 데이터 모델 객체는 'primary constructor'를 통해 모든 상태를 초기화하고, 이를 프로퍼티로 유지한다.

    data class User(
        val name: String,
        val email: String
    )
    
    // usage
    val user = User(name = "John", email = "dummy@email.com")

     

    이처럼 일반적으로 'primary constructor'를 통해 객체를 생성하는 게 좋은 방식이라는 것을 이해하기 위해, 생성자와 관련된 Java 패턴과 생성자를 비교해 보자.

    Telescoping constructor Pattern

    'Telescoping constructor pattern'는 여러 생성자를 제공하여 다양한 파라미터 조합으로 객체를 생성하는 디자인 패턴이다.

    class User {
        val name: String
        val email: String
        val location: String
    
        constructor(name: String, email: String, location: String) {
            this.name = name
            this.email = email
            this.location = location
        }
    
        constructor(name: String, email: String) : this(name, email, "")
        constructor(name: String) : this(name, "", "")
    }

     

    그러나, Kotlin은 'default argument'를 사용할 수 있기에, 'Telescoping constructor pattern'은 불필요한 방식이다. 'default argument'는 파라미터를 선택적으로 제공할 수 있고, 인자의 순서를 자유롭게 변경할 수 있다. 또한, 'named argument'를 사용하여 각 값들이 어떤 의미를 가지는지 명확하게 표현할 수 있다.

    class User(
        val name: String,
        val email: String = "",
        val location: String = ""
    )
    
    // usage
    val userA = User(name = "Alice",)
    val userC = User(name = "Charlie", email = "dummy@mail.com")
    val userD = User(name = "David", location = "Spain", email = "dummy2@mail.com") 

     

    이러한 이유로, 'primary constructor'를 사용하여 객체를 생성하는 것이 더 간결하고 보기 쉽다.

    Builder Pattern

    Java에서는 'named argument', 'default argument'를 허용하지 않기에, 'builder pattern'을 사용하여 다음과 같은 작업들을 한다.

    • 파라미터에 이름을 붙여, 각 파라미터의 역할을 쉽게 이해할 수 있다.
    • 어떤 순서로든 파라미터 값을 제공할 수 있다.
    • 'default value'를 가질 수 있어, 필요한 파라미터만 선택하여 객체를 생성할 수 있다.
    class User private constructor(
        val name: String,
        val email: String,
        val location: String
    ) {
        class Builder(private val name: String) {
            private var email: String = ""
            private var location: String = ""
    
            fun setEmail(email: String) = apply { this.email = email }
            fun setLocation(location: String) = apply { this.location = location }
    
            fun build() = User(name, email, location)
        }
    }
    
    // usage
    val userA = User.Builder("Alice").build()
    
    val userC = User.Builder("Charlie")
        .setEmail("dummy@email.com")
        .build()
    
    val userD = User.Builder("David")
        .setLocation("Spain")
        .setEmail("dummy2@email.com")
        .build()

     

    하지만, Kotlin에서는 'default argument'와 'named argument'를 통해 동일한 작업을 쉽게 할 수 있다.

    val UserA = User(name = "Alice")
    val UserC = User(name = "Charlie", email = "dummy@email.com")
    val userD = User(name = "David", location = "Spain", email = "dummy2@email.com")

     

    이를 통해, 'named argument'와 'default argument'를 사용하는 것이 'builder pattern'를 사용하는 것보다 더 많은 이점을 갖는 것을 알 수 있다.

    • 'builder' 보다 'default argument'를 갖는 생성자 또는 팩토리 함수가 더 간결하고, 유지 보수가 쉽다.
    • 객체 생성 과정을 'builder'로 확인하는 것보다 하나의 메서드에서 확인이 가능하여 더 이해하기 쉽다.
    • 'primary constructor'는 내장된 개념이라는 점에서 'builder'에서 요구하는 추가적인 지식 없이 더 자연스럽게 사용할 수 있다.
    • 드물지만, 'builder' 프로퍼티는 대부분 가변 요소이기에 동시성 문제를 일으킬 수 있다.

    그럼에도, 생성자 보다 'builder'를 사용하는 것이 더 좋은 시나리오도 있다.

     

    시나리오 1 - 객체 구성 시, 특정 메서드를 통해 필요한 값을 설정할 수 있고 여러 값을 객체에 설정할 때

    val dialog = AlertDialog.Builder(context)
        .setMessage("Are you sure you want to delete the file?")
        .setPositiveButton("Delete") { dialog, id ->
            // Yes, delete the file
        }
        .setNegativeButton("Cancel") { dialog, id ->
            // No, don't delete the file
        }
        .create()
    
    val router = Router.Builder()
        .addRoute(path = "/home", ::showHome)
        .addRoute(path = "/users", ::showUsers)
        .addRoute(path = "/detail", ::showDetail)
        .build()

     

    위와 비슷한 동작을 생성자로 구현하려면 단일 인자에서 더 많은 데이터를 보유할 수 있는 특수 타입을 도입해야 하기에 불편하다.

    val dialog = AlertDialog(
        context = context,
        message = "Are you sure you want to delete the file?",
        positiveButton = Button("Delete") { dialog, _ -> },
        negativeButton = Button("Cancel") { dialog, _ -> }
    )
    
    val router = Router(
        routes = listOf(
            Route(path = "/home", handler = ::showHome),
            Route(path = "/users", handler = ::showUsers),
            Route(path = "/detail", handler = ::showDetail)
        )
    )

     

    위와 같이 생성자를 통해 구현하는 것은 불편하기에, 대부분 DSL을 사용하여 구현하는 것을 선호한다.

    val dialog = context.alert("Are you sure you want to delete the file?") {
        positiveButton("Delete") { dialog, _ -> }
        negativeButton("Cancel") { dialog, _ -> }
    }
    
    val route = router {
        "/home" directsTo ::showHome
        "/users" directsTo ::showUsers
        "/detail" directsTo ::showDetail
    }

     

    시나리오 2 - 'builder'를 통해 생성되는 여러 타입의 객체를 팩토리 함수의 반환 객체로 사용할 때

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

     

    이를 생성자나 팩토리 함수에서 유사한 기능으로 활용하려면, 'currying'이 필요하지만 이는 Kotlin에서 지원되지 않는다. 'currying' 대신, 객체의 구성을 'data class'로 관리하고, 'copy'를 통해 객체를 수정하여 유사하게 사용할 수 있다.

    data class DialogConfig(
        @DrawableRes val icon: Int = -1,
        @StringRes val title: Int = -1,
        val onDismiss: (() -> Unit)? = null
    )
    
    fun makeDefaultDialogConfig() = DialogConfig(
        icon = R.drawable.ic_dialog,
        title = R.string.dialog_title,
        onDismiss = { it.cancel() }
    )

     

    그럼에도, 실제로는 두 시나리오 모두 Kotlin에서는 선택지로 고려되지 않는다. 왜냐하면, 팩토리 함수를 통해 모든 커스터마이징 요소를 'optional argument'로 전달하면 더 많은 제어권과 유연성을 가질 수 있기 때문이다.

    data class DialogOptions(
        val message: String,
        @DrawableRes val icon: Int? = null,
        @StringRes val title: Int? = null,
        val negativeButtonText: String? = null,
        val onNegativeButtonClick: ((DialogInterface, Int) -> Unit)? = null,
    )
    
    fun createDefaultDialog(options: DialogOptions): AlertDialog {
        val builder = AlertDialog.Builder(context)
    
        builder.setMessage(options.message)
    
        if (options.icon != null) {
            builder.setIcon(options.icon)
        }
    
        if (options.title != null) {
            builder.setTitle(options.title)
        }
    
        if (options.negativeButtonText != null) {
            builder.setNegativeButton(options.negativeButtonText, options.onNegativeButtonClick)
        }
    
        return builder.create()
    }

     

    그러나, 다른 언어로 작성된 라이브러리와 상호작용 할 때 해당 라이브러리들이 'builder'를 사용하는 경우와 다른 언어에서 'default argument'를 지원하지 않는 상황에서는 API를 쉽게 사용하도록 설계할 때 간혹 사용되기도 한다. 이런 상황을 제외하면 'primary constructor'를 사용하여 객체를 생성하는 것이 더 좋다.


    Item 35 : Consider defining a DSL for complex object creation

    Kotlin의 고차 함수, 확장 함수, 람다 표현식 등 여러 기능을 함께 사용하면 DSL(Domain Specific Language)을 정의할 수 있다. DSL은 복잡한 객체나 객체의 계층 구조를 생성하여, 보일러플레이트와 복잡성을 개발자의 의도를 명확하게 표현할 수 있다.

     

    예를 들어, 다음과 같이 여러 플랫폼에서 사용할 수 있다.

    // Android View DSL
    verticalLayout {
        val name = editText()
        button("Say Hello") {
            onClick {
                toast("Hello, ${name.text}!")
            }
        }
    }
    
    // Ktor API DSL
    fun Routing.api() {
        route("news") {
            get {
                val newsData = NewsUseCase.getAcceptedNewsData()
                call.respond(newsData)
            }
        }
    }
    
    // Gradle DSL
    dependencies {
        api("junit:junit:4.12")
        implementation("junit:junit:4.12")
        testImplementation("junit:junit:4.12")
    }
    
    configurations {
        implementation {
            resoulutionStrategy.failOnVersionConflict()
        }
    }
    
    sourceSets {
        main {
            java.srcDirs("src/core/java")
        }
    }
    
    java {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }

    Defining your own DSL

    DSL을 정의하는 방법을 이해하기 위해서는 '리시버가 있는 함수 타입'의 개념을 이해하는 것이 중요하다.

     

    그전에, '함수 타입'에 대한 개념을 간단히 살펴보자.

    함수 타입은 '함수처럼 사용될 수 있는 객체'를 나타내는 타입으로써, 아래는 이 타입에 대한 몇 가지 예시들이다.

    예시 설명
    () -> Unit 인자가 없고 Unit 반환
    (Int) -> Unit 인자로 Int를 받고, Unit 반환
    (Int) -> Int 인자로 Int를 받고, Int 반환
    (Int, Int) -> Int Int 타입의 두 인자를 받고, Int 반환
    (Int) -> () -> Unit 인자로 Int를 받고, 다른 함수를 반환, 이 다른 함수는 인자가 없고 Unit을 반환
    (() -> Unit) -> Unit 다른 함수를 인자로 받고, Unit을 반환, 이 다른 함수는 인자가 없고 Unit을 반환

     

    함수 타입 인스턴스를 생성하는 기본적인 방법은 다음과 같다.

    fun plus(a: Int, b: Int): Int = a + b               // regular function
    
    val plus1: (Int, Int) -> Int = { a, b -> a + b }    // 1. lambda expression
    val plus2: (Int, Int) -> Int = fun(a, b) = a + b    // 2. anonymous function
    val plus3: (Int, Int) -> Int = ::plus               // 3. function reference
    
    val plus4 = { a: Int, b: Int -> a + b }             // lambda expression type inference
    val plus5 = fun(a: Int, b: Int) = a + b             // anonymous function type inference

     

    익명 함수는 일반 함수와 비슷하지만 이름이 없고, 람다 표현식은 익명 함수와 비슷하지만 더 간결하다.

    이처럼, 함수 타입으로 함수를 정의할 수 있다는 것을 이해하면, 함수 타입을 통해 확장 함수도 표현할 수 있음을 알 수 있다.

    fun Int.myPlus(other: Int) = this + other                               // extension function
    val myPlus: Int.(Int) -> Int = fun Int.(other: Int) = this + other      // anonymous extension function

     

    위 예시와 같이 '익명 확장 함수'를 나타내기 위해 '리시버가 있는 함수 타입'을 사용한다. '리시버가 있는 함수 타입'은 일반적인 함수 타입과 비슷해 보이지만, 함수 타입의 앞에 'Dot(.)'으로 구분하여 리시버 타입이 추가로 명시된 것이다.

     

    '리시버가 있는 함수 타입'은 또다시 '리시버를 가진 람다 표현식'으로 표현할 수 있다.

    이는 람다 표현식 범위 내 'this'가 '확장 리시버를 참조'하기 때문에 가능하다.

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

     

    '리시버를 가진 익명 확장 함수'와 '리시버를 가진 람다 표현식'으로 생성된 객체는 다음과 같은 방식으로 호출할 수 있다.

    myPlus.invoke(1, 2)     // 표준 객체처럼 'invoke' 사용
    myPlus(1, 2)            // 확장 함수가 아닌, 일반 함수처럼 사용
    1.myPlus(2)             // 확장 함수처럼 사용

     

    이를 통해 알 수 있는 중요한 점은 '리시버를 가진 함수 타입'이 있을 때 'this'가 참조하는 대상이 변경된다는 점이다.
    예를 들어, 'apply' 함수는 리시버 객체의 메서드와 프로퍼티를 참조하기 쉽게 만들기 위해 'this'를 사용한다.

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

     

    이러한 특징으로 인해, '리시버를 가진 함수 타입'은 DSL의 가장 기본적인 구성 요소가 된다.

    다음은 HTML Table을 생성하는 간단한 DSL의 정의이다.

    fun createTable(): TableDsl =
        table {
            for (i in 1..2) {
                tr {
                    for (j in 1..4) {
                        td {
                            +"This is column $i-$j"
                        }
                    }
                }
            }
        }
    
    fun table(init: TableBuilder.() -> Unit) =
        TableBuilder().apply(init)
    
    class TableBuilder {
        var trs = listOf<TrBuilder>()
    
        fun tr(init: TrBuilder.() -> Unit) {
            trs += TrBuilder().apply(init)
        }
    }
    
    class TrBuilder {
        var tds = listOf<TdBuilder>()
    
        fun td(init: TdBuilder.() -> Unit) {
            tds += TdBuilder().apply(init)
        }
    }
    
    class TdBuilder {
        var text = ""
    
        operator fun String.unaryPlus() {
            text += this
        }
    }

     

    결과를 View 형태로 표현하면 다음과 같을 것이다.

    This is column1-1 This is column1-2 This is column1-3 This is column1-4
    This is column2-1 This is column2-2 This is column2-3 This is column2-4

    When should we use it?

    DSL은 어떤 종류의 정보든지 표현할 수 있지만, 정확히 어떻게 구축되는지 이해하기 어려울 수 있다. 또한, DSL 선언 방식이 익숙하지 않으면 사용하기 혼란스럽기에 개발 및 유지보수가 어려울 것이다. 이런 제한적인 요소들로 인해, 더 간단한 기능이 있음에도 불구하고 DSL을 사용하는 것은 'overkill'이라고 볼 수 있다.

     

    그러나, 다음과 같은 경우에는 DSL이 유용할 수 있다.

    • 트리 구조의 데이터를 표현하기 위한 마크업 언어(HTML, XML 등)와 같이 복잡한 데이터 구조를 가진 경우
    • Gradle, System configuration 등과 같이 계층 구조를 정의할 때
    • 복잡한 쿼리, 통계 분석 등 방대한 양의 데이터를 다룰 때

    결론은 DSL을 사용하지 않고 'builder pattern', 'constructor'만을 통해 모든 것을 표현할 수 있다. 하지만, DSL의 주된 이점은 'builder pattern', 'constructor'를 사용한 구조에 대한 보일러플레이트를 제거하는 데 있다. 이처럼, Kotlin은 간단한 기능만으로 해결하기 어려운 보일러플레이트가 나타날 때는 DSL 사용을 고려하는 것이 좋다.

Designed by Tistory.