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。
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中的角色和关系:
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")
}
你看,UserId 和 EventName 虽然底层都是String,但编译器不会让你把两者搞混。这就是内联类的价值——类型安全,零成本。
好了,这一章的内容就到这里。密封类加内联类,说白了就是让编译器替你打工——把能提前检查的错误,全部扼杀在编译期。下次你写DSL的时候,不妨试试这个组合拳。