預處理器和 PPX

預處理器是在編譯時呼叫的程式,以便在實際編譯之前修改程式。它們對於許多事情非常有用,例如包含檔案、條件編譯、樣板程式碼產生或擴展語言。

首先舉一個例子,以下原始程式碼會如何被這個預處理器修改

Printf.printf "This program has been compiled by user: %s" [%get_env "USER"]

當您使用預處理器編譯程式碼時,它會將 [%get_env "USER"] 替換為 USER 環境變數的內容。例如,如果 USER 環境變數設定為 "JohnDoe",則該行會變成

Printf.printf "This program has been compiled by user: %s" "JohnDoe"

經過這樣的修改,預處理器會在編譯時將 [%get_env "USER"] 替換為 USER 環境變數的內容,而這段程式碼應該可以在大多數系統上執行,而無需任何額外設定。在編譯時,預處理器會將 [%get_env "USER"] 替換為一個包含 USER 環境變數內容的字串,該變數通常包含編譯程式的人的使用者名稱。這發生在編譯時,因此在執行時,USER 變數的值不會有任何影響,因為它純粹在編譯後的程式中用於資訊目的。

在本指南中,我們盡可能簡潔地解釋 OCaml 中預處理器背後的不同機制。如果您只對如何在專案中使用 PPX 感興趣,請跳到此章節dune 文件。如果您對編寫 PPX 感興趣,請跳到此章節

OCaml 中的預處理

某些語言對預處理有內建支援,在某種意義上,一小部分的語言專門用於在編譯時執行。例如,C 就是這種情況,其中 C 預處理器語法和語義是語言的一部分;而 Rust 則使用其巨集系統。

在 OCaml 中,沒有任何巨集系統是語言的一部分,所有預處理器都是獨立的程式。但是,即使它不是語言的一部分,OCaml 平台也正式支援一個用於編寫此類預處理器的函式庫。

OCaml 支援執行兩種預處理器:一種在原始碼層級工作(如 C),另一種在程式的結構化表示上工作(如 Rust 的巨集):AST 層級。後者稱為「PPX」,是 Pre-Processor eXtension(預處理器擴充)的縮寫。

雖然兩種預處理類型都有其用例,但在 OCaml 中,建議盡可能使用 PPX,原因如下

  • 它們與 Merlin 和 Dune 非常好地整合,並且不會干擾編輯器中的錯誤報告和 Merlin 的「跳至定義」等功能。
  • 它們速度快,並且在充分編寫時可以很好地組合。
  • 它們尤其適用於 OCaml

本指南介紹 OCaml 中兩種預處理器的狀態,但重點放在 PPX 上。雖然後者是建議的編寫預處理器的方式,但我們從原始碼預處理開始,以便更好地理解為什麼 PPX 在 OCaml 中是必要的。

原始碼預處理器

如簡介中所述,預處理原始檔案對於可以使用字串操作解決的事情非常有用,例如檔案包含、條件編譯或巨集擴展。可以使用任何預處理器,例如C 預處理器m4等一般預處理器。然而,有些預處理器(例如cppo)是特別為與 OCaml 良好整合而製作的。

在 OCaml 中,預處理文字檔案沒有來自語言的特定支援。相反,驅動預處理是建置系統的責任。因此,套用預處理器將歸結為告知 Dune。僅為了教育目的,在下一節中,我們將展示如何使用 OCaml 編譯器預處理檔案,更相關的部分是使用 Dune 進行預處理

使用 ocamlcocamlopt 進行預處理

OCaml 的編譯器 ocamlcocamlopt 提供了 -pp 選項,可以在編譯階段預處理檔案(但請記住,我們鼓勵您使用 Dune 來驅動預處理)。考慮以下簡單的預處理器,它將字串 "World" 替換為字串 "Universe",這裡以 shell 腳本的形式呈現

$ cat preprocessor.sh
#!/bin/sh

sed 's/World/Universe/g' $1

使用此選項編譯經典的 "Hello, World!" 程式會改變編譯過程

$ cat hello.ml
print_endline "Hello, World!";;

