清单、索引和范围判断分别适合 Array、Hash 与 Range,但容器名称还不足以预测行为。数组里的对象可能共享,Hash 的默认值可能根本没有写进容器,Range 的 cover? 与 include? 也可能得到不同答案。本篇从这些差异解释集合契约。

文章卡

项目 内容
先修 第 02–03 篇;引用、字符串与数值相等
实验 bundle exec ruby labs/04/run.rb
核心问题 谁共享对象,键怎样相等,范围如何包含?
验收 Array 构造、Hash 默认值、数字键、字符串键与区间反例
工程应用 任务顺序用 Array,唯一 ID 用 Hash,优先级用数值 Range

数组保存引用,不隐含复制

Array.new(2, []) 只求值一次作为默认项的数组,然后把同一个对象放进两个位置。本篇验证:

1
2
3
4
5
6
7
shared = Array.new(2, [])
shared[0] << :changed
raise unless shared == [[:changed], [:changed]]

separate = Array.new(2) { [] }
separate[0] << :changed
raise unless separate == [[:changed], []]

block 形式每次产生新对象,才得到两个独立的空数组。这里不是 Ruby 随意复制失败,而是构造方式明确不同。重复存放不可变值可以复用同一引用,重复存放可变容器时要判断这种共享是否符合需求。Array

任务清单同样是引用图。对最外层 source.dup 只能得到一个新数组:

1
2
3
4
source = [{ tags: ['ruby'] }]
copy = source.dup
copy[0][:tags] << 'shared'
raise unless source[0][:tags] == ['ruby', 'shared']

新数组仍指向原来的 Hash,Hash 又指向原来的标签数组。Taskbook 的复制按已知结构进行,不使用 Marshal 或任意递归复制器。未来如果新增字段,需要更新复制策略和别名断言,否则旧验收无法覆盖新引用路径。

缺失元素与合法 nil 分开处理

数组索引不存在通常得到 nil;Hash 下标找不到键也通常得到 nil。若数据本身允许 nil,两种情况就无法靠返回值区分。需要“必须存在”的读取时,使用 fetch 或显式检查存在性,并为缺失定义错误。

Taskbook 的字段不允许随意缺失,因此先检查字段集合,再读取每个值。代码不会用 task[:priority] || 1 让缺失数据悄悄获得默认优先级。默认值适合配置中确实允许省略的项目,不适合掩盖输入格式错误。

Array 保留任务输入顺序,筛选也保留顺序。索引 Hash 用于检测唯一 ID,不代表业务输出顺序应改成数字排序。输出是否排序属于公开行为,加入排序需要调整验收,不能作为内部容器转换的附带效果。

Hash 默认值不是自动插入

这个 Hash 看起来能按键收集数组,实际却共享同一个默认数组:

1
2
3
4
wrong = Hash.new([])
wrong[:one] << 1
raise unless wrong[:two] == [1]
raise unless wrong.empty?

:one 和 :two 都未插入。缺失读取返回同一个默认对象,修改它后,另一个缺失键也看到变化。empty? 为真这一项断言说明“能读到内容”与“键已经存在”不是同一事实。Hash 默认值

需要按键创建并存储容器时,明确赋值:

1
2
3
4
right = Hash.new { |hash, key| hash[key] = [] }
right[:one] << 1
raise unless right[:two].empty?
raise unless right.keys == [:one, :two]

读取 :two 本身也插入了一个空数组。这个 default proc 具有副作用,因此“查询不存在的键”会改变 Hash。对于只读索引,如果不希望这种变化,使用 fetch(key, []) 返回临时默认值或明确查询;不要把默认 proc 当作线程安全的按键初始化方案。

键相等要遵守 eql? 与 hash

数字 1 与 1.0 在 == 下相等,但在 Hash 中可以是两个不同键:

1
2
3
raise unless 1 == 1.0
raise if 1.eql?(1.0)
raise unless { 1 => :integer, 1.0 => :float }.size == 2

因此,把浮点 ID 转成“数值相等的 ID”之前,要先明确领域契约。Taskbook 要求 Integer,不让 1.0 参与整数唯一性判断。若自定义对象成为键,需要使 eql? 相等的对象具有相同 hash,并考虑键进入容器后是否还能改变;第 10 篇给出完整的自定义键实验。

普通 Hash 对非冻结 String 键有特殊处理。lab 将可变字符串 'task' 作为键后再修改原字符串,仍能用旧内容查到记录,新内容查不到。这个行为保护了普通字符串键的内容,却不能外推到所有可变键对象,也不能证明 Hash 中的值被复制。

序列化边界还会改变键类型。JSON 使用字符串键,核心库使用 Symbol 键;转换需要统一并检测允许字段。把两个不同键默默合并,可能令输入值覆盖关系依赖处理顺序。

Range 比较区间与枚举成员

数值优先级可写成 (1..5).cover?(priority),.. 包含末端,... 排除末端。这个表达式适合检查已确认为 Integer 的值,单独使用它并不证明类型合法。

字符串范围能显示 cover? 与 include? 的区别:

1
2
3
range = ('a'..'z')
raise unless range.cover?('cc')
raise if range.include?('cc')

'cc' 在端点的排序边界之内,但不在这个范围按字符串后继生成的单字符成员中。对非数值范围,区间覆盖与枚举成员可能有不同代价和结果;不能靠方法名称把它们当作同义词。Range

没有末端的整数 Range 可以迭代,但直接 to_a 不能得到有限结果。第 08 篇将它与 Lazy 和 take 组合,并观察实际求值数量。范围能描述无界集合,不等于程序可以一次性把集合放进内存。

选择容器时同时选择约定

Taskbook 的 Array 负责顺序,Hash 负责字段与 ID 索引,Range 负责数值边界。默认对象共享、查询副作用、浅复制与键相等都写进独立 lab。累计应用不必为每个教学机制新增一个容器类,但应在真正会影响行为的位置留下检查。

练习是把任务按标签分组:每个标签对应独立数组,清单中同一个任务可以属于多个标签。先用 Hash.new([]) 写出失败实验,再改为存储默认 proc,检查至少两个不同标签及缺失读取。另一个练习是给整数 ID 和字符串 ID 定义明确的接受规则,确认 '1'、1 与 1.0 不会意外共用唯一性判断。

本篇运行输出 array_alias=[[:changed], [:changed]]、hash_missing=[1] 与 range=[true, false],最终为 PASS 04。这些观测证明固定反例的行为,并未测量容器在大规模任务上的性能。

参考资料

系列导航

导读 · 上一篇:03:String、Symbol 与编码 · 下一篇:05:方法与参数绑定 · 完整源码包