内联值类用一个新类型包装单个值;在合适的位置,编译器可以直接使用它的底层表示。
“内联”不是不分配对象的保证。泛型、接口和可空边界可能需要装箱,公开构造函数也可能绕过你原本想强制执行的工厂规则。
先用值类表达类型与不变量,再检查真实调用边界。只有性能重要且测量结果指向装箱时,才围绕具体热路径调整表示。
是什么,为什么存在
内联值类(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道找错题