一杯半 Kotlin 美式详解 value class

890 阅读6分钟

value.png

value class 是 Kotlin 为了优化性能而引入的一种特殊类,用来避免不必要的对象分配。

value class 只包装一个值,但在运行时会被内联,当作底层的那个值本身来处理,而不是一个完整的对象实例。这让它既轻量高效,又能提供良好的类型安全。

关键特性

value class 能够用更有语义的方式表达“一个属性值”,在保持高性能的同时提供类型安全:

  • 让原本相同基础类型的不同含义(例如 UserId 与 OrderId,都可能是 Int)在类型层面上被区分开来。
  • 避免在函数参数和领域建模中,把“长得一样”的基础类型误用在错误的位置。

下面是一个典型的 value class 定义与使用方式:

@JvmInline
value class Password(val value: String)

fun authenticate(password: Password) {
    println("Authenticating with password: ${password.value}")
}

val userPassword = Password("secure123")
authenticate(userPassword)

在这个例子里,Password 只是对 String 做了一层封装。在运行时,它通常会直接被表示为一个原始 String,因此不会额外分配 Password 对象的堆内存。

有什么好处

  1. 类型安全:
    通过为“同一基础类型的不同角色”建模,value class 可以在编译期就阻止类型混用,比如防止把 UserId 当成 ProductId 使用。

  2. 性能优化:
    在大多数情况下,编译器会直接把 value class 擦除成底层类型(如 String 或 Int),避免对象创建和额外的 GC 压力。

  3. 语义清晰:
    为一个值起一个有意义的类型名(如 Password、UserId),让代码意图更清楚,阅读和维护都更容易。

使用限制

当然,为了保持轻量和可优化,value class 有一些约束:

  • 主构造函数中只能有一个只读属性(val)。
  • 不支持继承,本质上就是一个“受约束的包装类型”。
  • 底层属性默认不能是可空类型,除非显式使用可空(例如 Int?)。

官方案例

如果你觉得这些只是一些华而不实的小花招,那就错了。

在 Jetpack Compose UI 中,官方就用 value class 来封装底层的键盘事件:

/**
 * When a user presses a key on a hardware keyboard, a [KeyEvent] is
 * sent to the item that is
 * currently focused. Any parent composable can intercept this [key
 * event][KeyEvent] on its way to
 * the focused item by using [Modifier.onPreviewKeyEvent()]
 * [onPreviewKeyEvent]. If the item is not
 * consumed, it returns back to each parent and can be intercepted by
 * using
 * [Modifier.onKeyEvent()][onKeyEvent].
 *
 * @sample androidx.compose.ui.samples.KeyEventSample
 */
@kotlin.jvm.JvmInline
value class KeyEvent(val nativeKeyEvent: NativeKeyEvent)

通过 value class,Compose 把底层平台的 NativeKeyEvent 包装成语义更清晰的 KeyEvent,同时保持良好的性能与类型安全。

嘿嘿,你有没有想过,如果跨平台的话,开发者只需要面对统一的 KeyEvent。

小结

value class 适合那些“只有一个字段、又希望有清晰语义和强类型约束”的场景。

你可以把它理解成:在不增加运行时成本的前提下,为原本的基础类型加上一层“领域含义”的外壳。

进阶:value 与 inline

其实早启的 Kotlin 中,存在的是另一个版本 —— inline class。

从功能上看,inline class 和 value class 非常相似——都是围绕单一值的轻量包装。但它们在命名、引入版本以及推荐使用方式上存在差异,反映了 Kotlin 语言在演进过程中对“清晰性”和“一致性”的追求。

  • inline class:在 Kotlin 1.3 中以实验特性引入。
  • value class:在 Kotlin 1.5 中正式命名和稳定下来,用来取代 inline class 的术语。

inline 是如何设计

早期的 inline class 使用 inline 修饰符作用在类上,用来表达“这个类是一个轻量包装器,编译器会尽量避免真实对象分配”。例如:

inline class InlineUserId(val value: Int)

fun processInlineId(id: InlineUserId) {
    println("Processing ID: ${id.value}")
}

inline class 最初被设计为实验性特性,用来在不牺牲性能的前提下提供更好的类型安全。然而,随着社区使用的深入,人们逐渐发现:

  • inline 这个关键字已经在 inline 函数中使用,它们的语义(内联调用点、支持非局部返回等)与 inline class 并不相同。
  • inline class 的行为也不等同于 inline 函数,它既不会自动让方法变成 inline 函数,也不会保证所有使用场景都被内联。

这导致了一定程度的概念混淆。

更清晰的命名

为了让语义更加清楚,Kotlin 在 1.5 中正式引入了 value class 的名称,并配合 @JvmInline 注解一起使用:

@JvmInline
value class ValueUserId(val value: Int)

fun processValueId(id: ValueUserId) {
    println("Processing ID: ${id.value}")
}

