09 · 组合解析:保留顺序与错误行号
目标:把逐行解析器组合成整个文本的解析器,返回第一处失败的原始行号。
一行解析成功,不代表整个文件合法。现在我们需要遍历每一行:空白行跳过;有效记录保留;坏记录立即返回错误。最容易犯的错误是先过滤空白行,再给剩下的行编号,这样报错行号与用户看到的文件对不上。
使用三个递归参数可以把责任说清楚:number 是当前原始行号,acc 是已经解析成功的记录,remaining 是尚未处理的行。每处理一行就把行号加一,包括空白行。用 :: 把记录加到累加器前面很便宜,最后反转一次恢复输入顺序。
实验:错误在哪一行,就报告哪一行
创建 parser.ml。这里重复给出上一课的完整解析函数,便于独立运行;新增部分从 parse_text 开始。
type session = { topic : string; minutes : int }
let parse_line line =
match String.split_on_char '|' line with
| [topic; raw_minutes] ->
let topic = String.trim topic in
let raw_minutes = String.trim raw_minutes in
if topic = "" then Error "empty topic"
else
(match int_of_string_opt raw_minutes with
| None -> Error "minutes must be an integer"
| Some minutes when minutes < 1 || minutes > 1440 ->
Error "minutes must be between 1 and 1440"
| Some minutes -> Ok { topic; minutes })
| _ -> Error "expected topic|minutes"
let parse_text text =
let rec loop number acc remaining =
match remaining with
| [] -> Ok (List.rev acc)
| line :: rest ->
if String.trim line = "" then loop (number + 1) acc rest
else
(match parse_line line with
| Ok session -> loop (number + 1) (session :: acc) rest
| Error message ->
Error (Printf.sprintf "line %d: %s" number message))
in
loop 1 [] (String.split_on_char '\n' text)
let show text =
match parse_text text with
| Error message -> print_endline message
| Ok sessions ->
sessions
|> List.map (fun s -> s.topic)
|> String.concat ", "
|> print_endline
let () =
show "OCaml|25\n\nTypes|35\n";
show "OCaml|25\n\nTypes|many\n"
字符串内的 \n 表示换行,字符 '\n' 用于切分。先预测第二次调用的错误行号,再运行:
ocaml parser.ml
预期输出:
OCaml, Types
line 3: minutes must be an integer
成功路径最终只执行一次 List.rev。失败路径直接返回 Error,不会继续读第四行,也不返回部分成功记录。这是一个明确的设计选择:报表要么基于全部有效输入,要么不生成。空文件得到 Ok [];末尾换行产生的空行被跳过。
把隐含约定变成可观察行为
- 在坏记录之前再插入一个
\n,报错应从第 3 行变为第 4 行。 - 去掉成功分支中的
List.rev并运行,主题顺序反转。恢复它,说明为什么前插改变顺序。 - 用
"OCaml|25\r\nTypes|35\r\n"检查 Windows 风格换行。切分留下的\r被String.trim去掉。
独立练习:写出空文本、连续空白行、保留记录顺序和“只报告首错”四个验收断言。无需改变解析器。
参考验收代码
追加到主文件并运行:
let () =
assert (parse_text "" = Ok []);
assert (parse_text "\n \n\t" = Ok []);
assert (parse_text "A|1\nB|2" =
Ok [{ topic = "A"; minutes = 1 }; { topic = "B"; minutes = 2 }]);
assert (parse_text "A|1\nB|bad\nC|0" =
Error "line 2: minutes must be an integer");
print_endline "text checks passed"
第三个断言比仅检查记录数量更强:反转后的列表长度仍然一样。第四个断言把“首错即停”变成契约。
若行号偏小,检查有没有先删除空行;若输出倒序,检查是否缺少反转或反转了两次;若嵌套 match 类型突然变成 result 而非 list,检查内层括号是否闭合。本课结束保留 parser.ml,第 12 课会把这些已有函数搬进项目模块。