不可替换字段,仍可能修改字段里的数组

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

Data.define 生成的数据对象没有普通字段写入器,实例也被冻结,但其中的标签数组仍可能被修改。数据载体、对象不可变性和结构匹配是三项独立能力,不能因为一种类同时提供几种接口就把它们混成一个保证。

本篇承接相等与协议,使用 Ruby 3.4 的 Struct、Data 和模式匹配。完整实验在 examples/ruby/labs/15/run.rb,重点是任务记录的表示与边界验证,不引入外部匹配库。

1
2
3
4
5
6
7
8
TaskValue = Data.define(:id, :tags)
tags = []
task = TaskValue.new(id: 1, tags: tags)
tags << :ruby

raise unless task.frozen?
raise unless task.tags == [:ruby]
raise unless task.tags.equal?(tags)

结果说明构造器保存了字段引用。Data 限制字段替换,不递归冻结被引用的对象。若需要完整任务快照,必须在构造边界处理标签所有权,不能从类型名称推出深度不可变。

Struct 提供可变记录

Struct.new 根据成员名建立一个类,默认提供读取与写入方法,以及按成员访问和拆解的能力。成员列表固定,成员值仍可以更新。

1
2
3
4
5
6
MutableTask = Struct.new(:id, :tags, keyword_init: true)
task = MutableTask.new(id: 1, tags: [])
task.id = 2

raise unless task.id == 2
raise unless MutableTask.new.id.nil?

实验明确指定 keyword_init: true,避免把初始化形式的选择留给读者猜测。未提供的字段可以是 nil;这适合允许部分构造的记录,却不能自动满足“ID 必须存在”的领域约束。

若任务具有复杂状态转换,仅有字段写入器通常过宽。可以为 Struct 添加行为,但所有生成的写入入口仍需纳入不变量设计。与其覆盖多种更新路径,不如在领域对象需要严格控制状态时使用普通类,在边界传输纯数据时使用记录。

Struct 的结构相等也不等于业务实体相等。两个 ID 相同但标题不同的记录是否属于同一任务,需要明确领域选择;不能因为生成类自带 == 就省略身份设计。任务索引使用稳定 ID,可以与记录的内容比较保持独立。

Data 固定字段引用

Data.define(:id, :tags) 生成另一种记录类,正常构造要求提供所有成员。本例遗漏 tags 会抛出 ArgumentError,脚本明确断言这个失败。

1
2
3
4
5
6
7
TaskValue = Data.define(:id, :tags)
original = TaskValue.new(id: 1, tags: [:ruby])
changed = original.with(id: 2)

raise unless original.id == 1
raise unless changed.id == 2
raise unless original.tags.equal?(changed.tags)

with 表达基于原值形成字段替换后的值,未替换字段仍共享引用。它方便维护值式接口,但没有通用深复制语义。测试应分别验证原字段没被替换,以及内部可变对象是否仍共享。

Data 与 Struct 都可以提供结构拆解,但 Data 不因此成为 Enumerable 的另一种集合。记录成员是一个固定结构,遍历业务任务集合是另一个协议。选择记录类时,应先判断需要成员读写、相等与复制中的哪几项能力。

官方 Data 文档直接说明浅层边界。本篇的标签实验进一步显示其工程后果:把数组放进 Data 之后再交给缓存,缓存值仍可能变化。需要稳定缓存键时,还要检查成员参与 eql? 与 hash 的方式。

模式匹配调用拆解协议

数组形状的匹配使用 deconstruct,Hash 形状使用 deconstruct_keys。Struct 和 Data 已提供这些接口;自定义对象也可以实现协议参与匹配。

1
2
3
4
5
6
7
8
9
10
11
TaskValue = Data.define(:id, :tags)
task = TaskValue.new(id: 1, tags: [:ruby])

result = case task
in TaskValue(id: Integer => task_id, tags:) if tags.include?(:ruby)
task_id
else
nil
end

raise unless result == 1

这个模式同时检查类、字段形状与守卫。Integer => task_id 在匹配类型模式的同时绑定变量;tags: 使用字段名作为绑定名称。守卫只有在结构匹配后才继续检查,因此可以使用已经绑定的变量。

Hash 模式默认允许没有写出的额外键。{id: 1, other: 2} 能匹配 {id: 1};若协议要求拒绝额外键,应使用 {id: 1, **nil}。导入数据时,这个差异关系到未知字段是否被默默忽略,必须由边界契约决定。

模式里的 Integer 等值模式使用匹配语义,不是统一调用普通 ==。设计自定义 === 或拆解接口时,应避免让匹配本身执行昂贵或有副作用的操作,否则一段看起来只是结构判断的代码会产生额外行为。

绑定与固定已有值

模式中的普通局部变量名通常用于绑定,不能把已有变量直接写进去就期待进行相等检查。要复用已有值作为条件,使用 pin 运算符 ^。

1
2
3
4
5
expected = 1
task = { id: 1 }

raise unless (task in { id: ^expected })
raise unless expected == 1

