位元詩人 OCaml 命令列參數解析:模式和反模式

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

本文以命令列參數解析為例,來展示 OCaml 程式設計的模式和反模式。

取得命令列參數

命令列參數儲存在 Sys.argv 陣列。其中 Sys.argv.(0) 是程式名稱,Sys.argv.(1) 以後是傳入的參數。Sys.argv 陣列元素的資料型態是字串。

在 OCaml 裡寫 C

這裡用命令式程式設計的思維來寫 OCaml 程式。範例程式碼如下:

let _ =
  if Array.length Sys.argv <= 1 then (
    Printf.eprintf "No source\n";
    Printf.eprintf "Usage: cli [option] <source>\n";
    exit 1
  ) else if Array.length Sys.argv > 2 then (
    Printf.eprintf "Too many argyments\n";
    Printf.eprintf "Usage: cli [option] <source>\n";
    exit 1
  ) else if Sys.argv.(1) = "-v" || Sys.argv.(1) = "--version" then
    Printf.printf "0.1.0\n"
  else if Sys.argv.(1) = "-h" || Sys.argv.(1) = "--help" then
    Printf.printf "Usage: cli [option] <source>\n"
  else if String.starts_with ~prefix:"-" Sys.argv.(1) then (
    Printf.eprintf "Unknown command: %s\n" Sys.argv.(1);
    Printf.eprintf "Usage: cli [option] <source>\n";
    exit 1
  ) else
    Printf.printf "[Shim] Handle file: %s\n" Sys.argv.(1)

這裡的程式碼雖然是 OCaml,但沒用到 OCaml 特性,充其量只是「在 OCaml 裡寫 C」。這樣寫是合法的,但不應該這樣寫。

在 OCaml 裡寫 JavaScript

這裡開始在程式碼中加入架構。範例程式碼如下:

let program = "cli"                                          (*  1 *)
let version = "0.1.0"                                        (*  2 *)

let arg_parse argv =                                         (*  3 *)
  let size = Array.length argv in                            (*  4 *)
  if size <= 1 then                                          (*  5 *)
    "empty"                                                  (*  6 *)
  else if size > 2 then                                      (*  7 *)
    "too_many"                                               (*  8 *)
  else                                                       (*  9 *)
    let arg = argv.(1) in                                    (* 10 *)
    if arg = "-v" || arg = "--version" then                  (* 11 *)
      "version"                                              (* 12 *)
    else if arg = "-h" || arg = "--help" then                (* 13 *)
      "help"                                                 (* 14 *)
    else if String.starts_with ~prefix:"-" arg then          (* 15 *)
      "unknown:" ^ arg                                       (* 16 *)
    else                                                     (* 17 *)
      "file:" ^ arg                                          (* 18 *)

let _ =                                                      (* 19 *)
  match arg_parse Sys.argv with                              (* 20 *)
  | "empty" ->                                               (* 21 *)
    Printf.eprintf "No source\n";                            (* 22 *)
    Printf.eprintf "Usage: %s [option] <source>\n" program;  (* 23 *)
    exit 1                                                   (* 24 *)
  | "too_many" ->                                            (* 25 *)
    Printf.eprintf "Too many arguments\n";                   (* 26 *)
    Printf.eprintf "Usage: %s [option] <source>\n" program;  (* 27 *)
    exit 1                                                   (* 28 *)
  | "version" ->                                             (* 29 *)
    Printf.printf "%s\n" version                             (* 30 *)
  | "help" ->                                                (* 31 *)
    Printf.printf "Usage: %s [option] <source>\n" program    (* 32 *)
  | s when String.starts_with ~prefix:"unknown:" s  ->       (* 33 *)
    let unknown = String.sub s 8 (String.length s - 8) in    (* 34 *)
    Printf.eprintf "Unknown command: %s\n" unknown;          (* 35 *)
    Printf.eprintf "Usage: %s [option] <source>\n" program;  (* 36 *)
    exit 1                                                   (* 37 *)
  | s when String.starts_with ~prefix:"file:" s ->           (* 38 *)
    let file = String.sub s 5 (String.length s - 5) in       (* 39 *)
    Printf.printf "[Shim] Handle file: %s\n" file            (* 40 *)
  | _ ->                                                     (* 41 *)
    Printf.eprintf "Unhandled case\n";                       (* 42 *)
    exit 1                                                   (* 43 *)

arg_parse 函式解析命令列參數 (第 318 行),主程式接收命令列參數,並執行程式 (第 1943 行)。

這裡用字串傳遞狀態,沒有充份利用 OCaml 的特性,等同於把 OCaml 降級成弱型態語言。傳遞字串在 JavaScript 文化是相當常見的,所以這是「在 OCaml 裡寫 JavaScript」。雖然這樣寫是合法的,也不建議這樣寫。

充份使用 OCaml 特性的範例

看完前兩個版本的範例,我們回頭來用 OCaml 特性寫解析命令列參數的程式。

設置常數

建立一些常數,取代魔術字串:

let program = "cli"
let version = "0.1.0"

解析命令列參數

用 variant 宣告解析命令列參數所回傳的狀態:

type command_t =
  | CmdVersion
  | CmdHelp
  | CmdFile of string

由於用到了 variant,會受到 OCaml 編譯器的保護。傳遞狀態時,編譯器會檢查是否滿足所有狀態。

實作解析命令列參數的工具函式:

(* Result (command_t, string) *)
let arg_parse argv =
  match Array.to_list argv with
  | [_] -> Error "No source"
  | [_; "-v"] | [_; "--version"] -> Ok CmdVersion
  | [_; "-h"] | [_; "--help"] -> Ok CmdHelp
  | [_; arg] when String.starts_with ~prefix:"-" arg ->
    Error (Printf.sprintf "Unknown option: %s" arg)
  | [_; file] -> Ok (CmdFile file)
  | _ -> Error "Too many arguments"

這裡根據命令列的「形狀」判斷狀態,所以很直觀。

印出幫助訊息

宣告串流狀態:

type stream_t =
  | Stdout
  | Stderr

注意這裡並不是真的標準輸出和標準錯誤,只是用這兩種狀態來命名 variant。

實作印出幫助訊息的工具函式:

let help_info stream =
  let template = Printf.sprintf "Usage: %s [option] <source>" program in
  match stream with
  | Stdout -> print_endline template
  | Stderr -> prerr_endline template

根據程式的狀態,同樣的訊息可導向標準輸出或標準錯誤,符合 Unix 慣例。

執行主程式

實作主程式:

(* main *)
let _ =
  let cmd = arg_parse Sys.argv in
  match cmd with
  | Ok CmdVersion ->
    Printf.printf "%s\n" version
  | Ok CmdHelp ->
    help_info Stdout
  | Ok (CmdFile file) ->
    Printf.printf "[Shim] Handle file: %s\n" file
  | Error err ->
    prerr_endline err;
    help_info Stderr;
    exit 1

由於我們已經實作好相關的工具函式,主程式很好寫。注意這裡利用了 pattern matching,OCaml 會協助開發者逐一完成所有情境。

關於作者

位元詩人 (ByteBard) 是資訊領域碩士,專注於從探索到產品的開發過程,並以工具驅動的方式改善專案。喜歡以開源專案作為成果,回饋社群。

主要方向包括:自用工具的打磨 (dogfooding)、編譯器技術在工具開發中的應用,以及將研究轉化為可維護的開源成果。

除了技術之外,也喜歡日本料理和黑咖啡,偶爾自助旅行,將生活中的靈感融入技術隨筆。