深入 Ruby 11:类、实例与可见性:对象怎样维护合法状态
属性能够写入,不代表状态转换合法
一个任务可以拥有 status 属性,但允许任意 task.status = value,就无法保证状态只在预定路径上改变。构造器验证、实例变量和方法可见性共同决定对象对外承诺,单纯把 Hash 改写成类不会自动产生约束。
本篇承接作用域与对象所有权,在 Ruby 3.4 中建立独立的 Task 实验。任务初始状态为 open,完成后成为 done;重复完成视为调用错误。这是教学领域规则,并非所有任务系统都应该采用的状态机。
1 | |
这里的 complete! 返回当前对象,使调用者可以继续访问状态。感叹号只是名称约定,不能使 Ruby 自动检查危险操作,也不能自动建立原子事务。真正的规则由方法体与测试负责。
new 与 initialize 的分工
对于普通的类构造流程,new 建立实例并调用 initialize 完成初始化。初始化方法操作的是新实例的状态;它不是一个必须返回该实例的工厂函数。编写普通业务类时,不应依赖 initialize 的最后一个表达式作为 Task.new 的返回值。
initialize 通常是私有实例方法。外部创建对象走公开构造入口,而不是先获得半初始化对象再公开调用初始化方法。类级工厂可以在此之上解释另一种输入,但最终仍应收敛到同一套不变量检查。
标题检查先验证类型,再判断内容。直接对未知输入调用 empty?,会把错误分类交给偶然的方法是否存在;对库边界而言,明确的 ArgumentError 更能表达“输入不满足约定”。本例没有自动把数字转换成字符串,因为隐式宽容会掩盖调用者的字段错误。
保存标题时先 dup 再 freeze。这个顺序让任务拥有自己的字符串快照,同时避免冻结调用者仍在使用的对象。实验在构造后修改原字符串,断言任务中的标题不变。这个断言比只看构造器是否调用过 freeze 更接近真实接口要求。
访问器仍是方法
attr_reader :status 定义读取实例变量的方法,attr_writer 定义写入方法,attr_accessor 同时定义两者。它们没有把实例变量转换成公共字段,也不会替写入执行任意领域验证。
如果状态只能通过 complete! 改变,就不提供状态写入器。优先级若允许修改,应定义一个检查范围的方法;若不允许修改,就只提供必要查询。可见性表达的是允许的调用面,实例变量保存的是状态,二者各有作用。
实例变量属于接收者对象。两个 Task 实例的 @status 彼此独立;类体里的 @status 则属于类对象。将计数或默认值写在类体,再期望实例方法直接读到,容易得到 nil。这时先确认 self,不要用类变量补丁掩盖状态归属。
一个对象即使只有读访问器,也不必然不可变。访问器返回内部可变数组,调用者仍能修改数组。前一篇的对象图检查必须延续到这里:公开读取哪些引用,实际上就是向外暴露了哪些修改通路。
private 在 Ruby 3.4 中允许字面 self
Ruby 3.4 的可见性文档明确允许私有方法通过隐式接收者或字面 self 调用。把“私有方法不能带接收者”当作无版本条件的规则,会误判下面的代码:
1 | |
self.require_open! 合法,而把同一对象赋值给局部变量 receiver 后使用 receiver.require_open!,会触发 NoMethodError。对象身份相同并不足以让调用形式等价,这项规则涉及语法上是否使用字面 self。
实际程序通常把内部辅助方法写成无接收者调用,阅读成本较低。需要区分局部变量与写入器等情形时,字面 self 有明确用途。理解规则是为了正确预测已有代码,并不意味着应把所有内部调用都改成带接收者形式。
可见性不是安全沙箱。Ruby 的反射和上下文执行接口可以触达非公开能力,因此 private 用于组织合作代码的接口,不用于执行不可信程序。对外部输入开放动态调用时,应另外建立允许的方法集合,并使用适合的公开调用接口。
protected 允许受约束的同类对象协作
比较两个任务的优先级时,类内部可能需要读取另一个任务的内部属性,同时又不希望所有调用者都依赖该读取方法。protected 可以表达这种协作边界。
1 | |
这里的调用来自合适的类层次内部,能通过显式的 other 读取受保护方法。顶层直接执行 task.priority 则失败。不要把它简化成“同一个文件内可见”或“任意子类之外都绝对不可访问”;发送方与方法所属类层次的关系才是讨论重点。
若 other 是不满足协议的对象,比较仍会失败。protected 不负责验证参数类型。需要跨实现比较时,公开一个语义明确的比较协议通常更合适;受保护字段访问把双方耦合到相近的对象模型。
本例为了研究可见性采用受保护读取。实际 Taskbook 若希望报告直接展示优先级,可以把它作为稳定的公开只读属性。技术机制能够限制调用,并不意味着领域一定需要这个限制。
不变量要覆盖所有修改入口
构造器建立合法初态,领域方法维持合法转换。完整实验对空标题、越界优先级、重复完成分别断言失败,并对失败后的任务状态作出可观察判断。一个测试只证明非法输入“抛了异常”,仍可能遗漏抛出前已写入部分状态的问题。
状态更新宜放在验证之后。若方法同时修改多个字段,应先计算并验证新的值,再提交到对象状态;一旦涉及文件或其他对象,内存赋值的先后顺序无法替代事务。并发环境还需要额外同步,private 与实例变量都不提供互斥。
业务是否允许重复完成,要由接口定义。有些命令希望重复执行无影响,另一些要求指出流程错误。两种选择都能用 Ruby 实现,但测试必须与契约一致,不能把重复调用碰巧没出错当成幂等保证。
完整 lab 把方法边界拆成三种可观察结果:公开行为成功、内部协作成功、外部越界调用失败。这样的测试在重构辅助方法时仍有效,比断言某个实例变量的具体名称更稳定。
| 状态责任 | 对应设计 |
|---|---|
| 创建时拒绝非法字段 | initialize 验证 |
| 阻止随意替换状态 | 只读访问器与领域方法 |
| 内部辅助逻辑 | 私有方法 |
| 同类对象间内部比较 | 有限使用 protected |
对象边界如何影响测试位置
测试任务完成操作,应从公开入口构造任务,再调用 complete! 并读取公开状态。直接给 @status 赋值虽然能制造某种内部形状,却绕过了初始化约束。这样的测试可能在重命名实例变量后失败,而用户可观察行为完全没变。
负例则要选能够说明边界的输入:空标题、非整数优先级、超过上限的整数与重复转换分别覆盖不同规则。只用一个非法样本,会让“类型正确但范围错误”的缺陷没有机会暴露。预期异常的范围也要窄,不能把所有错误都当成验证成功。
如果构造器接收一个已经合法的任务对象,是否复用、复制或拒绝,应作为独立工厂契约。构造器里混入大量“猜测输入类型再转换”的分支,会让对象不变量变得难以追踪。先在导入边界把数据整理成明确参数,再交给领域对象,错误归属更清楚。
访问器的命名还关系到外部依赖。公开 status 意味着状态值本身进入接口;公开 done? 可以只暴露调用者需要的判断。如果任务以后增加处理中间态,后者的实现可以变化而不要求每个调用方重新解释状态枚举。需要展示完整状态的报告仍可以使用读取器,两种接口可以各自服务不同需求。
继承会复用方法,也会复用不变量假设。子类若允许父类拒绝的非法状态,父类继承来的行为未必还能正常工作。若只是需要不同的优先级策略,把策略作为协作对象往往比改变任务的基本合法性更稳妥。是否使用继承,应检查继承的每个公开方法仍能履行原承诺。
本例未把状态修改声明为线程安全。两个线程同时通过状态检查再更新,是并发层面的另一项问题;通过可见性隐藏方法并不能使这段过程原子化。后续线程实验需要重新围绕这个不变量观察事件顺序。
实验与练习
运行 ruby examples/ruby/labs/11/run.rb。脚本验证标题快照、受保护比较、外部访问拒绝、字面 self 私有调用、别名接收者失败、合法转换与重复转换错误,末行是 PASS lab 11。
练习一:给任务加入 cancel!,明确 done 是否允许转为 cancelled。先写转换表,再给每条允许与禁止的边加断言,保证失败不会改变状态。
练习二:加入可变标签读取器,构造一个从外部绕过标签验证的反例。分别用冻结快照和返回副本修复,并解释两种方案对调用者编辑方式与分配成本的影响。
参考资料
系列导航
导读 · 上一篇:10:身份、相等、复制与冻结:谁共享了可变状态 · 下一篇:12:单例类、方法查找与 super:调用命中了哪个方法 · 完整源码包