如果去掉 ^,变量位置表达的是绑定目标,程序含义改变。对任务 ID 过滤而言,这种差异可能让原本应该拒绝的记录全部通过。测试应使用一个匹配输入和一个不匹配输入,只有正例不足以发现约束被意外取消。

value in pattern 适合取得布尔结果;右向赋值 value => pattern 则在不匹配时抛出 NoMatchingPatternError。完整 lab 对后者的失败作出断言,避免把解构失败与返回 nil 混淆。

case/in 提供多分支与守卫。没有匹配分支时如何处理,需要显式考虑兜底。读取不可信输入时,应给出稳定错误分类,而不是让内部模式文本直接成为对外错误消息。

拆解应提供稳定观察

deconstruct_keys 接收所需键的提示,允许实现只准备相关字段。它返回的仍应是满足协议的 Hash。若拆解需要访问数据库或文件,匹配代码就具有外部失败和成本,应该在接口文档中明确。

Ruby 官方模式匹配文档将失败模式中的变量值,以及拆解方法的具体调用次数列为不能依赖的行为。程序不应在匹配失败后读取部分绑定,也不应通过“每匹配一次就递增计数”实现业务逻辑。实验只断言最终匹配结果,不固定内部拆解调用次数。

对 Taskbook 的导入边界,可以先解析并验证普通 Hash,再构造合法记录。模式匹配便于表达已知结构,却不自动验证字符串编码、数值范围或业务唯一性。这些约束仍需普通条件或领域构造器承担。

结构化模式可以减少手写取字段代码,但深层嵌套也可能遮住错误归属。若一个模式同时验证十几个字段,失败时很难说明哪个字段有问题。需要精确诊断的边界,分阶段校验通常更清楚;模式更适合已知变体之间的分派。

需求 本篇选择
简单可变记录 Struct
固定字段引用的值式记录 Data
检查字段并绑定变量 Hash 模式
与已有变量比较 pin 运算符
拒绝未声明键 **nil

记录形式不能代替领域验证

结构匹配能确认 id 是整数,却无法自动确认它在当前任务集合中唯一。字段存在、字段类型、取值范围与跨记录关系,应分成不同层次。把全部问题压进一个很长的模式,只会让错误更难解释,而不会获得额外的验证能力。

同样,tags 是数组不等于数组中的每项都合法。模式可以继续描述成员,但若允许任意数量的标签并要求去重、长度上限和编码正确,普通校验循环更容易给出具体错误。模式负责选择已知形状,领域代码负责维护领域约束。

Data 的值式更新适合从旧记录产生新版本,却不会自动创建历史存储。原对象只有在仍被其他引用持有时才存在;如果把变量重新赋给新记录,旧值是否保留取决于应用。审计日志、撤销或版本比较都需要额外保存策略,不能把 with 当成事件溯源功能。

模式绑定的变量也会进入所在局部作用域。为了降低名字冲突,应使用能表达语义的名称,避免在长方法里多次绑定同名 id 却对应不同对象。复杂分派可以提取为短方法,使绑定的使用范围与实际职责接近。

对自定义拆解对象,返回一个全新的数组或 Hash 也不意味着其中元素被复制。匹配获得的标签引用可能仍指向对象内部状态。若调用方在匹配分支中修改标签,就可能修改原对象;值对象的只读约束仍应通过成员所有权实现。

测试 Data 与 Struct 的选择时,可以从调用者最常做的操作出发:是否需要编辑字段,是否需要把两个快照比较,是否需要按名字拆解,是否允许缺省。四项答案比“这个类更新,所以更好”更能确定接口。两种载体都不适合承担未知输入的全部清洗责任。

部分匹配成功后又被守卫拒绝,仍不应把临时绑定当作后续业务输入。把绑定变量的使用留在成功分支中,可以避免依赖失败路径的偶然状态。需要把结果传出时,显式返回新记录或明确的失败值,让外层只消费已经完成验证的结果。

记录类如果参与 Hash 键,成员的稳定性会直接影响查找。即使最外层是 Data,嵌套数组变化仍可能改变键内容;键设计应继续遵守前一篇的相等与哈希契约。

实验与练习

运行 ruby examples/ruby/labs/15/run.rb,验收覆盖 Struct 写入、Data 必填字段与浅冻结、with、两类拆解、类型模式、守卫、pin、额外键与右向匹配失败,最终输出 PASS lab 15。

练习一:为任务标签实现构造时复制与冻结,保证修改原数组不能影响记录。再使用 with(id: 2),验证未修改字段的共享是否仍然安全。

练习二:接受两种输入变体:带标题的创建任务和仅带 ID 的完成任务。用 case/in 分派,再添加未知操作与额外键反例;比较严格拒绝和忽略额外字段对兼容性的影响。

参考资料

系列导航

导读 · 上一篇:14:鸭子类型与委托:不继承也能满足什么契约 · 下一篇:16:动态方法、反射与钩子:元编程怎样保持可诊断 · 完整源码包