copy 之后,修改为何仍然影响原对象

两个账户都写着编号 A-1、余额 100,是否就应该相等?一张订单复制后修改备注,原订单的备注是否应该保持不变?这两个问题都无法仅凭字段内容回答。账户可能表示带生命周期的实体,订单可能表示某个时刻的数据值;而“复制”还必须交代复制了哪一层。

Scala 的 class 提供实例结构,object 定义单例对象,case class 为数据建模提供生成的方法。选择哪种声明,会影响构造方式、公开接口和相等约定,却不会替业务决定哪些数据必须保持独立。

本文固定 Scala 3.3.7、JDK 21、Scala CLI 1.9.1,示例仅使用标准库。完整程序位于 examples/scala-lab/snippets/04/Chapter04.scala,实验状态为 LAB_VERIFIED:本章断言已在 Scala CLI 与 sbt 的批次运行中通过,输出与下文一致。证据位于仓库 examples/scala-lab/evidence/20261002-batch01/all.log、sbt.log,对应 JSON 保存实际命令与零退出码。附件 RUN.md 给出复现入口。

构造参数、成员与访问边界

普通类把实例所需的数据和操作放在一起。构造参数上的 val 会形成可读取的成员,var 还允许赋值;没有这些修饰时,不应把参数一律理解成公开属性。需要什么公开接口,就显式声明什么接口。Tour of Scala:Classes

1
2
3
4
5
6
7
8
9
final class Account private (
val id: String,
private var balance: Int
):
def currentBalance: Int = balance

def deposit(amount: Int): Unit =
require(amount > 0)
balance += amount

id 是外部能够读取的稳定绑定。balance 是内部状态,只能通过公开方法读取或修改;deposit 在修改前检查金额为正。构造器前面的 private 则限制直接构造。字段私有和构造器私有是不同的权限:前者控制状态访问,后者控制实例创建入口。

这个账户只是观察访问边界的模型。它没有货币单位、溢出校验、持久化事务和并发存取协议,因此不能当作资金账户实现。即使字段已经私有,只要多个线程同时执行读改写,仍然需要独立的并发设计。private 解决可见范围,不能代替原子性。

账户工厂放入同名伴生对象:

1
2
3
4
5
6
object Account:
def open(id: String, initial: Int): Account =
require(id.nonEmpty && initial >= 0)
new Account(id, initial)

def balanceOf(account: Account): Int = account.balance

同一源文件中的类与同名对象形成伴生关系,可以访问彼此的私有成员。因此 open 能调用私有构造器,balanceOf 能读取私有余额。外部调用者不能因为看到了相同类名,就获得这些权限。Tour of Scala:Singleton Objects

这组设计让 Account.open("", -1) 在构造前失败,也让 account.balance = -1 无法从任意位置绕过方法。前一条属于运行时输入检查,后一条属于编译期访问检查。两者保障不同边界,任意删去一个都会改变调用者可以做的事。

包声明 package scalaexamples 负责组织名字。import scala.collection.mutable.ArrayBuffer 让后文能够简写类型名,不会授予额外访问权限。若某个字段已经私有,增加 import 不能把它变成公开成员。找不到名字和名字存在但无权访问,应按不同错误处理。

可迁移的封装方法是先列出合法状态转换,再暴露能够执行这些转换的操作。把字段全改成私有,只解决了入口数量;如果公开方法仍能任意写入非法状态,封装并没有建立业务不变量。

object 与对象身份

object Account 本身是一个对象,Account.open 是对这个对象的方法调用。new Account 则创建类的实例,两者不因名字相同而合并。工厂调用两次,可以产生两个账户实例;伴生对象负责组织工厂,并不要求缓存或复用所有实例。

单例的范围也要读清楚。顶层对象适合持有同一份公共定义;嵌在类实例内部的对象属于各自的外层实例,不能据此推断“整个程序只有一个”。在 JVM 的多个类加载器或多个进程之间,更不能把语言中的对象声明当作分布式唯一性协议。

先比较两个普通账户:

1
2
3
4
5
6
7
8
9
10
11
val first = Account.open("A-1", 100)
val second = Account.open("A-1", 100)
val alias = first

assert(first eq alias)
assert(!(first eq second))
assert(first != second)

first.deposit(20)
assert(alias.currentBalance == 120)
assert(second.currentBalance == 100)

alias 和 first 指向同一实例,所以通过一个名称修改后,另一个名称会看到相同余额。second 在另一轮构造中产生,即便最初字段相同,也有独立的状态。对这里没有重写相等方法的普通类,== 使用继承来的相等行为,并不会自动逐字段比较。

eq 用于引用身份比较:是否为同一个引用对象。== 则是 Scala 的相等操作,涉及对象定义的 equals 行为以及空值等规则。不能把 Java 引用类型上的 == 习惯直接套进 Scala,也不能把 Scala 的 == 一概翻译成“深度比较”。Scala 标准库:AnyRef

这个区别在测试中很实用。验证缓存是否复用同一个实例,可以使用 eq;验证重新计算的数据是否内容一致,应按照数据类型的相等定义使用 ==。如果缓存契约只要求返回等值数据,测试却强制要求同一实例,就会把合法实现误判为失败。反过来,要求共享状态的场景若只测内容相等,也抓不住重复创建实例的问题。

