Study/kotlin

[코틀린 프로그래밍] Chapter.13 내부 DSL 만들기

에디개발자 2021. 6. 7. 07:00
반응형

나를 닮았다고 한다...

DSL은 도메인 특화언어입니다. ( Domain-Specific languages ) DSL은 에러를 줄이는데 도움을 주는 동시에 프로그래머에게 유연성을 제공합니다. 이번 글에서는 DSL을 설계하는 방법에 대해서 작성해보겠습니다. 

DSL의 타입과 특징

외부 DSL vs 내부 DSL

외부 DSL은 높은 자유도를 얻을 수 있지만 DSL을 파싱하고 처리할 파서를 만들어야만 합니다. 파서를 만드는 데는 많은 리소스가 필요합니다. 외부 DSL의 예로는 CSS, ANT 빌드파일, Make 빌드파일 등이 있습니다. 하지만 이번 글에서는 내부 DSL에 초점을 맞출 것입니다.

 

내부 DSL을 위한 코틀린

DSL을 디자인할 때 도움이 되어줄 코틀린의 기능을 살펴보겠습니다.

생략 가능한 세미콜론

코틀린은 세미콜론을 강요하지 않습니다. 세미콜론을 사용하지 않는 것은 표현력이 강한 DSL 문법을 만드는데 중요합니다.

// 노이즈가 많다.
starts.at(14.30);
ends.by(15.20);
  
// 노이즈가 적다.  
starts.at(14.30)
ends.by(15.20)

 

Infix를 이용한 점과 괄호제거

코틀린은 infix 키워드를 이용한 중위표기법을 지원합니다. 그리고 이 기능은 DSL을 위한 좋은 특징입니다. 

// 적용전
starts.at(14.30)
ends.by(15.20)

// 적용후
starts at 14.30 )
ends by 15.20

 

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

이전 챕터에서 정리한 확장함수를 이용해보겠습니다.

// 적용전
2.days(ago)

// 적용후
2 days ago 

 

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

함수가 람다 하나만을 아규먼트로 받는다면 호출할 때 괄호가 필요없습니다. 함수가 클래스에 연관되어있다면 infix 키워드에 의해서 점과 괄호도 생략이 가능합니다.

// 적용전
"Realease Planning".meeting({
  starts.at(14.30);
  ends.by(15.20);
})

// 적용후
"Realease Planning" meeting {
  starts at 14.30
  ends by 15.20
)

 

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

코틀린으로 DSL을 설계할 때 가장 중요한 기능 중 하나는 람다 표현식에 암시적 리시버를 전달하는 것입니다. 리시버는 코드의 DSL 레이어 간에 컨텍스트 객체를 넘길 수 있는 좋은 방법입니다.

palceOrder {
  an item "Pencil"
  an item "Eraser"
  complete {
    this with creditcard number "1234-5678-1234-5678"
  }
}

 

위 코드는 주문, 결제 컨텍스트가 있습니다. 결제 트랜잭션을 실행하기 위해서는 두 컨텍스트가 모두 필요합니다. 암시적 리시버를 사용하면 두 컨텍스트가 필요하다는 점이 문제가 되지 않습니다. 

 

DSL을 돕기 위한 추가 특징

DSL을 설계할 때 사소한 기능이 유용할 때가 있습니다. Any 클래스의 메소드들이 DSL의 자연스러움과 표현력을 올려줍니다. also(), apply(), let(), run() 메소드가 람다를 실행시켜주고 암시적 리시버를 세팅해줍니다. 람다 표현식의 단일 파라미터를 참조할 경우 it을 사용한다. this, it 취향에 맞게 사용합니다.

 

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

확장함수 사용

이벤트와 날짜를 추적하는 어플리케이션을 만들어 보겠습니다. 이벤트가 일어난지 2일이 지났다는 말과 다른 이벤트가 3일 후 일어난다는 사실을 언급하길 원합니다. DSL 메소드 호출을 자연스럽게 하기 위하여 Int 클래스에 days() 라는 확장 메소드를 생성해보겠습니다.

infix fun Int.days(timing: DateUtil.Tense) = DateUtil(this, timing)

 

DateUtil 클래스는 days()에 입력된 일 수를 int 인스턴스로 받고 전인지 후인지 알려주는 enum을 입력받아 가상의 이벤트의 개최일을 리턴하기 위한 처리를 한다.

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.time.toString()
    }
}

 

작성한 코드를 사용해보자.

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 함수사용
// String 클래스에 확장함수 인젝트 사용
infix fun String.meeting(block: () -> Unit) {
    println("step 1 accomplished")
}

"Release Planning" meeting {}

 

DSL이 실행될 때 meeting() 확장함수가 호출됩니다. 다음으로 람다 내부에서 상태를 업데이트해야합니다. 상태 업데이트를 하기 위해서는 Meeting 클래스를 사용해보겠습니다.

// Meeting 클래스
class Meeting
infix fun String.meeting(block: Meeting.() -> Unit) {
    val meeting = Meeting()
    meeting.block()  // block 실행
    println(meeting)
}

"Release Planning" meeting {
    println("With in lambda: $this")
}

// result
With in lambda: Meetingdsl$Meeting@6b25ef1c
Meetingdsl$Meeting@6b25ef1c

 

Meeting 클래스를 생성하였습니다. String.meeting() 메소드의 block 파라미터는 Meeting 타입의 리시버를 받게됩니다. 

다음으로 Meeting 클래스에 at 과 by 메소드를 만들고 람다 안에서 실행시켜보겠습니다.

// 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)
}

// at, by 메소드 사용
"Release Planning" meeting {
    at(15.20)
    by(16.30)
}

 

위 코드는 몇 가지 문제점이 있습니다 먼저 at과 by가 무엇을 의미하는지 알 수 없는 문제점이 있습니다. 그리고 괄호 또한 필요없습니다. 

infix 를 사용하여 괄호를 제거하고 at과 by가 어떤 의미를 가지는 지 명확히 알 수 있도록 명시해보겠습니다.

class Meeting(val title: String) {
    var startTime: String = ""
    var endTime: String = ""
    var start = this
    var end = this

    private fun convertToString(time: Double) = String.format("%.02f", time)
    // infix 사용, at = startTime 명시
    infix fun at(time: Double) {
        startTime = convertToString(time)
    }

    // infix 사용, by = endTime 명시
    infix 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 {
    start at 15.20
    end by 16.30
}

 

위 코드를 좀 더 명확하게 메소드로 분류하여 적용해보겠습니다.

// Meeting 내부 DSL 최종판!!!!

open class MeetingTime(var time: String = "") {
    protected fun convertToString(time: Double) = String.format("%.02f", time)
}

// startTime 클래스 사용
class StartTime : MeetingTime() {
    infix fun at(theTime: Double) {
        time = convertToString(theTime)
    }
}

// endTime 클래스 사용
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}"
}

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

"Release Planning".meeting {
    start at 14.30
    end by 15.30
}
반응형