第 6 章:记录(Records)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 6。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
OCaml 最好的特性之一,是它用于声明新数据类型的系统既简洁又有表达力。记录(records)是这个系统中的关键元素。第 2 章“导览(A Guided Tour)”已经简要讨论过记录;本章会更深入,覆盖更多技术细节,并给出在软件设计中有效使用记录的建议。
记录表示一组作为整体存储在一起的值,其中每个组成部分由不同字段名标识。记录类型声明的基本语法如下:
type <record-name> =
{ <field> : <type>;
<field> : <type>;
...
}
注意,记录字段名必须以小写字母开头。
下面是一个简单例子:service_info 记录表示典型 Unix 系统中 /etc/services 文件的一条记录。该文件用于跟踪 FTP 或 SSH 等协议的知名端口和协议名。注意,这个例子会打开 Core 而不是 Base,因为我们用到了 Unix API,而这需要 Core。
open Core
type service_info =
{ service_name : string;
port : int;
protocol : string;
}
构造一个 service_info 和声明它的类型一样容易。下面的函数接收 /etc/services 文件中的一行作为输入,并尝试构造这样的记录。为此,我们会使用 Re,它是 OCaml 的一个正则表达式引擎。如果你不了解正则表达式,可以暂时把它们看成一种用于解析字符串的简单模式语言。(可能需要先运行 opam install re 安装它。)
#require "re";;
let service_info_of_string line =
let matches =
let pat = "([a-zA-Z]+)[ \t]+([0-9]+)/([a-zA-Z]+)" in
Re.exec (Re.Posix.compile_pat pat) line
in
{ service_name = Re.Group.get matches 1;
port = Int.of_string (Re.Group.get matches 2);
protocol = Re.Group.get matches 3;
}
;;
>val service_info_of_string : string -> service_info = <fun>
现在可以在文件中的一行上调用这个函数,构造一个具体记录。
let ssh = service_info_of_string "ssh 22/udp # SSH Remote Login Protocol";;
>val ssh : service_info = {service_name = "ssh"; port = 22; protocol = "udp"}
你可能会好奇,编译器是如何推断出这个函数返回 service_info 类型值的。在这个例子中,编译器基于构造记录时使用的字段名进行推断。当每个字段名只属于一个记录类型时,这种推断最直接。本章后面会讨论当不同记录类型共享字段名时会发生什么。
一旦手里有了记录值,就可以使用点号记法从记录字段中取出元素:
ssh.port;;
>- : int = 22
声明 OCaml 类型时,总是可以选择用多态类型对它进行参数化。记录在这方面并没有不同。例如,下面这个类型表示一个附带行号标记的任意项。
type 'a with_line_num = { item: 'a; line_num: int }
然后可以编写作用于这个参数化类型的多态函数。例如,下面这个函数接收文件内容,并把它解析为一系列行;解析每一行时,会使用调用者提供的解析函数。
let parse_lines parse file_contents =
let lines = String.split ~on:'\n' file_contents in
List.mapi lines ~f:(fun line_num line ->
{ item = parse line;
line_num = line_num + 1;
})
;;
>val parse_lines : (string -> 'a) -> string -> 'a with_line_num list = <fun>
接着可以用这个函数解析真实 /etc/services 文件中的一小段。
parse_lines service_info_of_string
"rtmp 1/ddp # Routing Table Maintenance Protocol
tcpmux 1/udp # TCP Port Service Multiplexer
tcpmux 1/tcp # TCP Port Service Multiplexer";;
>- : service_info with_line_num list =
>[{item = {service_name = "rtmp"; port = 1; protocol = "ddp"}; line_num = 1};
> {item = {service_name = "tcpmux"; port = 1; protocol = "udp"}; line_num = 2};
> {item = {service_name = "tcpmux"; port = 1; protocol = "tcp"}; line_num = 3}]
多态性让我们可以在解析不同格式时使用同一个函数,例如下面这个函数用于解析一个每行包含一个整数的文件。
parse_lines Int.of_string "1\n10\n100\n1000";;
>- : int with_line_num list =
>[{item = 1; line_num = 1}; {item = 10; line_num = 2};
> {item = 100; line_num = 3}; {item = 1000; line_num = 4}]
6.1 模式与穷尽性(Patterns and Exhaustiveness)
另一种从记录中取出信息的方法,是使用模式匹配,如下面的函数所示。
let service_info_to_string
{ service_name = name; port = port; protocol = prot }
=
sprintf "%s %i/%s" name port prot
;;
>val service_info_to_string : service_info -> string = <fun>
service_info_to_string ssh;;
>- : string = "ssh 22/udp"
注意,我们使用的模式只有一个情况,而不是使用多个由 | 分隔的情况。这里一个模式就够了,因为记录模式是不可反驳的(irrefutable),也就是说记录模式匹配在运行时永远不会失败。这是因为记录中可用的字段集合始终相同。一般来说,具有固定结构的类型,例如记录和元组,其模式是不可反驳的;而列表和变体这类具有可变结构的类型则不同。
记录模式的另一个重要特点是:它们不必完整;一个模式可以只提到记录字段的一个子集。这可能很方便,但也容易出错。特别是,当给记录添加新字段时,那些本来应该更新以响应新字段存在的代码,并不会被编译器标出来。
举个例子,假设我们想修改 service_info 记录,让它保留注释。可以提供一个新的 service_info 定义,加入 comment 字段:
type service_info =
{ service_name : string;
port : int;
protocol : string;
comment : string option;
}
service_info_to_string 的代码不用修改仍然会继续编译。但在这个情况下,我们也许应该更新代码,让生成的字符串在存在注释时包含该注释。如果类型系统能够警告我们应该考虑更新函数,那就很好了。
幸运的是,OCaml 为记录模式中缺少字段的情况提供了可选警告。打开这个警告后(在顶层环境中输入 #warnings "+9" 即可),编译器确实会警告我们。
#warnings "+9";;
let service_info_to_string
{ service_name = name; port = port; protocol = prot }
=
sprintf "%s %i/%s" name port prot
;;
>Line 2, characters 5-59:
>Warning 9 [missing-record-field-pattern]: the following labels are not bound in this record pattern:
>comment
>Either bind these labels explicitly or add '; _' to the pattern.
>val service_info_to_string : service_info -> string = <fun>
如果想为某个模式禁用该警告,可以显式承认我们正在忽略额外字段。做法是在模式中添加下划线:
let service_info_to_string
{ service_name = name; port = port; protocol = prot; _ }
=
sprintf "%s %i/%s" name port prot
;;
>val service_info_to_string : service_info -> string = <fun>
打开不完整记录匹配的警告,并在必要时用 _ 显式禁用它,是个好主意。
编译器警告(Compiler Warnings)
OCaml 编译器内置了大量有用警告,这些警告可以分别启用和禁用。它们记录在编译器本身中,因此我们可以像下面这样查到警告 9 的含义:
ocaml -warn-help | egrep '\b9\b'
> 9 [missing-record-field-pattern] Missing fields in a record pattern.
> R Alias for warning 9.
你可以把 OCaml 的警告看作一组强大的可选静态分析工具。它们对捕获各种 bug 极有帮助,你应该在构建环境中启用它们。通常不需要启用所有警告,不过编译器附带的默认设置已经相当不错。
本书示例构建时使用的警告由下面这个标志指定:-w @A-4-33-40-41-42-43-34-44。
可以通过运行 ocaml -help 查到 -w 的语法;但这个具体调用会把所有警告都当作错误打开,只禁用 A 后面明确列出的那些编号。
把警告视为错误(也就是让 OCaml 无法编译任何触发警告的代码)是一种好实践,因为如果不这样做,警告在开发过程中往往会被忽略。不过,在准备分发包时,这样做并不好,因为警告列表可能会随着编译器版本演进而增长,这可能导致你的包在较新的编译器版本上无法编译。
6.2 字段简写(Field Punning)
当变量名与记录字段名一致时,OCaml 提供了一些方便的语法简写。例如,下面函数中的模式会把相关字段全部绑定到同名变量。这称为字段简写(field punning):
let service_info_to_string { service_name; port; protocol; comment } =
let base = sprintf "%s %i/%s" service_name port protocol in
match comment with
| None -> base
| Some text -> base ^ " #" ^ text;;
>val service_info_to_string : service_info -> string = <fun>
字段简写也可以用于构造记录。考虑下面这个更新后的 service_info_of_string 版本:
let service_info_of_string line =
(* first, split off any comment *)
let (line,comment) =
match String.rsplit2 line ~on:'#' with
| None -> (line,None)
| Some (ordinary,comment) -> (ordinary, Some comment)
in
(* now, use a regular expression to break up the
service definition *)
let matches =
Re.exec
(Re.Posix.compile_pat
"([a-zA-Z]+)[ \t]+([0-9]+)/([a-zA-Z]+)")
line
in
let service_name = Re.Group.get matches 1 in
let port = Int.of_string (Re.Group.get matches 2) in
let protocol = Re.Group.get matches 3 in
{ service_name; port; protocol; comment };;
>val service_info_of_string : string -> service_info = <fun>
在前面的代码中,我们先定义了与记录字段对应的变量,然后记录声明本身只列出需要包含的字段。编写从带标签参数构造记录的函数时,可以同时利用字段简写和标签简写(label punning):
let create_service_info ~service_name ~port ~protocol ~comment =
{ service_name; port; protocol; comment };;
>val create_service_info :
> service_name:string ->
> port:int -> protocol:string -> comment:string option -> service_info =
> <fun>
这比不使用简写时得到的代码简洁得多:
let create_service_info
~service_name:service_name ~port:port
~protocol:protocol ~comment:comment =
{ service_name = service_name;
port = port;
protocol = protocol;
comment = comment;
};;
>val create_service_info :
> service_name:string ->
> port:int -> protocol:string -> comment:string option -> service_info =
> <fun>
字段简写和标签简写一起,鼓励你在整个代码库中传播相同名称。一般来说这是好实践,因为它鼓励一致命名,让源码更容易导航。
6.3 复用字段名(Reusing Field Names)
定义具有相同字段名的记录可能会带来问题。来看一个简单例子:考虑一组类型,它们表示日志服务器的协议。
我们会描述三种消息类型:log_entry、heartbeat 和 logon。log_entry 消息用于向服务器递交日志条目;logon 消息会在发起连接时发送,其中包含正在连接的用户身份以及用于认证的凭据;heartbeat 消息则由客户端周期性发送,用于向服务器表明客户端仍然存活且保持连接。所有这些消息都包含会话 ID 和消息生成时间。
type log_entry =
{ session_id: string;
time: Time_ns.t;
important: bool;
message: string;
}
type heartbeat =
{ session_id: string;
time: Time_ns.t;
status_message: string;
}
type logon =
{ session_id: string;
time: Time_ns.t;
user: string;
credentials: string;
}
复用字段名会导致一些歧义。例如,如果想写一个函数从记录中取得 session_id,它会有什么类型?
let get_session_id t = t.session_id;;
>val get_session_id : logon -> string = <fun>
在这个例子中,OCaml 只是选择该记录字段最近的定义。可以使用类型标注强制 OCaml 假设我们处理的是不同类型,例如 heartbeat:
let get_heartbeat_session_id (t:heartbeat) = t.session_id;;
>val get_heartbeat_session_id : heartbeat -> string = <fun>
虽然可以使用类型标注解析模糊字段名,但这种歧义可能有些令人困惑。考虑下面两个函数,它们用于从心跳消息中取出会话 ID 和状态:
let status_and_session t = (t.status_message, t.session_id);;
>val status_and_session : heartbeat -> string * string = <fun>
let session_and_status t = (t.session_id, t.status_message);;
>Line 1, characters 45-59:
>Error: This expression has type logon
> There is no field status_message within type logon
为什么第一个定义不需要类型标注就成功了,而第二个失败了?区别在于,在第一个例子中,类型检查器先考虑 status_message 字段,因此推断该记录是 heartbeat。当顺序被调换时,session_id 字段先被考虑,于是类型被推动为 logon;到这个时候,t.status_message 就不再有意义了。
添加类型标注可以消除歧义,无论字段以什么顺序被考虑都一样。
let session_and_status (t:heartbeat) = (t.session_id, t.status_message);;
>val session_and_status : heartbeat -> string * string = <fun>
我们可以完全避免歧义,做法是使用互不重叠的字段名,或者把不同记录类型放进不同模块中。事实上,把类型打包到模块中是一种广泛有用的习惯用法,Base 也大量使用这种方式。它为每个类型提供一个命名空间,用来放置相关值。使用这种风格时,标准实践是把与模块关联的类型命名为 t。因此,我们可以写成:
module Log_entry = struct
type t =
{ session_id: string;
time: Time_ns.t;
important: bool;
message: string;
}
end
module Heartbeat = struct
type t =
{ session_id: string;
time: Time_ns.t;
status_message: string;
}
end
module Logon = struct
type t =
{ session_id: string;
time: Time_ns.t;
user: string;
credentials: string;
}
end
现在,我们的日志条目创建函数可以写成如下形式:
let create_log_entry ~session_id ~important message =
{ Log_entry.time = Time_ns.now ();
Log_entry.session_id;
Log_entry.important;
Log_entry.message
};;
>val create_log_entry :
> session_id:string -> important:bool -> string -> Log_entry.t = <fun>
由于这个函数位于定义该记录的 Log_entry 模块之外,所以需要用模块名 Log_entry 限定字段。不过,OCaml 只要求对一个记录字段做模块限定,因此可以把它写得更简洁。注意,模块路径和字段名之间允许插入空白:
let create_log_entry ~session_id ~important message =
{ Log_entry.
time = Time_ns.now (); session_id; important; message };;
>val create_log_entry :
> session_id:string -> important:bool -> string -> Log_entry.t = <fun>
前面已经看到,可以通过添加类型标注来帮助 OCaml 理解想要使用哪个记录字段。这里也可以用这个方式,让例子更简洁。
let create_log_entry ~session_id ~important message : Log_entry.t =
{ time = Time_ns.now (); session_id; important; message };;
>val create_log_entry :
> session_id:string -> important:bool -> string -> Log_entry.t = <fun>
这不限于构造记录;模式匹配时也可以使用同样的方法:
let message_to_string { Log_entry.important; message; _ } =
if important then String.uppercase message else message;;
>val message_to_string : Log_entry.t -> string = <fun>
使用点号记法访问记录字段时,同样可以用模块限定字段。
let is_important t = t.Log_entry.important;;
>val is_important : Log_entry.t -> bool = <fun>
第一次看到这里的语法时,它会有点令人意外。需要记住的是,点号在这里以两种方式使用:第一个点是记录字段访问,点号右侧的一切都会被解释为字段名;第二个点则是在访问模块内容,引用 Log_entry 模块中的记录字段 important。Log_entry 以大写字母开头,因此不可能是字段名,正是这一点消除了两种用法之间的歧义。
用字段所属模块限定记录字段可能有些笨拙。幸运的是,如果 OCaml 能以其他方式推断出相关记录的类型,就不要求对记录字段做限定。特别是,可以通过添加类型标注并移除模块限定,重写上面的声明。
let message_to_string ({ important; message; _ } : Log_entry.t) =
if important then String.uppercase message else message;;
>val message_to_string : Log_entry.t -> string = <fun>
let is_important (t:Log_entry.t) = t.important;;
>val is_important : Log_entry.t -> bool = <fun>
这一语言特性有个略显吓人的名字:类型驱动的构造器消歧(type-directed constructor disambiguation)。它不仅适用于记录字段,也适用于变体标签,我们会在第 7 章“变体(Variants)”中看到。
6.4 函数式更新(Functional Updates)
你会相当经常地发现,自己想创建一个新记录,它只在部分字段上不同于某个已有记录。例如,设想我们的日志服务器有一个记录类型,用于表示某个给定客户端的状态,其中包括该客户端最后一次心跳被接收的时间。
#require "core_unix";;
type client_info =
{ addr: Core_unix.Inet_addr.t;
port: int;
user: string;
credentials: string;
last_heartbeat_time: Time_ns.t;
}
可以像下面这样定义一个函数,在收到新心跳时更新客户端信息。
let register_heartbeat t hb =
{ addr = t.addr;
port = t.port;
user = t.user;
credentials = t.credentials;
last_heartbeat_time = hb.Heartbeat.time;
};;
>val register_heartbeat : client_info -> Heartbeat.t -> client_info = <fun>
这相当冗长,因为实际上只想修改一个字段,而其他字段只是从 t 复制过来。可以使用 OCaml 的函数式更新语法,把它写得更简短。
下面展示如何使用函数式更新更简洁地重写 register_heartbeat。
let register_heartbeat t hb =
{ t with last_heartbeat_time = hb.Heartbeat.time };;
>val register_heartbeat : client_info -> Heartbeat.t -> client_info = <fun>
关键字 with 标记这是一次函数式更新,右侧的值赋值则表示要对 with 左侧的记录做哪些修改。
函数式更新让你的代码不依赖那些没有改变的记录字段的身份。这通常正是你想要的,但它也有缺点。特别是,如果修改记录定义以加入更多字段,类型系统不会提示你重新考虑代码是否需要改变以适应新字段。考虑如果决定添加一个字段,用于保存上次心跳中收到的状态消息,会发生什么:
type client_info =
{ addr: Core_unix.Inet_addr.t;
port: int;
user: string;
credentials: string;
last_heartbeat_time: Time_ns.t;
last_heartbeat_status: string;
}
原来的 register_heartbeat 实现现在会无效,因此编译器会有效地提醒我们思考如何处理这个新字段。但使用函数式更新的版本会继续原样编译,尽管它错误地忽略了新字段。正确做法是把代码更新成下面这样:
let register_heartbeat t hb =
{ t with last_heartbeat_time = hb.Heartbeat.time;
last_heartbeat_status = hb.Heartbeat.status_message;
};;
>val register_heartbeat : client_info -> Heartbeat.t -> client_info = <fun>
尽管存在这些缺点,函数式更新仍然非常有用;在修改记录时,如果并不重要去考虑记录的每个字段,它就是一个好选择。
6.5 可变字段(Mutable Fields)
和大多数 OCaml 值一样,记录默认是不可变的。不过,可以把单个记录字段声明为可变。下面的代码把 client_info 的最后两个字段设为了可变字段:
type client_info =
{ addr: Core_unix.Inet_addr.t;
port: int;
user: string;
credentials: string;
mutable last_heartbeat_time: Time_ns.t;
mutable last_heartbeat_status: string;
}
<- 运算符用于设置可变字段。带副作用版本的 register_heartbeat 可以写成如下形式:
let register_heartbeat t (hb:Heartbeat.t) =
t.last_heartbeat_time <- hb.time;
t.last_heartbeat_status <- hb.status_message;;
>val register_heartbeat : client_info -> Heartbeat.t -> unit = <fun>
注意,初始化时不需要可变赋值,也就是不需要 <- 运算符,因为创建记录时会指定记录的所有字段,包括可变字段。
OCaml 默认不可变的策略很好,但命令式编程也是 OCaml 编程中的重要组成部分。第 9 章“命令式编程(Imperative Programming)”会更深入地介绍如何以及何时使用 OCaml 的命令式特性。
6.6 一等字段(First-Class Fields)
考虑下面这个函数,它从 Logon 消息列表中抽取用户名:
let get_users logons =
List.dedup_and_sort ~compare:String.compare
(List.map logons ~f:(fun x -> x.Logon.user));;
>val get_users : Logon.t list -> string list = <fun>
这里,我们写了一个小函数 (fun x -> x.Logon.user) 来访问 user 字段。这类访问器函数是一种足够常见的模式,因此如果能自动生成会很方便。随 Core 一起提供的 ppx_fields_conv 语法扩展正好能做到这一点。
记录类型声明末尾的 [@@deriving fields] 标注会让该扩展应用到给定类型声明上。我们需要显式启用这个扩展:
#require "ppx_jane";;
然后就可以像下面这样定义 Logon:
module Logon = struct
type t =
{ session_id: string;
time: Time_ns.t;
user: string;
credentials: string;
}
[@@deriving fields]
end;;
>module Logon :
> sig
> type t = {
> session_id : string;
> time : Time_ns.t;
> user : string;
> credentials : string;
> }
> val credentials : t -> string
> val user : t -> string
> val time : t -> Time_ns.t
> val session_id : t -> string
> module Fields :
> sig
> val names : string list
> val credentials :
> ([< `Read | `Set_and_create ], t, string) Field.t_with_perm
> val user :
> ([< `Read | `Set_and_create ], t, string) Field.t_with_perm
> val time :
> ([< `Read | `Set_and_create ], t, Time_ns.t) Field.t_with_perm
...
> end
> end
注意,这会生成大量输出,因为 fieldslib 会生成一大批用于处理记录字段的辅助函数。这里只讨论其中一小部分;剩余内容可以从 fieldslib 附带的文档中学习。
我们得到的函数之一是 Logon.user,可以用它从登录消息中抽取 user 字段:
let get_users logons =
List.dedup_and_sort ~compare:String.compare
(List.map logons ~f:Logon.user);;
>val get_users : Logon.t list -> string list = <fun>
除了生成字段访问器函数之外,fieldslib 还会创建一个名为 Fields 的子模块,其中包含每个字段的一等表示,形式上是 Field.t 类型的值。Field 模块提供以下函数:
Field.name:返回字段名。Field.get:返回字段内容。Field.fset:对一个字段执行函数式更新。Field.setter:如果字段不可变则返回None;如果字段可变则返回Some f,其中f是用于修改该字段的函数。
一个 Field.t 有两个类型参数:第一个表示记录类型,第二个表示相关字段的类型。因此,Logon.Fields.session_id 的类型是 (Logon.t, string) Field.t,而 Logon.Fields.time 的类型是 (Logon.t, Time.t) Field.t。所以,如果对 Logon.Fields.user 调用 Field.get,会得到一个用于从 Logon.t 中抽取 user 字段的函数:
Field.get Logon.Fields.user;;
>- : Logon.t -> string = <fun>
因此,Field.t 的第一个参数对应传给 get 的记录,第二个参数对应字段中包含的值,也就是 get 的返回类型。
Field.get 的类型比你从前一个例子天真预期的稍微复杂一些:
Field.get;;
>- : ('b, 'r, 'a) Field.t_with_perm -> 'r -> 'a = <fun>
这里的类型是 Field.t_with_perm 而不是 Field.t,是因为字段有访问控制的概念。在一些特殊情况下,我们会暴露从记录中读取字段的能力,但不暴露创建新记录的能力,因此也不能暴露函数式更新。
可以使用一等字段完成一些事情,例如编写一个用于显示记录字段的通用函数:
let show_field field to_string record =
let name = Field.name field in
let field_string = to_string (Field.get field record) in
name ^ ": " ^ field_string;;
>val show_field :
> ('a, 'b, 'c) Field.t_with_perm -> ('c -> string) -> 'b -> string = <fun>
这个函数接收三个参数:Field.t,一个把相关字段内容转换为字符串的函数,以及一个可以从中取出该字段的记录。
下面是 show_field 的实际使用示例:
let logon =
{ Logon.
session_id = "26685";
time = Time_ns.of_string_with_utc_offset "2017-07-21 10:11:45Z";
user = "yminsky";
credentials = "Xy2d9W";
};;
>val logon : Logon.t =
> {Logon.session_id = "26685"; time = 2017-07-21 15:11:45.000000000Z;
> user = "yminsky"; credentials = "Xy2d9W"}
show_field Logon.Fields.user Fn.id logon;;
>- : string = "user: yminsky"
show_field Logon.Fields.time Time_ns.to_string logon;;
>- : string = "time: 2017-07-21 15:11:45.000000000Z"
顺带一提,前面的例子第一次用到了 Fn 模块。Fn 是 “function” 的缩写,提供一组处理函数时有用的基础功能。Fn.id 是恒等函数。
fieldslib 还提供更高层的操作符,例如 Fields.fold 和 Fields.iter,让你可以遍历记录字段。比如,对于 Logon.t,字段迭代器拥有如下类型:
Logon.Fields.iter;;
>- : session_id:(([< `Read | `Set_and_create ], Logon.t, string)
> Field.t_with_perm -> unit) ->
> time:(([< `Read | `Set_and_create ], Logon.t, Time_ns.t)
> Field.t_with_perm -> unit) ->
> user:(([< `Read | `Set_and_create ], Logon.t, string) Field.t_with_perm ->
> unit) ->
> credentials:(([< `Read | `Set_and_create ], Logon.t, string)
> Field.t_with_perm -> unit) ->
> unit
>= <fun>
这看起来有点吓人,主要是因为访问控制标记,但其结构实际上很简单。每个带标签参数都是一个函数,它接收必要类型的一等字段作为参数。注意,iter 传给每个回调的是 Field.t,而不是特定记录字段的内容。不过,可以使用记录和 Field.t 的组合查出该字段内容。
现在,用 Logon.Fields.iter 和 show_field 打印一个 Logon 记录的所有字段:
let print_logon logon =
let print to_string field =
printf "%s\n" (show_field field to_string logon)
in
Logon.Fields.iter
~session_id:(print Fn.id)
~time:(print Time_ns.to_string)
~user:(print Fn.id)
~credentials:(print Fn.id);;
>val print_logon : Logon.t -> unit = <fun>
print_logon logon;;
>session_id: 26685
>time: 2017-07-21 15:11:45.000000000Z
>user: yminsky
>credentials: Xy2d9W
>- : unit = ()
这种方法的一个不错副作用是:当记录字段发生变化时,它能帮助你调整代码。如果向 Logon.t 添加一个字段,Logon.Fields.iter 的类型也会随之改变,获得一个新参数。任何使用 Logon.Fields.iter 的代码,在修复为考虑这个新参数之前,都无法编译。
字段迭代器对各种与记录有关的任务都很有用,从构建记录验证函数,到根据记录类型搭建 Web 表单定义,都可以从中受益。这类应用受益于一个保证:相关记录类型的所有字段都已经被考虑到。