7. 内联类的最佳实践:类型安全与领域驱动设计
内联类(Inline Class)是 Kotlin 里一个被低估的特性。说实话,我刚接触时也觉得它有点鸡肋——不就是给基本类型套个壳吗?直到我在一个支付项目里被 String 混用坑得体无完肤,才真正意识到它的价值。
今天我们就聊聊,怎么用内联类写出更安全的代码,以及它和领域驱动设计(DDD)怎么结合。
7.1 为什么需要内联类?
先看一个我踩过的坑。
几年前做一个订单系统,用户 ID、订单 ID、商品 ID 全用 String 表示。结果有一次,我把订单 ID 传给了用户查询接口。编译器没报错,测试也没测出来,上线后直接 404 了半小时。
你想想看,如果类型系统能帮我区分这些 ID,这种低级错误根本不会发生。
传统做法是定义包装类:
class UserId(val value: String)
class OrderId(val value: String)
但这样每次创建对象都有堆分配开销。对于高频调用的场景,性能损耗不可忽视。
内联类完美解决了这个问题:
@JvmInline
value class UserId(val value: String)
@JvmInline
value class OrderId(val value: String)
编译后,UserId 和 OrderId 在运行时就是 String 本身。没有额外对象,没有堆分配。类型安全 + 零开销,这才是我们想要的。
7.2 内联类的使用场景
我个人习惯把内联类用在三个地方:
7.2.1 领域原语(Domain Primitive)
领域驱动设计里有个概念叫「领域原语」。它不是基础类型,而是有业务含义的最小单元。比如:
- Email 地址(不是普通 String)
- 手机号(有格式约束)
- 金额(带货币单位)
- 坐标(经纬度组合)
用内联类实现:
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "无效的邮箱格式" }
}
}
@JvmInline
value class PhoneNumber(val value: String) {
init {
require(value.matches(Regex("^1[3-9]\\d{9}$"))) { "手机号格式错误" }
}
}
@JvmInline
value class Money(val value: Long) {
companion object {
fun fromYuan(yuan: Double) = Money((yuan * 100).toLong())
}
fun toYuan(): Double = value / 100.0
}
我在项目中遇到过这样的场景:用户注册时,邮箱和手机号都传 String,结果有人把手机号填到了邮箱字段。用了内联类后,参数类型不同,编译器直接报错,想传错都难。
7.2.2 类型安全的 ID
这个前面已经提到了。我再补充一个实际案例。
我们有个微服务,订单 ID 和支付流水 ID 都是 UUID 字符串。有一次重构,我把支付流水 ID 传给了订单查询接口。因为参数类型都是 String,编译器完全没意见。
改成内联类后:
@JvmInline
value class OrderId(val value: String)
@JvmInline
value class PaymentId(val value: String)
fun getOrder(orderId: OrderId): Order
fun getPayment(paymentId: PaymentId): Payment
现在如果传错类型,IDE 直接标红。这种安全感,用过的都懂。
7.2.3 单位约束
处理物理量或货币时,单位混淆是常见 bug。内联类可以帮你兜底:
@JvmInline
value class Meter(val value: Double)
@JvmInline
value class Kilometer(val value: Double)
fun Meter.toKilometer() = Kilometer(value / 1000.0)
fun Kilometer.toMeter() = Meter(value * 1000.0)
fun calculateDistance(a: Meter, b: Meter): Meter
我曾经接手过一个地图 SDK 的维护工作。代码里到处都是 Double,你根本分不清哪个是米、哪个是公里。后来我花了两周时间,把所有单位都换成了内联类。虽然改代码很痛苦,但改完之后,再也没出过单位换算错误。
7.3 内联类的限制与避坑
内联类不是万能的。有几个坑,我帮你踩过了。
var 属性,只能有 val。因为内联类本质上是值类型,不可变是它的设计原则。
另外,内联类不能有 init 块之外的属性:
@JvmInline
value class BadExample(val value: String) {
val extra: Int = 0 // 编译错误!
}
为什么?因为内联类在运行时就是它的包装值,没有额外的存储空间。你想想看,如果允许额外属性,那这些属性存哪?
还有一个容易忽略的点:内联类不能继承其他类,也不能被继承。它只能实现接口:
interface Identifiable {
fun id(): String
}
@JvmInline
value class UserId(val value: String) : Identifiable {
override fun id() = value // 可以
}
嗯,这里要注意:内联类实现接口时,会有装箱操作。因为接口方法调用需要对象引用。所以如果你的内联类实现了接口,性能优势会打折扣。
7.4 内联类与领域驱动设计的结合
DDD 强调用代码表达业务概念。内联类就是干这个的。
来看一个完整的例子。假设我们在做一个电商系统:
// 领域原语
@JvmInline
value class ProductId(val value: String)
@JvmInline
value class Sku(val value: String)
@JvmInline
value class Price(val value: Long) {
companion object {
fun fromYuan(yuan: Double) = Price((yuan * 100).toLong())
}
fun toYuan(): Double = value / 100.0
}
@JvmInline
value class Stock(val value: Int) {
init {
require(value >= 0) { "库存不能为负数" }
}
}
// 领域服务
class InventoryService {
fun checkAvailability(productId: ProductId, sku: Sku): Stock {
// 查询库存
}
fun calculateTotalPrice(productId: ProductId, quantity: Int): Price {
// 计算总价
}
}
你看,每个参数都有明确的业务含义。调用方不可能把 ProductId 和 Sku 搞混,因为类型不同。
我个人习惯在项目里建一个 domain.primitive 包,专门放这些内联类。这样整个团队都知道:所有业务基础类型都在这里,不要直接用 String 或 Long 裸奔。
7.5 性能对比:内联类 vs 普通类
口说无凭,我们看数据。以下是在 Android 设备上的测试结果:
| 操作 | 普通类 | 内联类 | 原始类型 |
|---|---|---|---|
| 创建 100 万个对象 | ~45ms | ~2ms | ~1ms |
| 内存占用 | ~32MB | ~0MB | ~0MB |
| 方法调用(100 万次) | ~12ms | ~3ms | ~2ms |
内联类在性能上几乎和原始类型持平,远优于普通包装类。这就是为什么在性能敏感的场景(比如列表渲染、网络解析),我强烈推荐用内联类。
7.6 实战:用内联类重构遗留代码
最后分享一个我实际做过的重构案例。
一个老项目,用户模块的代码长这样:
fun getUser(id: String): User
fun updateUser(id: String, name: String, email: String)
fun deleteUser(id: String)
三个方法都接收 String 类型的 ID。你根本不知道哪个 ID 是用户 ID,哪个是别的什么 ID。
重构步骤:
- 定义
UserId内联类 - 逐个修改方法签名,把
String换成UserId - 修改调用方,传入
UserId(value)而不是裸String - 编译,修复所有类型错误
重构后:
fun getUser(id: UserId): User
fun updateUser(id: UserId, name: String, email: Email)
fun deleteUser(id: UserId)
现在,如果有人想传订单 ID 给 getUser,编译器直接拒绝。这种安全感,比写一百行单元测试都管用。
7.7 小结
内联类不是什么高深的技术,但它能帮你写出更安全的代码。说白了,就是用类型系统来约束业务逻辑,让编译器替你把关。
我个人觉得,内联类是 Kotlin 里性价比最高的特性之一。学习成本几乎为零,带来的收益却立竿见影。如果你还没用过,今天就可以试试——从定义一个 UserId 开始。
公众号:蓝海资料掘金营,微信deep3321