value class:零开销抽象与装箱的时机
你的函数签名长这样:fun transfer(from: String, to: String, amount: Long, currency: String)。四个参数,两个是 String,两个能互相传反 —— 而编译器一句话都不会说。这类 bug 有个名字叫「基本类型偏执(primitive obsession)」,它是线上事故的经典配方。value class 用一行代码解决它,而且号称零开销。这一章讲清那个「零」的边界在哪 —— 因为它比大多数人以为的窄,而不知道边界的人,正在热路径上装箱而不自知。
它解决的问题
// 编译通过,运行时把钱转给了自己,客服接到投诉 transfer(from = userId, to = userId, amount = cents, currency = "USD") // 编译通过,参数顺序写反了,钱转反了方向 transfer(to, from, amount, currency)
类型系统在这里完全失灵,因为它们的类型都是 String。你能做的只有:命名参数(能防一半,但没人强制你用)、单元测试(只能覆盖你想到的情况)、code review(人会累)。
value class 把这个问题移进编译期:
@JvmInline value class UserId(val raw: String) @JvmInline value class AccountId(val raw: String) @JvmInline value class Cents(val raw: Long) fun transfer(from: AccountId, to: AccountId, amount: Cents) transfer(userId, accountId, cents) // ✗ 编译错误:UserId 不是 AccountId
而且 —— 这是关键 —— 运行时它就是那个 String。没有包装对象,没有分配,没有间接引用。你白拿了类型安全。
凡是「类型相同但语义不同」的参数,全部上 value class。典型清单:各种 ID(userId / orderId / sessionId)、金额(分 vs 元)、时间戳(毫秒 vs 秒)、百分比(0-1 vs 0-100)、原始密码 vs 哈希后的密码、URL vs 路径。
这是本书里投入产出比最高的一条建议:改动量是每个类型一行,收益是一整类 bug 从「线上事故」降级为「编译错误」。
「零开销」的边界在哪
现在说那个没人说清的部分。@JvmInline value class UserId(val raw: String) 编译后,大多数时候 UserId 这个类型会消失,签名里只剩 String。但有四种情况下,编译器必须把它装箱回一个真对象:
| 写法 | 装箱? | 为什么 |
|---|---|---|
fun f(id: UserId) | ❌ 不装箱 | 直接替换成 String |
fun f(): UserId | ❌ 不装箱 | 同上 |
val id: UserId 字段 | ❌ 不装箱 | 字段类型就是 String |
fun f(id: UserId?) | ✅ 装箱 | 底层是 String,但 String 自己也能是 null —— 无法区分「null 的 UserId」和「UserId 里包着 null」,只能装箱 |
List<UserId> | ✅ 装箱 | 泛型实参,擦除后是 Object,必须是对象 |
f(id as Any) / 当接口用 | ✅ 装箱 | 要当对象用,就得是对象 |
| value class 实现了接口,且被当接口传 | ✅ 装箱 | 同上 |
注意那个可空的坑。这段代码看起来人畜无害:
data class Session(
val userId: UserId?, // ← 每一个 Session 都在多分配一个对象
val tags: List<TagId> // ← 每一个 tag 都在装箱
)
如果 Session 在一个长列表里、或者在高频路径上,这就是实打实的开销 —— 而你以为你在用「零开销抽象」。
@JvmInline value class Cents(val raw: Long) —— Cents? 会装箱成一个 Cents 对象,而那个对象里装着一个 long(不是 Long)。相比之下 Long? 装箱成 java.lang.Long,还能享受 JVM 的 Long 缓存(-128~127)。
结论:value class 包基本类型时,尽量别让它可空。用一个哨兵值(Cents(-1))或者外面包一层 sealed(第 7 章)会更划算。当然 —— 先 profile,再优化(第 22 章)。绝大多数业务代码根本到不了这个量级。
它和 typealias、data class 的区别
这三个经常被混淆,一张表钉死:
| 类型安全 | 运行时开销 | 用途 | |
|---|---|---|---|
typealias UserId = String | ❌ 没有 —— 它只是个别名,UserId 和 String 可以随便互相传 | 零 | 给冗长的类型起个短名(typealias Handler = (Event) -> Unit)。它不提供任何保护 |
@JvmInline value class UserId(val raw: String) | ✅ 真类型 | 零(除非装箱) | 给基本类型上语义 |
data class UserId(val raw: String) | ✅ 真类型 | 永远是一个对象 | 需要多个字段、或者需要继承/复杂行为时 |
typealias 是本表里最危险的东西,因为它给人一种「我做了类型建模」的错觉,实际上一点保护都没有。想要保护,就用 value class。
校验一次,类型作保
value class 的第二个用法,比防传反更值钱:把「已校验」这件事编进类型。
@JvmInline
value class Email private constructor(val raw: String) { // 私有构造:全世界只有一个出生通道
companion object {
fun of(raw: String): Email? =
raw.takeIf { it.contains('@') }?.let(::Email)
}
}
从此,任何一个类型是 Email 的值,都必然是校验过的 —— 因为它没有别的出生方式。于是接收 Email 的每个函数都免检:那些层层叠叠的「防御性校验」可以整批退休,因为类型系统已经替你担保了。
这个模式在函数式圈子里叫 parse, don't validate(解析,而非校验):
- 校验:
fun isValid(s: String): Boolean—— 检查完,信息就丢了。下游函数拿到的还是String,它不知道你检查过,只好再检查一遍。 - 解析:
fun parse(s: String): Email?—— 检查完,信息被编进了返回类型。下游拿到的是Email,编译器替你记住了「这个校验过」。
如果只需要「非空 + 简单区间」这类校验,还有更省事的写法 —— init 块(但它抛异常,不返回 null,适合「不可能失败,失败就是 bug」的场景):
@JvmInline
value class Percent(val raw: Int) {
init { require(raw in 0..100) { "百分比必须在 0..100,得到 $raw" } }
}
2.3.20 起,value class 默认使用更安全的名字解构(而不是位置解构)。背景见第 11 章 —— 位置解构写反顺序时编译器完全沉默,是 Kotlin 里最阴险的坑之一。value class 只有一个属性,本来就不该按位置解构,所以它率先切换了。
限制清单(别踩)
- 只能有一个属性,且必须在主构造里、必须是
val。(需要多个字段?那是data class的活。) - 不能有
init之外的额外状态:不能有带后备字段的属性(但可以有计算属性和函数 —— 这很有用:val Cents.dollars get() = raw / 100.0)。 - 不能继承类,也不能被继承。可以实现接口,但一旦被当接口用就会装箱。
equals/hashCode由编译器生成,基于那一个属性,且不装箱(有专门的静态方法)。- 身份比较(
===)被禁止 —— 因为它压根没有稳定的对象身份。
这是 value class 最大的实际限制,也是很多人吃过亏才知道的:参数或返回值里有 value class 的函数,方法名会被混淆(mangled)。
fun load(id: UserId): User
// Java 侧看到的是: public static User load-x7Kd9mQ(String id) // 名字后面挂了一段哈希! // 这个方法名在 Java 里根本没法调用('-' 不是合法标识符)
为什么要混淆?因为 load(UserId) 和 load(AccountId) 擦除后签名一样(都是 load(String)),会撞车 —— 混淆是编译器避免撞车的手段。
后果:value class 出现在公开 API 的签名里,那个 API 对 Java 就是不可用的。解法有两个:加 @JvmName("load") 手动指定名字(但你得自己保证不撞车),或者干脆不要把 value class 放在需要被 Java 调用的签名上。
对纯 Kotlin 项目(今天大多数 Android 项目)—— 这条限制完全不存在,尽情用。这又是第 2 章那句话:判据是「谁调用你」。
你其实已经在用它了
标准库里到处都是 value class,只是你没注意:
Duration(kotlin.time):底层就是一个Long。5.seconds不分配任何对象 —— 而3000L这个「毫秒还是秒?」的千古难题,被类型解决了。Result<T>:也是 value class,底层是Any?。UInt、ULong:无符号类型全是 value class 包着有符号类型。- Compose 的
Dp、Color、Offset:16.dp是零分配的 —— 在每帧都要跑的 UI 代码里,这很重要。Color甚至把 RGBA 压进一个ULong。
这就是「零开销抽象」在真实世界里的样子:Compose 之所以敢让你在 @Composable 里到处写 16.dp、Color.Red,正是因为它们编译后什么都不是。
1. 找出你项目里最长的那个函数签名,数一数有几个同类型参数挨在一起(两个 String、三个 Long)。给它们全部上 value class,然后看编译器报出多少处本来就传反了的调用 —— 我见过团队用这招在存量代码里挖出真实的线上 bug。
2. 挑一个「解析后就该一直有效」的东西(Email、手机号、URL、非空的 ID),用私有构造 + companion 工厂把它做成智能构造子。然后删掉下游所有对它的重复校验。
3. 搜项目里的 typealias,逐个问:它是在「起短名」还是在「假装做类型建模」?后者全部换成 value class。
4. 如果项目里有性能敏感的列表数据类:检查有没有 value class? 或 List<ValueClass>。它们正在偷偷装箱。
下一章:把「非法状态」从类型里彻底删掉 —— sealed、when,以及一个能算出你的 UiState 藏着多少个鬼状态的计数器。