선생님, 개발을 잘하고 싶어요.

Kotlin in Action - 2부 11장 - DSL 만들기 본문

일상/책 리뷰

Kotlin in Action - 2부 11장 - DSL 만들기

알고싶은 승민 2022. 6. 7. 20:00

다루는 거

  • 영역 특화 언어, DSL, Domain-Specific Language
  • 수신 객체 지정 람다,
  • invoke 관례

API에서 DSL로

  • 클래스간 상호작용을 이해하기 쉽고 명확하게 표현할 수 있게 만드는 것이 목표
  • 2부에서 살펴본 내용은 API를 깔끔하게 작성할 수 있게 돕는 코틀린 특성
    • 깔끔하다는 것은?
      • 코드를 읽을 때, 어떤 일이 벌어질지 명확하게 이해할 수 있어야함. 이를 위해서 이름과 개념의 선택을 잘 해야한다.
      • 코드가 간결해야함, 불필요한 구문이나 보일러플레이트가 가능한 적어야한다.

개념적 DSL?

  • 범용 프로그래밍 언어: 모든 문제를 충분히 풀 수 있는 기능을 제공
  • 영역 특화 언어: 특정 과업 또는 영역에 초점을 맞추고 그 영역에 필요하지 않은 기능을 제거, (e.g. SQL, 정규식)
  • DSL이 더 선언적(declarative)이다.
  • 선언적 언어는 원하는 결과를 기술만 하고, 그 결과를 달성하기 위한 세부 실행은 언어를 해석하는 엔진에 맡긴다. 최적화는 엔진이 한다.
  • DSL의 단점으로 DSL과 범용 언어로 만든 애플리케이션의 조합이 어렵는 점이 있다.
  • 이러한 DSL은 IDE의 지원도 빈약하고 컴파일 시점 검증도 약하다.
  • 이러한 문제를 해결하는 internal DSL이라는 개념이 유명해지고 있다.

내부 DSL

  • 내부 DSL은 범용 언어로 작성된 프로그램의 일부다.
  • 익스포즈드 라이브러리는 구체적인 과업(SQL)을 달성하기 위한 것이지만, 범용 언어(코틀린)의 라이브러리로 구현된다.

DSL의 구조

  • DSL과 API 사이 잘 정의된 일반적 경계는 없다.
  • DSL은 구조 또는 문법이 존재한다.
    • 명령-질의 API: 함수 호출 시퀀스에 아무런 구조가 없음, 클라이언트가 하나씩 호출
    • DSL: DSL 문법에 의해 정해지는 더 커다란 구조에 속함.
    • 문법이 있기에 내부 DSL을 일종의 언어라고 부를 수 있다.
  • DSL에서는 여러 함수 호출을 조합해서 연산한다.
  • 타입 검사기는 여러 함수 호출이 바르게 조합됐는지 검사한다.
  • 함수 이름은 보통 동사 역할을 한다.
  • 함수 인자는 명사 역할을 한다.
  • DSL 구조의 장점으로는 같은 문맥을 재사용할 수 있다는 점이다. → 문맥의 재사용, 문맥의 추상화
  • 람다 중첩 혹은 메서드 호출 연쇄를 통해서 DSL 구조를 만든다.

HTML DSL에서 보는 인사이트

  • 왜 내부 DSL로 만들까?
    • 코틀린이 타입 안정성 보장
    • DSL 안에서 코틀린 코드를 원하는 대로 사용 가능 (for, if …)

구조화된 API 구축: DSL에서 수신 객체 지정 DSL 사용

수신 객체 지정 람다

  • 수신 객체 지정 람다 (lambda with a receiver)
fun buildString(
  builderAction: StringBuilder.() -> Unit // 수신 객체가 있는 함수 타입의 파라미터 선언
): String {
  val sb = StringBuilder()
  sb.builderAction() // StringBuilder 인스턴스를 람다의 수신 객체로 넘김
  return sb.toString()
}

