Skip to main content

第 21 章:使用 S 表达式进行数据序列化(Data Serialization with S-Expressions)

原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 21。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。

S 表达式(s-expression)是一种带嵌套括号的表达式,其中的原子值是字符串。它最早由 20 世纪 60 年代的 Lisp 编程语言推广开来,此后一直是最简单、最有效的人类可读、可编辑结构化数据编码方式之一。

一个 S 表达式可以像下面这样:

(this (is an) (s expression))

S 表达式在 Base 和 Core 中扮演着重要角色,实际上可以看作默认的序列化格式。我们此前已经多次见过 S 表达式,包括第 8 章“错误处理(Error Handling)”、第 11 章“函子(Functors)”以及第 12 章“一等模块(First-Class Modules)”。

本章会更深入地讨论 S 表达式,尤其会覆盖以下主题:

  • S 表达式格式的细节,包括如何解析它,并在调试格式错误的输入时生成良好的错误消息
  • 如何在 S 表达式和任意 OCaml 类型之间生成转换器
  • 如何使用标注控制这些生成转换器的行为
  • 如何把 S 表达式集成进接口,尤其是在不破坏抽象边界的前提下,为模块添加 S 表达式转换器

本章最后会把这些内容串联起来,使用一个简单的 S 表达式格式 Web 服务器配置文件作为例子。

21.1 基本用法(Basic Usage)

用于表示 S 表达式的类型非常简单:

module Sexp : sig
type t =
| Atom of string
| List of t list
end

你可以把 S 表达式看成一棵树:每个节点包含一个子节点列表,而树的叶子是字符串。Core 在 Sexp 模块中为 S 表达式提供了良好支持,包括在 S 表达式和字符串之间转换的函数。让我们用这个类型重写前面的示例 S 表达式:

# open Core;;
# Sexp.List [
Sexp.Atom "this";
Sexp.List [ Sexp.Atom "is"; Sexp.Atom "an"];
Sexp.List [ Sexp.Atom "s"; Sexp.Atom "expression" ];
];;
- : Sexp.t = (this (is an) (s expression))

它能漂亮地打印出来,是因为 Core 为顶层环境注册了一个 pretty printer。这个 pretty printer 基于 Sexp 中在 S 表达式和字符串之间转换的函数:

# Sexp.to_string (Sexp.List [Sexp.Atom "1"; Sexp.Atom "2"]);;
- : string = "(1 2)"
# Sexp.of_string ("(1 2 (3 4))");;
- : Sexp.t = (1 2 (3 4))

注:Base、Core 与 Parsexp

在这些示例中,我们使用 Core 而不是 Base,因为 Core 通过 Parsexp 库集成了对解析 S 表达式的支持。如果只使用 Base,你会发现手边没有 Sexp.of_string

# open Base;;
# Sexp.of_string "(1 2 3)";;
Line 1, characters 1-15:
Alert deprecated: Base.Sexp.of_string
[since 2018-02] Use [Parsexp.Single.parse_string_exn]
Line 1, characters 1-15:
Error: This expression has type unit
This is not a function; it cannot be applied.

这是因为 Base 为了保持轻量,没有纳入 S 表达式解析函数。话虽如此,你始终可以通过调用 Parsexp 库中的对应函数来使用它们:

# Parsexp.Single.parse_string_exn "(1 2 3)";;
- : Sexp.t = (1 2 3)

除了提供 Sexp 模块之外,Base 和 Core 中的大多数基础类型也支持与 S 表达式之间的转换。例如,我们可以使用相应模块中为整数、字符串和异常定义的转换函数:

# Int.sexp_of_t 3;;
- : Sexp.t = 3
# String.sexp_of_t "hello";;
- : Sexp.t = hello
# Exn.sexp_of_t (Invalid_argument "foo");;
- : Sexp.t = (Invalid_argument foo)

