이번 블랙잭 미션을 하며 코틀린 DSL 수업을 하게 되었다.
하지만..수업을 들으면서도 이해가 잘 되지 않았기에 지금 이렇게 따로 정리하며 학습하려고 한다.
우리의 최종 목표는 코드를 간결하고 가독성 좋게 작성하는 것이다.
이렇게 깔끔한 API를 작성할 수 있도록 코틀린은 몇 가지 기능을 제공한다.
이러한 기능들 외에도 DSL 구축을 도와주는 코틀린 기능이 존재한다.
DSL의 반대 개념으로 우리가 보통 알고 있는 언어이다.
특정 작업이 아닌 전체 애플리케이션을 만드는 기능을 제공한다.
ex) Kotlin, Java, C++ 등의 프로그래밍 언어
이름 그대로 특정 도메인(영역, 작업)에 적합한 언어이다.
ex) SQL, 정규식
우리는 보통 SQL을 데이터베이스 조작이라는 특정 영역에서만 사용하지 전체 애플리케이션을 SQL로 작성하지 않는다.
독립적인 문법 구조를 가지는 외부 DSL과 다르게 내부 DSL은 범용 언어로
작성하기에 범용 언어와 같은 문법을 사용한다.
= 어떤 구체적인 작업을 달성하기 위한 것이지만 범용 언어의 라이브러리로 구현된다.
DSL과 일반 API에는 뚜렷한 경계가 없기에 주관적으로 판단될 수 있다.
하지만 DSL에만 존재하는 특징이 한 가지 있다. : 구조 또는 문법
일반적인 라이브러리는 여러 메소드로 이루어져 있으며 클라이언트는 하나의 메소드를 호출하며 사용한다. 한 호출과 다른 호출은 아무런 연관이 없다. 이러한 API를 명령-질의 API라고 한다.
반면, DSL에서는 람다를 중첩시키거나 메소드 호출을 연쇄시키는 방식으로 구조를 이룬다. 이 구조의 장점은 같은 문맥을 함수 호출 시마다 반복하지 않고도 재사용할 수 있다는 것이다.
예시)
dependencies {
compile("junit:junit:4.11")
compile("com.google.inject:guice:4.1.0")
}
위는 코틀린 DSL(빌드 스크립트)이고 밑은 명령-질의 API 코드이다. 위는 람다 중첩을 통해 구조를 만들어 선언적으로 되어있다.
DSL과 비교해 API 코드는 중복이 많다는 것을 확인할 수 있다.
project.dependencies.add("compile", "junit:jnuit:4.11")
project.dependencies.add("compile", "com.google.inject:guice:4.1.0")
코틀린 DSL 설계는 코틀린 언어의 두 가지 특성을 이용한다.
첫 번째는 수신 객체 지정 람다이고, 두 번째는 invoke 관례이다.
일단 이번에는 수신 객체 지정 람다에 대해 살펴보도록 하겠다.
수신 객체를 명시하지 않고 람다의 본문 안에서 객체의 메소드를 호출할 수 있다. 이러한 람다를 수신 객체 지정 람다라고 한다.
val sb = StringBuilder()
sb.append("Yes")
sb.append("No")
StringBuilder에 내용을 추가하는 과정이다.
불필요하게 sb를 반복하여 사용하고 있다.
위의 코드를 확장 함수의 일부인 범위 지정 함수를 사용하여 아래와 같이 작성하였다.
val sb = StringBuilder()
sb.apply {
this.append("Yes")
append("No")
}
apply
의 수신 객체가 전달 받은 람다의 수신 객체가 된다. 즉, append
함수의 객체는 sb이다.
this
를 명시할 수도 있고 생략할 수도 있다.
또한 apply
를 실행한 결과는 StringBuilder 객체(수신 객체)이다.
범위 지정 함수(scope-functions)에는 let, with, run, apply, also 가 있다.
이 함수들은 모두 객체에서 코드 블럭을 실행하는 동일한 기능을 수행한다.
다만, 전체 표현식의 결과 (반환 값) 등에서 차이를 갖는다.
또 다른 예시를 살펴보겠다.
data class Player(var name: String = "pingu", var betAmount: Int = 10000)
위와 같은 Player
클래스가 있다.
이 Player
를 만드는 DSL를 만들어보겠다.
fun makePlayer(
makePlayerAction: (Player) -> Unit
): Player {
val player = Player()
makePlayerAction(player)
return player
}
makePlayerAction
는 람다이다.
위의 함수를 사용하려면
println(makePlayer {
it.name = "james"
it.betAmount = 50000
})
와 같이 makePlayer
의 인자로 Player
의 정보를 설정하는 람다를 넣어준다.
위의 코드에서는 it
을 계속 사용하고 있다. 이를 없애주기 위해서는 람다로 확장함수 타입을 받아줘야한다.
fun makePlayer(
makePlayerAction: Player.() -> Unit
): Player {
val player = Player()
player.makePlayerAction()
return player
}
이렇게 인자로 Player.() -> Unit
를 받아준다면 Player
의 확장함수가 넘어가는 것이기 때문에 this
를 붙이거나 생략하여 사용할 수 있다.
println(makePlayer {
this.name = "james"
betAmount = 50000
})
Player.() -> Unit
에서 . 앞에 오는 Player
를 수신 객체 타입이라 부르며, 람다에 전달되는 타입의 객체를 수신 객체라고 부른다.
일반 람다를 사용할 때는 makePlayerAction(player)
를 사용해서 전달해야 하지만 수신 객체 지정 람다를 사용할 때는 player.makePlayerAction()
으로 전달한다. 확장함수처럼 makePlayerAction()
은 Player
클래스 안에 정의 되어 있는 함수가 아니다. 또한 player
는 확장함수의 인자일 뿐이다.
makePlayer()
를 더 간단히 쓴다면
fun makePlayer(
makePlayerAction: Player.() -> Unit
): Player {
// Player 타입의 수신 객체에 makePlayerAction 확장 함수를 적용한 Player 객체를 반환한다
return Player().apply(makePlayerAction)
}
위와 같이 작성할 수도 있다.
참고
Kotlin in Action
https://salix97.tistory.com/224
https://deque.tistory.com/141