上下文参数与类型安全构建器¶
Kotlin 的 DSL 能力来自几个语言特性的组合:函数类型、带接收者 lambda、扩展函数、运算符约定、泛型推断、@DslMarker,以及新的 context parameters。它们让 Gradle Kotlin DSL、Ktor 路由、HTML 构建器、Compose 风格 UI、测试 DSL 都能写成接近声明式的结构。
本章不把 DSL 当作“语法炫技”,而是解释它如何工作、哪里危险,以及和 Java builder 模式有什么根本差异。
Context parameters 是什么¶
Context parameters 允许函数或属性声明“调用时上下文中必须隐式存在的依赖”。官方文档说明,它们替代了旧的实验性 context receivers。
普通显式依赖写法:
interface Logger {
fun log(message: String)
}
fun createUser(logger: Logger, name: String) {
logger.log("Create user: $name")
}
使用 context parameter:
interface Logger {
fun log(message: String)
}
context(logger: Logger)
fun createUser(name: String) {
logger.log("Create user: $name")
}
调用时把值放进上下文:
fun main() {
val logger = object : Logger {
override fun log(message: String) {
println(message)
}
}
context(logger) {
createUser("Ada")
}
}
你可以把它理解为:函数签名仍然声明依赖,但调用点不再把依赖作为普通参数一层层传递。
Java 对比:
- Java 常用构造函数注入、方法参数、ThreadLocal、DI 容器、静态上下文来传递共享依赖。
- Context parameters 更像“类型检查过的词法作用域依赖”,不是全局变量,也不是运行期容器查找。
- 它仍由编译器在调用点解析,缺少上下文或上下文不明确都会编译失败。
Context parameter 的声明形式¶
函数可以声明多个上下文参数:
interface UserRepository {
fun findName(id: Long): String?
}
interface AuditLog {
fun record(event: String)
}
context(users: UserRepository, audit: AuditLog)
fun findUserName(id: Long): String? {
audit.record("find user: $id")
return users.findName(id)
}
属性也可以声明 context parameter:
context(users: UserRepository)
val firstUserName: String?
get() = users.findName(1)
注意:带 context parameter 的属性不能有 backing field 或 initializer,因为它的值依赖调用上下文。
错误方向:
// 不要把 context property 当作普通存储属性理解
context(users: UserRepository)
val firstUserName: String? = users.findName(1) // 不允许
使用 _ 匿名上下文参数¶
如果你只希望某个类型进入解析上下文,但函数体中不直接按名字访问它,可以用 _:
context(_: Logger)
fun logWelcome() {
createUser("guest")
}
这表示 Logger 可用于解析其他需要 Logger 的调用,但当前函数体不能通过名字访问这个上下文参数。
建议:业务代码里优先给上下文参数取有意义的名字。_ 更适合非常短的转发函数或 DSL glue code。
上下文解析规则¶
Kotlin 在调用点按类型寻找匹配的上下文值。如果同一作用域层级存在多个兼容值,会产生歧义:
interface Logger {
fun log(message: String)
}
context(logger: Logger)
fun writeLog(message: String) {
logger.log(message)
}
fun main() {
val consoleLogger = object : Logger {
override fun log(message: String) = println("console: $message")
}
val fileLogger = object : Logger {
override fun log(message: String) = println("file: $message")
}
context(consoleLogger, fileLogger) {
// writeLog("hello") // 编译错误:Logger 上下文不明确
}
}
这和 Java DI 容器里“同类型多个 Bean”很像,但发生在编译期。Java/Spring 常用 @Qualifier 解决,Kotlin context parameters 则需要你明确传入或调整上下文作用域。
显式传递上下文参数¶
官方文档提到,某些场景可以在调用点显式传 context argument,用于消除重载或上下文歧义。这个能力需要实验性编译器选项:
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xexplicit-context-arguments")
}
}
示例:
class EmailSender
class SmsSender
context(emailSender: EmailSender)
fun sendNotification() {
println("email")
}
context(smsSender: SmsSender)
fun sendNotification() {
println("sms")
}
context(defaultEmail: EmailSender, defaultSms: SmsSender)
fun notifyUser() {
sendNotification(emailSender = defaultEmail)
sendNotification(smsSender = defaultSms)
}
因为这是实验性能力,不建议在公共库 API 中重度依赖。应用内部 DSL 可以谨慎试用,但要把编译器选项写清楚。
Context parameters 的限制¶
当前官方文档列出的限制包括:
- 构造函数不能声明 context parameters。
- 带 context parameters 的属性不能有 backing field 或 initializer。
- 带 context parameters 的属性不能使用委托。
这说明它更适合表达“作用域内可用的能力”,不是对象状态初始化机制。
不适合:
class UserService context(logger: Logger) constructor() // 不允许
更现实的设计:
class UserService(
private val repository: UserRepository,
) {
context(logger: Logger)
fun create(name: String) {
logger.log("create: $name")
}
}
构造依赖仍显式注入,操作时的临时上下文通过 context parameter 提供。
什么时候使用 context parameters¶
适合:
- DSL 中共享作用域能力,例如 HTML tag、路由上下文、权限上下文。
- 日志、追踪、国际化、当前租户等“很多调用都需要但不应该全局化”的能力。
- 编译期可检查的 scoped operation。
- 少量、稳定、强语义的上下文依赖。
不适合:
- 替代所有函数参数。
- 隐藏核心业务依赖,让调用者不知道函数需要什么。
- 在公共 API 中引入大量隐式依赖。
- 用它模拟 Spring 容器或服务定位器。
Java 对比:Java 项目里大量隐式依赖往往通过 ThreadLocal 或 DI 容器处理,调试时不容易知道值从哪里来。Context parameters 的优势是编译器知道依赖,缺点是过度使用会让代码阅读者在作用域里追踪隐式值。
类型安全构建器是什么¶
类型安全构建器用 Kotlin 代码构建层级结构,同时保持静态类型检查。官方文档给的典型例子是 HTML/XML 标记和 Ktor 路由。
调用侧看起来像 DSL:
html {
head {
title {
+"Kotlin"
}
}
body {
h1 {
+"Hello"
}
p {
+"Type-safe builders"
}
}
}
这不是新语法。html { ... } 是函数调用,head { ... } 也是函数调用,+"Kotlin" 是 String.unaryPlus() 运算符约定。
带接收者 lambda¶
核心类型是:
HTML.() -> Unit
它表示“以 HTML 为接收者的函数”。在 lambda 内部,this 是 HTML:
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
调用:
html {
this.head {
title { +"Kotlin" }
}
}
this. 可以省略:
html {
head {
title { +"Kotlin" }
}
}
Java 对比:Java builder 通常是链式调用:
Html.html()
.head(head -> head.title("Kotlin"))
.body(body -> body.h1("Hello"));
Kotlin DSL 的差异在于 lambda 可以拥有接收者,所以内部调用像是在当前对象的方法里执行。
最小 HTML Builder¶
先定义模型:
interface Element {
fun render(builder: StringBuilder, indent: String)
}
class TextElement(
private val text: String,
) : Element {
override fun render(builder: StringBuilder, indent: String) {
builder.append(indent).append(text).append('\n')
}
}
定义标签基类:
abstract class Tag(
private val name: String,
) : Element {
private val children = mutableListOf<Element>()
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
protected fun addText(text: String) {
children.add(TextElement(text))
}
override fun render(builder: StringBuilder, indent: String) {
builder.append(indent).append('<').append(name).append(">\n")
children.forEach { child ->
child.render(builder, "$indent ")
}
builder.append(indent).append("</").append(name).append(">\n")
}
override fun toString(): String =
buildString { render(this, "") }
}
支持文本:
abstract class TagWithText(name: String) : Tag(name) {
operator fun String.unaryPlus() {
addText(this)
}
}
定义具体标签:
class HTML : TagWithText("html") {
fun head(init: Head.() -> Unit): Head = initTag(Head(), init)
fun body(init: Body.() -> Unit): Body = initTag(Body(), init)
}
class Head : TagWithText("head") {
fun title(init: Title.() -> Unit): Title = initTag(Title(), init)
}
class Body : TagWithText("body") {
fun h1(init: H1.() -> Unit): H1 = initTag(H1(), init)
fun p(init: P.() -> Unit): P = initTag(P(), init)
}
class Title : TagWithText("title")
class H1 : TagWithText("h1")
class P : TagWithText("p")
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
使用:
fun main() {
val page = html {
head {
title { +"Kotlin DSL" }
}
body {
h1 { +"Hello" }
p { +"Built with Kotlin" }
}
}
println(page)
}
这个例子展示了 DSL 的真实机制:
- 每个节点是对象。
- 每个嵌套块是带接收者 lambda。
initTag负责创建子节点、执行初始化、加入父节点。+"text"是运算符重载,不是字符串特殊语法。
@DslMarker 控制作用域¶
DSL 最大的问题是隐式接收者太多。没有约束时,内部 lambda 可以访问外层接收者的方法:
html {
head {
head {
// 语义上不应该允许在 head 里再调用外层 html.head()
}
}
}
用 @DslMarker 定义标记注解:
@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class HtmlDsl
标记 DSL 接收者:
@HtmlDsl
abstract class Tag(
private val name: String,
) : Element {
// ...
}
标记后,编译器会限制同一 DSL 中外层 receiver 的隐式访问。你仍然可以显式访问:
html outer@{
head {
this@outer.body {
p { +"explicit outer receiver" }
}
}
}
但这种显式访问应该很少见。如果经常需要 this@outer,说明 DSL 层级设计可能不自然。
@DslMarker 的作用目标¶
官方文档说明,DSL marker 只有应用在这些目标上才影响作用域控制:
- 类型声明:
AnnotationTarget.CLASS,例如 DSL receiver 类或接口。 - 类型使用:
AnnotationTarget.TYPE,例如@HtmlDsl HTML.() -> Unit。 - 类型别名:
AnnotationTarget.TYPEALIAS。
应用在普通函数或属性上不会产生作用域控制效果。
推荐写法:
@DslMarker
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.TYPE,
AnnotationTarget.TYPEALIAS,
)
annotation class RouteDsl
如果 DSL receiver 继承自同一个基类,标记基类通常足够。
Context parameters 与 DSL 的组合¶
Context parameters 可以给 DSL 提供作用域依赖:
interface RouteRegistry {
fun add(method: String, path: String, handler: () -> Unit)
}
context(routes: RouteRegistry)
fun get(path: String, handler: () -> Unit) {
routes.add("GET", path, handler)
}
使用:
context(registry) {
get("/health") {
println("OK")
}
}
这种写法让 DSL 的“环境能力”由类型系统表示,而不是依赖全局变量。
注意:实际 API 设计中要谨慎选择 context parameters 和 receiver。receiver 适合表达“当前正在构建的对象”,context parameter 适合表达“当前作用域可用的服务或能力”。
Context 与 receiver 同名冲突¶
官方 type-safe builders 页面提到,如果隐式 receiver 的成员与 context parameter 中可用声明同名,编译器会提示 shadowing 相关警告。解决方式是显式限定:
interface HtmlTag {
fun setAttribute(name: String, value: String)
}
context(tag: HtmlTag)
fun setAttribute(name: String, value: String) {
tag.setAttribute(name, value)
}
当 receiver 和 context 都有 setAttribute 语义时,调用者应该写清楚:
this.setAttribute("id", "main")
// 或按 context API 明确调用,具体形式依赖当前编译器支持
实践建议:DSL 中避免让 receiver 成员、扩展函数、context 函数使用同名但不同语义的名称。读者很难判断到底调用了谁。
Builder type inference¶
构建器经常是泛型的:
val names = buildList {
add("Ada")
add("Bob")
}
编译器能从 lambda 内部的 add("Ada") 推断 buildList 的类型参数是 String。官方文档称之为 builder type inference。
更典型的例子:
fun addEntryToMap(
baseMap: Map<String, Number>,
additionalEntry: Pair<String, Int>?,
) {
val result = buildMap {
putAll(baseMap)
if (additionalEntry != null) {
put(additionalEntry.first, additionalEntry.second)
}
}
println(result)
}
普通类型推断可能不知道 buildMap 的 K 和 V,但 builder inference 会分析 lambda 内部 putAll()、put() 调用,推断出 String 和 Number。
自定义 builder 要让 builder inference 生效,通常需要:
- builder lambda 是带接收者函数类型。
- receiver 类型使用需要推断的类型参数。
- receiver 提供公开成员或扩展,并在签名中使用这些类型参数。
示例:
class ItemHolder<T> {
private val items = mutableListOf<T>()
fun addItem(item: T) {
items.add(item)
}
fun toList(): List<T> = items.toList()
}
fun <T> itemHolder(init: ItemHolder<T>.() -> Unit): ItemHolder<T> =
ItemHolder<T>().apply(init)
val holder = itemHolder {
addItem("Kotlin")
}
holder 会被推断为 ItemHolder<String>。
DSL 与 Java Builder 的对比¶
| 维度 | Kotlin 类型安全构建器 | Java Builder |
|---|---|---|
| 结构表达 | 嵌套 lambda | 链式方法、嵌套 builder |
| 上下文对象 | receiver this |
显式变量或返回 builder |
| 编译期限制 | 类型、receiver、@DslMarker |
方法签名、泛型、运行时校验 |
| Java 调用体验 | 通常较差 | 原生友好 |
| 适用场景 | Kotlin-first 配置、路由、UI、结构化数据 | 跨 JVM 语言 API、公开库、复杂对象创建 |
如果 API 主要给 Kotlin 使用,DSL 可以显著提升可读性。如果 API 需要大量 Java 调用者,建议同时提供 Java-friendly builder:
class ClientConfig private constructor(
val endpoint: String,
val timeoutMillis: Long,
) {
class Builder {
private var endpoint: String = "http://localhost"
private var timeoutMillis: Long = 1_000
fun endpoint(value: String) = apply {
endpoint = value
}
fun timeoutMillis(value: Long) = apply {
timeoutMillis = value
}
fun build(): ClientConfig =
ClientConfig(endpoint, timeoutMillis)
}
}
fun clientConfig(init: ClientConfig.Builder.() -> Unit): ClientConfig =
ClientConfig.Builder().apply(init).build()
Kotlin:
val config = clientConfig {
endpoint("https://api.example.com")
timeoutMillis(2_000)
}
Java:
ClientConfig config = new ClientConfig.Builder()
.endpoint("https://api.example.com")
.timeoutMillis(2_000)
.build();
DSL 设计原则¶
好的 DSL 应该:
- 只为结构化、重复性强的领域引入。
- 让非法结构尽量编译失败。
- 控制 receiver 作用域,必要时使用
@DslMarker。 - 保持嵌套层级浅。
- 给关键节点命名,不滥用
Pair、String、Any。 - 对 Java 用户提供替代 API。
- 文档中解释 receiver、context、返回值和生命周期。
不好的 DSL 往往:
- 用隐式调用隐藏业务逻辑。
- 让读者不知道当前
this是谁。 - 过度依赖运算符重载。
- 错误只能运行时发现。
- 为了少写几个字符牺牲 IDE 搜索和调试体验。
常见误区¶
误区一:Context parameters 是依赖注入框架¶
它是语言特性,不是容器。它不会创建对象、管理生命周期、做扫描或装配。它只让函数声明“调用点必须有这些上下文值”。
误区二:DSL 看起来像语法,其实不可调试¶
DSL 本质是函数调用和对象构建。可以断点调试 html()、initTag()、head()。如果 DSL 难以调试,通常是 API 设计过度隐式。
误区三:@DslMarker 会禁止所有外层访问¶
它禁止同一 DSL marker 下外层 receiver 的隐式访问。显式 this@label 仍可访问。它是防误用机制,不是安全沙箱。
误区四:Builder inference 可以猜出任何类型¶
它只能根据 lambda 内部收集到的类型信息推断。如果信息矛盾或不足,仍需要显式类型:
val values = buildList<String> {
// 没有 add,也没有期望类型时,编译器可能无法推断
}
误区五:Kotlin DSL 对 Java 用户同样友好¶
带接收者 lambda、扩展函数、默认参数、context parameters 对 Java 调用者通常不友好。公开库如果要服务 Java,应设计平行入口。
官方参考¶
- Context parameters:https://kotlinlang.org/docs/context-parameters.html
- Type-safe builders:https://kotlinlang.org/docs/type-safe-builders.html
- Using builders with builder type inference:https://kotlinlang.org/docs/using-builders-with-builder-inference.html
- Scope functions:https://kotlinlang.org/docs/scope-functions.html