深入 Ruby 03:String、Symbol 与编码
任务标题 '任务' 包含两个字符,在 UTF-8 中占六个字节。给任意字节贴上 UTF-8 编码标记不会自动修复它。Ruby 的 String 同时涉及内容字节、编码与可变性,输入验收必须分别检查这些条件。
文章卡
| 项目 | 内容 |
|---|---|
| 先修 | 第 01–02 篇;字节与字符的概念 |
| 实验 | bundle exec ruby labs/03/run.rb |
| 核心问题 | 字节是否有效、转码是否发生、修改影响哪个引用? |
| 验收 | 字节数、字符数、非法输入、转码往返、别名与正则锚点 |
| 工程边界 | 标题与标签保持文本,固定状态使用 Symbol;外部数据在入口转换 |
内容与编码是两种信息
实验从一个可核对的短字符串开始:
1 | |
bytesize 适合限制读取或传输的字节量,length 按字符串编码计算字符数量。用户感知的字符还可能由多个码点组成,例如组合音标或某些 emoji;界面截断需要研究字素簇,不能把任意 byteslice 或固定码点数当成完整显示字符的保证。这三种长度服务于不同需求。String
ASCII-8BIT 表示按字节看字符串,.b 可以得到这样的字节字符串。force_encoding 更改解释字节时使用的编码标记,不进行编码转换。本篇用一个不合法的 UTF-8 字节验证这点:
1 | |
操作后仍是同一个非法字节。即使 bad.encoding 显示 UTF-8,也不能证明内容合法。若接收端规定输入是 UTF-8,必须检查 valid_encoding?;若原始字节确实是另一种编码,先按实际来源标记,再转码到目标编码。Encoding
转码与修复的契约不同
lab 将合法文本转成 UTF-16LE,再转回 UTF-8,比较原始文本是否保持。对非法字节向另一种字符编码转换,则验证 Encoding::InvalidByteSequenceError:
1 | |
这不是对全部编码的通用往返保证。某些字符在目标编码中没有表示,转码还可能产生 UndefinedConversionError。输入编码、目标编码和替代策略应随数据源明确,而不是让程序依赖终端的默认外部编码。
scrub 可以替换无效序列,本实验将这个字节变成替代字符 �。替换会损失原始内容,适用于显示容错时可以接受,作为用户标识或签名材料时却可能改变含义。Taskbook 的导入边界选择拒绝坏编码,避免把两个不同的坏输入归并成相同标题。日志可以单独采用替代策略,但不要让日志策略偷偷改变领域数据。
字符串修改通过别名传播
Ruby 变量保存对象引用。把字符串赋给另一个变量,不会产生独立内容:
1 | |
实验用 String.new 明确创建可变字符串。本工程的 Ruby 文件启用 frozen_string_literal: true,普通字符串字面量因此冻结;这个编译指令不是说明文字,移除它会改变修改字面量时的结果。Ruby 3.4 未写该指令的代码还有自身的字面量兼容行为,不能从某一个文件的结果外推全部 Ruby 代码。
<< 修改接收者,+ 通常产生新字符串。方法名带 ! 常用于标识比对应非感叹号方法更危险的版本,并不是“所有带 ! 都修改、所有不带 ! 都不修改”的语法规则;<< 已经是反例。需要知道一个方法是否改动输入,应读该方法的契约并运行别名实验。
Taskbook 的核心只接受有效 UTF-8 标题和标签;合法的其他编码也需要在入口按契约转码,否则明确拒绝。校验成功后复制标题和每个标签字符串。只复制最外层数组仍会共享任务 Hash 和内部 tags;只复制 tags 数组仍会共享各个标签字符串。复制深度取决于声明的数据结构,第 10 篇会检查 dup、clone 和冻结的边界。
Symbol 用于程序内的固定标识
任务状态 :todo 与文本 'todo' 不是同一种值:
1 | |
Symbol 适合有限的程序标识,例如已知字段和枚举状态;String 适合用户标题、标签和文件内容。外部 JSON 的状态是字符串,第 26 篇会校验允许值再映射为内部 Symbol,而不是对所有用户输入无条件 to_sym。
“Symbol 永远不会被回收”是需要版本限定的旧经验,不能拿它说明本系列基线;反过来,可回收也不意味着可接受任意量的外部标识。动态制造大量名称仍有解析、分配和缓存成本。工程边界更直接的规则是限制输入体积、校验允许集合,并保持未知内容为普通文本。
Hash 中的 :title 与 'title' 也是不同键。不要让读取方法在两个键之间无条件兜底,这会使两者同时出现时的优先级不明确。核心层用 Symbol 键,JSON/CSV 入口统一解释外部结构,再调用核心校验。
正则锚点决定匹配的边界
本篇 lab 比较完整字符串锚点与行锚点:
1 | |
\A 与 \z 对应整个输入的开头和结尾;^ 与 $ 可以在行边界匹配。完整字段验证使用哪一种,必须由输入契约决定。只在合法单行样本上测试,两种写法都通过,边界差异就会被遗漏。Regexp
match? 适合只需要真假时使用;需要捕获值时,可以获取 MatchData。本实验用命名捕获提取 'task:12' 的数字字段,后续仍需通过数值转换和范围检查。正则命中只能证明某个模式满足,不能自动证明业务值正确。
不可信正则是另一种问题:让用户输入任意正则会引入性能与资源风险。固定模式也要考虑输入规模、复杂回溯与超时能力。第 38 篇会将这些条件纳入安全边界,本篇不通过一个短输入宣称模式对任意长度均安全。
实验与练习
labs/03/run.rb 输出 utf8=[6, 2]、invalid=false、替代字符以及 alias=ruby!,最后为 PASS 03。坏编码反例和带换行的正则反例都包含失败判定。
练习是在临时输入中依次使用合法 UTF-8、非法 UTF-8 和合法 UTF-16LE,写出接收函数分别拒绝还是转码,并加断言。另一个练习是实现只能包含 ASCII 数字的任务 ID 字符串检查,确认尾部换行、全角数字和空字符串均被按契约处理。不要只看正则打印的 MatchData。