也可以转换列表或数组这类容器类型,它们对所包含的数据类型是多态的。

# #show List.sexp_of_t;;
val sexp_of_t : ('a -> Sexp.t) -> 'a list -> Sexp.t

注意,List.sexp_of_t 是多态的,并把另一个转换函数作为第一个参数,用它处理待转换列表中的元素。Base 和 Core 在为多态类型定义 sexp 转换器时,更广泛地使用了这种方案。下面是一个具体例子。

# List.sexp_of_t Int.sexp_of_t [1; 2; 3];;
- : Sexp.t = (1 2 3)

反方向的函数,也就是从 S 表达式重建 OCaml 值的函数,本质上也使用同样的技巧来处理多态类型,如下所示。

# List.t_of_sexp Int.t_of_sexp (Sexp.of_string "(1 2 3)");;
- : int list = [1; 2; 3]

当给定的 S 表达式与目标 OCaml 类型结构不匹配时,这样的函数会抛出异常。

# List.t_of_sexp Int.t_of_sexp (Sexp.of_string "(1 2 three)");;
Exception:
(Of_sexp_error "int_of_sexp: (Failure int_of_string)" (invalid_sexp three))

注:再谈顶层打印

我们创建的 S 表达式值在顶层环境中被正确打印成 S 表达式,而不是打印成它们实际由 AtomList 变体构成的树。

这是因为 OCaml 支持安装自定义的顶层打印器(top-level printer),它们可以把某些值改写成更适合在顶层环境展示的等价形式。它们通常作为名称以 top 结尾的 ocamlfind 包安装:

$ ocamlfind list | grep top
astring.top (version: 0.8.3)
cohttp.top (version: n/a)
compiler-libs.toplevel (version: [distributed with Ocaml])
core.top (version: v0.10.0)
...
uri.top (version: 1.9.6)
utop (version: 2.1.0)

core.top 包会加载 Core 扩展已经提供的打印器,而你的 .ocamlinit 文件默认应该已经加载了它。因此,要使用 S 表达式打印器,你不需要做任何额外操作。

21.1.1 新类型的 S 表达式转换器(S-Expression Converters for New Types)

如果想为一个全新的类型编写转换成 S 表达式的函数怎么办?当然可以手写。下面是一个例子。

# type t = { foo: int; bar: float };;
type t = { foo : int; bar : float; }
# let sexp_of_t t =
let a x = Sexp.Atom x and l x = Sexp.List x in
l [ l [a "foo"; Int.sexp_of_t t.foo ];
l [a "bar"; Float.sexp_of_t t.bar]; ];;
val sexp_of_t : t -> Sexp.t = <fun>
# sexp_of_t { foo = 3; bar = -5.5 };;
- : Sexp.t = ((foo 3) (bar -5.5))

这段代码写起来有些乏味;而当你考虑解析器,也就是 t_of_sexp 时,这种乏味会更明显,因为它复杂得多。手写这类解析与打印代码既机械又容易出错,更不用说也很烦人。

既然代码如此机械,你自然可以想象编写一个程序来检查类型定义,并自动为你生成转换代码。事实证明,确实有一个名为 ppx_sexp_conv语法扩展(syntax extension)专门做这件事,它会为每个带有 [@@deriving sexp] 标注的类型创建所需函数。为了启用 ppx_sexp_conv,我们会启用 ppx_jane;后者是一组更大的实用扩展集合,其中包含 ppx_sexp_conv

# #require "ppx_jane";;

可以像下面这样使用这个扩展:

# type t = { foo: int; bar: float } [@@deriving sexp];;
type t = { foo : int; bar : float; }
val t_of_sexp : Sexp.t -> t = <fun>
val sexp_of_t : t -> Sexp.t = <fun>
# t_of_sexp (Sexp.of_string "((bar 35) (foo 3))");;
- : t = {foo = 3; bar = 35.}

这个语法扩展也可以用在类型声明之外。正如第 8 章“错误处理(Error Handling)”所讨论的,[@@deriving sexp] 可以附加到异常声明上,从而改善 OCaml 顶层异常处理器打印出的错误质量。

下面有两个异常声明,一个带标注,一个不带:

exception Ordinary_exn of string list;;
exception Exn_with_sexp of string list [@@deriving sexp];;

下面是抛出这些异常时看到的差异:

# raise (Ordinary_exn ["1";"2";"3"]);;
Exception: Ordinary_exn(_).
# raise (Exn_with_sexp ["1";"2";"3"]);;
Exception: (//toplevel//.Exn_with_sexp (1 2 3))

ppx_sexp_conv 还支持内联声明,用来为匿名类型生成转换器。

# [%sexp_of: int * string ];;
- : int * string -> Sexp.t = <fun>
# [%sexp_of: int * string ] (3, "foo");;
- : Sexp.t = (3 foo)

Base 和 Core 附带的语法扩展几乎都有相同的基本结构:它们基于类型定义自动生成代码,实现一些理论上你可以手工实现的功能,但所需程序员劳动要少得多。

注:语法扩展与 PPX

OCaml 并不直接支持从类型定义派生 S 表达式转换器。相反,它提供了一种名为 PPX 的机制,允许你通过编译器的 -ppx 标志,在编译流水线中加入对 OCaml 程序进行语法级转换的代码。

PPX 作用于 OCaml 的抽象语法树(abstract syntax tree,AST)。AST 是一种数据类型,用于表示一个格式良好的 OCaml 程序的语法。像 [%sexp_of: int][@@deriving sexp] 这样的标注,是被称为扩展点(extension point)的特殊语法扩展的一部分;这些扩展点被加入语言中,是为了给 ppx_sexp_conv 这类语法扩展提供一个读取信息的位置。

ppx_sexp_conv 属于一系列语法扩展家族的一员。这个家族还包括第 15 章“映射与哈希表(Maps and Hash Tables)”中介绍的 ppx_compare,以及第 6 章“记录(Records)”中介绍的 ppx_fields。它们都会基于类型声明生成代码。

dune 文件中使用这些扩展很简单:只要在 (library)(executable) stanza 中加入下面的指令,表示这些文件应当先经过预处理器处理即可:

(executable
(name hello)
(preprocess (pps ppx_sexp_conv))
)

21.2 S 表达式格式(The Sexp Format)

S 表达式的文本表示非常直接。一个 S 表达式会被写成嵌套的括号表达式,其中原子是由空白分隔的字符串。包含括号或空格的原子会使用引号;反斜杠是转义字符;分号用于引入单行注释。因此,下面这个文件 example.scm

((foo 3.3) ;; This is a comment
(bar "this is () an \" atom"))

可以这样加载:

# Sexp.load_sexp "example.scm";;
- : Sexp.t = ((foo 3.3) (bar "this is () an \" atom"))

可以看到,注释不是加载出的 S 表达式的一部分。

整体而言,S 表达式格式支持三种注释语法:

; : 注释掉直到行尾的所有内容

#|,|# : 用于注释掉一个块的定界符

#; : 注释掉后面第一个完整的 S 表达式

下面的例子展示了这些注释语法:

;; comment_heavy_example.scm
((this is included)
; (this is commented out
(this stays)
#; (all of this is commented
out (even though it crosses lines.))
(and #| block delimiters #| which can be nested |#
will comment out
an arbitrary multi-line block))) |#
now we're done
))

