编程进阶网 编程进阶网
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 在线工具
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 加解密
  • 时间日期
  • 网络工具
  • 颜色设计
  • 二维码
  • 开发实用
  • 计算机的原理
  • 操作系统原理
  • 网络协议原理
  • 数据库的原理
  • 序卷导读
  • 数据本质
  • 运行模型
  • 并发设计
  • 内存真相
  • 交互系统
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 技能之旅
  • 体系建设
  • 代码品质
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 性能优化
  • 数据结构导论
  • 线性结构详解
  • 树哈希结构论
  • 容器设计实战
  • 经典算法思想
  • 工程案例剖析
  • 算法题库精练
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++编程技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript案例
  • JavaScript高级
  • Kotlin精通
  • Android库解读
  • Android专栏
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • 毛选解读
  • 自我精进
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

    • Kotlin精通

      • Kotlin精通模块总览
      • Kotlin基础语法
      • 类和对象
      • 高阶特性入门
      • 空安全类型原理
      • data类与equals原理
        • 1. 案例引入
          • 1.1 一段反常代码
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构概览
          • 2.1 五项自动生成
          • 2.2 为什么这么切
        • 3. equals契约详解
          • 3.1 五大契约条款
          • 3.2 生成代码逐行
          • 3.3 与Java比较
          • 3.4 字段类型影响
        • 4. hashCode实现细节
          • 4.1 生成算法逐行
          • 4.2 与Objects工具类
          • 4.3 可变字段风险
          • 4.4 大字段性能坑
        • 5. copy浅拷贝本质
          • 5.1 copy字节码剖析
          • 5.2 命名参数配合
          • 5.3 可变字段陷阱
          • 5.4 深拷贝的正解
        • 6. 解构声明底层
          • 6.1 componentN约定
          • 6.2 解构字节码
          • 6.3 集合结合解构
          • 6.4 非data类解构
        • 7. 数据类与继承
          • 7.1 open_data被禁止
          • 7.2 继承破坏对称性
          • 7.3 sealed组合方案
          • 7.4 组合胜于继承
        • 8. 与Record对比
          • 8.1 Java16_Record简介
          • 8.2 字节码差异比对
          • 8.3 语义差异对照
          • 8.4 互操作陷阱
        • 9. 序列化陷阱
          • 9.1 Gson反射侵入
          • 9.2 Moshi代码生成
          • 9.3 kotlinx_serialization
          • 9.4 Parcelize注解
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一个data类一生
          • 10.3 设计哲学回扣
          • 10.4 数据类速查表
      • 协程调度器原理
      • inline与reified原理
      • 委托与属性原理
    • 库的解读

    • 专栏博客

  • iOS开发和进阶

  • Web开发和进阶

  • Linux应用开发

  • IoT智能硬件开发

  • Apps
  • Android提升进阶
  • Kotlin精通
杨充
2026-07-03
目录

data类与equals原理

# 02.data类与equals原理

# 目录介绍

  • 1. 案例引入
    • 1.1 一段反常代码
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构概览
    • 2.1 五项自动生成
    • 2.2 为什么这么切
  • 3. equals契约详解
    • 3.1 五大契约条款
    • 3.2 生成代码逐行
    • 3.3 与Java比较
    • 3.4 字段类型影响
  • 4. hashCode实现细节
    • 4.1 生成算法逐行
    • 4.2 与Objects工具类
    • 4.3 可变字段风险
    • 4.4 大字段性能坑
  • 5. copy浅拷贝本质
    • 5.1 copy字节码剖析
    • 5.2 命名参数配合
    • 5.3 可变字段陷阱
    • 5.4 深拷贝的正解
  • 6. 解构声明底层
    • 6.1 componentN约定
    • 6.2 解构字节码
    • 6.3 集合结合解构
    • 6.4 非data类解构
  • 7. 数据类与继承
    • 7.1 open_data被禁止
    • 7.2 继承破坏对称性
    • 7.3 sealed组合方案
    • 7.4 组合胜于继承
  • 8. 与Record对比
    • 8.1 Java16_Record简介
    • 8.2 字节码差异比对
    • 8.3 语义差异对照
    • 8.4 互操作陷阱
  • 9. 序列化陷阱
    • 9.1 Gson反射侵入
    • 9.2 Moshi代码生成
    • 9.3 kotlinx_serialization
    • 9.4 Parcelize注解
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一个data类一生
    • 10.3 设计哲学回扣
    • 10.4 数据类速查表

# 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 个疑问:

  1. data 关键字究竟触发编译器生成了哪些方法?
  2. equals 的生成实现如何逐字段比较?是否深比较?
  3. copy 是深拷贝还是浅拷贝?字节码上如何实现?
  4. componentN 是什么?为什么支持 val (a, b) = point?
  5. 为什么 data class 继承 data class 是坏味道?
  6. data class 与 Java 16 Record 有什么本质差别?

