一杯半 Kotlin 美式详解 value class

0 阅读6分钟

value.png

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

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

关键特性

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

  • 让原本相同基础类型的不同含义(例如 UserIdOrderId,都可能是 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 擦除成底层类型(如 StringInt),避免对象创建和额外的 GC 压力。

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

使用限制

当然,为了保持轻量和可优化,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 classvalue 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 classvalue 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_implgetId_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 编译器开关,以便对整个项目统一开启这一行为。