람다의 this가 특정 객체가 되는 함수 타입.
// A.() -> Unit ← A 가 수신자다
fun buildString(block: StringBuilder.() -> Unit): String =
StringBuilder().apply(block).toString()
val s = buildString {
append("안녕") // this.append — this 가 StringBuilder 다
append("하세요")
}
DSL을 만드는 방법
class HtmlBuilder {
private val sb = StringBuilder()
fun body(block: HtmlBuilder.() -> Unit) { sb.append("<body>"); block(); sb.append("</body>") }
fun p(text: String) { sb.append("<p>$text</p>") }
override fun toString() = sb.toString()
}
fun html(block: HtmlBuilder.() -> Unit) = HtmlBuilder().apply(block).toString()
val page = html {
body {
p("안녕하세요") // 중첩된 스코프 안에서 그 객체의 함수를 바로 부른다
}
}
중괄호 안에서 그 컨텍스트의 함수를 바로 부를 수 있다는 것이 DSL의 문법적 토대다.
@DslMarker — 스코프가 새는 것을 막는다
@DslMarker annotation class HtmlDsl
@HtmlDsl class HtmlBuilder { ... }
// 이제 중첩된 안쪽에서 바깥 스코프의 함수를 실수로 부를 수 없다
Compose가 이 문법의 결정체다
Column(modifier = Modifier.padding(16.dp)) { // 이 람다의 수신자가 ColumnScope
Text("제목")
Spacer(Modifier.height(8.dp))
Row {
Text("왼쪽", modifier = Modifier.weight(1f)) // weight 는 RowScope 에만 있다
}
}
weight가 Row·Column 안에서만 보이는 것이 수신자 스코프 덕분이다 — 잘못된 위치에서 쓰면 컴파일 에러가 난다.
연산자 오버로딩
data class Money(val amount: Long) {
operator fun plus(other: Money) = Money(amount + other.amount)
operator fun compareTo(other: Money) = amount.compareTo(other.amount)
}
Money(100) + Money(200) // plus 가 불린다
Money(100) < Money(200) // compareTo 가 불린다
// Compose 의 Modifier 체이닝도 확장 함수의 연속이다
Modifier.padding(8.dp).background(Color.Red).clickable { }
중위 함수
infix fun Int.to(other: Int) = Pair(this, other)
1 to 2 // 점과 괄호 없이
mapOf("a" to 1, "b" to 2) // 표준 라이브러리의 to 가 이것이다
조건: 멤버 함수이거나 확장 함수 · 파라미터가 정확히 하나 · 기본값 없음
남용의 경계
DSL 은 배우는 비용이 있다
- 팀 전체가 자주 쓰는 구조에만 만든다
- 한 번 쓰이는 곳에 DSL 을 만들면 순수한 부채다
연산자 오버로딩도 마찬가지 — 의미가 직관적일 때만 (Money + Money 는 OK, Money * User 는 아니다)
면접 함정
- ❌ "수신자 있는 람다는 일반 람다와 같다" →
this가 달라 스코프 안의 함수를 직접 부를 수 있다. - ❌ "Compose는 특별한 컴파일러 마법" → 컴파일러 플러그인이 있긴 하지만, 문법의 뼈대는 수신자 있는 람다다.