带着这些问题,我们从"编译器生成了什么"这一入口切进去,逐个揭开 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 个方法?

论证:

  1. 手写这 5 个方法冗余、易错:Java 项目里每个 POJO 都要抄一遍 equals/hashCode/toString/hashCode——一个字段 5 处修改,加一个字段忘改一处就是 bug 源。IDE 自动生成也只是把手工工作外包给 IDE,代码库里仍然多出 50 行/类。
  2. Lombok 用注解处理器解决:@Data 一个注解也能达到同样效果,但依赖第三方注解处理器,IDE 支持(IntelliJ / Eclipse / VSCode)不一致,字节码在 .class 里而不是源码里,调试和阅读都有隔阂。
  3. Kotlin 干脆在语言层内建:data 一词即完成,源代码里不见样板,字节码里可见生成。语言级支持比库级支持有 3 个优势:无需插件、无版本兼容问题、能与其他特性(如 copy 的默认参数)联动。
  4. 生成的代码遵循固定模板:任何 Kotlin 编译器版本、任何平台(JVM / JS / Native)行为都一致,字节码可稳定预期。

结论:data 是 Kotlin 对"POJO 样板代码"的语言级消灭。它把"如何比较两个对象"从"程序员的重复劳动"变成"编译器的编译规则"。


# 3. equals契约详解

# 3.1 五大契约条款

任何 equals 都必须满足(继承自 Java Object):

  1. 自反性(reflexive):x.equals(x) == true——任何对象等于自己
  2. 对称性(symmetric):x.equals(y) == y.equals(x)——反过来结果一样
  3. 传递性(transitive):x.equals(y) && y.equals(z) → x.equals(z)——一条相等链上任意两点都相等
  4. 一致性(consistent):多次调用结果相同(不涉及可变状态时)
  5. 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:走短路,返回 true
  • other == null:getClass() != other.getClass() 判定为 true(null.getClass() 会 NPE,所以先判 null)
  • other 是子类:getClass() 不同,返回 false
  • other 是同类不同实例:字段逐一比较

# 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:

  1. 奇质数:31 * x = 32x - x = (x << 5) - x——JVM 会优化成位移+减法,比乘法快
  2. 分布好:Bloch 用真实字典单词做过实验,31 的碰撞率非常低
  3. 历史惯例: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(内存字段可变) ❌ 不稳定

保护方案对比:

  1. 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 层就杜绝改动。

  1. Guava ImmutableList
data class User(val id: Long, val tags: ImmutableList<String>)
val u = User(1, ImmutableList.of("a", "b"))

运行期不可变(改会抛 UnsupportedOperationException),API 上仍暴露 List 接口。

  1. Collections.unmodifiableList 的局限
val original = mutableListOf("a", "b")
val u = User(1, Collections.unmodifiableList(original))
original.add("c")    // 从后门改了!

unmodifiableList 只是包了一层,原始的 mutable 引用还能修改内容——保护是假的。

  1. 只读 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

建议:

  1. 只把"识别字段"标 data,其它字段挪到类体:
data class Article(val id: Long, val title: String) {
    var content: String = ""      // 类体属性——不参与 equals/hashCode/copy
    var thumbnail: ByteArray? = null
}
  1. 缓存 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?) = ...
}
  1. 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 提出的三种):

  1. instanceof 而非 getClass():牺牲 Liskov 替换性 —— 子类和父类相等,即使字段不同
  2. getClass() 严格类型:牺牲多态相等 —— 父类和子类永不相等,但字段值一样也不相等
  3. 组合而非继承:把父类作为字段包进来,绕开继承

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!

根因:

  1. Gson 用 sun.misc.Unsafe 或 Constructor.newInstance() 分配对象,跳过主构造
  2. 然后用反射 Field.set 逐个赋值——完全不看 Kotlin 的 NotNull 注解
  3. 结果:u.name 在 JVM 层是 null,但在 Kotlin 类型系统里是 String(非空)
  4. 一旦访问 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 个疑问:

  1. data 生成的方法:equals / hashCode / toString / copy / componentN——共 5 类。
  2. equals 逐字段浅比较——对引用类型调用 Intrinsics.areEqual 委托给字段的 equals。
  3. copy 是浅拷贝——直接把字段的引用值传给新构造的对象。
  4. componentN 让解构成为可能——按主构造声明顺序生成 component1/2/...。
  5. data class 继承破坏 equals 对称性——语言层禁止。
  6. 与 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 背后又是什么样的线程池。

上次更新: 2026/07/05, 15:10:54
空安全类型原理
协程调度器原理

← 空安全类型原理 协程调度器原理→

最近更新
01
11.数据库的原理总结
07-12
02
11.操作系统原理总结
07-10
03
资深程序员软能力
07-10
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 鄂ICP备2024073355号-1 | 鄂ICP备2024073355号
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式