$ ocamlopt -pp ./preprocessor.sh hello.ml
$ ./a.out
Hello, Universe!

使用 Dune 進行預處理

Dune 的建構系統有一個特定的語法來對檔案應用預處理。完整的文檔可以在這裡找到,應該作為參考。在本節中,我們僅提供幾個範例。

對原始碼檔案應用預處理的語法是 (preprocess (action (<action>)))<action> 部分可以是任何使用者定義的動作,例如呼叫外部程式,如 (system <program>) 所指定。

將它們組合在一起,以下 dune 檔案將使用我們先前編寫的 preprocessor.sh 重寫相應的模組檔案

(executable
 (name hello)
 (preprocess
  (action
   (run ./preprocessor.sh %{input-file}))))

操作文字檔案的限制

程式語言語法的複雜性使得很難以與語法相關的方式操作文字。例如,假設與之前的範例類似,您想將所有出現的 "World" 替換為 "Universe",但僅限於 *程式的 OCaml 字串內部*。這相當複雜,並且需要對 OCaml 語法有很好的了解才能做到,因為字串有多個分隔符號(例如 {| ...|})和換行符號,或者註解可能會妨礙...

考慮另一個範例。假設您定義了一個型別,並且您想在編譯時從此特定型別產生序列化器,以將其編碼為 json 格式,例如 Yojson(有關必須在編譯時產生的原因,請參閱這裡)。此序列化程式碼可以由預處理器編寫,該預處理器會在檔案中尋找型別,並根據型別結構(即它是變體型別、記錄型別、其子型別的結構)以不同的方式對其進行序列化。

所有這些困難都來自於我們想要產生一個程式,但我們正在操作它的平面表示形式,即純文字。這種表示形式缺乏結構有幾個缺點

  • 很難讀取程式的某些部分,例如在上述範例中產生序列化器的型別。
  • 以純文字形式編寫程式容易出錯,因為無法保證產生的程式碼始終符合程式語言的語法。程式碼產生中的此類錯誤可能很難除錯!

使用更具結構化的程式表示形式可以解決讀寫問題。這正是 PPX 所做的!

PPXs

PPx 是一種不同類型的預處理器 — 它不是在文字原始碼上執行,而是在解析結果上執行:抽象語法樹 (AST),在 OCaml 編譯器中稱為 Parsetree。為了更好地理解 PPX,我們需要了解這個 Parsetree 是什麼。

OCaml 的 Parsetree:OCaml AST

在編譯階段,OCaml 的編譯器會將輸入檔案解析為其內部表示形式,稱為 Parsetree。程式表示為樹狀結構,具有複雜的 OCaml 型別,您可以在 Parsetree 模組中找到。

讓我們看看這棵樹的一些屬性

  • AST 中的每個節點都有一個對應於不同角色的型別,例如「let 定義」、「表達式」、「模式」等。
  • 樹的根是一個 structure_item 的清單
  • structure_item 可以表示頂層表達式、型別定義、let 定義等。這是使用變體型別來確定的。

有多種補充方法可以掌握 Parsetree 型別。一種是閱讀API 文件,其中包含每個型別和值所代表的範例。另一種是檢查精心設計的 OCaml 程式碼的 Parsetree 值。這可以使用外部工具 astexplorer、我們的 OCaml VSCode 擴充功能(在命令面板中開啟 OCaml: Open AST explorer)或甚至直接使用 OCaml 頂層並搭配 -dparsetree 選項(也可在 UTop 中使用)來實現。


$ ocaml -dparsetree
[Omitted output]
# let x = 1 + 2 ;;

