KotlinInAction/DSL 만들기

invoke 관례를 사용한 더 유연한 블록 중첩

webmaster 2022. 8. 21. 11:13
728x90

invoke 관례: 함수처럼 호출할 수 있는 객체

invoke 관례는 괄호를 사용한다(operator 변경자가 붙은 invoke 메서드 정의가 들어있는 클래스를 호출할 수 있다)

클래스 안에서 invoke 메서드 정의
class Greeter(val greeting: String) {
    operator fun invoke(name: String){ //Greeter 안에 invoke 메서드를 정의
        println("$greeting, $name")
    }
}
fun main() {
    val bavarianGreeter = Greeter("Servus")
    bavarianGreeter("Dmitry") //Greeter 인스턴스를 함수처럼 호출한다
}
  •  bavarianGreeter("Dmitry")는 내부적으로 bavarianGreeter.invoke("Dmitry")로 컴파일된다.
  • invoke 또한 다른 관례랑 마찬가지로 미리 정해둔 이름을 사용한 메서드를 통해 짧고 간결하게 식을 쓸수 있게 해준다
  • invoke 메서드의 시그니처에 대한 요구사항은 없으므로 원하는 대로 파라미터 갯수나 타입을 지정할 수 있다.
  • invoke를 오버로딩 할 수도 있는데,오버로딩한 invoke가 있는 클래스의 인스턴스를 함수처럼 사용할 때는 오버로딩한 여러 시그니처를 모두 다 활용할 수 있다.

invoke 관례와 함수형 타입

인라인 하는 람다를 제외한 모든 람다는 함수형 인터페이스를 구현하는 클래스로 컴파일된다. 각 함수형 인터페이스 안에는 그 인터페이스 이름이 가리키는 개수만큼 파라미터를 받는 invoke 메서드가 들어있다.

람다를 함수처럼 호출하면 이 관례에 따라 invoke 메서드 호출로 변환되는데, 이 사실을 알면 람다를 여러 메서드로 분리하면서도 여전히 분리 전의 람다처럼 외부에서 호출할 수 있는 객체를 만들 수 있다.

함수 타입을 확장하면서 invoke를 오버라이딩
data class Issue(
    val id: String, val project: String, val type: String,
    val priority: String, val description: String
)

class ImportantIssuesPredicate(val project: String) : (Issue) -> Boolean { //함수 타입을 부모 클래스로 사용한다
    override fun invoke(issue: Issue): Boolean { //invoke 메서드 구현
        return issue.project == project && issue.isImportant() //invoke 메서드를 구현한다.
    }
    private fun Issue.isImportant(): Boolean {
        return type == "Bug" &&
                (priority == "Major" || priority == "Critical")
    }
}
fun main() {
    val i1 = Issue(
        "IDEA-154446", "IDEA", "Bug", "Major",
        "Save settings failed"
    )
    val i2 = Issue(
        "KT-12183", "Kotlin", "Feature", "Normal",
        "Intention: convert several calls on the same receiver to with/apply"
    )
    val predicate = ImportantIssuesPredicate("IDEA")
    for (issue in listOf(i1, i2).filter(predicate)) { //술어를 filter에 넘긴다.
        println(issue.id)
    }
}
  • 술어의 로직이 복잡해 여러 메서드로 나누고 싶어 위와 같이 분리하였다.
  • 람다를 함수 타입 인터페이스를 구현하는 클래스로 변환하고, 클래스의 invoke 메서드를 오버라이딩 하면 위와 같은 리펙토링이 가능해 진다.
    • 람다 본문에서 따로 분리해 낸 메서드가 영향을 끼치는 영역을 최소화 할 수 있다.(술어 내부애서만 람다를 분리해낸 메서드 확인 가능)
    • 여러 관심사를 깔끔하게 분리해낼수 있는 것이 가장 큰 장점이다.

DSL의 invoke 관례: Gradle에서 의존관계 정의

Gradle 의존 관계 여러가지 경우 지원
dependencies.compile("junit:junit:4.11") // 1번 형식
dependencies{ //2번 형식
    compile("junit:junit:4.11")
}
  •  1, 2 번 형식 모두 지원하고 싶다( 설정 많으면 2번 DSL 형식, 설정 적으면 1번 함수 호출 형식으로 사용할 수 있다.)
  • 1번 형식 : dependencies 변수에 대해 compile 메서드를 호출한다.
  • 2번 형식: dependencies 안에 람다를 받는 invoke 메서드를 정의하면 된다.
유연한 DSL 문법을 제공하기 위해 invoke 사용하기
class DependencyHandler {
    fun compile(coordinate: String) { //일반적인 명령형 API 정의
        println("Added dependency on $coordinate")
    }

    operator fun invoke(
        body: DependencyHandler.() -> Unit
    ) {// invoke를 정의해 DSL 스타일 API를 제공
        body() //this가 함수의 수신객체가 되므로 this.body()와 같다
    }
}

fun main() {
    val dependencies = DependencyHandler()
    dependencies.compile("org.jetbrains.kotlin:kotlin-stdlib:1.0.0")
    dependencies{
        compile("org.jetbrains.kotlin:kotlin-stdlib:1.0.0")
    }
}
  • dependencies 객체는 DependencyHandler 클래스의 인스턴스다.
    • compile, invoke 메서드가 있다
    • invoke 는 수신 객체 지정 람다를 파라미터로 받으며, 이 람다의 수신객체는 다시 DependencyHandler다
    • DependencyHandler가 묵시적 수신 객체이므로, 람다안에서 compile과 같은 본인의 메서드를 직접 호출할 수 있다.
  • dependencies{ ...} 이러한 호출은 dependencies.invoke({...})와 같이 변환되어 호출된다고 보면 된다.
    • dependencies를 함수처럼 호출하면서 람다를 인자로 넘긴다.
    • 람다는 확장 함수 타입이며, 지정한 수신객체 타입은 DependencyHandler이다.(invoke 메서드는 이 수신 객체 지정 람다를 호출)
    • invoke가 DependencyHandler 메서드이므로 메서드 내부에서 this는 DependencyHandler 이다.
    • 결론적으로 invoke안에서 DependencyHandler 타입의 객체를 따로 명시하지 않고, compile()을 호출할 수 있다.
728x90