val s = buildString {
  this.append("Hello, ") // this 키워드는 StringBuilder를 지칭
  append("World") // this는 묵시적으로 StringBuilder 지칭
}
  • 확장 함수 타입 (extension function type)HTML을 만들기 위한 코틀린 DSL → HTML 빌더 (type-safe builder)

  • 객체 계층 구조를 선언적으로 정의할 수 있다.
  • 코틀린 빌더는 타입 안정성을 보장한다.
  • ⭐️ 수신 객체 지정 람다가 이름 결정 규칙을 바꾼다.
  • @DslMarker 애노테이션을 사용해 중첩된 람다에서 외부 람다의 수신 객체를 접근하지 못하게 제한할 수 있다.
  • 수신 객체 지정 람다를 사용하면 코드 블록 내부에서 이름 결정 규칙을 바꿀 수 있으므로 이를 이용해 API에 구조를 추가할 수 있다.
    • 코드 블록 내부에서는 receiver의 함수를 호출할 수 있다. 즉 수신 객체 지정 람다를 사용하면 호출할 수 있는 함수(이름)을 원하는 형태로 조정할 수 있다는 것
  • 외부 DSL인 SQL이나 HTML을 별도 함수로 분리해 이름 부여하기는 어렵다. 하지만 내부 DSL은 일반 코드와 마찬가지로 반복되는 DSL 코드 조각을 새 함수로 묶어서 재사용할 수 있다.

invoke 관례

  • invoke 관례를 활용해 객체를 호출가능하게 하는 것은 일상적인 기능은 아니라는 점에 유의하자.
  • 복잡한 람다식에 이름을 부여하고 싶을 때, 코드 블럭을 나누고 싶을 때, invoke 관례를 이용할 수 있다.
class ImprtanctIssuePredicate(val project: String)
  : (Issue) -> Boolean { // 함수 타입을 부모 클래스로 활용한다.
 
  // invoke 관례를 구현한다. 
  override fun invoke(issue: Issue): Boolean {
    return issue.project == project && issue.isImportant()
  }

  // 복잡한 술어 로직을 별도의 함수로 분리해서 관리할 수 있게되었다.
  private fun Issue.isImportant(): Boolean {
    return type == "Bug" && (priority == "Major" || priority == "Critical")
  }
}
  • invoke 관례를 활용하면 DSL를 다양한 방법으로 사용할 수 있다. 이럴 때 invoke 관례의 파라미터로 수신 객체 지정 람다를 받는 패턴을 활용할 수 있다.
dependencies.compile("junit:junit:4.11") // 일반 함수 처럼 표현하고 싶거나
dependencies { // 중첩 람다로 표현하고 싶을 수도 있다.
  compile("junit:junit:4.11")
}

class DependencyHandler {
  // 일반 함수 호출
  fun compile(coordinate: String) { .. }

  // 중첩 람다 표현 가능
  operator fun invoke(body: DependencyHandler.() -> Unit) { body() }
}

중위 호출 연쇄

  • 중위 호출 함수, infix: 메서드 호출에 따른 잡음을 줄여주는 기능
  • 문맥을 결정하기 위한 용도의 object를 선언해서 사용할 수 있다.
    • 하나의 함수 이름으로 적합한 오버로딩 함수를 선택하기 위함
    • 적절한 반환을 받을 수 있음

멤버 확장 함수

  • 클래스 안에서 확장 함수와 확장 프로퍼티를 선언하는 것
class Table {
  fun <T> Column<T>.primaryKey(): Column<T>
  fun Column<Int>.autoIncrement(): Column<Int>
}
  • 다음과 같은 제약을 할 수 있다.
    • Table 클래스 밖에서 이 둘을 호출할 수 없다.
    • 수신 객체 타입을 제한한다. autoIncrement 같은 경우에는 Int 타입 칼럼에만 가능하다.
  • 멤버 확장은 Table 원본 소스코드를 수정하지 않고는 확장할 수 없다.
  • 맥락을 지정하기 위한 object를 내부적으로 활용하기도 한다.
fun Table.select(where: SqlEXpressionBuilder.() -> Op<Boolean>) : Query

object SqlExpressionBuilder {
  infix fun<T> Column<T>.eq(t: T) : Op<Boolean>
}
  • eq와 같은 함수는 SqlExpressionBuilder 통해 제공되어서 select의 조건을 지정하는 경우에만 사용할 수 있는 맥락을 제공한다.
Comments