3.1 数据类与相等性语义


3.1 数据类与相等性语义

本节摘要:数据类 data class 是 Kotlin 对"纯装数据的类"的一行解决方案:编译器自动生成 equals、hashCode、toString、copy 与解构声明,把手写 JavaBean 的事故面(equals 写错字段、hashCode 忘记同步、copy 漏字段)整体移除。本节讲生成规则、结构相等的判定、copy 的spread 用法、可变数据类的坑,以及"字段一律 val"的工程纪律。

一个equals 引发的幽灵 bug

先看一个真实的幽灵 bug 形态。订单列表页有"去重"逻辑,Java 实现里 Order 类手写了 equals:

public class Order { private final String id; private final long amount; private final int status; // 1 待支付 2 已支付 public Order(String id, long amount, int status) { /* 赋值 */ } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Order order = (Order) o; return id.equals(order.id); // 事故:金额与状态没参与比较,但 hashCode 是 IDE 五个字段全选生成的 } @Override public int hashCode() { /* 用了全部三个字段 */ } }

equals 只比 id,hashCode 用了全部字段——违反"相等对象必须哈希相同"的契约。于是 HashSet 里出现重复订单(hashCode 不同落到不同桶,equals 没机会被调用),而 list.contains(order) 的结果取决于字段组合是否恰好一致。这种 bug 不崩溃、不报错,只在运营对不上账时被发觉,排查动辄半天。

手写 JavaBean 的成本清单远不止这一个:toString 不写则日志里全是无意义的对象地址;想复制一个只改一个字段的对象,要么手写构造调用把所有参数抄一遍,要么写一堆 setter 逐个设;解构赋值更是不存在,取三个字段写三行。约一百行模板代码里,任何一处出错都是一个静默 bug

一行数据类

Kotlin 的回答:

data class Order( val id: String, val amount: Long, val status: Int )

data 修饰符让编译器根据主构造函数的全部属性,自动生成六个成员:equals 与 hashCode(基于全部主构造属性,契约自动满足)、toString(Order(id=A1024, amount=9900, status=1))、componentN 解构函数(N 为主构造属性序号)、copy 函数。生成物互相一致,人写错的空间被整体移除——这是安全消除术在本节的形态:不是帮你检查模板代码,是不再需要模板代码。

逐个看生成物的用法。相等性:

val a = Order("A1024", 9900, 1) val b = Order("A1024", 9900, 1) a == b // true:全部主构造属性相等 a != b.copy(status = 2) // true:status 不同

copy 的具名参数改值是数据更新的标准姿势:

val paid = a.copy(status = 2) // 复制并改状态,原对象不动 val (id, amount, status) = a // 解构声明

解构在遍历 Map 时尤其顺手:

orderMap.forEach { (id, order) -> println("订单 $id 金额 ${order.amount}") }

注意生成范围的边界:只有主构造函数里的属性参与生成。类体里声明的属性不进 equals、不进 copy:

data class User(val id: String) { var cachedAvatar: Bitmap? = null // 不参与相等性与复制 }

这个边界通常是优点(缓存、派生字段不该影响相等性),但要意识到它存在——把业务字段放进类体再抱怨"两个对象不相等",是数据类的高频误解。

结构相等的规则

数据类的 equals 实现的是结构相等:逐属性用 ==(也就是各属性自己的 equals)比较。嵌套数据类自然递归:

data class Address(val city: String, val street: String?) data class Profile(val name: String, val address: Address) val p1 = Profile("阿禾", Address("杭州", "文一西路")) val p2 = Profile("阿禾", Address("杭州", "文一西路")) p1 == p2 // true:递归结构相等

数组属性是个例外:数据类对数组用引用比较(equals 对数组是同一个对象才真)。含数组的模型要么换成 List,要么手写 equals 覆盖——实践中几乎总是应该换成 List,顺带拿到只读接口(第 4 章)。

另一个容易踩的点:数据类的相等性只认运行时类相同,子类与父类不相等。用数据类建继承层次时,相等语义可能与直觉不符——这其实是本章 3.2 密封类登场的前奏:有限类型集合的建模不该用继承,该用密封层次

copy 与不可变更新

copy 配合 val 字段构成 Kotlin 的"不可变更新"模式:

val draft = Order("A1024", 9900, 1) val paid = draft.copy(status = 2) val refunded = paid.copy(status = 4)

原对象不动,新对象携带变更。它在状态管理里的价值要到第 8 章才会完全显形——StateFlow 每次发射新状态,靠 copy 一行完成"基于旧状态造新状态"。这里先立住习惯:改数据用 copy,不用 setter。setter 式更新(order.setStatus(2))有三个已知事故面:共享引用的持有者被动看到变化(第 4 章的主题)、多线程写丢失更新、无法回溯历史状态。

copy 的参数支持一次改多个,也支持不改:

val same = draft.copy() // 值相等的新实例(引用不同)

copy() 返回新引用这点在"身份敏感"的场景要留意——如果你依赖引用相等(===)做去重,copy 会破坏它。反过来这也是一条提醒:身份语义与值语义该分清,多数业务数据要的是值语义。

JavaBean 与数据类对照透视

下表把两类写法的成本与事故面对齐:

JavaBean 与数据类对照透视

可变数据类:把好处还给事故

数据类允许 var 字段,但这是一笔亏本买卖:

data class CartItem(var sku: String, var count: Int) val item = CartItem("S1", 1) val inSet = mutableSetOf(item) item.count = 2 // hashCode 变了 inSet.contains(item) // false:还在桶里 但按新 hash 找不到

可变字段让 hashCode 漂移,哈希容器行为错乱;可变字段让 equals 的结果随时间变化,断言与测试变得不稳定;可变字段还重新引入了第 4 章要讲的共享可变状态问题。纪律:数据类字段一律 val,需要变化就 copy 一个新的。Detekt 有对应的 DataClassContainsFunctions 与 MutableDataClass 规则族可以把这条纪律固化到 CI(第 8 章)。

还有一个关于设计立场的提醒:数据类表达的是"值的身份由内容决定",适合 DTO、领域模型、UI 状态;不适合"有身份的实体在生命周期里演化"的场景——后者的相等性应基于业务 ID(比如数据库主键),此时可以只把 ID 放进主构造、其余字段放类体,或者干脆用普通类手写基于 ID 的 equals。识别建模意图,比记住语法重要。

数据类与持久化:字段选型的下游影响

数据类一旦越过内存边界——存进 Room、塞进 Bundle、走序列化——字段选型的约束就来了。Room 实体:主键字段建议放进主构造(参与 copy 与解构),类型映射受 SQLite 限制(Long 对整型、String 对文本),嵌套对象需要 TypeConverter,而转换器里的可空处理要格外小心——数据库列允许 null 是常态,转换函数应该接受可空输入。Bundle 与 SavedStateHandle:只接受平台可序列化类型,数据类字段若含自定义类型,要么实现 Parcelable(安卓推荐,性能好但样板多,可用插件生成),要么收敛为基本类型组合。JSON 序列化(kotlinx.serialization 或 Moshi):可空性直接映射序列化行为——String? 字段缺省时输出 null 或被忽略(可配置),非空字段缺失则反序列化失败——接口字段"可能不返回"的业务事实,必须在数据类上以问号表达,否则解析层天天炸。

这组约束汇成一个设计建议:传输用的 DTO 与领域用的模型分开建。DTO 照着接口文档的可空性建(问号多),领域模型按业务不变量建(问号少),两者之间一个 toDomain 函数完成收窄与校验。试图用一个类同时伺候两头,结果是每个字段都取最宽松的可空性,第 2 章的消解负担被摊到全工程。

常见问题

问:两个内容相同的数据类实例放进 HashSet,为什么有时"去重失败"?
先检查两件事。其一,字段里有没有数组——数据类对数组按引用比较(本节讲过的例外),含数组的实例内容相同也hashCode 不同;换成 List 字段即可。其二,有没有 var 字段在入集合后被修改——hashCode 漂移导致"还在桶里但按新哈希找不到",这是可变数据类的经典死法,纪律是字段一律 val。两者都不是的话,检查是不是手写了 equals 覆盖却没覆盖 hashCode——契约破坏后哈希容器的行为不再可推理。

问:数据类的 copy 是浅拷贝吗,嵌套的可变对象会不会被共享?
是浅拷贝。copy 只复制引用,嵌套对象与原实例共享——如果嵌套的是可变对象(MutableList、含 var 的类),改副本的嵌套内容会影响原件。浅拷贝在嵌套层全 val 全只读的结构里完全够用(第 4 章的快照纪律),这也是"数据类字段用 val、集合字段用只读 List"的另一重回报:浅拷贝在这样的结构上语义等价于深拷贝,因为根本没有可变的深层可以共享。反过来说,当你发现需要"深拷贝"一个数据类时,多半是建模里混进了不该有的可变性。

问:什么时候该用普通类而不是数据类?
三个信号。有身份语义(相等性基于 ID 而非内容,如数据库实体的聚合根);有行为重心(类的主要价值是方法而非数据,如策略对象);有不变量需要构造期校验(init 块里检查字段组合合法性,数据类的 copy 会绕过这类校验)。数据类表达"值的集合",普通类表达"有规则的实体"——用错方向的代价通常先是隐隐的不舒服(到处 copy 加校验函数),然后是具体的事故(copy 出非法状态)。

本节要点回顾

  • data 生成六件套:equals、hashCode、toString、copy、componentN 解构,全部基于主构造属性、互相一致;
  • 手写 Bean 的事故面被整体移除:equals 漏字段、契约失配、toString 缺失、复制漏参——不再有手写环节就没有手写错误;
  • 生成边界:类体属性不参与,缓存与派生字段放类体是正解;数组按引用比较,建模改用 List;
  • copy 是不可变更新的标准姿势:具名参数改值,原对象不动,为第 8 章状态管理铺路;
  • 字段一律 val:可变数据类的 hashCode 漂移与共享可变状态是双重回退;
  • 值语义与身份语义分清:内容定身份用数据类,ID 定身份的手写 equals 表达演化实体。

数据类解决"状态携带什么",下一节解决"状态集合怎么被穷举"——密封类加 when,把分支遗漏从线上静默错误变成编译期标红。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U