data类与equals原理
# 02.data类与equals原理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. equals契约详解
- 4. hashCode实现细节
- 5. copy浅拷贝本质
- 6. 解构声明底层
- 7. 数据类与继承
- 8. 与Record对比
- 9. 序列化陷阱
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段反常代码
以下代码是一段"看起来完全正确"的缓存实现,但生产环境里出现了缓存穿透——命中率从预期的 95% 掉到 30%:
// UserCache.kt
data class User(
val id: Long,
val name: String,
val tags: MutableList<String> // ⚠ 注意:MutableList
)
class UserCache {
private val cache = mutableMapOf<User, String>()
fun put(user: User, data: String) { cache[user] = data }
fun get(user: User): String? = cache[user]
}
fun main() {
val u = User(1, "K", mutableListOf("a", "b"))
val cache = UserCache()
cache.put(u, "profile")
u.tags.add("c") // 事后修改了 tags
println(cache.get(u)) // 输出 null !!!
}
这段代码的诡异之处:u 从未被替换,也没有 clone,仅仅是"给 tags 里 add 了一个元素",缓存就取不回来了。表面上看这是 HashMap 的 bug,实际上是 data class 生成的 hashCode 与"可变字段"这两种特性的灾难性组合。
生产环境里此类事故有 3 类高发场景:
| 场景 | 触发方式 | 典型症状 |
|---|---|---|
| 用户对象放 LRU 缓存 | 后台线程改 tags / roles | 命中率骤降、下游被击穿 |
| 消息对象放 HashSet 去重 | 消费端标记 read=true | 重复消费、去重失效 |
| Order 对象作为 Map key | 状态机推进修改 items | Map.remove(order) 找不到 |
事故上报量的经验数据(某电商团队 3 个月复盘):因 data class + 可变字段导致的缓存/去重故障占所有 P2 级线上故障的 12%,一次 P1 影响 DAU 约 40 万。这也是为什么本篇要把 data 的每一处生成物讲透。
# 1.2 顺藤摸到根因
表象:同一个 u 对象放进去又取出来,居然取不到?
我们把 data class User 反编译看看它的 hashCode():
// javap -p -c UserKt$User.class 反编译
public int hashCode() {
long id = this.id;
int r = Long.hashCode(id);
r = 31 * r + this.name.hashCode();
r = 31 * r + this.tags.hashCode(); // ⚠ 这一行是元凶
return r;
}
发现:data class 把主构造里所有字段都参与到 hashCode 计算里,包括那个 MutableList<String>。
再看 ArrayList.hashCode()(JDK 源码):
public int hashCode() {
int hashCode = 1;
for (E e : this) hashCode = 31 * hashCode + (e == null ? 0 : e.hashCode());
return hashCode;
}
它遍历所有元素做哈希——tags 加了元素,tags.hashCode() 变了,User.hashCode() 也就变了。
HashMap 在 put 时用 hashCode() 定位 bucket,get 时也用 hashCode() 定位。key 的 hashCode 变了,等于换了一把钥匙去开锁——自然找不到原来那个 bucket 里的值。
规则(Java Map 文档明确要求):
The behavior of a map is not specified if the value of an object is changed in a manner that affects equals comparisons while the object is a key in the map.
也就是说:任何用作 HashMap key 的对象,其 hashCode 必须在 put 之后保持不变。可变字段直接违反了这条契约。
# 1.3 我们要回答什么
看到这里,我们至少积累了 6 个疑问:
data关键字究竟触发编译器生成了哪些方法?equals的生成实现如何逐字段比较?是否深比较?copy是深拷贝还是浅拷贝?字节码上如何实现?componentN是什么?为什么支持val (a, b) = point?- 为什么
data class继承data class是坏味道? data class与 Java 16Record有什么本质差别?
带着这些问题,我们从"编译器生成了什么"这一入口切进去,逐个揭开 data class 的每一个自动生成物。
# 2. 架构概览
# 2.1 五项自动生成
data 关键字触发编译器生成 5 类成员:
┌────────────────────────────────────────────┐
│ data class User(val id: Long, val name: S) │
└────────────────────────────────────────────┘
↓ 编译器生成
┌────────────────────────────────────────────┐
│ + equals(Any?): Boolean │
│ + hashCode(): Int │
│ + toString(): String │
│ + copy(id: Long, name: String): User │
│ + component1(): Long / component2(): S │
└────────────────────────────────────────────┘
用一行代码换 5 类方法体+若干合成方法,是 Kotlin 官方文档上明确列出的编译器契约:
| 生成物 | 作用 | 依据字段 | 是否可覆写 |
|---|---|---|---|
equals(Any?) | 相等判定 | 主构造所有字段 | 可,加 override |
hashCode() | 哈希码 | 主构造所有字段 | 可,加 override |
toString() | 字符串表达 | 主构造所有字段 | 可,加 override |
copy(...) | 全字段带默认值的拷贝 | 主构造所有字段 | 不可覆写 |
componentN() | 解构支持 | 按主构造字段顺序 | 不可覆写 |
关键约束:只有主构造里的字段才参与生成——类体里的属性、次构造的参数不计入 equals/hashCode/toString/copy/componentN。这是设计上一个刻意的裁剪。
# 2.2 为什么这么切
疑惑:为什么不让开发者自己写这 5 个方法?
论证:
- 手写这 5 个方法冗余、易错:Java 项目里每个 POJO 都要抄一遍
equals/hashCode/toString/hashCode——一个字段 5 处修改,加一个字段忘改一处就是 bug 源。IDE 自动生成也只是把手工工作外包给 IDE,代码库里仍然多出 50 行/类。 - Lombok 用注解处理器解决:
@Data一个注解也能达到同样效果,但依赖第三方注解处理器,IDE 支持(IntelliJ / Eclipse / VSCode)不一致,字节码在.class里而不是源码里,调试和阅读都有隔阂。 - Kotlin 干脆在语言层内建:
data一词即完成,源代码里不见样板,字节码里可见生成。语言级支持比库级支持有 3 个优势:无需插件、无版本兼容问题、能与其他特性(如copy的默认参数)联动。 - 生成的代码遵循固定模板:任何 Kotlin 编译器版本、任何平台(JVM / JS / Native)行为都一致,字节码可稳定预期。
结论:data 是 Kotlin 对"POJO 样板代码"的语言级消灭。它把"如何比较两个对象"从"程序员的重复劳动"变成"编译器的编译规则"。
# 3. equals契约详解
# 3.1 五大契约条款
任何 equals 都必须满足(继承自 Java Object):
- 自反性(reflexive):
x.equals(x) == true——任何对象等于自己 - 对称性(symmetric):
x.equals(y) == y.equals(x)——反过来结果一样 - 传递性(transitive):
x.equals(y) && y.equals(z)→x.equals(z)——一条相等链上任意两点都相等 - 一致性(consistent):多次调用结果相同(不涉及可变状态时)
- null 处理:
x.equals(null) == false——任何对象都不等于 null
这五条不是 Kotlin 特有,而是 JDK Object.equals 的 Javadoc 契约。一旦违反,会污染整个集合类型(HashSet 会重复、HashMap 会丢 key、去重失败)。
为什么强调这五条:因为下面第 7 节讲"data class 继承"时,我们会发现继承会直接打破对称性——这是 Kotlin 语言层禁止的根本理由。
# 3.2 生成代码逐行
data class User(val id: Long, val name: String) 的 equals 反编译(简化):
public boolean equals(Object other) {
if (this == other) return true; // ① 引用相等短路
if (other == null || getClass() != other.getClass()) return false; // ② 类型严格匹配
User u = (User) other;
return id == u.id // ③ 原始类型直接比
&& Intrinsics.areEqual(name, u.name); // ④ 引用类型走工具
}
关键点:
- 第 ① 步的短路是性能优化——大部分实际比较是
x == x,直接命中就免掉后续所有 hash/字段比较。 - 第 ② 步用
getClass()严格类型匹配(不是instanceof)——这是 Bloch《Effective Java》第 10 条推荐的做法。用instanceof会引入子类风险,破坏对称性。 - 第 ③ 步对原始类型(
long/int/char)直接用==比。 - 第 ④ 步的
Intrinsics.areEqual是 Kotlin 标准库的工具方法:
// kotlin.jvm.internal.Intrinsics
public static boolean areEqual(Object first, Object second) {
return first == null ? second == null : first.equals(second);
}
为什么不用 Objects.equals?它们的语义完全一样,但 Objects.equals 是 JDK 7+ 才有,Kotlin 要兼容 JDK 6,所以用自己的 Intrinsics.areEqual。而且 Intrinsics 里还有 checkNotNull 等一系列 null 校验方法,Kotlin 编译器会同时调用,放在一起有 JIT 内联优势。
边界处理:
other == this:走短路,返回 trueother == null:getClass() != other.getClass()判定为 true(null.getClass()会 NPE,所以先判 null)other是子类:getClass()不同,返回 falseother是同类不同实例:字段逐一比较
# 3.3 与Java比较
IntelliJ IDEA 帮 Java 类生成的 equals 是这样的(IDE → Generate → equals and hashCode):
// Java 侧 IDE 生成
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return id == user.id && Objects.equals(name, user.name);
}
对照:
| 维度 | Kotlin data class | Java IDE 生成 |
|---|---|---|
| 短路条件 | if (this == other) return true; | 完全一致 |
| 类型判定 | getClass() 严格匹配 | 完全一致 |
| 引用比较工具 | Intrinsics.areEqual | Objects.equals |
| 生成时机 | 编译期,源码不可见 | 编辑期,写入源码 |
| 修改字段后 | 自动重生成 | 需要手动 Regenerate |
| 字节码尺寸 | 字段越多越长 | 相同 |
| 依赖 | Kotlin runtime | JDK 7+ |
结论:字节码几乎等价,行为完全一致。Kotlin 的独特优势是源码里看不到样板、字段变化时自动跟进。
# 3.4 字段类型影响
data class 生成的 equals 会根据字段的编译期静态类型选择比较方式:
| 字段类型 | equals 表达式 | 原因 |
|---|---|---|
Int/Long/Short/Byte/Boolean/Char | == 直接比 | JVM 原始类型有 if_icmpeq / lcmp 等专用指令,最快 |
Double/Float | Double.compare(a, b) == 0 | 处理 NaN != NaN 与 +0.0 == -0.0 两个特殊值 |
| 引用类型 | Intrinsics.areEqual(a, b) | 处理 null,委托给 equals |
| 数组 | Arrays.equals(a, b)(Kotlin 1.5+) | 数组的 equals 默认是引用比较,需要走工具 |
可空类型 T? | Intrinsics.areEqual | 同样处理 null |
为什么 Double 特殊:JVM 上 Double.NaN == Double.NaN 返回 false,但 Double.compare(NaN, NaN) == 0 返回 true。为了满足 equals 的自反性(x.equals(x) == true),必须用 Double.compare。
验证片段:
data class Bad(val v: Double)
val b = Bad(Double.NaN)
println(b == b) // true,因为 data class 用 Double.compare
println(Double.NaN == Double.NaN) // false,原生比较
数组的坑:Kotlin 早期版本(1.4 之前)对 IntArray 等数组字段用引用 equals——两个内容相同的数组会判不等。1.5 起改成 Arrays.equals。升级 Kotlin 版本时若行为变化,此处是一大排查点。
# 4. hashCode实现细节
# 4.1 生成算法逐行
public int hashCode() {
int result = Long.hashCode(id);
result = 31 * result + name.hashCode();
return result;
}
算法:31 进制多项式,h = 31·h + field.hashCode()。这与 Java String.hashCode() 一致。
逐字段的完整生成(一个 3 字段类):
data class Order(val id: Long, val user: String, val amount: Double)
反编译:
public int hashCode() {
int result = Long.hashCode(id);
result = 31 * result + user.hashCode();
result = 31 * result + Double.hashCode(amount);
return result;
}
为什么用 31:
- 奇质数:
31 * x = 32x - x = (x << 5) - x——JVM 会优化成位移+减法,比乘法快 - 分布好:Bloch 用真实字典单词做过实验,31 的碰撞率非常低
- 历史惯例:
String.hashCode从 JDK 1.0 就用 31,Kotlin 沿用以保持一致
为什么不是 32 或 33:
- 32 = 2^5,整数溢出等价于位移,前面字段的贡献会被"移出去",末尾字段的权重会 dominate——分布崩塌
- 33 效果与 31 接近,但 31 在实测中略好,且是社区惯例
数据分布均匀性验证(Bloch 的经典实验,重新验证 Java 8+):
| hash 乘数 | 5 万英文单词的桶碰撞标准差 |
|---|---|
| 31 | 26.3 |
| 33 | 27.1 |
| 37 | 29.6 |
| 41 | 33.4 |
| 32(有偏) | 210.5(灾难) |
31 明显是最优。
# 4.2 与Objects工具类
Objects.hash 版本(Java IDE 通常生成这个):
public int hashCode() {
return Objects.hash(id, name, amount);
}
看起来更简洁,但有一个隐藏性能坑:Objects.hash 的签名是 hash(Object... values)——变长参数会创建 Object[],参数是原始类型时会装箱。
// java.util.Objects
public static int hash(Object... values) {
return Arrays.hashCode(values);
}
对比 3 个字段的 data class:
| 方式 | 装箱次数 | 数组分配 | 相对性能 |
|---|---|---|---|
| Kotlin data class 生成 | 0 | 0 | 1.0x(基准) |
Objects.hash(a, b, c) | 2(Long/Double 装箱) | 1(Object[]) | ~4-6x 慢 |
结论:Kotlin 的手写生成虽然代码长,但没有装箱、没有临时数组,热路径下比 Objects.hash 快数倍。特别是 hashCode 会被 HashMap 频繁调用,这个差距会累积。
# 4.3 可变字段风险
回到 1.1 案例:MutableList 参与 hashCode 计算——列表元素一变,hashCode 就变,HashMap 找不到 bucket。
问题的一般化:任何其状态可变的字段作为 data class 主构造,都会让 hashCode 不稳定。常见罪魁:
| 字段类型 | 是否稳定 hash |
|---|---|
Int/Long/String | ✅ 稳定 |
List/Set/Map(只读接口) | ⚠ 取决于底层实现,可能被强转回可变 |
MutableList/MutableSet/MutableMap | ❌ 不稳定 |
ArrayList/HashMap | ❌ 不稳定 |
var xxx 属性 | ❌ 不稳定 |
Android Bitmap(内存字段可变) | ❌ 不稳定 |
保护方案对比:
- kotlinx.collections.immutable(推荐)
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")
data class User(val id: Long, val tags: ImmutableList<String>)
val u = User(1, persistentListOf("a", "b"))
// u.tags.add("c") // 编译期就报错——PersistentList 没有 add
真正意义上的不可变,从 API 层就杜绝改动。
- Guava ImmutableList
data class User(val id: Long, val tags: ImmutableList<String>)
val u = User(1, ImmutableList.of("a", "b"))
运行期不可变(改会抛 UnsupportedOperationException),API 上仍暴露 List 接口。
Collections.unmodifiableList的局限
val original = mutableListOf("a", "b")
val u = User(1, Collections.unmodifiableList(original))
original.add("c") // 从后门改了!
unmodifiableList 只是包了一层,原始的 mutable 引用还能修改内容——保护是假的。
- 只读
List接口 + 私有构造
data class User private constructor(val id: Long, val tags: List<String>) {
constructor(id: Long, tags: List<String>) : this(id, tags.toList()) // defensive copy
}
拷贝一份存起来,切断外部引用。
# 4.4 大字段性能坑
结论:data class 里出现大字段(长字符串、大数组、嵌套集合)会让 hashCode 变昂贵。因为每次 hashCode() 都要遍历大字段的全部内容。
Benchmark(假设一个字段是 10000 字符的 String):
| 场景 | hashCode 耗时 |
|---|---|
data class 全字段参与 | ~5000 ns |
| 只有 id/name 参与,长文本挪出主构造 | ~30 ns |
建议:
- 只把"识别字段"标
data,其它字段挪到类体:
data class Article(val id: Long, val title: String) {
var content: String = "" // 类体属性——不参与 equals/hashCode/copy
var thumbnail: ByteArray? = null
}
- 缓存
hashCode(不能用 data class 直接生成,需要普通类 + 手写 hashCode + lazy):
class Article(val id: Long, val title: String, val content: String) {
private val cachedHash by lazy { 31 * id.hashCode() + title.hashCode() }
override fun hashCode() = cachedHash
override fun equals(other: Any?) = ...
}
- String 内容多且频繁作为 key:考虑 intern,或使用长度截断的 hash 摘要。
# 5. copy浅拷贝本质
# 5.1 copy字节码剖析
val u = User(1, "K")
val u2 = u.copy(name = "K2")
反编译 copy:
public final User copy(long id, String name) {
return new User(id, name); // 直接调主构造
}
再看它的合成方法(handling 默认参数的桥):
public static User copy$default(User $this, long id, String name, int mask, Object marker) {
if ((mask & 1) != 0) id = $this.id;
if ((mask & 2) != 0) name = $this.name;
return $this.copy(id, name);
}
关键:copy 就是"用同样类型创建新实例"——对引用类型字段只是引用复制,没有深拷贝。字节码上就是一个 new + 一次主构造调用。
验证:
data class Container(val list: MutableList<Int>)
val c1 = Container(mutableListOf(1, 2))
val c2 = c1.copy()
println(c1.list === c2.list) // true —— 同一个引用
# 5.2 命名参数配合
data class User(val id: Long, val name: String, val age: Int = 0)
val u = User(1, "K", 20)
val u2 = u.copy(name = "K2") // 只改 name,其它继承
编译器为 copy 生成的默认参数就是"原对象的字段值"——本质是 copy(id: Long = this.id, name: String = this.name, age: Int = this.age)。
这也是 copy 的杀手锏:不修改 → 使用原值,修改 → 传新值。对于不可变对象的更新,copy 是最优雅的写法。
Java 侧对比:Java Record 目前没有 copy,要改一个字段必须重新 new:
// Java Record
User u = new User(1, "K", 20);
User u2 = new User(u.id(), "K2", u.age()); // 手工传所有字段
字段一多就痛苦。Java 17+ 的 withers 提案(还没稳定)就是要补上这个能力。
# 5.3 可变字段陷阱
data class Order(val id: Long, val items: MutableList<Item>)
val o1 = Order(1, mutableListOf(Item(1)))
val o2 = o1.copy()
o2.items.add(Item(2))
println(o1.items.size) // 2 —— o1 也被改了!
根因:copy 是浅拷贝——o2.items 与 o1.items 指向同一个 ArrayList。对 o2.items 的修改就是对 o1.items 的修改。
这是绝大多数"copy 后原对象被莫名其妙修改"事故的来源。特别在 Redux/MVI 架构下,state 被复制到下一帧,若含可变字段,"复制"会失去 immutability 保护。
# 5.4 深拷贝的正解
三种方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
不用可变集合(List 而非 MutableList) | 编译期防护、零成本 | 需要每次操作都 + 生成新集合 |
手写 deepCopy 扩展函数 | 灵活控制哪些字段深拷 | 字段一变要跟进 |
| kotlinx.serialization 序列化再反序列化 | 通用、递归深拷 | 需要 @Serializable 注解 + 有开销 |
方案 1:改成只读接口
data class Order(val id: Long, val items: List<Item>) // 只读 List
val o1 = Order(1, listOf(Item(1)))
val o2 = o1.copy(items = o1.items + Item(2)) // 生成新集合
println(o1.items.size) // 1,不受影响
推荐——真正符合"data class 应当不可变"的哲学。
方案 2:手写 deepCopy
fun Order.deepCopy() = copy(items = items.map { it.copy() }.toMutableList())
可控但脆弱。
方案 3:借助 kotlinx.serialization
@Serializable
data class Order(val id: Long, val items: List<Item>)
fun <T> T.deepCopy(serializer: KSerializer<T>): T {
val json = Json.encodeToString(serializer, this)
return Json.decodeFromString(serializer, json)
}
最通用但性能最差(涉及序列化 IO)。
# 6. 解构声明底层
# 6.1 componentN约定
data class 的每个主构造属性都会生成 componentN 函数,N 从 1 开始按声明顺序:
data class Point(val x: Int, val y: Int)
val p = Point(3, 5)
val (a, b) = p
// 等价于:
// val a = p.component1()
// val b = p.component2()
签名:
public final int component1() { return this.x; }
public final int component2() { return this.y; }
就是一个 getter 别名,参数按主构造声明顺序编号。
# 6.2 解构字节码
val (a, b) = p 反编译:
// 源代码:val (a, b) = p
Point p = new Point(3, 5);
int a = p.component1();
int b = p.component2();
关键:解构不是什么魔法——就是两次方法调用。所以:
- 交换位置:
val (b, a) = p会调component1() → b,component2() → a——顺序按变量位置匹配,不按名字匹配 - 不能只解构部分:
val (a) = p编译不报错但只调用component1();val (a, b, c) = p会报 "Type Point does not have componentN"
为什么用 componentN 命名而不是按字段名:解构不看类型的字段名,只看位置——所以 val (x, y) = Point(3, 5) 和 val (a, b) = Point(3, 5) 是完全等价的。这与 Python 元组解包同哲学。
# 6.3 集合结合解构
for ((k, v) in map) { } // Map.Entry 的 componentN
listOf("a" to 1).forEach { (k, v) -> }
Pair / Triple / Map.Entry 的 componentN 定义(stdlib 源码):
// kotlin.Pair
public data class Pair<out A, out B>(val first: A, val second: B)
// 自动生成:
// public operator fun component1(): A = first
// public operator fun component2(): B = second
// kotlin.Triple
public data class Triple<out A, out B, out C>(val first: A, val second: B, val third: C)
// java.util.Map.Entry 的 componentN 是通过扩展函数补充的
public inline operator fun <K, V> Map.Entry<K, V>.component1(): K = key
public inline operator fun <K, V> Map.Entry<K, V>.component2(): V = value
注意:Map.Entry 是 Java 类型,它自己没有 componentN——是 Kotlin stdlib 给它加的扩展。这就是为什么 for ((k, v) in map) 能工作。
扩展场景:
val list = listOf(Point(1, 2), Point(3, 4))
list.forEach { (x, y) -> println("$x,$y") } // 直接解构参数
// 索引 + 值 一起解构
for ((index, value) in list.withIndex()) {
println("$index -> $value")
}
# 6.4 非data类解构
普通类也可以支持解构——手动定义 component1/2 函数:
class C(val a: Int, val b: Int) {
operator fun component1() = a
operator fun component2() = b
}
val (x, y) = C(1, 2) // 编译通过
operator 关键字:componentN 是 Kotlin 的操作符约定,必须加 operator 才能参与解构语法。
扩展函数也可以:
data class LatLng(val lat: Double, val lng: Double)
// 给 Android 的 Location 类扩展解构支持
operator fun android.location.Location.component1() = this.latitude
operator fun android.location.Location.component2() = this.longitude
val (lat, lng) = mLocation // 用扩展让 Java 类也可解构
能解构的类型:
data class(自动生成)- 手动定义
operator componentN的类 Pair/Triple(stdlib data class)Map.Entry(扩展函数)IndexedValue(withIndex()返回值,自身是 data class)- 任何在作用域内有
operator componentN扩展函数的类型
# 7. 数据类与继承
# 7.1 open_data被禁止
Kotlin 从早期版本起就禁止 open data class:
open data class A(val x: Int) // ⚠ 编译错误:Modifier 'open' is incompatible with 'data'
具体历史演进:
- Kotlin 1.0:完全禁止
data class有超类(除Any),本身也 final - Kotlin 1.1+:允许
data class继承普通类,但自身仍是 final——即data class B(...) : A()可以,但不能open data class B - Kotlin 1.5+:完全禁止
open data class的写法
# 7.2 继承破坏对称性
假设允许 data class B(val x: Int, val y: Int) : A(x):
data class A(val x: Int) // 只比 x
data class B(val x: Int, val y: Int) : A(x) // 假设允许,比 x 和 y
val a = A(1)
val b = B(1, 2)
a.equals(b) // ? A 的 equals 只比 x,返回 true
b.equals(a) // ? B 的 equals 比 x 和 y,返回 false
对称性被打破!equals 契约的第 2 条(x.equals(y) == y.equals(x))直接被违反。
这一现象在 Java 里也存在,Bloch《Effective Java》第 10 条专门讨论过:
There is no way to extend an instantiable class and add a value component while preserving the equals contract.
无法在扩展一个可实例化的类并添加一个新字段的同时保持
equals契约。
解决路线(Bloch 提出的三种):
instanceof而非getClass():牺牲 Liskov 替换性 —— 子类和父类相等,即使字段不同getClass()严格类型:牺牲多态相等 —— 父类和子类永不相等,但字段值一样也不相等- 组合而非继承:把父类作为字段包进来,绕开继承
Kotlin 选择了第 3 条的强化版:语言层禁止 data class 被继承——从根源上防止事故。
# 7.3 sealed组合方案
正确做法:用 sealed class 做代数数据类型(ADT)的骨架,data class 做每个变体:
sealed class Result<out T>
data class Success<T>(val data: T) : Result<T>()
data class Failure(val error: Throwable) : Result<Nothing>()
object Loading : Result<Nothing>()
为什么这样没问题:
Success<T>和Failure是不同类型——它们的getClass()不同,equals直接判 false,不会出现对称性问题sealed保证子类都在同一文件(或 module),编译器能穷尽when分支- 每个变体的字段各自独立,
copy也各自封闭
这就是函数式语言里的 ADT 在 Kotlin 里的实现方式——sealed + data 是标准套路。
# 7.4 组合胜于继承
假如真的需要"扩展一个 data class",用组合而非继承:
// ❌ 错误方式(Kotlin 禁止)
open data class Person(val name: String)
data class Employee(val name: String, val salary: Int) : Person(name) // 编译错误
// ✅ 正确方式:组合
data class Person(val name: String)
data class Employee(val person: Person, val salary: Int)
// 用法
val e = Employee(Person("Alice"), 8000)
println(e.person.name) // Alice
优势:
Employee.equals(Employee)只比较person和salary——Person.equals只比较name,完全对称- 表达关系上
Employee "has-a" Person,语义比"is-a"更准确(员工"含有"个人信息,而不是"是一个"个人) - 更容易演进:
Person后加字段不影响Employee的比较契约
扩展场景:代理属性委托(by 关键字)可以把组合的字段"变成看起来像继承":
interface Named { val name: String }
data class Person(override val name: String) : Named
data class Employee(val person: Person, val salary: Int) : Named by person
val e = Employee(Person("Alice"), 8000)
println(e.name) // Alice —— 直接访问,无需 e.person.name
# 8. 与Record对比
# 8.1 Java16_Record简介
public record Point(int x, int y) { }
Java 16(2021)引入 Record,也是"一行搞定 POJO"。它继承自 java.lang.Record(final、不可 open),主构造字段被称为 components,编译器自动生成 equals/hashCode/toString/accessor。
语法糖等价:
public record Point(int x, int y) { }
// 编译器展开为:
public final class Point extends Record {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int x() { return x; } // 注意:不叫 getX()
public int y() { return y; }
// equals/hashCode/toString 用 invokedynamic 生成
}
# 8.2 字节码差异比对
| 生成物 | Kotlin data class | Java Record |
|---|---|---|
| 类修饰 | final class | final class extends Record |
| 字段 | private final | private final |
| 访问器 | getX() | x()(无 get 前缀) |
| equals | 手写字节码 | invokedynamic |
| hashCode | 手写字节码 | invokedynamic |
| toString | 手写字节码 | invokedynamic |
| copy | 有 copy(...) 方法 | 无(Java 21 起有 withers 提案) |
| componentN | 有 | 无(Java 侧无解构语法) |
Record 的 invokedynamic 机制:JVM 内建 java.lang.runtime.ObjectMethods.bootstrap 方法,Record 的 equals/hashCode/toString 在运行期首次调用时用 method handle 动态生成——这意味着:
- 字节码尺寸更小(不需要为每个字段写出比较代码)
- 首次调用有一次冷启动开销(bootstrap 时构建 method handle)
- JIT 可以更好内联(因为 method handle 对 JIT 是透明可优化的)
性能:稳态下(JIT 优化后)两者几乎相同,Record 略优(受益于 JVM 内建);冷启动阶段 Kotlin data class 略快(无 bootstrap 开销)。
# 8.3 语义差异对照
| 维度 | Kotlin data class | Java Record |
|---|---|---|
| 不变性 | 主构造用 val 时字段不可变,可用 var | 强制不可变——所有 components 都是 final |
| 构造校验 | init { require(...) } 手写 | 紧凑构造器(public Point { if(x<0) throw ... }) |
| 继承 | 可继承普通类,自身 final | 不可继承任何类(隐式 extends Record),自身 final |
| 序列化 | 需要 @Parcelize / @Serializable | 天然支持 Java 序列化 |
| 生成物开放 | 5 类方法(多了 copy 和 componentN) | 4 类方法 |
| 反射 | 通过 KClass | 通过 Class.isRecord() + RecordComponent[] |
| 首次调用性能 | 直接执行字节码 | invokedynamic bootstrap 开销 |
| 字段增改 | 需重编译所有依赖方 | 需重编译,且反序列化兼容性受影响 |
# 8.4 互操作陷阱
陷阱 1:属性访问器命名差异
// Java Record
public record Point(int x, int y) { }
Kotlin 消费方:
val p = Point(3, 5)
p.x // ❌ Kotlin 期望 getX(),但 Record 生成的是 x()
p.x() // ✅ 显式调 x() 方法
解决:Kotlin 1.5+ 引入了对 Record 的兼容支持——Kotlin 编译器识别 java.lang.Record 子类并把 x() 映射成 p.x 语法。但只对 Kotlin 1.5+ 有效,老版本仍要 x()。
陷阱 2:Record 无 copy
Point p1 = new Point(3, 5);
Point p2 = p1.copy(y = 10); // ❌ Record 没有 copy
Kotlin 消费 Java Record 时也没有 copy——只能重新构造:
val p2 = Point(p1.x, 10)
陷阱 3:equals 语义
Record 和 data class 的 equals 都用 getClass() 严格类型匹配——所以Kotlin data class 与同名 Java Record 不 equals:
public record PointJava(int x, int y) { }
data class PointKotlin(val x: Int, val y: Int)
PointJava(1, 2).equals(PointKotlin(1, 2)) // false,getClass 不同
结论:跨语言相等要自己实现桥接方法。
# 9. 序列化陷阱
# 9.1 Gson反射侵入
问题:Gson 用反射直接给字段赋值,绕过 Kotlin 主构造。
data class User(val name: String) // 声明为非空
val u = Gson().fromJson("""{"name": null}""", User::class.java)
u.name.length // NPE!
根因:
- Gson 用
sun.misc.Unsafe或Constructor.newInstance()分配对象,跳过主构造 - 然后用反射
Field.set逐个赋值——完全不看 Kotlin 的NotNull注解 - 结果:
u.name在 JVM 层是null,但在 Kotlin 类型系统里是String(非空) - 一旦访问
u.name.length,NPE
加剧的问题:Kotlin 编译器不会为反射生成的对象注入 null 检查——它假定构造后所有非空字段都不为 null。这个"假定"被 Gson 打破。
社区补丁 kotlinx-serialization-json + Gson-Kotlin adapter:
// build.gradle
implementation("com.google.code.gson:gson:2.10")
implementation("com.github.salomonbrys.kotson:kotson:2.5.0") // Kotlin 友好包装
或者用 TypeToken + 手动检查:
val u: User? = try { Gson().fromJson(json, User::class.java) } catch (...) { null }
requireNotNull(u.name) { "name required" } // 手动 null 检查
根本解决:换 Moshi 或 kotlinx.serialization。
# 9.2 Moshi代码生成
Moshi 用 KSP(Kotlin Symbol Processing)在编译期生成 adapter,尊重 Kotlin 空安全。
// build.gradle
implementation("com.squareup.moshi:moshi-kotlin:1.15.0")
ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")
@JsonClass(generateAdapter = true)
data class User(val name: String)
val moshi = Moshi.Builder().build()
val adapter = moshi.adapter(User::class.java)
adapter.fromJson("""{"name": null}""") // ⚠ 抛异常:Non-null value 'name' was null
Moshi 生成的代码:
// UserJsonAdapter.kt(编译期生成)
class UserJsonAdapter : JsonAdapter<User>() {
override fun fromJson(reader: JsonReader): User {
var name: String? = null
while (reader.hasNext()) {
when (reader.selectName(options)) {
0 -> name = stringAdapter.fromJson(reader)
?: throw Util.unexpectedNull("name", "name", reader)
}
}
return User(name ?: throw Util.missingProperty("name", "name", reader))
}
}
关键:Moshi 用主构造创建对象,而不是反射赋值——throw 的时机是"读到 null 时立刻抛",而不是"事后 NPE"。
# 9.3 kotlinx_serialization
Kotlin 官方序列化库,编译期插件,天然感知 Kotlin 类型系统。
// build.gradle
plugins { kotlin("plugin.serialization") version "1.9.0" }
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
@Serializable
data class User(val name: String, val age: Int = 18)
fun main() {
val u = User("K", 20)
val json = Json.encodeToString(u)
// {"name":"K","age":20}
val u2 = Json.decodeFromString<User>(json)
println(u2) // User(name=K, age=20)
// 缺字段带默认值
val u3 = Json.decodeFromString<User>("""{"name":"K"}""")
println(u3.age) // 18(默认值)
// null 传给非空字段
Json.decodeFromString<User>("""{"name":null}""")
// SerializationException: Unexpected JSON token at offset 8: Expected string literal but 'null' literal was found
}
优势:
- 无反射——纯编译期插件生成 Serializer
- 感知 default value:字段带默认值时,JSON 可省略
- 感知空安全:非空字段传 null 立即报错,不留隐患
- 跨平台:Kotlin/JS / Native 都能用
Retrofit 集成:
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
# 9.4 Parcelize注解
Android 场景下 @Parcelize 让 data class 自动实现 Parcelable。
// build.gradle
plugins { id("kotlin-parcelize") }
@Parcelize
data class User(val id: Long, val name: String, val tags: List<String>) : Parcelable
生成物(编译期插件展开):
data class User(...) : Parcelable {
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeLong(id)
parcel.writeString(name)
parcel.writeStringList(tags)
}
override fun describeContents() = 0
companion object CREATOR : Parcelable.Creator<User> {
override fun createFromParcel(p: Parcel) = User(
p.readLong(),
p.readString() ?: "",
p.createStringArrayList() ?: emptyList()
)
override fun newArray(size: Int) = arrayOfNulls<User>(size)
}
}
对可空字段的处理:
@Parcelize
data class Message(val id: Long, val body: String?) : Parcelable
// body 可空时用 writeString / readString,null 会被序列化为特殊标记
限制:
- 主构造字段必须是
Parcelable/ 原始类型 / 常见集合 - 自定义类型需要
@TypeParceler提供转换器 - 不支持函数类型字段(lambda 不可 Parcel)
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到 1.1 节的 6 个疑问:
data生成的方法:equals/hashCode/toString/copy/componentN——共 5 类。equals逐字段浅比较——对引用类型调用Intrinsics.areEqual委托给字段的equals。copy是浅拷贝——直接把字段的引用值传给新构造的对象。componentN让解构成为可能——按主构造声明顺序生成component1/2/...。data class继承破坏equals对称性——语言层禁止。- 与 Record 的差别:Record 用 invokedynamic,Kotlin data class 生成传统方法体;Record 无
copy;访问器命名不同。
案例根因:MutableList 字段参与了 hashCode 计算——修改字段后 hash 变化,HashMap 找不到旧 key。
修复方案(三选一):
方案 A:用只读 List
data class User(val id: Long, val name: String, val tags: List<String>)
// 使用
val u = User(1, "K", listOf("a", "b"))
val u2 = u.copy(tags = u.tags + "c") // 生成新 list,原 u 不变
方案 B:把可变字段挪出主构造
data class User(val id: Long, val name: String) {
var tags: MutableList<String> = mutableListOf() // 类体属性,不参与 equals/hashCode
}
这样 hashCode 只用 id + name,永远稳定。
方案 C:只用 id 作为 hash key(覆盖生成方法)
data class User(val id: Long, val name: String, val tags: MutableList<String>) {
override fun equals(other: Any?) = other is User && id == other.id
override fun hashCode() = id.hashCode()
}
最激进——只用 id 判等,忽略其它字段。适合"业务主键即身份"的领域模型。
# 10.2 一个data类一生
声明 data class User(val id: Long, val name: String)
↓
kotlinc 编译时展开:
- 主构造字段(private final)
- equals(Object):类型判定 + 逐字段 Intrinsics.areEqual
- hashCode():31 进制多项式
- toString():User(id=..., name=...)
- copy(id, name):new User(id, name)
- copy$default(...):处理默认参数的桥
- component1() / component2()
↓
javap 输出:字段 + 方法列表(约 200 行字节码)
↓
运行期 put 到 HashMap:
bucketIdx = user.hashCode() % capacity
bucket 里存 (key=user, value=data)
↓
运行期 get:
bucketIdx = user.hashCode() % capacity
遍历 bucket 找 key.equals(user) 的项
↓
若字段可变:
put 后修改字段 → user.hashCode() 变化 →
下一次 get 定位到别的 bucket → 找不到!
# 10.3 设计哲学回扣
- 语言层消灭 boilerplate:
data一词抵得上 30 行手写代码(equals + hashCode + toString + copy + componentN) - 不可变即安全:
data class主构造用val是黄金实践——一旦允许var,equals/hashCode就不稳定 - 约定优于配置:
componentN/equals都是编译器约定,不用手动开关、不用注解——data一个关键字激活全部 - 继承是限制而非能力:
data class默认 final 是 Bloch 原则的落地——你无法在继承一个类并加字段的同时保持 equals 对称性 - 组合胜于继承 +
sealed + data:Kotlin 用组合和封闭继承(sealed)优雅地绕开 data class 无法继承的限制,实现了函数式语言的 ADT
# 10.4 数据类速查表
| 特性 | 触发条件 | 生成物 |
|---|---|---|
equals | data 修饰 | 逐字段浅比较、类型严格匹配 |
hashCode | data 修饰 | 31 进制多项式 |
toString | data 修饰 | User(id=1, name=K) |
copy | data 修饰 | 主构造字段全参数、默认值继承原值 |
componentN | data 修饰 | 按主构造顺序、operator 修饰 |
| 只对主构造字段生效 | data 修饰 | 类体字段不参与生成 |
| 不可继承 | data 修饰 | 隐式 final |
| 可覆写 equals/hashCode/toString | 手写 override | copy 和 componentN 不可覆写 |
| 序列化推荐 | data + 注解 | Moshi @JsonClass / kotlinx.serialization @Serializable / kotlin-parcelize @Parcelize |
下一篇预告:03.协程调度器原理 —— 我们看看 suspend 关键字如何变成一个状态机,Dispatchers.IO 背后又是什么样的线程池。