相比 inline class,value class 更直接地表达了“这里的重点是值(value),而不是对象标识(identity)”:

  • 没有对象标识和引用相等性(===)的概念。
  • 主要关注的是“这个值在类型、约束和语义上的含义”。

inline 是用户自定义的 value

根据 KEEP’s Design Notes on Kotlin Value Classes 的说明,自 Kotlin 1.2.30 起的 inline class,本质上就是一种用户自定义的 value class。它们的核心特征在于:

  • 显式移除了标识和引用相等性(===),对 inline class 使用 === 会直接导致编译错误。
  • 为编译器提供了优化空间,可以在更多场景下避免真正的对象分配。

但 inline class 使用 inline 关键字,容易让人误以为“它和 inline 函数的行为/保证是类似的”,这并不准确:

  1. inline class 的成员函数本身并不会自动成为 inline 函数。
  2. inline class 并不提供类似 inline 函数那样的语义能力(例如非局部返回)。
  3. inline class 并不会在所有地方都被内联,某些场景下依然会被装箱。

因此,从 Kotlin 1.5 开始,官方统一采用 value class 这一更贴切的术语,将其定位为“无标识、轻量级的值抽象”。

@JvmInline

@JvmInline 注解的引入,一方面保持了与早期 inline class 使用者的概念连续性,另一方面也为与 JVM 未来的 Project Valhalla 对接铺平道路:

  • 它向 JVM 明确标记:这个类在字节码层面有特殊的值类型行为。
  • 将来当 Valhalla 的值类型(例如 primitive class)完全成熟时,Kotlin 就可以更自然地映射到这些 JVM 级别的值类型上。

设计说明中还提到一个有趣的点:

如果 Valhalla 最终把对应概念命名为 “inline class”,那 Kotlin 反而要考虑弃用 @JvmInline 这个名字,以免产生双重含义的混淆。

从这点来看,我为 Kotlin 感到些许伤感

总之,

  • inline class 是早期的术语和实验形态。
  • value class 是稳定后的正式名词和推荐用法。
  • 从 Kotlin 1.5 起,新代码应优先使用 value class + @JvmInline,而不是继续依赖已弃用的 inline class。

进阶:Java 字节码

value class 最吸引人的一点是:既能在类型层面提供强约束,又几乎不增加运行时成本。

这是通过编译器在背后做的大量工作实现的,核心机制可以概括为:拆箱(unboxing) + 擦除(erasure)。

好熟悉!如果你深入研究过 Java 泛型,那么对这两个概念并不陌生。

先来看一段简单的 Kotlin 代码:

@JvmInline
value class UserId(val id: String)

fun processId(userId: UserId) {
    println("Processing user with ID: ${userId.id}")
}

fun main() {
    val myId = UserId("user-123")
    processId(myId)
}

在 Kotlin 代码层面:

  • UserId 是一个有语义的类型,而不是随处可见的裸 String。
  • processId 只能接收 UserId,避免了误把其他 String 传进来。

但在运行时,它到底会不会分配一个真实的 UserId 对象?

把上面的代码编译并反编译成 Java,可以看到类似下面的结构(概念化示例):

public final class IdKt {
    // 包装类本身依然存在于字节码中,用于反射和装箱场景。
    public static final class UserId {
        private final String id;

        // 合成构造函数和 getter,带有混淆名称
        public static String constructor_impl(String id) {
            return id;
        }

        public static String getId_impl(UserId $this) {
            return $this.id;
        }

        // ... equals、hashCode、toString 等也会基于 String 生成 ...
    }

    // 对外可见的 processId,参数类型已经变成 String。
    public static final void processId(String userId) {
        String var1 = "Processing user with ID: " + userId;
        System.out.println(var1);
    }

    public static final void main() {
        // 构造调用被擦除,变成简单的静态辅助调用。
        String myId = UserId.constructor_impl("user-123");

        // 传入的也是 String,而不是 UserId。
        processId(myId);
    }
}

从这个结构中可以看到几个关键点:

  1. 包装类型被擦除成基础类型
    Kotlin 里的 fun processId(userId: UserId),在 Java 视角下变成了 public static final void processId(String userId)。
    对调用方来说,它只看到了一个 String,但 Kotlin 在源码层面依然保持了 UserId 的类型约束。

  2. 没有额外堆分配
    val myId = UserId("user-123") 并没有编译成 new UserId("user-123"),而是编译成一次静态函数调用 + String 赋值。
    也就是说,在这种简单场景下,运行时根本不会创建任何 UserId 对象。

  3. 通过混淆命的辅助函数维持语义
    像 constructor_impl、getId_impl 这样的辅助函数,属于编译器内部使用的 API,用来维持“值类仍然是一个类型”的语义,同时不暴露给普通调用者。

这就是所谓的“零成本抽象”:在源码层面增加了类型和语义信息,但生成的字节码几乎等价于直接使用基础类型。

什么时候需要装箱

当然,编译器也不是所有时候都能完全擦除包装类型,在某些使用方式下,它必须退回到“真实对象”的形式,也就是装箱(boxing)。

