11. DSL 만들기
- invoke 관계를 사용하면 DSL 코드 안에서 람다와 프로퍼티 대입을 더 유연하게 조합할 수 있다.
11.1 API에서 DSL로
- 궁극적인 목표는 코드의 가독성과 유지 보수성을 좋게 유지하는 것이다.
- 상호작용을 이해하기 쉽고 명확하게 표현할 수 있게 만들어야 프로젝트를 계속 유지 보수할 수 있다.
깔끔한 api의 특징
- 이름과 개념을 잘 선택하면 클라이언트가 해당 코드에 대해 어떤 일이 벌어질지 명확하게 이해할 수 있다.
- 코드가 간결해야한다. 불필요한 구문이나 번잡한 준비 코드가 가능한 한 적어야 한다.
- 코틀린 DSL은 간결한 구문을 제공하는 기능과 그런 구문을 확장해서 여러 메소드 호출을 조함으로써 구조를 만들어내는 기능에 의존한다.
- 코틀린 언어의 다른 특성과 마찬가지로 코틀린 DSL도 온전히 컴파일 시점에 타입이 정해진다.
11.1.1 영어 특화 언어라는 개념
DSL(영역 특화 언어)
- ex) SQL, 정규식 등등
- 스스로 제공하는 기능을 제한함으로써 오히려 더 효율적으로 목표를 달성할 수 있게한다.
- 압축적인 문법을 사용함으로써 DSL은 범용 언어를 사용하는 경우보다 특정 영역에 대한 연산을 더 간결하게 기술할 수 있다.
- DSL은 범용 프로그래밍 언어와 달리 더 선언적이다.
선언적 언어
- 원하는 결과를 기술하기만 하고, 그 결과를 달성하기 위해 필요한 세부 실행은 언어를 해석하는 엔진에 맡긴다.
DSL의 단점
- 자체 문법이 있기 떄문에 다른 언어의 프로그램 안에 직접 포함시킬 수가 없다
- 따라서 DSL로 작성한 프로그램을 다른 언어에서 호출하려면 DSL 프로그램을 별도의 파일이나 문자열 리터럴로 저장해야한다.
- 이런식으로 DSL을 저장하면
- 컴파일 시점에 검증이 힘듦
- DSL 프로그램 디버깅 힘듦
- DSL 코드 작성을 돕는 IDE 기능 제공 어려움
- 이런 단점을 해결하기 위해 내부 DSL이라는 개념이 나왔다.
11.1.2 내부 DSL
- 독립적인 문법 구조를 가진 외부 DSL과는 반대로 내부 DSL은 범용 언어로 작성된 프로그램의 일부다.
- 범용 언어와 동일한 문법을 사용한다.
- DSL의 핵심 장점을 유지하면서 주 언어를 특별한 방법으로 사용하는 것이다.
- 외부 DSL
SELECT Country.name, Count(Customer.id)
FROM Country
JOIN Customer
ON Country.id = Customer.country_id
GROUP BY Country.name
ORDER BY COUNT(Customer.id) DESC
LIMIT 1
- 내부 DSL
- 코드는 어떤 구체적인 과업을 달성하기 위한 것이지만, 범용 언어의 라이브러리로 구현된다.
(Country join Customer)
.slice(Country.name, Count(Customer.id))
.selectAll()
.groupBy(Country.name)
.orderBy(Count(Customer.id), isAsc = false)
.limit(1)
11.1.3 DSL의 구조
- 다른 API에는 존재하지 않지만 DSL에만 존재하는 특징은 구조 or 문법이다.
- API를 명령 - 질의 API라고 부른다. 반대로 DSL의 메소드 호출은 DSL 문법에 의해 정해지는 더 커다란 구조에 속한다.
- 코틀린 DSL에서는 보통 람다를 중첩 시키거나 메소드 호출을 연쇄시키는 방식으로 구조를 만든다.
- 메소드를 조합해서 만든 질의는 질의에 필요한 인자를 메소드 호출 하나에 모두 다 넘기는 것보다 훨씬 더 읽기 쉽다.
DSL 구조의 장점
- 같은 문맥을 함수 호출 시마다 반복하지 않고도 재사용할 수 있다는 점이다.
코틀린 DSL 예시
dependencies {
compile(",,,")
compile(",,,")
}
명령 - 질의 API 예시
- 코틀린 DSL 코드보다 중복이 많다는걸 알 수 있다.
project.dependencies.add("compile", ",,,")
project.dependencies.add("compile", ",,,")
11.1.4 내부 DSL로 HTML 만들기
- 코틀린 코드로 HTML을 만들려는 이유
- 코틀린 버전은 타입 안전성을 보장한다.
11.2 구조화된 API 구축: DSL에서 수신 객체 지정 DSL 사용
- 수신 객체 지정 람다는 구조화된 API를 만들 때 도움이 되는 강력한 코틀린 기능이다.
11.2.1 수신 객체 지정 람다와 확장 함수 타입
fun buildString(
builderAction: (StringBuilder) -> Unit // 함수 타입인 파라미터를 정의한다.
): String {
val sb = StringBuilder()
builderAction(sb) // 람다 인자로 StringBuilder 인스턴스를 넘긴다.
return sb.toString()
}
fun main() {
val s = buildString {
it.append("Hello, ") // it 은 StringBuilder 인스턴스를 가리킨다.
it.append("World!")
}
println(s) // Hello, World!–
}
- 수신 객체 지정 람다로 바꾸면, it을 붙이지 않고 간단하게 호출할 수 있다.
- buildString에게 수신 객체 지정 람다를 인자로 넘기기 때문에 람다 안에서 it을 사용하지 않아도 된다.
fun buildString(
builderAction: StringBuilder.() -> Unit // 수신 객체가 있는 함수 타입의 파라미터를 선언한다.
): String {
val sb = StringBuilder()
sb.builderAction() // 람다의 수신 객체로 StringBuilder 인스턴스를 넘긴다.
return sb.toString()
}
fun main() {
val s = buildString {
this.append("Hello, ") // "this" 키워드는 StringBuilder 인스턴스를 가리킨다.
append("World!") // "this" 를 생략해도 암묵적으로 StringBuilder 인스턴스가 수신 객체로 취급된다.
}
println(s) // Hello, World!
}
- 파라미터 타입을 선언할 때 일반 함수 타입 대신 확장 함수 타입을 사용했다.
- (StringBuilder)를 수신 객체 타입이라 부르고, 람다에 전달되는 그런 타입의 객체를 수신 객체라고 부른다.
// before
fun buildString(
**builderAction: (StringBuilder) -> Unit**
): String {
,,,
}
// after
fun buildString(
**builderAction: StringBuilder.() -> Unit**
): String {
,,,
}
- buildString 함수(수신 객체 지정 람다)의 인자는 확장 함수 타입(builderAction)의 파라미터와 대응한다.
- 호출된 람다 본문 안에서는 수신 객체(sb)가 묵시적 수신 객체(this)가 된다.
buildString { this.append("!") }
fun buildString(builderAction: StringBuilder.() -> Unit): String {
val sb = StringBuilder()
sb.builderAction()
}
11.2.2 수신 객체 지정 람다를 HTML 빌더 안에서 사용
- HTML을 만들기 위한 코틀린 DSL을 HTML 빌더라고 부른다.
- 빌더를 사용하면 객체 계층 구조를 선언적으로 정의할 수 있다.
- 코틀린 빌더는 타입 안전성을 보장한다.
fun createSimpleTable() = createHTML().
table {
tr { // == (this@table).tr
td { +"cell" } // == (this@tr).td
}
}
- 사용된 table, tr, td 는 고차 함수로 수신 객체 지정 람다를 인자로 받는 형태이다.
- 각 수신 객체 지정 람다가 이름 결정 규칙을 바꾸는 방식으로 되어있다.
- table 함수에 넘겨진 람다에서는 tr 함수를 사용해 <tr> HTML 태그를 먼들 수 있지만 그 람다 밖에서는 tr 함수를 찾을 수 없다.
- table에 전달된 수신 객체는 TABLE 이라는 특별한 타입이며, 그 안에 tr 메소드 정의가 있다.
- 수신 객체 지정 람다가 다른 수신 객체 지정 람다 안에 들어가면 내부 람다에서 외부 람다에 정의된 수신 객체를 사용할 수 있다
- 하지만 코틀린 1.1 부터는 @DslMarker 어노테이션을 사용해 중첩된 람다에서 외부 람다의 수신 객체를 접근하지 못하게 제한할 수 있다.
11.2.3 코틀린 빌더: 추상화와 재사용을 가능하게 하는 도구
- 내부 DSL을 사용하면 일반 코드와 마찬가지로 반복되는 내부 DSL 코드 조각을 새 함수로 묶어서 재사용할 수 있다.
- 부트스트랩 라이브러리에서 제공하는 드롭다운 메뉴가 있는 HTML 페이지를 코틀린 빌더를 통해 생성해본다.
fun dropdownExample() = createHTML().dropdown {
dropdownButton { +"Dropdown" }
dropdownMenu {
item("#", "Action")
item("#", "Another action")
divider()
dropdownHeader("Header")
item("#", "Separated link")
}
}
fun UL.item(href: String, name: String) = li { a (href) { +name } }
- item 함수
- href 주소(첫번째 파라미터)와 메뉴 원소의 이름(두번째 파라미터)로 구현되어 있다.
- li { a (href) { +name } }라는 원소를 새로 추가한다.
- 해당 함수를 UL 태그의 확장 함수로 구현하여 li 태그를 추가할 수 있도록 구현한다.
- 이렇게 item 함수를 정의하고 나면 모든 UL 태그 안에서 item을 호출할 수 있다.
11.3 invoke 관례를 사용한 더 유연한 블록 중첩
- invoke 관례를 사용하면 객체를 함수처럼 호출할 수 있고, 함수처럼 호출할 수 있는 객체를 만드는 클래스를 정의할 수 있다.
11.3.1 invoke 관계: 함수처럼 호출할 수 있는 객체
- bavarianGreeter("Dmitry") 는 bavarianGreeter.invoke(”Dmitry”)로 컴파일된다.
- 미리 정해둔 이름을 사용한 메소드를 통해서 긴 식 대신 더 짧고 간결한 식을 쓸 수 있게 해준다.
class Greeter(val greeting: String) {
operator fun invoke(name: String) { // invoke 메소드를 정의한다.
println("$greeting, $name")
}
}
fun main() {
val bavarianGreeter = Greeter("Servus")
bavarianGreeter("Dmitry") // Greeter 인스턴스를 함수처럼 호출한다.
// Servus, Dmitry
}
- 오버로딩한 invoke가 있는 클래스의 인스턴스를 함수처럼 사용할 때는 오버로딩한 여러 시그니처를 모두 다 활용할 수 있다.
11.3.2 invoke 관례와 함수형 타입
- 인라인하는 람다를 제외한 모든 람다는 함수형 인터페이스를 구현하는 클래스로 컴파일된다.
- 각 함수형 인터페이스 안에는 그 인터페이스 이름이 가리키는 개수만큼 파라미터를 받는 invoke 메소드가 들어있다.
interface Function2<in P1, in P2, out R> {
operator fun invoke(p1: P1, p2: P2): R
}
- 람다를 함수처럼 호출하면 이 관례에 따라 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()
}
private fun Issue.isImportant(): Boolean {
return type == "Bug" &&
(priority == "Major" || priority == "Critical")
}
}
11.3.3 DSL의 invoke 관례: 그레이들에서 의존관계 정의
- 그레이들에서의 dependencies 객체는 DependencyHandler 클래스의 인스턴스다
- DependencyHandler 클래스 구현(simple ver)
class DependencyHandler {
fun compile(coordinate: String) {
println("add dependency on $coordinate")
}
operator fun invoke(body: DependencyHandler.() -> Unit) {
body()
}
}
- 이 경우에는 compile 메소드를 직접 호출한다
dependencies.compile("라이브러리1")
- 아래와 같은 호출은 invoke 메소드를 호출하게끔 변환된다
//before
dependencies {
compile("라이브러리2")
}
//after
dependencies.invoke({
this.compile("라이브러리2")
})
11.4 실전 코틀린 DSL
11.4.1 중위 호출 연쇄: 테스트 프레임워크의 should
- 코틀린 테스트 DSL에서 메소드 호출 연쇄시키기
// 중위 호출
"kotlin" should start with "kot"
// 중위 호출을 일반 메소드 호출로 변경
"kotlin".should(start).with("kot")
- should 함수 중에는 start 객체를 파라미터 타입으로 사용하는 특별한 오버로딩 버전이 있다.
- should 함수는 중간 래퍼 객체를 돌려주는데, 이 래퍼 객체 안에는 중위 호출 가능한 with 메소드가 들어있다.
object start
infix fun String.should(x: start): StartWrapper = StartWrapper(this)
class StartWrapper(val value: String) {
infix fun with(prefix: String) =
if (!value.startsWith(prefix))
throw AssertionError("string does not with $prefix: $value")
else Unit
}
11.4.2 원시 타입에 대한 확장 함수 정의: 날짜 처리
val Int.days: Period
get() = Period.ofDays(this) // this는 상수의 값을 가리킨다
val Period.ago: LocalDate
get() = LocalDate.now() - this // 연산자 구문을 사용해서 LocalDate.minus를 호출한다.
val Period.fromNow: LocalDate
get() = LocalDate.now() + this // 연산자 구문을 사용해서 LocalDate.plus를 호출한다.
- LocalDate라는 JDK 클래스에는 코틀린 -, + 연산자 관례와 일치하는 minus, plus 메소드가 들어있다
//
@Override
public LocalDate minus(TemporalAmount amountToSubtract) {
if (amountToSubtract instanceof Period) {
Period periodToSubtract = (Period) amountToSubtract;
return minusMonths(periodToSubtract.toTotalMonths()).minusDays(periodToSubtract.getDays());
}
Objects.requireNonNull(amountToSubtract, "amountToSubtract");
return (LocalDate) amountToSubtract.subtractFrom(this);
}
@Override
public LocalDate plus(TemporalAmount amountToAdd) {
if (amountToAdd instanceof Period) {
Period periodToAdd = (Period) amountToAdd;
return plusMonths(periodToAdd.toTotalMonths()).plusDays(periodToAdd.getDays());
}
Objects.requireNonNull(amountToAdd, "amountToAdd");
return (LocalDate) amountToAdd.addTo(this);
}
11.4.3 멤버 확장 함수: SQL을 위한 내부 DSL
- 클래스 안에서 확장 함수와 확장 프로퍼티를 선언하는 것.
- 이렇게 정의한 확장 함수나 확장 프로퍼티는 그들이 선언된 클래스의 멤버인 동시에 그들이 확장하는 다른 타입의 멤버이기도 하다.
- 이런 함수나 프로퍼티를 멤버 확장이라 부른다.
- 각 칼럼의 속성을 지정하는 방법을 살펴보면, 이때 멤버 확장이 쓰인다.
- primaryKey(), autoIncrement() 는 Table 클래스의 멤버다
- 멤버 확장으로 정의하는 이유는 메소드가 적용되는 범위를 제한하기 위해서다.
class Table {
fun integer(name: String): Column<Int>
fun varchar(name: String, length: Int): Column<String>
fun <T>Column<T>.primaryKey(): Column<T>
fun Column<Int>.autoIncrement(): Column<Int>
// ...
}
val id = integer("id").autoIncrement().primaryKey()
- 확장 함수의 다른 장점은 수신 객체 타입을 제한하는 기능이다.
- autoIncrement를 할 수 있는 컬럼은 정수 타입이어야한다.
fun Column<Int>.autoIncrement(): Column<Int>
11.5 요약
- 수신 객체 지정 람다는 람다 본문 안에서 메소드를 결정하는 방식을 재정의함으로써 여러 요소를 중첩시킬 수 있는 구조를 만들 수 있다.
- invoke 관례를 사용하면 객체를 함수처럼 다룰 수 있다.
'kotlin' 카테고리의 다른 글
[jackson] 데이터가 null일때 empty list로 바꾸는 옵션 (0) | 2022.09.19 |
---|---|
10. 애노테이션과 리플렉션 (0) | 2022.03.26 |
9. 제네릭스 (0) | 2022.02.28 |
8. 고차 함수: 파라미터와 반환 값으로 람다 사용 (0) | 2022.02.13 |
7. 연산자 overloading과 관례 (0) | 2022.01.23 |