同样,把这个文件加载为 S 表达式时,注释会被丢弃:

# Sexp.load_sexp "comment_heavy.scm";;
- : Sexp.t = ((this is included) (this stays) (and now we're done))

如果在 S 表达式中引入错误,比如创建一个 broken_example.scm 文件,它和 example.scm 一样,只是去掉了 bar 前面的左括号,那么会得到一个解析错误:

# Sexp.load_sexp "example_broken.scm";;
Exception:
(Sexplib.Sexp.Parse_error
((err_msg "unexpected character: ')'") (text_line 4) (text_char 30)
(global_offset 78) (buf_pos 78)))

21.3 保持不变式(Preserving Invariants)

模块和模块接口是 OCaml 代码组织与设计的重要组成部分。我们使用模块接口的关键原因之一,是让代码能够强制维护不变式。具体来说,通过限制某个类型的值如何创建和变换,接口可以让你强制执行各种规则,包括确保数据格式良好。

当你向 API 添加 S 表达式转换器,或者说添加任何反序列化器时,你就在创建值的常规路径之外,又加入了一条替代路径。如果不小心,这条替代路径可能会违反代码中精心维护的不变式。

接下来,我们会展示这个问题如何出现,以及如何解决。考虑一个用于表示闭区间整数区间的 Int_interval 模块,它与第 11 章“函子(Functors)”中描述的模块类似。

下面是签名:

type t [@@deriving sexp]

(** [create lo hi] creates an interval from [lo] to [hi] inclusive,
and is empty if [lo > hi]. *)
val create : int -> int -> t

val is_empty : t -> bool
val contains : t -> int -> bool

除了用于创建和检查区间的基本操作之外,这个接口还暴露了 S 表达式转换器。注意,[@@deriving sexp] 语法也可以在签名中使用,但在这里它只会加入转换函数的签名,而不会加入实现。

下面是 Int_interval 的实现:

open Core

(* for [Range (x,y)], we require that [y >= x] *)
type t =
| Range of int * int
| Empty
[@@deriving sexp]

let create x y = if x > y then Empty else Range (x, y)

let is_empty = function
| Empty -> true
| Range _ -> false

let contains i x =
match i with
| Empty -> false
| Range (low, high) -> x >= low && x <= high

这里一个关键不变式是:Range 只用于表示非空区间。以高于上界的下界调用 create 时,会返回 Empty

现在我们用第 18 章“测试(Testing)”中介绍的 expect test 框架,通过一些测试演示它的功能。首先写一个测试辅助函数,它接收一个区间和一组点,打印出空性检查结果,并把哪些点在区间内、哪些点在区间外分类出来。

let test_interval i points =
let in_, out =
List.partition_tf points ~f:(fun x -> Int_interval.contains i x)
in
let to_string l =
List.map ~f:Int.to_string l |> String.concat ~sep:", "
in
print_endline
(String.concat
~sep:"\n"
[ (if Int_interval.is_empty i then "empty" else "non-empty")
; "in: " ^ to_string in_
; "out: " ^ to_string out
])

可以在一个非空区间上运行这个测试:

let%expect_test "ordinary interval" =
test_interval (Int_interval.create 3 6) (List.range 1 10);
[%expect
{|
non-empty
in: 3, 4, 5, 6
out: 1, 2, 7, 8, 9 |}]

也可以在一个空区间上运行:

let%expect_test "empty interval" =
test_interval (Int_interval.create 6 3) (List.range 1 10);
[%expect {|
empty
in:
out: 1, 2, 3, 4, 5, 6, 7, 8, 9 |}]

注意,is_empty 的结果与哪些元素属于或不属于区间的测试结果一致。

现在我们测试 S 表达式转换器,从 sexp_of_t 开始。这个测试可以看到,上下界翻转的区间会被表示为 Empty

let%expect_test "test to_sexp" =
let t lo hi =
let i = Int_interval.create lo hi in
print_s [%sexp (i : Int_interval.t)]
in
t 3 6;
[%expect {| (Range 3 6) |}];
t 4 4;
[%expect {| (Range 4 4) |}];
t 6 3;
[%expect {| Empty |}]

接下来要检查 t_of_sexp 转换器,这里就会遇到一个问题。具体来说,考虑如果从 S 表达式 (Range 6 3) 创建区间会发生什么。这是库本身永远不应该生成的 S 表达式,因为区间不应该有颠倒的边界。但没有任何东西能阻止我们手工生成这个 S 表达式。

let%expect_test "test (range 6 3)" =
let i = Int_interval.t_of_sexp (Sexp.of_string "(Range 6 3)") in
test_interval i (List.range 1 10);
[%expect
{|
non-empty
in:
out: 1, 2, 3, 4, 5, 6, 7, 8, 9 |}]

可以看到,糟糕的事情发生了:这个区间被判定为非空,却似乎不包含任何东西。问题源于 t_of_sexp 没有检查 create 会检查的同一个不变式。我们可以通过覆盖自动生成的 S 表达式转换器来修复它,在这里就是调用 create 检查不变式。

let t_of_sexp sexp =
match t_of_sexp sexp with
| Empty -> Empty
| Range (x, y) -> create x y

在 OCaml 中,用新定义覆盖已有函数定义完全可以接受。因为 t_of_sexp 是用普通 let 定义的,而不是 let rec,所以这里对 t_of_sexp 的调用会指向派生出来的旧版函数,而不是形成递归调用。

注意,与其修复不变式,我们也可以在不变式被破坏时抛出异常。无论如何,我们采用的方法意味着重新运行测试会得到更一致、更合理的结果。

let%expect_test "test (range 6 3)" =
let i = Int_interval.t_of_sexp (Sexp.of_string "(Range 6 3)") in
test_interval i (List.range 1 10);
[%expect
{|
empty
in:
out: 1, 2, 3, 4, 5, 6, 7, 8, 9 |}]

21.4 获得良好的错误消息(Getting Good Error Messages)

从 S 表达式反序列化一个类型有两个步骤:首先把文件中的字节转换成 S 表达式;其次把这个 S 表达式转换成目标类型。这样做的一个问题是,错误可能很难定位到正确位置。考虑下面这个例子。

open Core

type t =
{ a : string
; b : int
; c : float option
}
[@@deriving sexp]

let () =
let t = Sexp.load_sexp "example.scm" |> t_of_sexp in
printf "b is: %d\n%!" t.b

如果在一个格式错误的文件上运行它,例如下面这个文件:

((a not-a-string)
(b not-a-string)
(c 1.0))

会得到下面的错误。(注意,这里设置了 OCAMLRUNPARAM 环境变量来抑制栈跟踪。)

$ OCAMLRUNPARAM=b=0 dune exec -- ./read_foo.exe
Uncaught exception:

(Of_sexp_error "int_of_sexp: (Failure int_of_string)"
(invalid_sexp not-a-string))

[2]

如果你手里只有错误消息和字符串,它并不算特别有信息量。具体来说,你知道解析在原子 "not-an-integer" 上出了错,但不知道是哪一个。在一个大文件中,这种糟糕的错误消息会非常痛苦。

不过还有希望!我们可以对代码做一个很小的修改,就能显著改善错误消息:

open Core

type t =
{ a : string
; b : int
; c : float option
}
[@@deriving sexp]

let () =
let t = Sexp.load_sexp_conv_exn "example.scm" t_of_sexp in
printf "b is: %d\n%!" t.b

再次运行后,会看到更有信息量的错误:

$ OCAMLRUNPARAM=b=0 dune exec -- ./read_foo.exe
Uncaught exception:

(Of_sexp_error example.scm:2:4 "int_of_sexp: (Failure int_of_string)"
(invalid_sexp not-an-integer))

[2]

这里的 example.scm:2:5 表示错误发生在 "example.scm" 文件的第 2 行第 5 个字符。这是弄清楚问题所在的更好起点。能否找到错误的精确位置,取决于 sexp 转换器是否使用 of_sexp_error 函数报告错误。ppx_sexp_conv 生成的转换器已经这么做了;但当你编写自定义转换器时,也应该确保这样做。

21.5 S 表达式转换指令(Sexp-Conversion Directives)

ppx_sexp_conv 支持一组指令,用于修改自动生成的 sexp 转换器的默认行为。这些指令允许你自定义类型表示成 S 表达式的方式,而不必手写转换器。

21.5.1 @sexp.opaque

最常用的指令是 [@sexp_opaque],它的目的是把类型中的某个组件标记为不可转换。任何带有 [@sexp.opaque] 属性的内容,都会在 to-sexp 转换器中显示为原子 <opaque>,并会让 from-sexp 转换器抛出异常。

注意,被标记为 opaque 的组件类型不需要定义 sexp 转换器。默认情况下,如果我们定义一个没有 sexp 转换器的类型,然后尝试把它用作另一个带 sexp 转换器的类型的一部分,就会出错:

# type no_converter = int * int;;
type no_converter = int * int
# type t = { a: no_converter; b: string } [@@deriving sexp];;
Line 1, characters 15-27:
Error: Unbound value no_converter_of_sexp

但有了 [@sexp.opaque],就可以把这个 opaque 的 no_converter 类型嵌入另一个数据结构,而不会报错。

type t =
{ a: (no_converter [@sexp.opaque]);
b: string
} [@@deriving sexp];;

现在如果把这个类型的值转换成 S 表达式,就会看到字段 a 的内容被标记为 opaque:

# sexp_of_t { a = (3,4); b = "foo" };;
- : Sexp.t = ((a <opaque>) (b foo))

注意,t_of_sexp 仍然会被生成,但调用时会在运行期失败。

# t_of_sexp (Sexp.of_string "((a whatever) (b foo))");;
Exception:
(Of_sexp_error "opaque_of_sexp: cannot convert opaque values"
(invalid_sexp whatever))

为包含 [@sexp.opaque] 值的类型创建解析器看起来可能有点反常,但它并没有看上去那么无用。特别是,这样的转换器不一定会在所有输入上失败。考虑一个包含 opaque 值列表的记录:

type t =
{ a: (no_converter [@sexp.opaque]) list;
b: string
} [@@deriving sexp];;

只要这个列表为空,t_of_sexp 函数仍然可以成功。

# t_of_sexp (Sexp.of_string "((a ()) (b foo))");;
- : t = {a = []; b = "foo"}

不过有时候,某一个转换器根本没有用,你会想明确选择要生成哪些转换器。可以使用 [@@deriving sexp_of][@@deriving of_sexp],而不是 [@@deriving sexp]

21.5.2 @sexp.list

有时候,sexp 转换器会生成比理想情况更多的括号。考虑下面这个变体类型。

type compatible_versions =
| Specific of string list
| All
[@@deriving sexp];;

它的具体语法如下:

# sexp_of_compatible_versions
(Specific ["3.12.0"; "3.12.1"; "3.13.0"]);;
- : Sexp.t = (Specific (3.12.0 3.12.1 3.13.0))

围绕版本列表的那一组括号可以说有些多余。可以使用 [@sexp.list] 指令去掉这些括号。

type compatible_versions =
| Specific of string list [@sexp.list]
| All [@@deriving sexp]

得到的语法更轻量:

# sexp_of_compatible_versions
(Specific ["3.12.0"; "3.12.1"; "3.13.0"]);;
- : Sexp.t = (Specific 3.12.0 3.12.1 3.13.0)

21.5.3 @sexp.option

默认情况下,可选值会被表示为 ()(对应 None)或单元素列表(对应 Some)。下面是一个包含 option 的记录类型例子:

type t =
{ a: int option;
b: string;
} [@@deriving sexp]

其具体语法如下:

# sexp_of_t { a = None; b = "hello" };;
- : Sexp.t = ((a ()) (b hello))
# sexp_of_t { a = Some 3; b = "hello" };;
- : Sexp.t = ((a (3)) (b hello))

这一切都符合预期;但在记录语境中,你可能想要另一种行为:让字段本身变成可选。[@sexp.option] 指令正是为此而来。

type t =
{ a: int option [@sexp.option];
b: string;
} [@@deriving sexp]

下面是新的语法。注意,当 a 的值是 Some 时,它会直接出现在 S 表达式中;当它是 None 时,整个记录字段都会被省略。

# sexp_of_t { a = Some 3; b = "hello" };;
- : Sexp.t = ((a 3) (b hello))
# sexp_of_t { a = None; b = "hello" };;
- : Sexp.t = ((b hello))

21.5.4 指定默认值(Specifying Defaults)

[@sexp.option] 提供了一种解释记录 S 表达式的方法,让其中某些字段可以不指定。[@default] 指令提供了另一种方式。

考虑下面这个类型,它表示一个非常简单的 Web 服务器配置:

type http_server_config = {
web_root: string;
port: int;
addr: string;
} [@@deriving sexp];;

可以想象,其中一些参数是可选的;特别是,我们可能希望 Web 服务器默认绑定到 80 端口,并监听 localhost。可以这样写:

type http_server_config = {
web_root: string;
port: int [@default 80];
addr: string [@default "localhost"];
} [@@deriving sexp];;

现在,如果尝试转换一个只指定了 web_root 的 S 表达式,就会看到其他值被填入了期望的默认值:

# let cfg =
"((web_root /var/www/html))"
|> Sexp.of_string
|> http_server_config_of_sexp;;
val cfg : http_server_config =
{web_root = "/var/www/html"; port = 80; addr = "localhost"}

如果把这个配置再转换回 S 表达式,你会注意到所有字段都会出现,尽管严格来说它们并不是必要的:

# sexp_of_http_server_config cfg;;
- : Sexp.t = ((web_root /var/www/html) (port 80) (addr localhost))

我们也可以使用 [@sexp_drop_default] 指令,让生成出的 S 表达式省略默认值:

type http_server_config = {
web_root: string;
port: int [@default 80] [@sexp_drop_default.equal];
addr: string [@default "localhost"] [@sexp_drop_default.equal];
} [@@deriving sexp];;

下面是一个例子:

# let cfg =
"((web_root /var/www/html))"
|> Sexp.of_string
|> http_server_config_of_sexp;;
val cfg : http_server_config =
{web_root = "/var/www/html"; port = 80; addr = "localhost"}
# sexp_of_http_server_config cfg;;
- : Sexp.t = ((web_root /var/www/html))

可以看到,处于默认值的字段会从生成的 S 表达式中省略。另一方面,如果转换一个包含非默认值的配置,这些字段就会出现在生成出的 S 表达式中。

# sexp_of_http_server_config { cfg with port = 8080 };;
- : Sexp.t = ((web_root /var/www/html) (port 8080))
# sexp_of_http_server_config
{ cfg with port = 8080; addr = "192.168.0.1" };;
- : Sexp.t = ((web_root /var/www/html) (port 8080) (addr 192.168.0.1))

在设计配置文件格式时,这会非常有用:它既能让格式保持合理简洁,也便于生成和维护。它也有助于向后兼容:如果你给配置记录新增一个字段,但把该字段设为可选,那么仍然应该能够解析旧版本配置。

你应当使用哪个确切属性,取决于你希望丢弃默认值的类型上可用哪些比较函数:

  • 如果类型支持 [%compare],使用 [@sexp_drop_default.compare]
  • 如果类型支持 [%equal],使用 [@sexp_drop_default.equal]
  • 如果想比较 sexp 表示,使用 [@sexp_drop_default.sexp]
  • 也可以使用 [@sexp_drop_default f],并给出一个显式的相等函数

Base 和 Core 提供的大多数类型定义都带有比较与相等操作,因此这些默认属性通常是合理选择。