副本里的修改为何出现在原对象中

系列导读 · 下载本篇完整实验

任务清单调用 dup 后得到一个新数组,但修改副本中的任务标题,原清单可能同时改变。顶层容器确实复制了,内部引用却仍指向原来的对象。判断这个问题需要画出对象与引用,而不能只数变量名。

本篇以前面的集合与作用域为基础,使用 Ruby 3.4 的核心对象接口。完整实验位于 examples/ruby/labs/10/run.rb。对 Taskbook 而言,目标是明确任务数据由谁持有、谁可以修改,以及哪些状态可以稳定地作为 Hash 键。

1
2
3
4
5
6
7
original = [+'write']
copy = original.dup
copy.first << '!'

p original
p original.equal?(copy)
p original.first.equal?(copy.first)

结果依次是 ["write!"]、false、true。一元加号确保这里使用可变字符串,让实验不受字符串字面量冻结设置干扰。容器身份与元素身份是两项不同的断言。

身份回答对象是否相同

equal? 用来检查两个引用是否指向同一对象。值比较通常使用 ==,具体含义由类定义。两个独立数组可以内容相等,但身份不同;同一个数组经由不同变量访问时,身份保持一致。

object_id 适合在一次运行中观察对象关系,不适合当作持久业务 ID。任务 ID 需要在文件导入、重新启动甚至另一台机器上维持语义,对象身份没有这样的业务承诺。日志里观察对象身份和领域里识别任务,应使用不同字段。

1
2
3
4
5
6
7
left = [1, 2]
right = [1, 2]
alias_of_left = left

raise unless left == right
raise if left.equal?(right)
raise unless left.equal?(alias_of_left)

赋值仅改变引用的绑定,不会自动创建对象。参数传递后,方法中的局部变量可以重新绑定而不改变调用者变量,但通过这个引用修改可变对象,调用者能观察到修改。讨论“传值还是传引用”时,如果不指明复制的是引用还是对象,很容易遗漏这一层。

Object 文档分别定义复制与冻结接口;BasicObject 文档说明身份与相等的关系。下面的具体判断均可通过本篇独立实验重现。

Hash 使用 eql? 与 hash 组成键契约

1 == 1.0 为真,而 1.eql?(1.0) 为假。数值比较可以认为它们相等,Hash 键等价关系却可以区分类型。自定义对象只重写 ==,不代表它们马上就能作为可替换的 Hash 键。

键契约要求:若两个对象的 eql? 为真,它们的 hash 值必须相等。反向推导不成立,因为哈希碰撞允许发生。不能用“哈希值相同”证明两项业务数据相等,更不能把进程里的哈希值保存成永久标识。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class TaskKey
attr_reader :id

def initialize(id)
@id = id
end

def eql?(other)
other.instance_of?(self.class) && id.eql?(other.id)
end

alias == eql?

def hash
[self.class, id].hash
end
end

left = TaskKey.new(7)
right = TaskKey.new(7)
raise unless { left => :task }[right] == :task

这里将类与 ID 同时纳入键语义。若业务允许不同类代表同一种 ID,类型限制需要另行设计,并为对称性与传递性添加测试。直接套用这个类检查只是本例的选择,不能代替领域定义。

键对象进入 Hash 后,参与 hash 或 eql? 的状态应保持稳定。实验用数组作为键,修改数组后调用 rehash 重建索引,验证能用修改后的等价值查到结果。这证明索引需要与键状态对齐,不是推荐每次写入后依靠 rehash 修复。任务索引应优先使用稳定 ID,减少可变键带来的维护义务。

dup 与 clone 都是浅复制

dup 通常复制普通对象的实例变量引用,产生未冻结副本;clone 还保留单例方法和默认的冻结状态。实验在一个普通对象上定义单例方法后将其冻结,再分别复制:

1
2
3
4
5
6
7
8
9
10
11
object = Object.new
def object.label = :singleton
object.freeze

duplicate = object.dup
cloned = object.clone

raise if duplicate.frozen?
raise if duplicate.respond_to?(:label)
raise unless cloned.frozen?
raise unless cloned.label == :singleton

这些检查关注普通对象的默认行为。类可以定制复制钩子,因此库对象的复制语义仍需阅读具体实现。clone(freeze: false) 能请求一个未冻结的克隆,但解除外层冻结没有复制所有子对象,也没有改变原对象。

复制后的实例变量与原对象仍可能指向同一个数组、字符串或业务对象。需要的若是“修改副本不会影响原对象”,必须先定义哪些节点应复制、哪些对象允许共享。复制一个任务时,可以复制标题字符串和标签集合,同时继续共享不可变的任务 ID。

所谓通用深复制经常隐含了没有说明的策略:循环引用怎样处理,两个字段本来指向同一子对象是否保留这个关系,文件句柄是否应该复制,自定义类的约束如何建立。对小型业务对象,显式构造新对象比不受约束的递归复制更容易说明和测试。

把同样的浅复制规则应用到任务的标签字段,可以直接观察外层 Hash 与嵌套数组的身份:

1
2
3
4
5
6
7
task = { tags: [+'ruby'] }
copy = task.dup
copy[:tags] << 'review'

raise if task.equal?(copy)
raise unless task[:tags].equal?(copy[:tags])
raise unless task[:tags] == ['ruby', 'review']
flowchart LR
  T["task:原 Hash"] -->|tags| A["同一个 Array:ruby、review"]
  C["copy:dup 产生的新 Hash"] -->|tags| A

