Kotlin

[Effective Kotlin] 15 - 리시버를 명시적으로 참조하라

매운돌 2023. 5. 6. 22:40

무언가를 더 자세하게 설명하기 위해서, 명시적으로 긴 코드를 사용할 때가 있습니다. 대표적으로 함수와 프로퍼티를 지역 또는 톱레벨 변수가 아닌 다른 리시버로부터 가져온다는 것을 나타낼 때가 있습니다.

 

예로 클래스와 메서드라는 것을 나타내기 위한 this가 있습니다.

class User: Person() {
	private var beersDrunk: Int = 0
    
    fun drinkBeer(num: Int) {
    	// ...
        this.beerDrunk += num
        // ...
    }
}

 

비슷하게 확장 리시버(확장 메서드에서의 this)를 명시적으로 참조하게 할 수 도 있습니다.

// 명시적으로 표시하지 않은 퀴소트 구현
fun <T : Comparable<T>> List<T>.quickSort(): List<T> {
	if (size < 2) return this
    val pivot = first()
    val (smaller, bigger) = drop(1).partition {it < pivot }
    return smaller.quickSort() + pivot + bigger.quick.Sort()
}

// 명시적으로 표시
fun <T : Comparable<T>> List<T>.quickSort(): List<T> {
	if (this.size < 2) return this
    val pivot = this.first()
    val (smaller, bigger) = this.drop(1).partition {it < pivot }
    return smaller.quickSort() + pivot + bigger.quick.Sort()
}

 

여러 개의 리시버

스코프 내부에 둘 이상의 리시버가 있는 경우, 리시버를 명시적으로 나타내면 좋습니다.

apply, with, run 함수를 사용할 때가 대표적인 예입니다. 

class Node(val name: String) {
	fun makeChild() = create("$name.$childName").apply { print("Created ${name}") }
    
    fun create(name: String): Node? = Node(name)
}

fun main() {
	val node = Node("parent")
    node.makeCild("child")
}

위의 결과가 Created parent.child가 출력될거라 생각하지만 실제로는 Created parent가 출력됩니다.

class Node(val name: String) {
	fun makeChild(childName: String) = 
    	create("$name.$childName").apply { 
        	print("Created ${this?.name} in " + " ${this@Node.name}") }
    
    fun create(name: String): Node? = Node(name)
}

fun main() {
	val node = Node("parent")
    node.makeCild("child")
    
    // Created parent.child in parent 출력
}

위 처럼 명시적으로 리시버를 적어서, 어떤 리시버를 활용하는지 의미가 훨씬 명확해 졌습니다. 이렇게 명확하게 작성하면, 코드를 안전하게 사용할 수 있을 뿐만 아니라 가독성도 향상됩니다.

 

DSL 마커

Kotlin DSL을 사용할 때는 여러 리시버를 가진 요소들이 중첩되더라도 리시버를 명시적으로 붙이지 않습니다. DSL은 원래 그렇게 사용하도록 설계되었기 때문입니다. 그런데  DSL에서는 외부의 함수를 사용하는 것이 위험한 경우가 있습니다.

예로 간단하게 HTML table요소를 만드는 HTML DSL을 생각해봅시다.

table {
	tr {
    	td { +"Column 1" }
        td { +"Column 2" }
    }
	tr {
    	td { +"Value 1" }
        td { +"Value 2" }
    }
}

기본적으로 모든 스코프에서 외부 스코프에 있는 리시버의 메서드를 사용할 수 있습니다. 하지만 이렇게 하면 코드에 문제가 발생합니다.

 

이러한 잘못된 사용을 막으려면, 암묵적으로 외부 리시버를 사용하는 것을 막는 DslMarker라는 메타 어노테이션(어노테이션을 위한 어노테이션)을 사용해야합니다.

@DslMarker
annotation class HtmlDsl

fun table(f: TableDsl.() -> Unit) { /* ... */ }

@HtmlDsl
class TableDsl { /*...*/ }

이렇게 하면 암묵적으로 외부 리시버를 사용하는 것이 금지됩니다.

 

table {
	tr {
    	td { +"Column 1" }
        td { +"Column 2" }
        tr { // 컴파일 오류
            td { +"Value 1" }
            td { +"Value 2" }
        }
    }
}

외부 리시버의 함수를 사용하려면, 다음과 같이 명시적으로 해야 합니다.

table {
	tr {
    	td { +"Column 1" }
        td { +"Column 2" }
        this@table.tr { // 컴파일 오류
            td { +"Value 1" }
            td { +"Value 2" }
        }
    }
}

DSL 마커는 가장 가까운 리시버만을 사용하게 하거나, 명시적으로 외부 리시버를 사용하지 못하게 할 때 활용할 수 있는 굉장히 중요한 메커니즘입니다. DSL 설계에 따라서 사용 여부를 결정하는 것이 좋으므로, 설계에 따라서 사용하기 바랍니다.

 

정리

여러 개의 리시버가 있는 상황 등에는 리시버를 명시적으로 적어 주는 것이 좋습니다. 리시버를 명시적으로 지정하면, 어떤 리시버의 함수인지를 명확하게 알 수 있으므로, 가독성이 향상됩니다. DSL에서 외부 스코프에 있는 리시버를 명시적으로 적게 강제하고 싶다면, DslMarker 메타 어노테이션을 사용합니다.