Course Builder个人学习工作台 · 本地模式
OCaml:从类型推导到可测试的命令行工具

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 [];末尾换行产生的空行被跳过。

把隐含约定变成可观察行为

  1. 在坏记录之前再插入一个 \n,报错应从第 3 行变为第 4 行。
  2. 去掉成功分支中的 List.rev 并运行,主题顺序反转。恢复它,说明为什么前插改变顺序。
  3. 用 "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 课会把这些已有函数搬进项目模块。

参考资料