第 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 表达式,而不是打印成它们实际由
Atom和List变体构成的树。这是因为 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 提供的大多数类型定义都带有比较与相等操作,因此这些默认属性通常是合理选择。