图中两个 Hash 是不同对象,两条 tags 引用指向同一个数组。copy[:tags] << 'review' 修改的正是这个共享数组,因此从 task 也能看到新标签;图中的两个元素是追加后的实际值。这里尚未复制数组,更没有复制其中的字符串。

freeze 约束当前对象

1
2
3
4
5
6
7
8
9
10
11
12
task = { title: +'write', tags: [] }.freeze
task[:tags] << :ruby
task[:title] << '!'

raise unless task[:tags] == [:ruby]
raise unless task[:title] == 'write!'

begin
task[:status] = :done
rescue FrozenError
puts 'outer hash rejected mutation'
end

外层 Hash 无法增加字段,但已存在引用所指向的数组和字符串仍可修改。这是浅冻结。只有逐项检查所有需要稳定的子对象,才能论证一个具体数据结构的深度不可变性。

冻结也不是复制。把调用者提供的数组直接 freeze,会同时限制调用者持有的那个数组。若构造器希望拥有自己的数据快照,应先复制需要拥有的节点,再冻结副本。单纯为了阻止内部代码修改而冻结外部对象,会把副作用转移给调用者。

Taskbook 可以约定标签仅包含 Symbol 或独立冻结的字符串,构造器复制标签数组并冻结它。这个约定比“任意对象都接受,递归冻结全部内容”更窄,却更容易验证。若标签允许自定义复杂对象,契约就必须重新定义。

冻结对象仍可以通过读取共享的外部状态表现出变化。一个方法读取全局配置,即使接收者已被冻结,也不保证每次返回相同值。由此判断缓存是否安全时,既要看对象自身可变性,也要看方法依赖的其他状态。

以所有权决定复制位置

读取函数若只遍历输入,不保留、不修改它,通常不需要防御性复制。长期保存输入的构造器则需要明确约定:拥有副本、共享不可变对象,还是允许调用者继续修改。每个边界都复制会增加分配,却不一定消除最深处的共享引用。

任务报告可以输出新 Hash,同时引用原任务的冻结标题。这种共享有明确前提:标题确实不可变,报告使用者也不需要原地修改。若报告用于交给任意调用者加工,就可以在出口复制可编辑字段。选择取决于接口承诺,不取决于 dup 写起来是否方便。

一个有用的测试顺序是先断言顶层身份,再断言子对象身份,最后分别修改输入与输出。只检查最终值相等,可能把共享错误隐藏掉;只检查 frozen?,又会漏掉嵌套引用。

要证明的性质 最小观察
两个变量指向同一对象 equal?
两个键可互换 eql?、相同 hash 与真实 Hash 查询
外层不允许修改 修改操作抛出 FrozenError
副本隔离指定字段 子对象身份与双向修改实验

快照测试应从调用者两侧施加修改

假设导出函数返回一个任务 Hash 的副本,验收不能停在“返回值与输入相等”。可以先记录输入与输出的标题和标签身份,再修改输出标签,最后检查输入。这个顺序能发现共享引用,却仍未检查调用者修改输入后的反向影响,所以还应从输入侧重复一次。

如果导出结果计划供用户编辑,两侧互不影响通常是合理要求;如果接口声明返回只读视图,共享引用可能是有意设计。此时测试应确认所有可达修改入口都被约束,而不是强求每个节点身份都不同。复制全部对象与正确封装不是同一件事。

对业务键,还要区分测试中的数值和运行中的哈希实现。随机化的哈希种子意味着不应写死某次得到的整数。断言“相等键的哈希相等”足够表达契约;断言某个固定哈希值,只会让实现细节进入业务测试。

冻结前后也应观察原有别名。一个数组经两个字段共享,冻结它会同时影响两个访问路径;对数组做副本再冻结,另一个字段仍指向原数组。对象图能解释为什么两段看起来都调用了 freeze 的代码,对外部调用者造成不同影响。

处理自引用结构时,手工递归冻结需要记录访问过的节点,否则可能无限递归。当前任务模型限定为标题与标签等简单字段,因而没有引入通用图遍历器。这项简化来自输入结构已知;若未来允许任意嵌套对象,应先扩充模型契约,再决定复制与冻结算法。

序列化再反序列化也不能无条件替代复制。序列化格式可能丢失对象类型、别名关系和非文本状态,还会引入解析错误与安全边界。任务快照若只需几个已知字段,显式建立新结构能更准确地保留需要的语义。

实验与练习

运行 ruby examples/ruby/labs/10/run.rb。验收覆盖值相等与身份、数字键等价、浅复制、浅冻结、单例方法复制、克隆冻结开关、Hash 键契约和重建索引。全部断言通过时输出 PASS lab 10;预期的冻结错误被明确捕获,其他失败继续传播。

练习一:为任务构造器加入标签数组,分别实现直接保存、仅复制外层和复制字符串元素三种版本。修改调用者的数组与字符串,列出每个版本仍共享的对象。

练习二:给 TaskKey 增加可变状态字段,分别让它参与和不参与键相等。写一个真实 Hash 查询实验,解释状态变化为何只应在确有业务需要时改变键身份。

参考资料

系列导航

导读 · 上一篇:09:作用域、self 与常量:名字怎样绑定到对象 · 下一篇:11:类、实例与可见性:对象怎样维护合法状态 · 完整源码包