case class 为哪些数据生成相等行为

订单明细更适合描述一个数据值:

1
2
3
4
5
6
7
8
9
10
11
final case class Line(sku: String, quantity: Int)

val line = Line("book", 2)
val equalLine = Line("book", 2)
val copiedLine = line.copy(quantity = 3)

assert(line == equalLine)
assert(!(line eq equalLine))
assert(line.hashCode == equalLine.hashCode)
assert(line.quantity == 2)
assert(copiedLine.quantity == 3)

对这类没有自定义相等逻辑的 case class,编译器为构造参数生成访问成员,并提供 equals、hashCode、copy 以及用于模式匹配等操作的支持。相等比较因此能够按数据组成进行,两个不同实例也可以相等。Scala 3 Book:Domain Modeling Tools

Line("book", 2) 常通过伴生对象中的 apply 创建值。copy(quantity = 3) 创建另一个 Line,未指定的 sku 使用当前实例的值。命名实参让更新意图清楚:商品仍是 book,数量变成 3。原实例的数量保持 2,因此可以同时保存更新前后的值。

这里的等值对象具有相同哈希值,但相同哈希值不足以证明对象相等。哈希结果空间有限,不同数据可能碰撞;容器必须继续按相等协议区分它们。测试 line.hashCode == equalLine.hashCode 验证的是相等契约的一部分,不能替代 line == equalLine。

case class 的生成相等也不是遍历全部内存字段。完整实验还有一个故意把备注放在类体里的例子:

1
2
3
4
5
6
7
8
9
final case class Annotated(id: String):
var note: String = ""

val a = Annotated("A")
val b = Annotated("A")
a.note = "left"
b.note = "right"
assert(a == b)
assert(a.copy().note == "")

两个实例的 id 相同,生成相等逻辑不会因为类体中的 note 不同而判为不等。copy() 通过构造创建新实例,类体初始化使备注重新成为空串,并不把修改后的备注自动转移过去。这个例子同时区分“参与相等的数据”和“实例持有的其他状态”。

把备注移到构造参数中,就改变了数据模型的相等含义;那是语义变更,不能仅视为代码排版。若备注属于对象值的一部分,应把它表达为值的一部分;若它只是观察过程中的缓存或附属信息,应为它设计独立生命周期。仅为少写构造样板而选 case class,容易把这些取舍隐藏起来。

浅拷贝如何产生共享修改

把不可变整数换成可变缓冲区,copy 的边界便能直接观测:

1
2
3
4
5
6
7
8
9
10
11
12
import scala.collection.mutable.ArrayBuffer

final case class Draft(id: String, notes: ArrayBuffer[String])

val draft = Draft("D-1", ArrayBuffer("new"))
val copiedDraft = draft.copy(id = "D-2")

assert(!(draft eq copiedDraft))
assert(draft.notes eq copiedDraft.notes)

copiedDraft.notes += "checked"
assert(draft.notes.toList == List("new", "checked"))

外层实例是新的,id 也已经变化,未指定的 notes 则沿用原引用。因此两个实例持有同一个缓冲区,向任一入口追加内容都会改变这个缓冲区。官方教程将这种行为称为浅拷贝。Tour of Scala:Case Classes

1
2
3
draft ────────> Draft("D-1", notes) ──┐
├──> ArrayBuffer("new", "checked")
copiedDraft ──> Draft("D-2", notes) ──┘

这个反例没有给 notes 标注 var。字段默认是 val,但只能阻止给字段重新赋一个缓冲区引用,无法阻止对现有缓冲区调用修改方法。要判断深层不可变性,必须继续检查字段所引用的对象,以及对象中的元素。外层出现几个 val,不是可变范围的完整描述。

把复制写成 draft.copy(notes = draft.notes.clone()),可以为这个 ArrayBuffer[String] 创建独立容器。若元素改成可变的 Note 实例,容器复制依然会共享这些元素,届时还必须决定是否复制每个元素。复制多深取决于所需隔离边界,不能用一个通用“clone 已调用”的标签结束检查。

更新场景通常可以改成不可变数据:

1
2
3
4
5
6
7
8
9
final case class Order(id: String, lines: Vector[Line])

val order = Order("O-1", Vector(line))
val revised = order.copy(
lines = order.lines.updated(0, line.copy(quantity = 3))
)

assert(order.lines.head.quantity == 2)
assert(revised.lines.head.quantity == 3)

这里先得到数量为 3 的新明细,再得到替换了该位置的新向量,最后得到新订单。断言同时查看旧订单和新订单,能检验更新没有穿过共享引用改变旧值。若只测 revised 的数量为 3,前面的可变缓冲区方案也可能满足这一半要求,从而掩盖原数据被改动的问题。

不可变容器允许实现共享未修改的内部结构。安全性来自这些共享部分不能被后续修改,不要求每次都复制整张对象图。反之,如果 Vector 的元素本身可变,容器的不可变性仍不足以保证订单快照稳定。测试应沿真正的修改路径覆盖到元素,而不是停在集合类型名上。

