이펙티브 코틀린 Item 15: 리시버를 명시적으로 참조하라

woga·2023년 5월 6일
0

코틀린 공부

목록 보기
17/54
post-thumbnail

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

class User: Person() {
	private var beersDrunk: Int = 0
    
    fun drinkBeers(num: Int) {
    	// ...
        this.beersDrunk += 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.quickSort()

명시적으로 표기하면 아래와 같다.

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.quickSort()

물론 이 두 함수의 차이는 없다.

여러 개의 리시버

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

apply, with, run 함수를 사용할 때가 대표적인 예이다. 다음 코드를 예시로 자세하게 알아보자

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

	fun create(name: String): Node? = Node(name)
 }
 
 fun main() {
 	val node = Node("parent")
    node.makeChild("child")
 }

이 코드는 일반적으로 Created parent.child가 출력된다고 예상하지만, 실제로는 Created parent가 출력된다.

class Node(val name: String) {
	fun makeChild(childName: String) = create("$name.$childName")
										.apply{ print("Created ${this.name}") } // 컴파일 오류

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

create() 반환이 nullable이라 두번째처럼 리시버를 선언해야 오류가 나지 않는다.

그러나 이 코드에서 apply는 잘못된 사용 예이다. also를 사용하면 이런 문제 자체가 일어나지 않는다.

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

리시버가 명확하지 않다면 명시적으로 리시버를 적어서 명확하게 해주는게 좋다
레이블 없이 리시버를 사용하면, 가장 가까운 리시버를 의미한다.
외부에 있는 리시버를 사용하려면 레이블이 필요하다.

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.makeChild("child")
}

이렇게 명확하게 작성하면 코드를 안전하게 사용할 수 있을 뿐만 아니라 가독성도 향상된다.

DSL 마커

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

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

하지만 이렇게 쓰면 문제가 발생한다

tabe {
	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 { /*...*/ }
tabe {
	tr {
		td {+"Column 1"}
		td {+"Column 2"}
        tr { // 컴파일 오류
			td {+"Value 1"}
			td {+"Value 2"}
		}	
	}
}
tabe {
	tr {
		td {+"Column 1"}
		td {+"Column 2"}
        this@table.tr { // ok
			td {+"Value 1"}
			td {+"Value 2"}
		}	
	}
}

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

정리

짧게 적을 수 있단 이유만으로 리시버를 제거하지 말자.

여러 개의 리시버가 있으면 리시버를 명시적으로 적어 주는 것이 좋다. 리시버를 명시적으로 지정하면, 어떤 리시버의 함수인지를 명확하게 알 수 있으므로, 가독성이 향상된다.

DSL에서 외부 스코프에 있는 리시버를 명시적으로 적게 강제하고 싶다면, DslMaker 메타 어노테이션을 사용한다.

profile
와니와니와니와니 당근당근

0개의 댓글