
value class 是 Kotlin 为了优化性能而引入的一种特殊类,用来避免不必要的对象分配。
value class 只包装一个值,但在运行时会被内联,当作底层的那个值本身来处理,而不是一个完整的对象实例。这让它既轻量高效,又能提供良好的类型安全。
关键特性
value class 能够用更有语义的方式表达"一个属性值",在保持高性能的同时提供类型安全:
- 让原本相同基础类型的不同含义(例如
UserId与OrderId,都可能是Int)在类型层面上被区分开来。 - 避免在函数参数和领域建模中,把"长得一样"的基础类型误用在错误的位置。
下面是一个典型的 value class 定义与使用方式:
kotlin
@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 对象的堆内存。
有什么好处
-
类型安全 :
通过为"同一基础类型的不同角色"建模,
value class可以在编译期就阻止类型混用,比如防止把UserId当成ProductId使用。 -
性能优化 :
在大多数情况下,编译器会直接把
value class擦除成底层类型(如String或Int),避免对象创建和额外的 GC 压力。 -
语义清晰 :
为一个值起一个有意义的类型名(如
Password、UserId),让代码意图更清楚,阅读和维护都更容易。
使用限制
当然,为了保持轻量和可优化,value class 有一些约束:
- 主构造函数中只能有一个只读属性(
val)。 - 不支持继承,本质上就是一个"受约束的包装类型"。
- 底层属性默认不能是可空类型,除非显式使用可空(例如
Int?)。
官方案例
如果你觉得这些只是一些华而不实的小花招,那就错了。
在 Jetpack Compose UI 中,官方就用 value class 来封装底层的键盘事件:
kotlin
/**
* 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 修饰符作用在类上,用来表达"这个类是一个轻量包装器,编译器会尽量避免真实对象分配"。例如:
kotlin
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 注解一起使用:
kotlin
@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 函数的行为/保证是类似的",这并不准确:
inline class的成员函数本身并不会自动成为inline函数。inline class并不提供类似inline函数那样的语义能力(例如非局部返回)。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 代码:
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,可以看到类似下面的结构(概念化示例):
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);
}
}
从这个结构中可以看到几个关键点:
-
包装类型被擦除成基础类型
Kotlin 里的
fun processId(userId: UserId),在 Java 视角下变成了public static final void processId(String userId)。对调用方来说,它只看到了一个
String,但 Kotlin 在源码层面依然保持了UserId的类型约束。 -
没有额外堆分配
val myId = UserId("user-123")并没有编译成new UserId("user-123"),而是编译成一次静态函数调用 +String赋值。也就是说,在这种简单场景下,运行时根本不会创建任何
UserId对象。 -
通过混淆命的辅助函数维持语义
像
constructor_impl、getId_impl这样的辅助函数,属于编译器内部使用的 API,用来维持"值类仍然是一个类型"的语义,同时不暴露给普通调用者。
这就是所谓的"零成本抽象":在源码层面增加了类型和语义信息,但生成的字节码几乎等价于直接使用基础类型。
什么时候需要装箱
当然,编译器也不是所有时候都能完全擦除包装类型,在某些使用方式下,它必须退回到"真实对象"的形式,也就是装箱(boxing)。
典型场景包括:
kotlin
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 平台。
那么,我们看看主要矛盾在哪里?
考虑下面这个值类:
kotlin
@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)),编译器会对这些函数做名称混淆,大致变成:
java
public static final int add-1bc5(int $this, int other)
这对 Java 开发者来说基本不可用:
- 名称既不直观,也不稳定。
PositiveInt的构造函数是synthetic,无法new PositiveInt(5)。
从 Java 角度看,这个值类几乎"形同虚设"。
于是,@JvmExposeBoxed 生成一组"面向 Java 的包装 API"。
@JvmExposeBoxed 的意义就在于:为值类自动生成第二套"装箱版"API,用来服务 Java 调用者。
加上注解后:
kotlin
@JvmExposeBoxed
@JvmInline
value class PositiveInt(val number: Int) {
init { require(number >= 0) }
fun add(other: PositiveInt): PositiveInt =
PositiveInt(this.number + other.number)
}
反编译后(概念化示例)会看到类似结构:
java
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 实例方法)本质上就是一个桥接层,它遵循三步流程:
-
拆箱输入 :
将
PositiveInt other这样的参数,通过unbox-impl还原成基础类型int。 -
调用未装箱逻辑 :
调用混淆名称的静态方法
add-1df3,它是为 Kotlin 优化过的版本。 -
对结果装箱 :
用
box-impl把返回的基础类型结果重新包装成PositiveInt实例,再返回给 Java 调用者。
设计目标与收益
这种设计同时满足了三个目标:
-
保持 Java 互操作性 :
Java 世界拥有一个"看起来就像普通类"的 API,可以自然使用构造函数和实例方法。
-
对 Kotlin 零额外成本 :
Kotlin 仍然只和未装箱版本打交道,性能与原先保持一致。装箱/拆箱的成本只在 Java 调用边界产生。
-
不变量得以保障 :
公共构造函数内部仍然会执行
init逻辑,确保 Java 代码无法绕过约束(例如创建一个负数的PositiveInt)。
此外,@JvmExposeBoxed 既可以标注在整个类上,让所有相关成员都暴露装箱版本,也可以只标注在部分函数或构造函数上,让库作者按需控制 API 表面。提案中还建议提供 -Xjvm-expose-boxed 编译器开关,以便对整个项目统一开启这一行为。