Today I Learned

내부 DSL 만들기 본문

Kotlin/다재다능 코틀린 프로그래밍

내부 DSL 만들기

하이라이터 2023. 10. 20. 02:36
728x90
  • 범용 프로그래밍 언어가 가진 엄격하고 완전한 능력이 필요할 때가 있다.
  • 반면에 특화되고, 규모가 작고 효율적인 도메인 특화 언어(domain-specific languages :DSL)이 필요한 경우도 있다.
  • CSS, 정규표현식, XML, Gradle 또는 Rake 빌드파일, React JSX 등도 모두 DSL이라 부를 수 있다.
  • 이번 챕터는 DSL을 사용하는 방법이 아니라 DSL을 설계하는 방법을 살펴본다.

 

DSL의 타입과 특징

외부 DSL vs 내부 DSL

  • 외부 DSL은 자유도가 높지만 DSL을 파싱하고 처리할 파서를 만들어야 한다.
  • 내부 또는 임베디드 DSL은 언어의 컴파일러와 툴들이 파서의 역할을 해주지만, 자연스럼과 표현력을 확보하기 위한 노력이 필요하다.

 

컨텍스트 주도와 유창성

  • DSL은 컨텍스트 주도적이고 유창성이 높다.
  • 컨텍스트는 커뮤니케이션을 간결하고 명확하게 만들어주며 표현력을 강화해준다. 또한 에러의 가능성을 줄여준다.
  • 유창성은 노이즈를 줄여주는 동시에 아이디어를 표현하기 쉽게 만들어준다.

 

내부 DSL을 위한 코틀린

  • 일반적으로 내부 DSL을 호스트로 쓰려는 언어에서 정접 타입은 큰 한계가 되지만, 코틀린의 특별한 기능들이 내부 DSL을 만들 때 도움이 되어준다.

 

생략 가능한 세미콜론

  • 흐름을 방해하고 많은 상황에서 노이즈로 작용하는 세미콜론을 생략할 수 있다.

 

infix를 이용한 점과 괄호제거

  • infix 키워드를 이용한 중위표기법 사용으로 점과 괄호를 제거할 수 있다.

 

확장 함수를 이용한 도메인 특화

  • 함수를 인젝팅하면 다음과 같은 코드를 쓸 수 있다.
2.days(ago)
  • 게다가 확장 함수에는 infix 키워드를 사용할 수 있다.
2 days ago
  • 위와 같은 자연스러운 코드와 표현력을 얻을 수 있다.

 

람다를 전달할 때 괄호는 필요없다

  • 함수의 마지막 파라미터 타입이 람다 표현식이라면 람다를 괄호 밖에 위치시킬 수 있다.
  • 또한 함수가 람다 하나만을 아규먼트로 받는다면 호출할 때 괄호가 필요 없다.
  • 함수가 클래스에 연관되어 있다면 infix 키워드에 의해 점과 괄호도 생략할 수 있다.
"Release Planning".meeting({
  starts.at(14.30);
  ends.by(15.20);
})
  • 위와 같은 코드를 다음과 같이 바꿀 수 있다.
"Release Planning".meeting {
  starts at 14.30
  ends by 15.20
}

 

DSL 생성을 도와주는 암시적 리시버

placeOrder {
  an item "Pencil"
  an item "Eraser"
  complete {
    this whit creditcard number "1234-5678-1234-5678"
  }
}
  • 위 코드에는 주문 컨텍스트와 결제 컨텍스트가 있고, 결제 트랜잭션을 실행하기 위해서는 두 컨텍스트가 모두 필요하다.
  • 암시적 리시버를 사용하면 파라미터를 전달하거나 전역상태를 사용할 필요 없이 코드의 레이어 간에 프로세스를 진행하기 위해 상태를 전달하는 것을 쉽게 만들어준다.

 

DSL을 돕기 위한 추가 특징

  • Any 클래스의 메소드들이 람다를 실행시켜주고 암시적 리시버를 세팅해줘서 코드를 줄여준다.
  • 현재 객체를 참조하기 위한 this와 람다 표현식의 단일 파라미터를 참조하기 위한 it 키워드를 통해 문법을 표현력있게 만들 수 있다.

 

유창성 확립 시 마주하는 난관

확장함수 사용

  • 이벤트와 날짜를 추적하는 어플리케이션을 만들고 있다고 가정해보자.
package datedsl

import java.util.Calendar
import datedsl.DateUtil.Tense.*

infix fun Int.days(timing: DateUtil.Tense) = DateUtil(this, timing)
  • days() 메소드는 DateUtil.Tense enum 값을 받아 DateUtil 클래스의 인스턴스를 리턴한다.
class DateUtil(val number: Int, val tense: Tense) {
  enum class Tense {
    ago, from_now
  }
  