典型场景包括:

val id1: Any = UserId("abc")          // 作为 Any 存储时会强制装箱
val id2: UserId? = UserId("def")      // 使用可空类型时可能触发装箱
val listOfIds = listOf(UserId("1"), UserId("2")) // 放入泛型集合中

在这些场景下:

  • 运行时会真正创建一个 UserId 对象实例。
  • 这个对象依然很轻量,但会有一次标准的堆分配。

当你从 listOfIds 中取出元素并传给 processId 时,编译器会自动把 UserId 拆箱成 String 再调用对应的函数。
换句话说:对使用者来说,这个拆箱过程是透明的。

进阶:@JvmExposeBoxed

虽然值类在 Kotlin 世界里用起来很优雅,但在 Java 世界里却不那么友好。

由于方法名混淆、构造函数设为 synthetic 等原因,Java 代码往往既看不到干净的 API,也无法直接 new 一个值类。

第一次听说 synthetic?
一句话,synthetic 是 Java 用来标识那些并非由程序员手写、而是 Java 编译器生成的代码。

为了解决这一点,KEEP jvm expose boxed 提案中引入了 @JvmExposeBoxed 注解,专门面向 JVM 平台。

那么,我们看看主要矛盾在哪里?

考虑下面这个值类:

@JvmInline
value class PositiveInt(val number: Int) {
    init { require(number >= 0) }
}

fun PositiveInt.add(other: PositiveInt): PositiveInt =
    PositiveInt(this.number + other.number)

在 Kotlin 视角,它很好理解:表示一个“必须为非负数”的整数,并提供一个 add 扩展函数。

但编译到 JVM 时,为了避免签名冲突(add(Int) 和 add(PositiveInt) 在 JVM 上都会变成 add(int)),编译器会对这些函数做名称混淆,大致变成:

public static final int add-1bc5(int $this, int other)

这对 Java 开发者来说基本不可用:

  • 名称既不直观,也不稳定。
  • PositiveInt 的构造函数是 synthetic,无法 new PositiveInt(5)。

从 Java 角度看,这个值类几乎“形同虚设”。

于是,@JvmExposeBoxed 生成一组“面向 Java 的包装 API”。

@JvmExposeBoxed 的意义就在于:为值类自动生成第二套“装箱版”API,用来服务 Java 调用者。

加上注解后:

@JvmExposeBoxed
@JvmInline
value class PositiveInt(val number: Int) {
    init { require(number >= 0) }

    fun add(other: PositiveInt): PositiveInt =
        PositiveInt(this.number + other.number)
}

反编译后(概念化示例)会看到类似结构:

public final class PositiveInt {
    // 1. 生成了一个公共且非 synthetic 的构造函数
    public PositiveInt(int number) {
        // 内部调用 constructor-impl,执行 init 逻辑
    }

    // 用于 Kotlin 内部的混淆名称未装箱版本
    public static final int add-1df3(int $this, int other) { /* ... */ }

    // 2. 面向 Java 的装箱实例方法,名称干净可读
    public final PositiveInt add(PositiveInt other) {
        int result = add-1df3(this.unbox-impl(), other.unbox-impl());
        return PositiveInt.box-impl(result);
    }

    // ... 其他诸如 box-impl、unbox-impl、constructor-impl 等内部方法 ...
}

可以看到,现在:

  • Java 可以直接写:new PositiveInt(5)。
  • 也可以写:positiveInt1.add(positiveInt2)。
  • Kotlin 代码则继续直接调用未装箱版本,不会为 Java 友好性付额外成本。

内部做了什么

装箱版方法(例如上面的 add 实例方法)本质上就是一个桥接层,它遵循三步流程:

  1. 拆箱输入:
    将 PositiveInt other 这样的参数,通过 unbox-impl 还原成基础类型 int。

  2. 调用未装箱逻辑:
    调用混淆名称的静态方法 add-1df3,它是为 Kotlin 优化过的版本。

  3. 对结果装箱:
    用 box-impl 把返回的基础类型结果重新包装成 PositiveInt 实例,再返回给 Java 调用者。

设计目标与收益

这种设计同时满足了三个目标:

  • 保持 Java 互操作性:
    Java 世界拥有一个“看起来就像普通类”的 API,可以自然使用构造函数和实例方法。

  • 对 Kotlin 零额外成本:
    Kotlin 仍然只和未装箱版本打交道,性能与原先保持一致。装箱/拆箱的成本只在 Java 调用边界产生。

  • 不变量得以保障:
    公共构造函数内部仍然会执行 init 逻辑,确保 Java 代码无法绕过约束(例如创建一个负数的 PositiveInt)。

此外,@JvmExposeBoxed 既可以标注在整个类上,让所有相关成员都暴露装箱版本,也可以只标注在部分函数或构造函数上,让库作者按需控制 API 表面。提案中还建议提供 -Xjvm-expose-boxed 编译器开关,以便对整个项目统一开启这一行为。