内联值类用一个新类型包装单个值;在合适的位置,编译器可以直接使用它的底层表示。
“内联”不是不分配对象的保证。泛型、接口和可空边界可能需要装箱,公开构造函数也可能绕过你原本想强制执行的工厂规则。
先用值类表达类型与不变量,再检查真实调用边界。只有性能重要且测量结果指向装箱时,才围绕具体热路径调整表示。
是什么,为什么存在
内联值类(inline value class) 是没有稳定对象标识、以一个主构造函数属性承载数据的 Kotlin 类。
在 JVM 后端,声明同时使用 value 与 @JvmInline:@JvmInline value class UserId(val value: Long)。
它在 Kotlin 类型系统中是独立类型,但编译器可以在运行时用被包装的值表示实例。
值类解决的是“多个概念碰巧使用同一种基础类型”的问题。
如果用户 ID 与订单 ID 都是 Long,普通参数可以被意外对调,而且编译器无法发现。
把它们声明成 UserId 与 OrderId 后,错误会停在编译期,而存储和协议边界仍可显式访问原始 Long。
你会在标识符、计量单位、已规范化字符串和受约束标量中遇到值类。 它适合一个值就能完整表达的概念。 需要多个数据属性、可复制快照或常规对象标识时,应考虑数据类或普通类,而不是把几个字段塞进集合或字符串后再包装。
类型别名不创建新类型
typealias UserId = Long 只给 Long 增加另一个源码名称。
它与 Long 赋值兼容,另一个指向 Long 的别名也能传给 UserId 参数。
类型别名适合缩短复杂类型签名,不适合阻止相同底层类型之间的误传。
值类则创建真正的新类型。
UserId(7) 不能直接传给需要 OrderId 或 Long 的函数,反方向也不行。
这种边界是值类最可靠的收益;运行时是否装箱属于另一层问题,不应混为“类型是否真实存在”。
工作原理
声明约束
JVM 内联值类必须有且只有一个主构造函数属性。
该属性使用 val,不能是 vararg;类本身始终是 final,不能声明为 inner、data 或 enum,也不能继承普通类。
值类可以实现接口,因此可以在保留领域类型的同时加入一项行为契约。
构造函数、init 块、次构造函数、成员函数与计算属性都可以出现在值类中。
除主构造函数属性外,其他属性不能拥有后备字段,所以 lateinit 属性与委托属性不适用。
伴生对象可以提供解析或规范化入口,也不会给每个实例增加字段。
主构造函数的单个属性是 底层类型(underlying type) 的来源。 它可以是原始数值、引用类型,或带上界的类型参数。 “只有一个属性”限制的是当前内联表示,不表示被引用对象本身不可变或只有一个字段。
表示由使用位置决定
编译器为每个值类保留包装类,同时尽量在允许的位置使用底层值。
直接接收 OrderId 的 JVM 函数通常可接收它的底层 long,成员调用也可编译成处理底层值的静态方法。
这是一种编译策略,不是源码层面可以观察或依赖的对象布局契约。
实例以另一种类型使用时,通常要进行 装箱(boxing) 。
典型边界包括泛型类型参数、实现的接口、Any,以及底层值无法同时表达 null 的可空值类。
同一个源码值可能在一处使用底层表示,在另一处使用包装表示。
表示切换不会取消 Kotlin 的静态类型检查。
装箱后的 UserId 仍不是 OrderId,拆箱后返回 UserId 的泛型函数也不会把它变成 Long。
它只改变运行时承载方式,并可能改变分配与调用成本。
成员、相等性与标识
值类可以声明成员函数、实现接口方法和重写 toString()。
编译器基于唯一数据属性提供 equals() 与 hashCode();当前规则不允许值类自行重写这两个成员。
使用 == 比较同一值类的值,语义与底层属性的值相等性一致。
值类没有可依赖的引用标识,因为同一个值可能被内联,也可能被包装为不同对象。
因此,不要用 === 或 !== 推断两个值类值是否来自同一个构造调用。
Kotlin 编译器会拒绝许多这样的比较,即使某种写法通过,其结果也不是领域语义。
val 只禁止重新赋值主构造函数属性。
如果底层值是 MutableList 或其他可变对象,其内容仍能变化,继而改变基于底层值生成的相等性和哈希。
值类不是深不可变容器。
示例
下面四个程序都是独立文件。 它们已使用 Kotlin 2.4.10 编译器编译,并在 JRE 21 上运行;紧随代码的输出来自实际执行。 代码块没有浏览器运行标记,因为本站的浏览器运行器不执行 Kotlin。
区分相同的底层类型
第一个例子让两个 Long 承担不同角色。
Order 的构造函数因此写出了字段含义,而不是依赖参数顺序和命名约定。
@JvmInline
value class UserId(val value: Long)
@JvmInline
value class OrderId(val value: Long)
data class Order(val id: OrderId, val buyerId: UserId)
fun label(order: Order): String =
"order=${order.id.value} buyer=${order.buyerId.value}"
fun main() {
val order = Order(OrderId(42), UserId(7))
println(label(order))
println("same raw value=${order.id.value == UserId(42).value}")
// label(Order(OrderId(42), OrderId(7))) 无法通过编译。
}order=42 buyer=7
same raw value=true第二行输出说明两个包装值的原始 Long 可以相等。
它们在源码中仍属于不同类型,所以相等的位模式不会让 OrderId 自动兼容 UserId。
注释中的调用若取消注释,第二个参数会在编译期报告类型不匹配。
这个边界也让重构更安全。 字段或参数更名只能改善提示,独立类型则让编译器检查每个调用点。 在数据库、JSON 或 URL 边界解包仍是显式操作,代码审查可以看到类型安全从哪里开始、在哪里结束。
在构造边界建立不变量
值类可以让已经规范化并通过校验的字符串拥有专用类型。
私有主构造函数阻止同一模块中的普通调用点绕过 parse()。
@JvmInline
value class EmailAddress private constructor(val value: String) {
init {
require('@' in value) { "email must contain @" }
require(value.none(Char::isWhitespace)) { "email must not contain whitespace" }
}
val domain: String
get() = value.substringAfter('@')
companion object {
fun parse(raw: String): EmailAddress =
EmailAddress(raw.trim().lowercase())
}
}
fun main() {
val email = EmailAddress.parse(" [email protected] ")
println(email.value)
println(email.domain)
val message = runCatching { EmailAddress.parse("missing-at-sign") }
.exceptionOrNull()
?.message
println(message)
}[email protected]
example.com
email must contain @parse() 先修剪并统一大小写,随后由 init 检查构造结果。
成功返回 EmailAddress 后,使用方不必重复检查空白或 @。
失败仍是公开 API 的一部分;若输入错误属于正常分支,可以提供返回可空值或领域结果类型的另一个工厂。
这里的规则只是教学用的最小约束,不是完整邮箱标准。 值类不会把简单的字符串判断升级为标准验证器,也不会自动改变 JSON 或数据库格式。 真实边界仍要明确决定解析、错误报告与序列化方式。
观察装箱边界
下一个值类实现接口,并分别穿过泛型、可空和接口参数。 这些函数内部通过 JVM 类名观察到包装表示。
interface Renderable {
fun render(): String
}
@JvmInline
value class CustomerId(val value: Long) : Renderable {
override fun render(): String = "customer-$value"
}
fun <T : Any> describeGeneric(value: T): String =
"${value.javaClass.simpleName}:$value"
fun describeNullable(value: CustomerId?): String =
"${value?.javaClass?.simpleName}:$value"
fun describeInterface(value: Renderable): String =
"${value.javaClass.simpleName}:${value.render()}"
fun main() {
val id = CustomerId(7)
println(id.render())
println(describeGeneric(id))
println(describeNullable(id))
println(describeInterface(id))
}customer-7
CustomerId:CustomerId(value=7)
CustomerId:CustomerId(value=7)
CustomerId:customer-7直接的 id.render() 可以使用值类自身的表示。
另外三次调用要求值充当 T、CustomerId? 或 Renderable,运行时因此能看到名为 CustomerId 的包装类。
默认 toString() 也把类名和底层属性写进输出。
这段输出证明这些具体调用存在包装表示,却不是微基准。 JIT 仍可能消除某些临时分配,其他后端的表示规则也不同。 如果成本影响目标服务,应在相同编译选项、后端和调用形状下测量,而不是从类名推算速度。
给 Java 一个稳定入口
值类参数会参与 JVM 名称修饰。
@JvmName 可以给采用底层表示的顶层函数指定一个合法且稳定的 Java 名称。
@file:JvmName("OrderQueries")
@JvmInline
value class OrderId(val value: Long)
@JvmName("findOrderById")
fun findOrder(id: OrderId): String = "order-${id.value}"
fun main() {
println(findOrder(OrderId(42)))
}order-42对编译产物运行 javap,可以看到 OrderQueries.findOrderById(long)。
Java 调用方传入 long,而 Kotlin 实现立即恢复 OrderId 的领域边界。
这种桥接适合只需传递底层表示的稳定 Java API。
Kotlin 2.4.10 还提供实验性的 @JvmExposeBoxed 与模块级 -Xjvm-expose-boxed,用于生成 Java 可访问的包装构造函数和装箱方法变体。
它们需要 ExperimentalStdlibApi 选择加入。
是否暴露装箱 API 是 ABI 设计决定,不应为了方便 Java 调用而在整个模块中无差别开启。
陷阱
修复方法: 先按类型安全设计接口,再定位实际热路径。 使用目标 Kotlin 版本的编译产物和分析器确认装箱,并用贴近生产调用形状的基准判断它是否重要。
修复方法: 作为哈希键或长期值使用时,让整条相等性对象图保持稳定。 在构造边界复制可变输入,或包装真正不可变的表示;不要把只读接口误当作没有别名的保证。
修复方法: 不变量必须统一时,限制主构造函数可见性,并让所有公开工厂经过同一校验路径。 同时测试大小写、前后空白、空值和边界值,确认每个入口产生一致结果。
修复方法: 使用 == 表达值相等,并为底层类型选择正确的相等性语义。
若业务需要独立实体标识,把标识本身放进值类,用该值比较,而不是比较包装对象引用。
修复方法: 为底层表示提供带 @JvmName 的明确桥接,或在接受实验性 ABI 后有选择地使用 @JvmExposeBoxed。
用 javap 检查发布产物,并从 Java 测试源码实际编译调用点。
修复方法: 敏感值需要明确的允许字段日志策略,而不只是重写一个显示方法。
可以重写 toString() 降低意外泄漏风险,但序列化、调试器、反射和显式属性访问仍要单独审查。
JVM 表示与 ABI
包装类始终存在
JVM 后端会为值类生成包装类,因为接口、泛型和其他对象位置需要真实引用。 同一个产物还包含处理底层值的合成函数,例如构造、成员调用、相等性、装箱和拆箱辅助方法。 这些方法让编译器在不同表示之间转换,但名称和具体形状属于后端 ABI。
直接的值类参数通常编译成底层 JVM 类型。
例如,以 Long 为底层类型的 OrderId 可以出现在方法描述符中作为原始 long。
如果底层类型本来就是引用,未装箱表示仍是那个引用;“未装箱”不等于“原始数值”。
源码层面的构造调用也不保证创建包装对象。
编译器可以把 OrderId(42) 编译成底层值,并在遇到对象边界时才装箱。
反过来,泛型函数返回后若静态类型恢复为 OrderId,编译器又可以拆箱供后续直接调用使用。
名称修饰避免签名冲突
若 OrderId 以 long 表示,fun load(id: Long) 与 fun load(id: OrderId) 在 JVM 上原本会得到同一参数描述符。
Kotlin 为使用值类的函数名增加稳定哈希,以免两个声明发生平台签名冲突。
哈希解决了 JVM 层面的重载,却让默认方法名不适合直接写在 Java 源码中。
@JvmName 改变导出的 JVM 名称,但不会让值类包装构造函数自动变成 Java API。
当 Java 只需传递底层值时,命名桥接通常最小也最清楚。
桥接函数应立即构造领域类型,这样校验与 Kotlin 内部 API 仍集中在一处。
需要 Java 持有值类对象时,@JvmExposeBoxed 会生成装箱入口。
它在 Kotlin 2.4.10 中仍是实验性 API,所以升级编译器时要重新验证生成签名。
模块级开关影响范围更大,库作者还要把它视为二进制兼容性承诺。
可空表示取决于底层类型
对于底层为非空原始值的 CustomerId,CustomerId? 必须区分一个有效数字与 null,因此通常使用包装引用。
但可空规则不能缩写成“问号永远分配对象”。
底层本身可空、泛型上界和后续内联都会影响可用表示,JIT 也可能消除临时对象。
不要为了逃避可能的装箱而引入魔法数字或空字符串哨兵。 这种做法把表示问题变成数据正确性问题,还可能让一个本应非法的值流入数据库或协议。 先保留准确的空值语义,再根据测量决定是否需要改造热路径的数据布局。
泛型底层类型在 JVM 上会映射到其运行时可用的上界,未指定更窄上界时通常是 Any?。
这让 value class Box<T>(val value: T) 保留静态类型区别,却不能承诺原始类型专门化。
需要扁平原始数组或数值批处理时,应检查生成表示,而不是从 value 修饰符推断。
不变量、相等性与可变性
构造成功才建立不变量
类不变量(class invariant) 是在成功构造后以及每次公开操作后都必须成立的规则。
值类的 init 块可以拒绝非法底层值,私有构造函数则能迫使普通 Kotlin 调用方经过命名工厂。
两者组合适合完成修剪、大小写统一、范围检查或格式解析。
工厂名称应说明失败模型。
parse() 可以抛出格式错误,parseOrNull() 应以 null 表示失败,返回领域结果的工厂则能保留原因。
模型生成代码常混用这些形式,使调用方既捕获异常又检查空值;公开入口应选择并记录一种契约。
框架边界需要单独验证。 序列化库、ORM、反射和 Java 代码不一定沿用手写 Kotlin 调用点的构造路径,支持方式也随库和插件版本变化。 为每个真实适配器写往返测试,并在边界恢复领域类型后立即断言不变量。
值语义不等于深不可变
值类的 equals() 与 hashCode() 由唯一数据属性决定。
当底层属性拥有稳定值语义时,这正适合作为映射键;两个独立构造的 UserId(7) 会按值相等并得到一致哈希。
无需也不应观察某个包装对象是否被复用。
底层引用若可变,风险会透过包装继续存在。
把包装的列表插入 HashMap 后再修改列表内容,可能改变哈希码,让原键无法在原存储桶中找到。
声明中的 val 只固定列表引用,没有冻结列表元素。
对数组还要额外小心,因为数组的默认 equals() 与列表的内容相等性不同。
如果领域含多个组成部分、需要结构复制或明确控制每个字段的相等性,数据类通常更诚实。
为获得“单字段”形式而把数据编码进数组,会隐藏而不是解决模型问题。
值类、数据类与普通类
选择值类的首要条件是领域概念能由一个底层值完整表达,并且没有对象标识语义。 它的优势是静态类型边界以及可能使用紧凑表示。 可能的内联是收益,不应成为省略正确建模的理由。
数据类适合由多个主构造函数属性描述的值,并提供 copy() 与解构。
它拥有普通对象表示,copy() 仍是浅拷贝,也不自动提供深不可变。
从数据类改成值类会改变源码 API、相等性边界和 JVM ABI,不是机械性能重构。
普通类适合资源所有权、可变生命周期或对象标识比内容更重要的情况。 如果两个字段相同的实例仍应被视为两个独立对象,值类的无标识模型就不合适。 此时应显式建模实体 ID,而不是让包装对象引用承担身份。
最后看调用边界。 一个贯穿泛型集合、反射框架和 Java API 的类型可能经常装箱,但仍能凭借类型安全值得使用。 是否保留它应由错误预防价值和实测成本共同决定,而不是由“零成本”口号决定。
4个问题 · 1 道输出预测题 · 1 道找错题