8. 密封类与内联类的结合:构建类型安全的DSL

说实话,DSL(领域特定语言)在Kotlin里一直是个让人又爱又恨的东西。爱的是它能让代码读起来像自然语言,恨的是——稍不留神,类型安全就崩了。我早期做的一个支付SDK项目,就因为DSL里混进了不该有的状态,线上出了好几次事故。

后来我找到了一个组合拳:密封类 + 内联类。这俩东西单独用已经很香了,但合在一起,才是构建类型安全DSL的真正杀招。

8.1 为什么需要类型安全的DSL?

先问你一个问题:你写的DSL,能不能在编译期就拦住非法状态?

举个例子,假设我们要写一个网络请求的DSL:

// 不安全的DSL
httpClient {
    method = "GET"      // 字符串,拼错了也没人管
    url = "https://api.example.com"
    body = "{}"         // GET请求不该有body,但编译器不拦
}

这种写法,说白了就是把运行时错误的风险埋在了代码里。我在项目里见过太多次了——测试环境跑得好好的,一上线就崩,查了半天发现是method拼成了"GEET"。

核心思路: 用密封类约束可选值,用内联类包装原始类型,让编译器替你把关。

8.2 密封类:定义DSL的合法状态集

密封类最适合干的事,就是枚举一组有限的、已知的子类型。用在DSL里,正好用来定义"合法操作"。

sealed class HttpMethod {
    object GET : HttpMethod()
    object POST : HttpMethod()
    object PUT : HttpMethod()
    object DELETE : HttpMethod()
    object PATCH : HttpMethod()
}

sealed class HttpBody {
    object NoBody : HttpBody()
    data class JsonBody(val json: String) : HttpBody()
    data class FormBody(val fields: Map<String, String>) : HttpBody()
}

这样一来,DSL的调用方就只能从这几个选项里选,不可能传个"GEET"进来。我记得有一次代码评审,看到新人用枚举类做同样的事,我建议他换成密封类——因为密封类可以携带不同的数据载荷,扩展性更强。

8.3 内联类:零开销的类型包装

内联类在Kotlin 1.5之后稳定了,它的价值在于:编译时是包装类型,运行时是原始类型。零开销,但能提供类型安全。

@JvmInline
value class Url(val value: String) {
    init {
        require(value.startsWith("http://") || value.startsWith("https://")) {
            "URL必须以http://或https://开头"
        }
    }
}

@JvmInline
value class Timeout(val millis: Long) {
    init {
        require(millis > 0) { "超时时间必须大于0" }
    }
}

你想想看,如果直接用String表示URL,那传个"abc"进来也没人拦。但用内联类包装后,构造函数里就能做校验。而且运行时没有额外开销——它就是个String。

我的习惯: 所有DSL里的原始类型参数,我都用内联类包一层。虽然写起来多打几个字,但调用方永远传不错参数顺序。

8.4 组合起来:类型安全的DSL构建器

现在我们把密封类和内联类组合起来,写一个完整的DSL构建器。这里用到了带接收者的lambda,这是Kotlin DSL的标配。

class HttpRequestBuilder {
    var method: HttpMethod = HttpMethod.GET
    var url: Url? = null
    var body: HttpBody = HttpBody.NoBody
    var timeout: Timeout = Timeout(30_000L)
    var headers: Map<String, String> = emptyMap()

    fun header(key: String, value: String) {
        headers = headers + (key to value)
    }

    fun json(body: String) {
        // 只有POST/PUT/PATCH允许有body
        require(method !is HttpMethod.GET && method !is HttpMethod.DELETE) {
            "${method::class.simpleName} 请求不能携带body"
        }
        this.body = HttpBody.JsonBody(body)
    }

    fun build(): HttpRequest {
        val finalUrl = url ?: throw IllegalStateException("URL不能为空")
        return HttpRequest(method, finalUrl, body, timeout, headers)
    }
}

data class HttpRequest(
    val method: HttpMethod,
    val url: Url,
    val body: HttpBody,
    val timeout: Timeout,
    val headers: Map<String, String>
)

fun httpRequest(block: HttpRequestBuilder.() -> Unit): HttpRequest {
    val builder = HttpRequestBuilder()
    builder.block()
    return builder.build()
}

调用方写起来是这样的:

val request = httpRequest {
    method = HttpMethod.POST
    url = Url("https://api.example.com/users")
    json("""{"name": "Alice"}""")
    timeout = Timeout(5000L)
    header("Authorization", "Bearer token123")
}

注意看,json() 方法里做了校验——GET和DELETE请求不允许设置body。这个校验是在编译期之后、运行期之前做的,但比没有强太多了。

8.5 避坑指南:内联类的限制

嗯,这里要注意。内联类虽然好用,但有几个坑:

  • 不能有init块之外的属性——内联类只能有一个属性,就是它包装的那个。
  • 不能继承其他类——但可以实现接口,这个倒是够用。
  • Java互调时会被脱糖——如果你有Java调用方,内联类会变成原始类型,类型安全就没了。

我曾经在一个混合语言的项目里踩过这个坑。Kotlin侧写得好好的,Java侧直接传了个空字符串进来,内联类的校验完全没生效。后来我加了个@JvmName注解,强制Java调用方走工厂方法,才算解决。

8.6 知识体系总览

下面这张图总结了密封类与内联类在DSL中的角色和关系:

密封类 + 内联类 → 类型安全DSL 密封类 (Sealed Class) 定义有限状态集 每个子类可携带不同数据 when 表达式必须穷举 编译期检查所有分支 内联类 (Inline Class) 零开销类型包装 运行时就是原始类型 构造函数可做校验 防止参数顺序错误 类型安全DSL构建器 密封类 → 约束可选值(如 HttpMethod.GET/POST) 内联类 → 包装原始类型(如 Url, Timeout) 带接收者的lambda → 流畅的DSL语法 编译期安全 + 运行时零开销

8.7 实战:Android状态管理中的DSL

最后分享一个我在Android项目里的实际用法。我们用密封类定义UI状态,用内联类包装ID和事件:

sealed class UiState {
    object Loading : UiState()
    data class Success(val data: List<User>) : UiState()
    data class Error(val message: String) : UiState()
}

@JvmInline
value class UserId(val value: String)

@JvmInline
value class EventName(val value: String)

// DSL 构建UI事件
class EventBuilder {
    var name: EventName = EventName("")
    var userId: UserId? = null
    var metadata: Map<String, String> = emptyMap()

    fun track(block: EventBuilder.() -> Unit): Event {
        this.block()
        return Event(name, userId, metadata)
    }
}

data class Event(
    val name: EventName,
    val userId: UserId?,
    val metadata: Map<String, String>
)

调用方写起来很干净:

val event = EventBuilder().track {
    name = EventName("user_login")
    userId = UserId("u_12345")
    metadata = mapOf("source" to "homepage")
}

你看,UserIdEventName 虽然底层都是String,但编译器不会让你把两者搞混。这就是内联类的价值——类型安全,零成本

注意: 内联类在Kotlin 1.5+中标记为稳定,但如果你用Java调用Kotlin代码,内联类会被脱糖为原始类型。这种情况下,建议在Kotlin侧提供工厂函数,强制调用方走类型安全的路径。

好了,这一章的内容就到这里。密封类加内联类,说白了就是让编译器替你打工——把能提前检查的错误,全部扼杀在编译期。下次你写DSL的时候,不妨试试这个组合拳。


公众号:蓝海资料掘金营,微信deep3321