Perl 40:对象聚合与组合扩展边界
原定第 29 章要求的不只是“见过 bless”,而是把有状态聚合器封装成对象,并证明两个实例互不污染。LogTool::Course::Aggregator->new 只保存一张私有计数表;add($record) 接受散列记录或提供 level 方法的对象;rows 每次返回新的数组引用和新的行散列,行形状固定为 {level,count},按 level 排序。总数不放进对象,由调用方按输入条数计算,避免对象同时承担聚合和报表格式两种职责。
t/course-engineering.t 对这个契约做了四个反例保护:同一输入下 aggregate_rows($records) 与对象版结果相同;第二个 Aggregator 实例只加入 ERROR,不会改变第一个实例的 INFO/WARN 计数;调用方修改 rows 返回值里的 count,再向数组追加 ERROR 行,下一次 rows 仍回到原计数;旧 LogTool::report_rows 只把对象行转换回旧二维数组,保持 CLI 兼容。这样补的是原定第 29 章的“对象状态与函数版 parity”,不是把第 26 篇的 LogTool::Record 重新贴标签。
原定第 30 章关心扩展成本。experiments/course-composition.pl 故意写了一个继承版 CourseComposition::InheritedAggregator,新增 add_error_alias 时直接访问 $self->{counts}{ERROR}。这能跑通,却把子类绑定到父类当前的内部散列名;只要 Aggregator 改成数组、闭包或 XS 对象,子类就会坏。这个反例说明传统 bless 对象没有自动保护内部布局,@ISA 也不会把内部哈希变成公共协议。
同一个实验还写了组合版 CourseComposition::JsonReporter。它只持有一个 Aggregator 实例,通过 add 和 rows 协议取得聚合结果,再交给 LogTool::Course::Output::jsonl_report。组合版不读 $aggregator->{counts},因此它依赖的是公开方法和行形状,而不是对象内部存储。测试断言 composition_uses_rows_protocol=1,正文里的结论只覆盖这份受控代码,不把“组合一定优于继承”写成通用规则。
输出策略也按原计划拆开。LogTool::Course::Output::tsv_report 生成 TOTAL\tN 与 level 行;jsonl_report 使用核心 JSON::PP 生成一行 total 和多行 level,测试逐行 decode,确认数字仍是数字、level 仍是字段,不把 TSV 的字符串列硬解释成 JSON。这里的 JSON Lines 只覆盖本聚合报表,不承担通用日志导入格式;坏 JSON 行、缺字段和中文往返由原定第 28 章的更大输入契约继续约束。
练习:如果一个新报表类通过 $aggregator->{counts} 直接读 INFO,再手写 WARN 排序,哪里会先出问题?它依赖了未公开的内部哈希名,也复制了排序规则;父类改存储结构或新增 level 时,报表类可能静默漏行。最小修复是只调用 rows,再在输出层验证行形状。官方来源为冻结 Perl 5.38.2 pod/perlobj.pod 的 bless、方法和继承说明,pod/perlref.pod 的引用语义,以及核心 JSON::PP 文档;可运行证据见 examples/perl-language/t/course-engineering.t 和 experiments/course-composition.pl。