相等协议与业务标识

对于账户实体,业务上也许规定编号相同就代表同一账户;这不等于把编号、余额都纳入相等最合适。余额会变化,而账户的业务标识通常保持稳定。若需要按编号检索,可以显式使用编号作为键,避免让余额变化参与集合定位。

对数据值而言,比较组成部分则更直接:商品编号和数量相同的明细可以视为等值。改变数量得到新的明细,让旧订单保留旧数量。于是同一个业务里可以同时使用普通类表示有状态对象、case class 表示稳定的数据值,不必要求所有领域类型遵循同一种相等策略。

还要区分“允许比较”和“比较得到什么”。默认语言模式与严格相等检查模式的编译规则不完全相同,严格相等将在后续类型章节单独实验。本文只比较同一具体类的实例,也没有自定义 equals。因此这些断言不能证明任意两种类型都应允许比较,不能替跨类型的对称性、传递性和继承边界背书。

若确需自定义 equals,就必须成组考虑 hashCode 与相等关系的契约,而不能只满足一个眼前断言。把参与相等的数据放进可变字段,还会使比较结果随时间变化,给集合键、去重和缓存带来额外约束。基础数据模型优先保持这些组成部分稳定,比在每个使用位置补特殊判断更容易验证。

正常路径与编译反例

从仓库根目录运行:

1
2
scala-cli run examples/scala-lab/snippets/04/Chapter04.scala --scala 3.3.7 --jvm 21 --server=false --main-class scalaexamples.Chapter04
scala-cli compile examples/scala-lab/negative/04/private-access/Example.scala --scala 3.3.7 --jvm 21 --server=false

正例应以零退出码结束;本章在批次 Scala CLI 与 sbt 运行中的实测输出为:

1
2
3
4
5
identity=true,false;balances=120,100
valueEqual=true;referenceEqual=false;quantities=2,3
shallowCopy=shared;originalNotes=new,checked
immutableUpdate=2,3
bodyField=ignoredByEquality;copyNote=empty

编译反例单独定义 Vault(private val secret: String),再从外部读取 new Vault("hidden").secret。预期因无法访问私有成员失败。验收需同时满足编译器非零退出和诊断包含私有成员访问错误;源码找不到、JDK 未安装或依赖下载失败都不是这个语义的证据。

该反例已返回退出码 1,诊断包含 secret cannot be accessed。原始记录为 examples/scala-lab/evidence/20261002-batch01/negative-04-private-access.log,同名 JSON 保存实际命令、退出码和匹配式。本批实际 JDK 为 Amazon Corretto 21.0.11,通过 --jvm system 选中;文中 --jvm 21 的简写只指定主版本。

正例中浅拷贝造成共享修改,是有意保留的运行时反例。程序用断言确认共享确实发生,因此整段实验仍应成功。若把断言反过来要求原备注不变,程序失败只能说明原需求未被当前实现满足;正确修复应改变复制或数据模型,而不是删除这条断言。

练习与解答

手算题:把 val copiedDraft = draft.copy(id = "D-2") 改为 val copiedDraft = draft.copy()。在尚未追加备注时,draft == copiedDraft、draft eq copiedDraft、draft.notes eq copiedDraft.notes 分别是什么?

预期依次为 true、false、true。构造字段内容相同,外层是两个实例,内层沿用同一缓冲区。随后通过 copiedDraft.notes 追加内容,两边字段仍指向相同数据。这个结果再次说明,值相等和引用共享能够同时存在,必须用各自的断言观察。

修改题:把 Draft.notes 改成 Vector[String],实现 appendNote(draft, text),要求返回新草稿,并保留原草稿的备注。解答可以是:

1
2
3
4
final case class Draft(id: String, notes: Vector[String])

def appendNote(draft: Draft, text: String): Draft =
draft.copy(notes = draft.notes :+ text)

应补两条断言:旧实例的备注仍为 Vector("new"),新实例为 Vector("new", "checked")。再把元素从字符串换成可变对象,重新判断这组断言是否足以验证深层隔离。只有修改到元素内部时,才会暴露不可变外壳中继续存在的共享可变状态。

需求 需要区分的对象 验证方式
控制合法写入 可见字段与公开操作 私有访问编译反例、状态断言
复用同一实例 引用与字段内容 eq
比较数据值 相等定义中的组成部分 == 与相同哈希约束
更新并保留旧值 外层对象与嵌套对象 同时断言新旧数据
制作独立快照 容器与元素的修改能力 沿修改路径逐层检查别名

资料与系列导航

类、伴生对象和 case class 的规则入口分别列在正文链接中。本文的引用图与更新步骤属于针对示例的推导,完整程序结果已由冻结版本验证。相等生成代码可以从 Scala 3.3.7 SyntheticMembers 的 equalsBody 阅读,避免把滚动教程的概括当成所有自定义类的规则。

前篇:Scala 03:方法、函数与闭包。后篇:Scala 05:trait 线性化与初始化。实验基线:Scala 00:工具链与可复现实验。

顺序导航:系列入口:00 · 上一篇:03 · 下一篇:05。