Ptop_def
  [
    structure_item (//toplevel//[1,0+0]..[1,0+13])
      Pstr_value Nonrec
      [
        <def>
          pattern (//toplevel//[1,0+4]..[1,0+5])
            Ppat_var "x" (//toplevel//[1,0+4]..[1,0+5])
          expression (//toplevel//[1,0+8]..[1,0+13])
            Pexp_apply
            expression (//toplevel//[1,0+10]..[1,0+11])
              Pexp_ident "+" (//toplevel//[1,0+10]..[1,0+11])
            [
              <arg>
              Nolabel
                expression (//toplevel//[1,0+8]..[1,0+9])
                  Pexp_constant PConst_int (1,None)
              <arg>
              Nolabel
                expression (//toplevel//[1,0+12]..[1,0+13])
                  Pexp_constant PConst_int (2,None)
            ]
      ]
  ]

val x : int = 3

請注意,Parsetree 是在鍵入程式之前發生的程式碼內部表示形式,因此可以重寫錯誤鍵入的程式。鍵入後的內部表示形式稱為 Typedtree。您可以使用 ocaml-dtypedtree 選項來檢查它。使用 utop-full 時,您可以透過執行 Clflags.dump_typedtree := true 在 REPL 中啟用 -dtypedtree。在接下來的內容中,我們將使用 AST 來指代 parsetree。

PPX 重寫器

PPX 重寫器的核心只是一個轉換,它會接收 Parsetree 並傳回可能修改過的 Parsetree,但其中有一些細微之處。首先,PPX 在 Parsetree 上運作,而 Parsetree 是 OCaml 解析的結果,因此原始碼檔案需要具有有效的 OCaml 語法。因此,我們無法引入自訂語法,例如 C 預處理器中的 #if。相反,我們將使用 OCaml 4.02 中引入的兩個特殊語法:擴充節點和屬性。

其次,大多數 PPX 的程式碼轉換不需要給出完整的 AST;它們可以在 AST 的子部分中局部運作。對於涵蓋大多數用例的一般 PPX 重寫器,有兩種這樣的局部限制:擴充器和衍生器。它們分別對應於剛才提到的 OCaml 4.02 的兩種新語法:擴充節點和屬性。

屬性和衍生器

屬性是可以附加到任何 AST 節點的額外資訊。該資訊可以由編譯器本身使用(例如,啟用或停用警告)、新增「已棄用」警告或強制/檢查函式是否已內聯。編譯器使用的屬性的完整清單可在手冊中找到。

屬性的語法是在節點後加上 [@attribute_name payload],其中 payload 本身是 OCaml AST。 @ 的數量決定了屬性附加到哪個節點: @ 用於最近的節點(表達式、模式等),@@ 用於最近的區塊(型別宣告、類別欄位等),而 @@@ 是浮動屬性。有關語法的更多資訊,請參閱手冊

module X = struct
  [@@@warning "+9"]  (* locally enable warning 9 in this structure *)
end
[@@deprecated "Please use module 'Y' instead."]

之前的範例使用了為編譯器保留的屬性,但是任何屬性名稱都可以放入原始碼中並由 PPX 用於其預處理。

type int_pair = (int * int) [@@deriving yojson]

一種特定的 PPX 與屬性相關聯:衍生器,一種 PPX,它會從結構或簽名項目(例如型別定義)產生(或 *衍生*)一些程式碼。它使用上述語法應用,其中多個衍生器以逗號分隔。請注意,產生的程式碼會新增在輸入程式碼之後,而輸入程式碼保持不變。

衍生器非常適合產生依賴於已定義型別結構的函式(這是在操作文字檔案的限制中給出的範例)。實際上,正確數量的資訊已傳遞到 PPX,而且我們也知道 PPX 不會修改原始碼的任何部分。

衍生器的範例包括

擴充節點和擴充器

擴充節點是 Parsetree 中的「漏洞」。解析器在許多地方都接受它們,例如模式、表達式、核心型別或模組型別。若要找出某個位置是否允許擴充節點,您可以查看 Parsetree 以查看對應的節點是否具有 extension 建構函式。但是,擴充節點稍後會被編譯器拒絕。因此,它們 *必須* 由 PPX 重寫,編譯才能繼續。

擴充節點的語法為 [%extension_name payload],其中 % 的數量再次決定了擴充節點的種類:% 用於「內部節點」,例如表達式和模式,而 %% 用於「頂層節點」,例如結構/簽名項目或類別欄位。有效載荷是一個結構節點;也就是說,解析器接受與擴充節點有效載荷相同的 .ml 檔案內容。請參閱正式語法

(* An extension node as an expression *)
let v = [%html "<a href='ocaml.org'>OCaml!</a>"]

(* An extension node as a let-binding *)
[%%html let v = "<a href='ocaml.org'>OCaml!</a>"]

當擴充節點和有效載荷的型別相同時,可以使用較短的中綴語法。該語法要求將擴充節點的名稱附加到定義區塊的關鍵字(例如 letbeginmoduleval 等),並且等效於整個區塊被包裝在有效載荷中。有關語法的正式定義,請參閱 OCaml 手冊

(* An equivalent syntax for [%%html let v = ...] *)
let%html v = "<a href='ocaml.org'>OCaml!</a>"

請注意,有一種方法可以變更有效載荷的預期型別。透過在擴充名稱後新增 :,預期的有效載荷現在是一個簽名節點(也就是說,與 .mli 檔案中接受的相同)。同樣,? 會將預期的有效載荷轉換為模式節點。

(* Two equivalent syntaxes, with signatures as payload *)
[%ext_name: val foo : unit]
val%ext_name foo : unit

(* An extension node with a pattern as payload *)
let [%ext_name? a :: _ ] = ()

擴充節點旨在由 PPX 重寫,並且在這方面,它們對應於特定種類的 PPX:擴充器。擴充器是一個 PPX 重寫器,它會將所有出現的擴充節點替換為相符的名稱。它使用一些僅依賴於有效載荷產生的程式碼來執行此操作,而無需擴充節點的內容資訊(也就是說,無法存取程式碼的其餘部分),並且不會修改其他任何內容。

擴充器的範例包括

  • 允許使用者直接以該語言撰寫表示另一種語言的 OCaml 值的擴充器。例如: - ppx_yojson 透過撰寫 JSON 程式碼產生 Yojson 值 - tyxml-ppx 透過撰寫 HTML 程式碼產生 Tyxml.Html 值 - ppx_mysql 透過撰寫 MySQL 查詢產生 ocaml-mysql 查詢
  • ppx_expect 從擴充節點的有效載荷產生 CRAM 測試。

使用 PPX

與文字原始碼的預處理器類似,OCaml 的編譯器提供了 -ppx 選項,以便在編譯階段執行 PPX。PPX 將從檔案中讀取 Parsetree,其中已傾印了已封送的值,並以相同的方式輸出重寫的 Parsetree。

但同樣地,由於負責驅動編譯的工具是 Dune,因此使用 PPX 只是撰寫 dune 檔案的問題。這次應該使用相同的 preprocess 語法,並搭配 pps

  (preprocess (pps ppx1 ppx2))

這就是使用 PPX 所需的一切!雖然這些指示適用於大多數 PPX,但請注意,第一個資訊來源將是套件文件,因為某些 PPX 可能需要一些特殊處理才能與 Dune 整合。

使用 [@@deriving_inline] 放棄 PPX 相依性

某些衍生器僅用於產生樣板。在這種情況下,沒有強烈的要求將它們作為硬相依性包含:新增的樣板程式碼可以透過 PPX 進行漂亮列印並新增到原始碼中。可以使用 Dune 和 ppxlib 來實作此機制。

[@@deriving_inline <deriver_name>] 附加到項目上,會像使用一般的 [@@deriving <deriver_name>] 屬性一樣,從該項目衍生出一些程式碼。然而,它不會將產生的程式碼附加到帶有屬性的項目之後,而是會檢查產生的程式碼是否已存在於該項目之後。如果存在,則無需執行任何操作。否則,它將產生一個正確的檔案,Dune 會讓您能夠使用這個正確的檔案來更新您的原始碼(使用 dune promote 命令)。

由於新檔案包含產生的程式碼,因此不再需要由 PPX 進行預處理,可以按原樣編譯和發佈,並且可以從依賴項中移除 PPX。然而,每當附加屬性的項目發生變更時,仍然需要執行 PPX。這可以透過在 @lint 目標上執行 PPX 來實現。讓我們來看一個範例,包含以下檔案:

$ cat dune
(library
 (name library_name)
 (lint (pps ppx_yojson_conv)))
$ cat lib.ml
type t = int [@@deriving_inline yojson]

[@@@deriving.end]

現在,我們執行 PPX 並將產生的程式碼提升到原始檔案中。

$ opam exec -- dune build @lint
File "lib/lib.ml", line 1, characters 0-0:
diff --git a/_build/default/lib/lib.ml b/_build/default/lib/lib.ml.lint-corrected
index 4999e06..5516d41 100644
--- a/_build/default/lib/lib.ml
+++ b/_build/default/lib/lib.ml.lint-corrected
@@ -1,3 +1,8 @@
 type t = int [@@deriving_inline yojson]

+let _ = fun (_ : t) -> ()
+let t_of_yojson = (int_of_yojson : Ppx_yojson_conv_lib.Yojson.Safe.t -> t)
+let _ = t_of_yojson
+let yojson_of_t = (yojson_of_int : t -> Ppx_yojson_conv_lib.Yojson.Safe.t)
+let _ = yojson_of_t
 [@@@deriving.end]
Promoting _build/default/lib/lib.ml.lint-corrected to lib/lib.ml.

檔案現在包含產生出的值。雖然它仍然是一個開發依賴項,但編譯專案可以捨棄 PPX 依賴項。

$ cat lib.ml
type t = int [@@deriving_inline yojson]
let _ = fun (_ : t) -> ()
let t_of_yojson = (int_of_yojson : Ppx_yojson_conv_lib.Yojson.Safe.t -> t)
let _ = t_of_yojson
let yojson_of_t = (yojson_of_int : t -> Ppx_yojson_conv_lib.Yojson.Safe.t)
let _ = yojson_of_t
[@@@deriving.end]

請注意,雖然它允許專案捨棄對 ppxlib 和使用的 PPX 的依賴,但使用 deriving_inline 也有一些缺點。它可能會增加程式碼庫的大小(和可讀性),並且它依賴於從 AST 返回到原始碼的列印器,這可能不可靠。無論如何,如果內聯失敗,ppxlib 將透過解析產生的原始碼並檢查它是否與產生的 AST 對應的往返檢查來偵測到它。這樣,錯誤會在編譯時被捕獲。

為何 PPX 在 OCaml 中特別有用

既然我們了解了 PPX 是什麼,並且看過了一些範例,讓我們來看看它為何在 OCaml 中特別有用。

首先,類型在執行時會遺失。這意味著無法在執行時解構類型的結構來控制流程。這就是為什麼在編譯後的二進位檔中,無法存在通用的 to_string : 'a -> stringprint : 'a -> () 函數(在 toplevel 中,類型會被保留)。

因此,任何依賴類型結構的通用函數都必須在編譯時編寫,因為那時類型仍然可用。

其次,OCaml 的強大功能之一是它的類型系統,它可以用來檢查許多東西的健全性。一個例子是 Tyxml 函式庫,它使用類型系統來確保產生的 HTML 值符合大多數 W3C 標準。然而,與編寫 HTML 語法相比,編寫 Tyxml 值可能會很繁瑣。在編譯時將 HTML 程式碼轉換為 OCaml 值,允許使用者同時保留對產生值的類型檢查,並保有編寫 HTML 程式碼的熟悉感。

其他重寫器,例如 ppx_expect,表明透過 PPX 重寫器來豐富語法非常有用,即使在 OCaml 的特殊性之外也是如此。

控制 PPX 生態系統的需求:ppxlib

雖然 PPX 非常適合在編譯時產生程式碼,但它們會引發一些問題,尤其是在存在多個 PPX 重寫器的情況下。

  • 多個 PPX 的組合的語義是什麼?順序可能很重要。
  • 當使用第三方 PPX 時,我如何信任程式碼的某些部分不會被修改?
  • 當使用許多重寫器時,我如何保持編譯時間短?
  • 如果我必須處理 AST 的解析或解碼,我如何輕鬆地編寫 PPX?
  • 我如何處理像 Parsetree 中這樣長而複雜的類型
  • 我如何解決新的 OCaml 版本傾向於在語言中添加新功能,因此需要豐富和破壞 Parsetree 類型的問題?

許多這些問題源於 OCaml 沒有巨集語言部分,並且前處理器始終是獨立的程式。這意味著它們可以執行任何操作(而巨集通常會限制預處理的表達能力),並且編譯器無法控制它們。然而,OCaml 平台包含一個用於編寫 PPX 的函式庫,它在某種程度上充當了巨集語言,而不會失去 PPX 的完全通用性:ppxlib。這個函式庫提供了編寫 擴展器衍生器 的通用方法,確保它們可以很好地協同工作,並消除了我們在多個任意轉換中遇到的組合問題。ppxlib 還提供了一個驅動程式,即使在註冊多個轉換的情況下,也會輸出單個二進位檔。

透過 ppxlib,PPX 作者可以專注於他們自己的部分:重寫邏輯。然後,他們可以註冊他們的轉換,ppxlib 將負責所有 PPX 必須執行的其餘工作:獲取 Parsetree,將其傳回編譯器,並建立具有良好 CLI 的可執行檔。

對於那些對 OCaml 歷史感興趣的人,請注意在 ppxlib 之前,還有其他「官方」函式庫來處理 PPX。Camlp4 是一種透過新增的結構來擴展 OCaml 解析器、重寫它並以常規 OCaml 語法漂亮列印它的方法。OMP 是一種使 PPX 跨 OCaml 版本相容的工具,現在已包含在 ppxlib 中。

一個適用於多個 OCaml 版本的 PPX

編寫 PPX 的一個微妙之處在於,當在語言中新增新功能時,Parsetree 模組的類型可能會發生變化。為了使 PPX 與新版本相容,它必須更新從舊類型到新類型的轉換。但是,這樣做會失去與舊 OCaml 版本的相容性。理想情況下,單個版本的 PPX 可以預處理不同的 OCaml 版本。

ppxlib 透過將 Parsetree 類型轉換為最新版本並從最新版本轉換來處理此問題。然後,PPX 作者只需要維護他們針對 OCaml 最新版本的轉換,即可獲得一個適用於任何 OCaml 版本的 PPX。例如,當使用 OCaml 4.08 編譯期間應用為 OCaml 5.0 編寫的 PPX 時,ppxlib 將取得 4.08 Parsetree,將其轉換為 5.00 Parsetree,使用註冊的 PPX 轉換 Parsetree,並將其轉換回 4.08 Parsetree 以繼續編譯。

限制 PPX 以實現組合、速度和安全性

ppxlib 明確支援註冊對應於擴展器和衍生器的受限轉換。編寫這些受限 PPX 有很多優點:

  • 擴展器和衍生器不會修改您現有的程式碼,除了擴展節點之外。這樣較不容易出錯,錯誤的嚴重影響較小,並且使用者可以確信他們的程式碼中沒有任何敏感部分被更改。
  • 由於擴展器和衍生器是「無上下文」的,因為它們僅使用 AST 的有限部分作為輸入來執行,因此它們都可以在 AST 的單次傳遞中執行。此外,它們不是「一個接一個」地執行,而是同時執行,因此它們的組合語義不取決於執行順序。
  • 這種單次傳遞也意味著更快的重寫,因此對於使用多個 PPX 的專案來說,編譯時間更快。

與此相比,在整個 AST 上運作的重寫器也可以在 ppxlib 中註冊,並且它們會在無上下文傳遞之後,按名稱的字母順序執行。

請注意,Dune 會將所有使用 ppxlib 編寫的 PPX 合併到單個前處理器二進位檔中,即使 PPX 來自不同的套件也是如此。

編寫 PPX

如果您想編寫自己的 PPX,那麼首先要看的是 ppxlib 的文件

協助改進我們的文件

所有 OCaml 文件都是開放原始碼。看到錯誤或不清楚的地方嗎?提交提取請求。

OCaml

創新。社群。安全性。