[kotlin] 코틀린 DSL

핑구·2023년 3월 12일
0

kotlin

목록 보기
2/4
post-thumbnail

이번 블랙잭 미션을 하며 코틀린 DSL 수업을 하게 되었다.
하지만..수업을 들으면서도 이해가 잘 되지 않았기에 지금 이렇게 따로 정리하며 학습하려고 한다.

그 전에

우리의 최종 목표는 코드를 간결하고 가독성 좋게 작성하는 것이다.
이렇게 깔끔한 API를 작성할 수 있도록 코틀린은 몇 가지 기능을 제공한다.

  • 확장 함수
  • 중위 호출
  • 연산자 오버로딩
  • get 메서드에 대한 관례
  • 람다를 괄호 밖으로 빼내는 관례
  • 수신 객체 지정 람다

이러한 기능들 외에도 DSL 구축을 도와주는 코틀린 기능이 존재한다.

DSL이란?

범용 프로그래밍 언어 (General-purpose language)

DSL의 반대 개념으로 우리가 보통 알고 있는 언어이다.
특정 작업이 아닌 전체 애플리케이션을 만드는 기능을 제공한다.
ex) Kotlin, Java, C++ 등의 프로그래밍 언어

  • 명령적 : 어떠한 연산을 위한 각 단계의 순서를 지정한다.
    -> 각 단계에 대한 최적화를 독립적으로 해줘야 한다.

도메인 특화 언어 DSL(Domain-specific language)

이름 그대로 특정 도메인(영역, 작업)에 적합한 언어이다.
ex) SQL, 정규식
우리는 보통 SQL을 데이터베이스 조작이라는 특정 영역에서만 사용하지 전체 애플리케이션을 SQL로 작성하지 않는다.

  • 선언적 : 원하는 결과만 지정하고 세부 실행은 언어를 해석하는 엔진에 맡긴다.
    -> 전체 기능을 한 번에 최적화해서 더 효율적이라고 볼 수 있다.
  • 단점) DSL 자체 문법이 있기에 다른 언어로 작성한 프로그램 안에 포함시킬 수 없다.
    : 중간에 해석해주는 연결다리 기능을 만들어야 한다.
    : 두 언어를 모두 알아야하고 코드를 읽기 힘들다는 단점도 있다.
    -> 해결방법 : 내부 DSL

내부 DSL

독립적인 문법 구조를 가지는 외부 DSL과 다르게 내부 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

코틀린 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

profile
발전중

0개의 댓글