上下文参数与类型安全构建器

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 内部,thisHTML

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

普通类型推断可能不知道 buildMapKV,但 builder inference 会分析 lambda 内部 putAll()put() 调用,推断出 StringNumber

自定义 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
  • 保持嵌套层级浅。
  • 给关键节点命名,不滥用 PairStringAny
  • 对 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