  override fun toString(): String {
    val today = Calendar.getInstance()
    
    when (tense) {
      ago -> today.add(Calendar.DAY_OF_MONTH, -number)
      from_now -> today.add(Calendar.DAY_OF_MONTH, number)
    }
    
    return today.getTime().toString()
  }
}
  • DateUtil 클래스는 Tense enum를 담고 있고 생성자 파라미터를 이뮤터블 속성으로 저장하는 클래스다.
  • toString() 메소드는 tense 변수에 따라 다른 처리를 하여 그에 맞는 시간 인스턴스를 리턴한다.
import datedsl.*
import datedsl.DateUtil.Tense.*

println(2 days ago)
println(3 days from_now)

 

리시버와 infix 사용

"Release Planning" meeting  {
  start at 14.30
  end by 15.20
}
  • 위 코드를 동작하게 하기 위해서는 먼저 meeting() 메소드를 String 클래스에 확장 함수로 인젝트해야 한다.
  • 그리고 meeting()을 infix 메소드로 만들어서 점을 제거해야 한다.
infix fun String.meeting(block: () -> Unit) {
  println("step 1 accomplished")
}

"Release Planning" meeting  {}
  • 다음으로 상태 업데이트를 위해 회의의 세부 상항인 상태를 가지고 있는 Meeting 클래스를 사용해서, 람다 내부에서 상태를 업데이트해보자.
class Meeting

infix fun String.meeting(block: Meeting.() -> Unit) {
  val meeting = Meeting()
  
  meeting.block()
  
  println(meeting)
}

"Release Planning" meeting  {
  println("With in lambda: $this")
}
  • String.meeting() 메소드의 block 파라미터는 Meeting 타입의 리시버를 받는다.
  • String.meeting() 메소드 안에서 Meeting 인스턴스를 만들고, 이 인스턴스는 컨텍스트에서 람다를 실행한다.
  • 다음으로 Meeting 클래스에 at과 by 메소드를 만들고 람다 안에서 둘 다 실행시켜보자.
class Meeting(val title: String) {
  var startTime: String = ""
  var endTime: String = ""
  
  private fun convertToString(time: Double) = String.format("%.02f", time)
  fun at(time: Double) { startTime = convertToString(time) }
  fun by(time: Double) { endTime = convertToString(time) }
  
  override fun toString() = "$title Meeting starts $startTime ends $endTime"
}

infix fun String.meeting(block: Meeting.() -> Unit) {
  val meeting = Meeting(this)
  
  meeting.block()
  
  println(meeting)
}

"Release Planning" meeting  {
  at(14.30)
  by(15.20)
}
  • at() 메소드가 주어진 Double 값을 String으로 컨버팅한 이후에 startTime 속성에 저장하고, by 메소드는 endTime을 저장한다.
  • toString() 메소드는 Meeting 객체의 상태를 보고한다.
  • 기대한대로 잘 동작하지만, DSL이 의도에서 너무 벗어나 버렸고 at과 by가 무엇을 의미하는지 알기 어렵다.
  infix fun at(time: Double) { startTime = convertToString(time) }
  infix fun by(time: Double) { endTime = convertToString(time) }
"Release Planning" meeting  {
  this at 14.30
  this by 15.20
}
  • 괄호를 제거하기위해 at()과 by()에 infix를 사용했다. 하지만 infix를 사용하려면 메소드가 호출될 인스턴스가 필요하다.
  • at 앞에 인스턴스의 참조를 위해 잠시 this를 사용해야 한다.
class Meeting(val title: String) {
  var startTime: String = ""
  var endTime: String = ""
  val start = this
  val end = this
  
  ...

"Release Planning" meeting  {
  start at 14.30
  end by 15.20
}
  •  start와 end 변수에 this를 바인딩해서 this 대신 사용할 수 있도록 했다.
  • 하지만 DSL을 사용하는 유저가 start at 대신 start by를 호출하거나 end at을 호출하는 것을 막을 수 없다.
  • at과 by 메소드를 분리된 클래스로 옮겨서 잠재적인 에러를 예방할 수 있다.
open class MeetingTime(var time: String = "") {
  protected fun convertToString(time: Double) = String.format("%.02f", time)
}

class StartTime : MeetingTime() {
  infix fun at(theTime: Double) { time = convertToString(theTime) }  
}

class EndTime : MeetingTime() {
  infix fun by(theTime: Double) { time = convertToString(theTime) }  
}

class Meeting(val title: String) {
  val start = StartTime()
  val end = EndTime()
  
  override fun toString() =
    "$title Meeting starts ${start.time} ends ${end.time}"
}

infix fun String.meeting(block: Meeting.() -> Unit) {
  val meeting = Meeting(this)
  
  meeting.block()
  
  println(meeting)
}

"Release Planning" meeting  {
  start at 14.30
  end by 15.20
}
  • MeetingTime은 베이스 클래스로서 동작하고 String 타입의 time 속성을 가지고 있다.
  • startTime 클래스는 MeetingTime을 확장한 클래스이고 at() 메소드를 가지고 있다.
  • End 클래스는 at() 대신 by() 메소드를 가지고 있다.
  • Meeting의 start 속성은 startTime의 인스턴스를 리턴하고, end 속성은 endTime의 인스턴스를 리턴한다.

 

 

 

728x90
Comments