코틀린이란 언어는 상당히 문법적으로 코드를 간결하게 작성할 수 있는 기능을 많이 제공한다. "중위 연산자", "확장 함수", "람다구문의 it" 등 을 지원하고 있다. 우리는 이러한 방법을 이용해 좀 더 간결하게 코드를 작성할 수 있다. 예를 들면 아래와 같이 말이다.
1.to("one")
1 to "one"
확장함수를 이용하면 아래처럼 조금 더 객체지향 적인 코드를 짤 수 있다고 생각한다.
StringUtil.capitalize(s) // java
s.capitalize() // kotlin
내부 DSL
또한 코틀린에서는 좀 더 가독성 좋은 코드를 작성할 수 있도록 내부 DSL 또한 지원한다. 여기서 짚고 넘어가야 할 점이 있는데 DSL 에 대해서 조금 알아보자. 우리가 보통 잘 알고 있는 DSL 은 SQL 문이 있다. 아래와 같이 많이 작성을 해보았을 것이다.
SELECT * FROM User;
위와 같은 문장으로 우리는 특정 어플리케이션을 만들기는 힘들다. 다만 SQL 저장소에 있는 어떠한 정보를 가져오고, 정보를 수정하는 연산에는 최적화되어 이용 가능하다. 여기서 DSL 의 특성을 알 수 있는데, DSL 은 특정 영역에 제한적이다. 위의 예를 다시보면 SQL 이라는 DSL 은 데이터베이스에서 정보를 가져오고 추출하는 연산 도메인에만 특화되어 있다는 것을 알 수 있다.
DSL 과 API 사이의 경계
DSL 과 API 사이에 잘 정의된 경계는 존재하지 않는다. 다만 큰 차이점이 있다면 DSL 이라는 하나의 거대한 문법 구조를 맞춰야만 호출이 가능할 수도 있다는 것이다. 즉, 우리가 DSL 을 통해서 특정 결과를 도출해내기 위해서는 DSL 문법(grammer) 에 맞춰서 질의를 해야만 가능하다는 것이다. 우리가 사용하는 SQL 을 생각해보면 쉽게 이해할 수 있을 것 이다.
이런 특성때문에 DSL 을 Domain-Specific Language 라고 부른다고 생각한다. 즉, 하나의 언어로서 취급한다는 것이다.
DSL 을 통하여 HTML 만들기
아래와 같이 코틀린 내부 DSL 을 통하여 HTML 구조를 만드는 방법을 한번 살펴보자.
fun createSimpleTable() = createHTML() .
table {
tr {
td { +"cell" }
}
}
위와 같은 구조가 어떤 형태로 HTML 을 만들어 줄지는 예측하기 쉬워 보인다고 생각한다.
<table>
<tr>
<td>cell</td>
</tr>
</table>
위와 같이 DSL 을 이용하면 좀 더 Domain 에 대한 이해도를 높일 수 있다고 생각한다.
또한 중요한건 여기서 계층 구조를 만들 수 있다는 것 또한 느낄 수 있을 것이다. tr 은 반드시 table 아래에만 적힐 수 있는 등, 컴파일 시점에 해당 부분을 검사 가능하다.
수신 객체 람다를 이용하여 DSL 만들기
위와 같이 코틀린의 수신 객체 람다를 이용하면 좀 더 가독성 좋게 DSL 을 구성할 수 있다.
백문이 불여일타 라고 코드로 보는것이 훨씬 더 나을 것이다.
아래는 아직 수신 객체 람다를 이용하지 않은 예제이다.
현재는 it 이라는 것을 붙여야 해서 사실 가독성이 엄청좋다? 라고 말하기는 힘들것 같다.
fun buildString(
builderAction: (StringBuilder) -> Unit
): String {
val sb = StringBuilder()
builderAction(sb)
return sb.toString()
}
val s = buildString {
it.append("Hello, ")
it.append("World!")
}
fun main() {
println(s)
}
이제 한번 수신 객체 람다를 이용해보자. 수신 객체 람다의 개념을 이해하지 못한 사람이 있을까봐 간단히 설명하면 람다의 인자 중 하나에 수신 객체라는 상태를 부여하면 람다에서 해당 객체를 바로 이용할 수 있는 개념이다. 즉, 람다에 객체를 사용할 수 있도록 수신시켜주는 있는 기능이다.
fun buildString(
builderAction: StringBuilder.() -> Unit
): String {
val sb = StringBuilder()
builderAction(sb)
return sb.toString()
}
val s = buildString {
append("Hello, ")
append("World!")
}
fun main() {
println(s) // "Hello, World!"
}
이제는 수신 객체 람다를 이용해서 좀 더 가독성이 좋아진 느낌이다.
우리는 buildString 을 하는데 내부에서 append 를 통해서 문자열을 붙인다는 것을 추측할 수 있다.
사실 코틀린의 apply 를 공부했다면 apply 자체는 수신객체 자체를 반환하므로 이게 apply 를 이용하면 조금 더 쉽게 풀릴 수 있다는 생각을 한 사람도 있을 것이다.
val z = StringBuilder().apply {
append("Hello, ")
append("World! ")
}.toString()
with 로도 간단하게 구현 가능하다.
val c = with(StringBuilder()) {
append("Hello, ")
append("World! ")
toString()
}
Invoke 를 이용한 좀 더 유연한 블록 중첩
코틀린에는 여러 관례가 존재한다. 예를 들면 "foo.get(bar)" 의 경우에는 foo[bar] 로 관례 처럼 선언 가능하다.
invoke 함수는 아래 예제 처럼 이용 가능하다.
class Greeter(private val greeting: String) {
operator fun invoke(name: String) {
println("$greeting: $name")
}
}
fun main() {
val roach = Greeter("Roach")
roach("Roach!!!") // invoke
}
코틀린의 인라인 하는 람다를 제외한 모든 람다는 아래와 같은 함수형 인터페이스로 컴파일 된다.
public interface Function1<in P1, out R> : Function<R> {
/** Invokes the function with the specified argument. */
public operator fun invoke(p1: P1): R
}
public interface Function2<in P1, in P2, out R> : Function<R> {
/** Invokes the function with the specified arguments. */
public operator fun invoke(p1: P1, p2: P2): R
}
즉 Lambda 의 경우 invoke 를 통해서 호출한다는 것이다. 위와 같은 형태로 변환된다는 것을 알게 되면 장점이 하나 있다.
복잡한 Lambda 를 invoke 를 통해서 분리가능하다는 것이다.
data class Issue(
val id: String,
val project: String,
)
class ImportantIssuesPredicate(
val project: String
) : (Issue) -> Boolean {
override fun invoke(p1: Issue): Boolean {
return p1.project == project && p1.isImportant()
}
private fun Issue.isImportant(): Boolean {
return id == "1"
}
}
fun main() {
val issue1 = Issue(
id = "1",
project = "roach's side"
)
val issue2 = Issue(
id = "2",
project = "roach's side2"
)
val predicate = ImportantIssuesPredicate("roach's side")
val result = listOf(issue1, issue2).filter(predicate)
println(result) // [Issue(id=1, project=roach's side)]
}
위의 코드를 보면 filter 에서 predicate 의 invoke 를 호출한다는 것을 알 수 있다.
Java 로 컴파일 된 부분의 일부를 보여주면 아래와 같다.
while(var9.hasNext()) {
Object element$iv$iv = var9.next();
if ((Boolean)((Function1)predicate).invoke(element$iv$iv)) {
destination$iv$iv.add(element$iv$iv);
}
}
Gradle 의 경우 invoke 를 통하여 아래 처럼 이용 가능하다.
class DependencyHandler {
fun compile(coordinate: String) {
println("Add Dependency on $coordinate")
}
operator fun invoke(
body: DependencyHandler.() -> Unit
) {
body()
}
}
fun main() {
val dependencies = DependencyHandler()
dependencies {
compile("spring.boot.jpa")
}
}
후기
이렇게 코틀린에서 DSL 을 통해서 좀 더 도메인에 특화되도록 가독성이 높은 코드를 작성하는 방법에 대해서 공부해봤다. 사실 예전 부터 ExposedU 라이브러리 처럼 기성 QueryDSL 을 Kotlin 스럽게 Wrapping 할 수는 없을까? 에 대한 고민이 많았다. Line 에서 만든 KotlinDSL 라이브러리도 써볼까 했는데, 나중에 따로 사이드에서 오늘 익힌 방법들로 QueryDSL 을 Wrapping 해봐야겠다.
참고한 도서
https://book.naver.com/bookdb/book_detail.nhn?bid=12685155
'Kotlin' 카테고리의 다른 글
Kotlin Class 내부 object 에서 Class Property 참조하는 법 (0) | 2022.05.11 |
---|---|
고차함수에서 Return 이 안되는 이유 (1) | 2022.05.03 |
Kotlin 의 확장함수가 좋은 이유 (0) | 2022.03.18 |
Ko-Test Framework 사용해보기 (0) | 2022.01.21 |
Kotlin 에서 Null 을 다루기 (0) | 2022.01.18 |