炉 II · 熔炼CH 06进度 06/26

value class:零开销抽象与装箱的时机

你的函数签名长这样:fun transfer(from: String, to: String, amount: Long, currency: String)。四个参数,两个是 String,两个能互相传反 —— 而编译器一句话都不会说。这类 bug 有个名字叫「基本类型偏执(primitive obsession)」,它是线上事故的经典配方。value class 用一行代码解决它,而且号称零开销。这一章讲清那个「零」的边界在哪 —— 因为它比大多数人以为的窄,而不知道边界的人,正在热路径上装箱而不自知。

@JvmInline装箱时机智能构造子名字混淆

它解决的问题

// 编译通过,运行时把钱转给了自己,客服接到投诉
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❌ 没有 —— 它只是个别名,UserIdString 可以随便互相传给冗长的类型起个短名(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" } }
}
✦ 新炉料 · value class 与名字解构 2.3.20 实验

2.3.20 起,value class 默认使用更安全的名字解构(而不是位置解构)。背景见第 11 章 —— 位置解构写反顺序时编译器完全沉默,是 Kotlin 里最阴险的坑之一。value class 只有一个属性,本来就不该按位置解构,所以它率先切换了。

限制清单(别踩)

  • 只能有一个属性,且必须在主构造里、必须是 val。(需要多个字段?那是 data class 的活。)
  • 不能有 init 之外的额外状态:不能有带后备字段的属性(但可以有计算属性和函数 —— 这很有用:val Cents.dollars get() = raw / 100.0)。
  • 不能继承类,也不能被继承。可以实现接口,但一旦被当接口用就会装箱。
  • equals/hashCode 由编译器生成,基于那一个属性,且不装箱(有专门的静态方法)。
  • 身份比较(===)被禁止 —— 因为它压根没有稳定的对象身份。
▸ 旧模具 · Java 侧的丑陋真相:名字混淆

这是 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,只是你没注意:

  • Durationkotlin.time):底层就是一个 Long5.seconds 不分配任何对象 —— 而 3000L 这个「毫秒还是秒?」的千古难题,被类型解决了。
  • Result<T>:也是 value class,底层是 Any?
  • UIntULong:无符号类型全是 value class 包着有符号类型。
  • Compose 的 DpColorOffset16.dp 是零分配的 —— 在每帧都要跑的 UI 代码里,这很重要。Color 甚至把 RGBA 压进一个 ULong

这就是「零开销抽象」在真实世界里的样子:Compose 之所以敢让你在 @Composable 里到处写 16.dpColor.Red,正是因为它们编译后什么都不是。

✚ 动手锻打

1. 找出你项目里最长的那个函数签名,数一数有几个同类型参数挨在一起(两个 String、三个 Long)。给它们全部上 value class,然后看编译器报出多少处本来就传反了的调用 —— 我见过团队用这招在存量代码里挖出真实的线上 bug。

2. 挑一个「解析后就该一直有效」的东西(Email、手机号、URL、非空的 ID),用私有构造 + companion 工厂把它做成智能构造子。然后删掉下游所有对它的重复校验。

3. 搜项目里的 typealias,逐个问:它是在「起短名」还是在「假装做类型建模」?后者全部换成 value class。

4. 如果项目里有性能敏感的列表数据类:检查有没有 value class?List<ValueClass>。它们正在偷偷装箱。

下一章:把「非法状态」从类型里彻底删掉 —— sealed、when,以及一个能算出你的 UiState 藏着多少个鬼状态的计数器。