第 23 章:外部函数接口(Foreign Function Interface)
原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 23。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。
本章包含 Jeremy Yallop 的贡献。
OCaml 有几种方式可以与非 OCaml 代码交互。编译器可以通过 C 代码链接外部系统库,也可以生成独立的原生目标文件,让它们嵌入到其他非 OCaml 应用中。
让一种编程语言中的代码能够调用另一种编程语言中的例程,这种机制称为外部函数接口(foreign function interface,FFI)。本章将会:
- 展示如何直接从 OCaml 代码调用 C 库中的例程
- 教你如何基于底层 C 绑定,在 OCaml 中构建更高层抽象
- 通过完整示例演示如何绑定终端界面以及 UNIX 日期/时间函数
OCaml 中最简单的外部函数接口甚至不要求你编写任何 C 代码。Ctypes 库让你可以用纯 OCaml 定义 C 接口,随后由该库负责加载 C 符号并调用外部函数。
我们直接进入一个真实示例,看看这个库是什么样子。我们会为 Ncurses 终端工具包创建绑定,因为它在大多数系统上都很常见,也没有复杂依赖。
注:安装 Ctypes 库
如果想以交互方式使用 Ctypes,还需要安装
libffi库作为使用 Ctypes 的前置条件。它是一个相当流行的库,应该可以在你的操作系统包管理器中找到。如果你使用 opam 2.1 或更高版本,安装ctypes-foreign时它会提示你自动安装该依赖。$ opam install ctypes ctypes-foreign$ utop# require "ctypes-foreign" ;;第一个示例还需要 Ncurses 库。它在 macOS 等许多操作系统上预装,而 Debian Linux 以
libncurses5-dev包提供它。
23.1 示例:终端接口(Example: A Terminal Interface)
Ncurses 是一个库,用于以相当高效的方式构建不依赖具体终端的文本界面。它被 Mutt 和 Pine 这类控制台邮件客户端,以及 Lynx 这类控制台 Web 浏览器使用。
完整的 C 接口很大,并在在线文档中解释。我们只使用一小段接口,因为这里只想演示 Ctypes 的工作方式:
typedef struct _win_st WINDOW;
typedef unsigned int chtype;
WINDOW *initscr (void);
WINDOW *newwin (int, int, int, int);
void endwin (void);
void refresh (void);
void wrefresh (WINDOW *);
void addstr (const char *);
int mvwaddch (WINDOW *, int, int, const chtype);
void mvwaddstr (WINDOW *, int, int, char *);
void box (WINDOW *, chtype, chtype);
int cbreak (void);
Ncurses 函数要么作用于当前伪终端,要么作用于通过 newwin 创建的窗口。WINDOW 结构保存库的内部状态,并且在 Ncurses 外部被视为抽象结构。Ncurses 客户端只需要把指针存放在某处,并把它传回 Ncurses 库调用;随后由库调用解引用其中内容。
注意,Ncurses 中有 200 多个库调用,所以这个示例只绑定其中一小部分。initscr 和 newwin 分别为全局窗口和子窗口创建 WINDOW 指针。mvwaddrstr 接收窗口、x/y 偏移量和字符串,并把字符串写到屏幕上的对应位置。只有调用 refresh 或 wrefresh 后,终端才会被更新。
Ctypes 提供了一个 OCaml 接口,让你可以把这些 C 函数映射成等价的 OCaml 函数。该库会负责把 OCaml 函数调用和参数转换成 C 调用约定,在 C 库中调用外部函数,最后把结果作为 OCaml 值返回。
我们先定义所需的基本值,从 WINDOW 状态指针开始:
open Ctypes
type window = unit ptr
let window : window typ = ptr void
我们不知道窗口指针的内部表示,因此把它当作 C 的 void 指针处理。本章稍后会改进这一点,但现在已经足够。第二条语句定义了一个表示 WINDOW C 指针的 OCaml 值。稍后在 Ctypes 函数定义中会用到这个值:
open Foreign
let initscr = foreign "initscr" (void @-> returning window)
这就是调用第一个函数 initscr 来初始化终端所需的全部内容。foreign 函数接收两个参数:
- C 函数调用名,它会通过 POSIX 的
dlsym函数查找。 - 一个值,定义完整的 C 函数参数集合及其返回类型。
@->运算符向 C 参数列表添加一个参数,而returning用返回类型终止参数列表。
Ncurses 绑定的其余部分只是扩展这些定义:
let newwin =
foreign "newwin" (int @-> int @-> int @-> int @-> returning window)
let endwin = foreign "endwin" (void @-> returning void)
let refresh = foreign "refresh" (void @-> returning void)
let wrefresh = foreign "wrefresh" (window @-> returning void)
let addstr = foreign "addstr" (string @-> returning void)
let mvwaddch =
foreign
"mvwaddch"
(window @-> int @-> int @-> char @-> returning void)
let mvwaddstr =
foreign
"mvwaddstr"
(window @-> int @-> int @-> string @-> returning void)
let box = foreign "box" (window @-> char @-> char @-> returning void)
let cbreak = foreign "cbreak" (void @-> returning int)
这些定义都是从 Ncurses 头文件中的 C 声明直接映射而来。注意,这里的 string 和 int 值与 OCaml 类型声明无关;它们是文件顶部打开 Ctypes 模块后得到的值。
Ncurses 示例中的大多数参数都表示相当简单的标量 C 类型,除了 window(指向库状态的指针)和 string。后者会把具有特定长度的 OCaml 字符串,映射到 C 字符缓冲区;C 缓冲区的长度由紧跟字符串数据之后的终止空字符定义。
ncurses.mli 的模块签名看起来很像普通 OCaml 签名。你可以通过运行 ocaml-print-intf 命令,直接从 ncurses.ml 推断出它;这个命令可通过 opam 安装。
$ ocaml-print-intf ncurses.ml
type window = unit Ctypes.ptr
val window : window Ctypes.typ
val initscr : unit -> window
val newwin : int -> int -> int -> int -> window
val endwin : unit -> unit
val refresh : unit -> unit
val wrefresh : window -> unit
val addstr : string -> unit
val mvwaddch : window -> int -> int -> char -> unit
val mvwaddstr : window -> int -> int -> string -> unit
val box : window -> char -> char -> unit
val cbreak : unit -> int
ocaml-print-intf 工具会检查编译器为模块文件推断出的默认签名,并把它打印成人类可读的输出。你可以把它复制进对应的 mli 文件,并通过让某些内部细节更抽象,来为外部调用者改善安全性。
下面是我们可以从其他库安全使用的定制版 ncurses.mli 接口:
type window
val window : window Ctypes.typ
val initscr : unit -> window
val endwin : unit -> unit
val refresh : unit -> unit
val wrefresh : window -> unit
val newwin : int -> int -> int -> int -> window
val mvwaddch : window -> int -> int -> char -> unit
val addstr : string -> unit
val mvwaddstr : window -> int -> int -> string -> unit
val box : window -> char -> char -> unit
val cbreak : unit -> int
注意,现在签名中的 window 类型是抽象的,这确保窗口指针只能通过 Ncurses.initscr 函数构造。这样可以防止从其他来源得到的 void 指针被误传给 Ncurses 库调用。
现在编译一个 “hello world” 终端绘图程序,把这些内容串起来:
open Ncurses
let () =
let main_window = initscr () in
ignore (cbreak ());
let small_window = newwin 10 10 5 5 in
mvwaddstr main_window 1 2 "Hello";
mvwaddstr small_window 2 2 "World";
box small_window '\000' '\000';
refresh ();
Unix.sleep 1;
wrefresh small_window;
Unix.sleep 5;
endwin ()
hello 可执行程序通过链接 ctypes-foreign 包编译而成。我们还加入一个 (flags) 指令,告诉编译器把系统 ncurses C 库链接进可执行程序。如果不在 dune 文件中指定 C 库,程序可能能够成功构建,但尝试调用可执行文件时会失败,因为不是所有依赖都可用。
(executable
(name hello)
(libraries ctypes-foreign)
(flags :standard -cclib -lncurses))
现在可以用 Dune 构建它。
$ dune build hello.exe
运行 hello.exe 时,终端中应该会显示 Hello World。
当然,如果 Ctypes 只能定义简单 C 类型,它就没那么有用了。它完整支持 C 指针算术、指针转换、通过指针读写、把 OCaml 函数作为函数指针传给 C 代码,以及结构体和联合体定义。
本章余下部分会用一些 POSIX 日期函数作为贯穿示例,更详细地介绍这些功能。
注:链接模式:libffi 与存根生成
ctypes 的核心是一组用于描述 C 类型结构的 OCaml 组合子,包括数值类型、数组、指针、结构体、联合体和函数。随后你可以用这些组合子描述想要调用的 C 函数的类型。真正链接到包含函数定义的系统库时,有两种完全不同的方式:动态链接(dynamic linking)和存根生成(stub generation)。
本章使用的
ctypes-foreign包使用底层libffi库动态打开 C 库,查找正在调用的函数调用所需的符号,并根据操作系统的应用二进制接口(ABI)整理函数参数。虽然大量工作都在幕后发生,并且在开发绑定时允许方便的交互式编程,但它并不总是生产环境中想使用的方案。
ctypes-cstubs包提供了另一种机制,把大量链接工作挪到构建时一次完成,而不是在每次调用函数时完成。它会接收同一套 OCaml 绑定描述,并生成中间 C 源文件,其中包含对应的 C/OCaml 胶水代码。当这些文件通过普通 dune 构建编译时,生成的 C 代码会像任何手写代码一样被处理,并针对系统头文件编译。这让某些无法动态探测的 C 值也可以使用,例如预处理器宏定义;如果 C 头文件不匹配,也可以在编译时捕获定义错误。不过 C 很少会让生活更简单。有些定义无法完全表示成静态 C 代码,例如动态函数指针,这些就需要使用
ctypes-foreign和libffi。使用 ctypes 确实可以让绝大多数定义在两种链接模式之间共享,同时避免直接编写 C 代码。本章后续不会进一步覆盖 C 存根生成的细节;你可以在 dune 手册的 “Dealing with foreign libraries” 一章中阅读更多关于这种模式的用法。
23.2 基本标量 C 类型(Basic Scalar C Types)
首先看看如何定义基本标量 C 类型。每个 C 类型都会通过一个单独的类型定义表示为一个 OCaml 等价物:
type 'a typ
Ctypes.typ 是表示 C 类型的 OCaml 值的类型。每个 typ 实例都关联两种类型:
- 用于在外部库中存储和传递值的 C 类型。
- 对应的 OCaml 类型。
'a类型参数包含 OCaml 类型,使得类型为t typ的值用于读写类型为t的 OCaml 值。
在 Ctypes 中,typ 值还有多种其他用途,例如:
- 为绑定原生函数构造函数类型
- 构造用于读写 C 管理存储中位置的指针
- 描述结构体、联合体和数组的组成字段
下面是大多数标准 C99 标量类型的定义,其中也包括一些依赖平台的类型:
val void : unit typ
val char : char typ
val schar : int typ
val short : int typ
val int : int typ
val long : long typ
val llong : llong typ
val nativeint : nativeint typ
val int8_t : int typ
val int16_t : int typ
val int32_t : int32 typ
val int64_t : int64 typ
val uchar : uchar typ
val uint8_t : uint8 typ
val uint16_t : uint16 typ
val uint32_t : uint32 typ
val uint64_t : uint64 typ
val size_t : size_t typ
val ushort : ushort typ
val uint : uint typ
val ulong : ulong typ
val ullong : ullong typ
val float : float typ
val double : float typ
val complex32 : Complex.t typ
val complex64 : Complex.t typ
这些值的类型都是 'a typ。值名(例如 void)告诉你对应的 C 类型,而 'a 部分(例如 unit)是该 C 类型的 OCaml 表示。大多数映射都很直接,但其中一些需要稍作解释:
- Void 值在 OCaml 中表现为
unit类型。在参数或结果类型规格中使用void会产生接收或返回unit的 OCaml 函数。与 C 中一样,解引用指向void的指针是错误的,并会抛出IncompleteType异常。 - C 的
size_t类型是某个无符号整数类型的别名。size_t的实际大小和对齐要求在不同平台之间会变化。Ctypes 提供了一个 OCaml 的size_t类型,它会被别名到合适的整数类型。 - OCaml 只支持双精度浮点数,因此 C 的
float和double类型都会映射到 OCaml 的float类型,而 C 的float complex和double complex类型都会映射到 OCaml 双精度的Complex.t类型。
23.3 指针与数组(Pointers and Arrays)
指针是 C 的核心,因此它也必然是 Ctypes 的一部分。Ctypes 支持指针算术、指针转换、通过指针读写,以及在函数之间传入和返回指针。
我们已经在 Ncurses 示例中见过指针的简单用法。现在通过绑定下面几个 POSIX 函数,开始一个新示例:
time_t time(time_t *);
double difftime(time_t, time_t);
char *ctime(const time_t *timep);
time 函数返回当前日历时间,是一个简单的起点。第一步是打开一些 Ctypes 模块:
Ctypes
: Ctypes 模块提供在 OCaml 中描述 C 类型的函数。
PosixTypes
: PosixTypes 模块包含一些额外的 POSIX 特定类型,例如 time_t。
Foreign
: Foreign 模块暴露 foreign 函数,使调用 C 函数成为可能。
有了这些 open,就可以直接在顶层环境中创建 time 绑定。
# #require "ctypes-foreign";;
# #require "ctypes.top";;
# open Core;;
# open Ctypes;;
# open PosixTypes ;;
# open Foreign;;
# let time = foreign "time" (ptr time_t @-> returning time_t);;
val time : time_t Ctypes_static.ptr -> time_t = <fun>
foreign 函数是 OCaml 与 C 之间的主要连接点。它接收两个参数:要绑定的 C 函数名,以及一个描述被绑定函数类型的值。在 time 绑定中,函数类型指定一个 ptr time_t 类型的参数,并返回 time_t。
现在可以立即在同一个顶层环境中调用 time。它的参数其实是可选的,所以这里直接传入一个被强制转换为 time_t 空指针的 null 指针:
# let cur_time = time (from_voidp time_t null);;
val cur_time : time_t = <abstr>
由于接下来会多次调用 time,先创建一个包装函数来传入这个 null 指针:
# let time' () = time (from_voidp time_t null);;
val time' : unit -> time_t = <fun>
由于 time_t 是抽象类型,我们无法直接对它做任何有用的事情。需要绑定第二个函数,才能对 time 返回的值做有用操作。我们继续绑定原型列表中的第二个 C 函数 difftime:
# let difftime = foreign "difftime" (time_t @-> time_t @-> returning double);;
val difftime : time_t -> time_t -> float = <fun>
下面展示得到的 difftime 函数如何使用。
# #require "core_unix"
# let delta =
let t1 = time' () in
Core_unix.sleep 2;
let t2 = time' () in
difftime t2 t1;;
val delta : float = 2.
上面的 difftime 绑定已经足以比较两个 time_t 值。
23.3.1 为指针分配有类型的内存(Allocating Typed Memory for Pointers)
来看一个稍微不那么平凡的例子,其中会把非 null 指针传给函数。继续沿用前面的主题,绑定 ctime 函数;它会把一个 time_t 值转换为人类可读的字符串:
# let ctime = foreign "ctime" (ptr time_t @-> returning string);;
val ctime : time_t Ctypes_static.ptr -> string = <fun>
这个绑定继续在顶层环境中加入到我们不断增长的集合里。不过,我们不能直接把 time 的结果传给 ctime:
# ctime (time' ());;
Line 1, characters 7-17:
Error: This expression has type time_t but an expression was expected of type
time_t Ctypes_static.ptr = (time_t, [ `C ]) pointer
这是因为 ctime 需要的是指向 time_t 的指针,而不是按值传递的 time_t。因此,我们需要为 time_t 分配一些内存,并取得它的内存地址:
# let t_ptr = allocate time_t (time' ());;
...
allocate 函数接收要分配内存的类型和初始值,并返回一个类型合适的指针。现在可以把该指针作为参数传给 ctime:
# ctime t_ptr;;
...
23.3.2 使用视图映射复杂值(Using Views to Map Complex Values)
虽然标量类型通常有 1:1 的表示,其他 C 类型则需要额外工作才能转换进 OCaml。视图(view)会创建新的 C 类型描述,让它们在读写 C 值时具有特殊行为。
前面定义 ctime 时,我们已经用过一个视图。string 视图包装 C 类型 char *(在 OCaml 中写作 ptr char),并在每次读写值时,在 C 字符串表示和 OCaml 字符串表示之间转换。
下面是 Ctypes.view 函数的类型签名:
val view :
read:('a -> 'b) ->
write:('b -> 'a) ->
'a typ -> 'b typ
Ctypes 有一些内部低层转换函数,用于通过复制内容,在 OCaml string 和 C 字符缓冲区之间映射。它们具有下面的类型签名:
val string_of_char_ptr : char ptr -> string
val char_ptr_of_string : string -> char ptr
有了这些函数,使用视图的 Ctypes.string 值定义就很简单:
let string =
view (char ptr)
~read:string_of_char_ptr
~write:char_ptr_of_string
这个 string 函数的类型是普通的 typ,从外部看不出使用了 view 函数:
val string : string typ
注:OCaml 字符串与 C 字符缓冲区
从接口视角看,OCaml 字符串可能像 C 字符缓冲区,但它们的内存表示非常不同。
OCaml 字符串存放在 OCaml 堆中,并带有显式定义其长度的头部。C 缓冲区也是固定长度的,但按照惯例,C 字符串由 null(
\0字节)字符终止。C 字符串函数会通过扫描缓冲区直到遇到第一个 null 字符来计算长度。这意味着,把 OCaml 字符串传给 C 函数时,需要小心其中不能包含任何 null 值,因为第一次出现的 null 字符会被视为 C 字符串结尾。Ctypes 默认还会为字符串使用复制式接口,也就是说,当你希望库原地修改缓冲区时,不应该使用它们。在这种情况下,应改用 Ctypes 的
Bigarray支持,以按引用方式传递内存。
23.4 结构体与联合体(Structs and Unions)
C 中的 struct 和 union 构造可以基于已有类型构建新类型。Ctypes 中也有以类似方式工作的对应物。
23.4.1 定义结构体(Defining a Structure)
现在改进前面编写的计时器函数。POSIX 函数 gettimeofday 会以微秒分辨率获取时间。gettimeofday 的签名如下,其中包括结构体定义:
struct timeval {
long tv_sec;
long tv_usec;
};
int gettimeofday(struct timeval *, struct timezone *tv);
使用 Ctypes,我们可以在顶层环境中继续沿用前面的定义,如下描述这个类型:
# type timeval;;
type timeval
# let timeval : timeval structure typ = structure "timeval";;
val timeval : timeval structure typ =
Ctypes_static.Struct
{Ctypes_static.tag = "timeval";
spec = Ctypes_static.Incomplete {Ctypes_static.isize = 0}; fields = []}
第一条命令定义了一个新的 OCaml 类型 timeval,我们会用它实例化这个 struct 的 OCaml 版本。这是一个幻影类型(phantom type),只用于把底层 C 类型与其他指针类型区分开。这个特定的 timeval 结构体现在具有与其他地方定义的结构体不同的类型,这有助于避免把它们混在一起。
第二条命令调用 structure 来创建一个新的结构体类型。此时结构体类型是不完整的:我们可以添加字段,但还不能在 foreign 调用中使用它,也不能用它创建值。
23.4.2 向结构体添加字段(Adding Fields to Structures)
timeval 结构体定义仍然没有任何字段,因此接下来需要添加字段:
# let tv_sec = field timeval "tv_sec" long;;
val tv_sec : (Signed.long, timeval structure) field =
{Ctypes_static.ftype = Ctypes_static.Primitive Ctypes_primitive_types.Long;
foffset = 0; fname = "tv_sec"}
# let tv_usec = field timeval "tv_usec" long;;
val tv_usec : (Signed.long, timeval structure) field =
{Ctypes_static.ftype = Ctypes_static.Primitive Ctypes_primitive_types.Long;
foffset = 8; fname = "tv_usec"}
# seal timeval;;
- : unit = ()
field 函数会向结构体追加一个字段,如 tv_sec 和 tv_usec 所示。结构体字段是有类型的访问器,与特定结构体相关联,并对应 C 中的标签。
每次添加字段都会改变结构体变量,并记录一个新的大小,具体值取决于刚刚添加字段的类型。一旦对结构体执行 seal,就可以使用它创建值,但向已经 sealed 的结构体继续添加字段是错误的。
23.4.3 不完整结构体定义(Incomplete Structure Definitions)
由于 gettimeofday 需要一个 struct timezone 指针作为第二个参数,我们还需要定义第二个结构体类型:
# type timezone;;
type timezone
# let timezone : timezone structure typ = structure "timezone";;
val timezone : timezone structure typ =
Ctypes_static.Struct
{Ctypes_static.tag = "timezone";
spec = Ctypes_static.Incomplete {Ctypes_static.isize = 0}; fields = []}
我们从不需要创建 struct timezone 值,因此可以让这个 struct 保持不完整,不添加任何字段,也不 seal 它。如果你尝试在需要知道其具体大小的场景中使用它,库会抛出 IncompleteType 异常。
现在终于可以绑定 gettimeofday:
# let gettimeofday = foreign "gettimeofday" ~check_errno:true
(ptr timeval @-> ptr timezone @-> returning int);;
val gettimeofday :
timeval structure Ctypes_static.ptr ->
timezone structure Ctypes_static.ptr -> int = <fun>
这里还出现了一个新特性:returning_checking_errno 函数的行为类似 returning,但它会检查被绑定的 C 函数是否修改了 C 错误标志。对 errno 的修改会被映射成 OCaml 异常,并像标准库函数一样抛出 Unix.Unix_error 异常。
和前面一样,可以创建一个包装函数,让 gettimeofday 更容易使用。make、addr 和 getf 函数分别用于创建结构体值、获取结构体值地址,以及获取结构体字段的值。
# let gettimeofday' () =
let tv = make timeval in
ignore (gettimeofday (addr tv) (from_voidp timezone null) : int);
let secs = Signed.Long.to_int (getf tv tv_sec) in
let usecs = Signed.Long.to_int (getf tv tv_usec) in
Float.of_int secs +. Float.of_int usecs /. 1_000_000.0;;
val gettimeofday' : unit -> float = <fun>
现在可以调用这个函数获取当前时间。
# gettimeofday' ();;
- : float = 1650045389.278065
小结:打印时间的命令
前一节中构建了不少绑定,因此用一个完整示例来回顾它们,并把它们与命令行前端连接起来:
open Core
open Ctypes
open PosixTypes
open Foreign
let time = foreign "time" (ptr time_t @-> returning time_t)
let difftime =
foreign "difftime" (time_t @-> time_t @-> returning double)
let ctime = foreign "ctime" (ptr time_t @-> returning string)
type timeval
let timeval : timeval structure typ = structure "timeval"
let tv_sec = field timeval "tv_sec" long
let tv_usec = field timeval "tv_usec" long
let () = seal timeval
type timezone
let timezone : timezone structure typ = structure "timezone"
let gettimeofday =
foreign
"gettimeofday"
~check_errno:true
(ptr timeval @-> ptr timezone @-> returning int)
let time' () = time (from_voidp time_t null)
let gettimeofday' () =
let tv = make timeval in
ignore (gettimeofday (addr tv) (from_voidp timezone null) : int);
let secs = Signed.Long.to_int (getf tv tv_sec) in
let usecs = Signed.Long.to_int (getf tv tv_usec) in
Float.of_int secs +. (Float.of_int usecs /. 1_000_000.)
let float_time () = printf "%f%!\n" (gettimeofday' ())
let ascii_time () =
let t_ptr = allocate time_t (time' ()) in
printf "%s%!" (ctime t_ptr)
let () =
Command.basic
~summary:"Display the current time in various formats"
(let%map_open.Command human =
flag "-a" no_arg ~doc:" Human-readable output format"
in
if human then ascii_time else float_time)
|> Command_unix.run
可以按常规方式编译并运行它:
(executable
(name datetime)
(preprocess (pps ppx_jane))
(libraries core ctypes-foreign core_unix.command_unix))
$ dune build datetime.exe
$ ./_build/default/datetime.exe
1633964258.014484
$ ./_build/default/datetime.exe -a
Mon Oct 11 15:57:38 2021
注:为什么需要使用 returning?
细心的读者可能会好奇,为什么所有这些函数定义都必须以
returning终止:(* correct types *)val time: ptr time_t @-> returning time_tval difftime: time_t @-> time_t @-> returning double这里的
returning函数看起来似乎是多余的。为什么不能直接把类型写成下面这样?(* incorrect types *)val time: ptr time_t @-> time_tval difftime: time_t @-> time_t @-> double原因涉及更高阶类型,以及 OCaml 和 C 对函数处理方式的两个差异。函数在 OCaml 中是一等值,但在 C 中不是。例如,在 C 中可以从函数返回函数指针,但不能返回真正的函数。
其次,OCaml 函数通常以柯里化风格定义。两个参数函数的签名写作:
val curried : int -> int -> int但它真正的意思是:
val curried : int -> (int -> int)参数可以一次提供一个,从而创建闭包。相比之下,C 函数会一次性接收全部参数。等价的 C 函数类型如下:
int uncurried_C(int, int);参数也必须总是一起提供:
uncurried_C(3, 4);以柯里化风格编写的 C 函数则非常不同:
/* A function that accepts an int, and returns a functionpointer that accepts a second int and returns an int. */typedef int (function_t)(int);function_t *curried_C(int);/* supply both arguments */curried_C(3)(4);/* supply one argument at a time */function_t *f = curried_C(3); f(4);由 Ctypes 绑定时,
uncurried_C的 OCaml 类型是int -> int -> int,也就是一个双参数函数。由ctypes绑定时,curried_C的 OCaml 类型是int -> (int -> int),也就是一个单参数函数,它返回另一个单参数函数。在 OCaml 中,这些类型当然完全等价。由于 OCaml 类型相同,但 C 语义截然不同,我们需要某种标记来区分这些情况。这就是函数定义中
returning的目的。
23.4.4 定义数组(Defining Arrays)
C 中的数组是同一类型值的连续块。前面定义的任意基础类型都可以通过 Array 模块分配为块:
module Array : sig
type 'a t = 'a array
val get : 'a t -> int -> 'a
val set : 'a t -> int -> 'a -> unit
val of_list : 'a typ -> 'a list -> 'a t
val to_list : 'a t -> 'a list
val length : 'a t -> int
val start : 'a t -> 'a ptr
val from_ptr : 'a ptr -> int -> 'a t
val make : 'a typ -> ?initial:'a -> int -> 'a t
end
这些数组函数类似标准库 Array 模块中的函数,只不过它们操作的是使用扁平 C 表示存储的数组,而不是第 24 章“值的内存表示(Memory Representation of Values)”中描述的 OCaml 表示。
与标准 OCaml 数组一样,数组和列表之间的转换需要复制值,对于大型数据结构来说可能很昂贵。注意,你还可以把数组转换为指向底层缓冲区开头的 ptr 指针;如果需要把指针和大小参数分别传给 C 函数,这会很有用。
C 中的联合体是可以映射到同一底层内存上的命名结构体。Ctypes 也完全支持它们,不过这里不再展开。
23.4.4.1 用于解引用和算术的指针运算符(Pointer Operators for Dereferencing and Arithmetic)
Ctypes 定义了许多运算符,让你可以像在 C 中一样操作指针和数组。当然,Ctypes 的对应物有更强类型的好处。
!@ p会解引用指针p。p <-@ v会把值v写入地址p。- 如果
p指向一个数组元素,p +@ n会计算第n个后续元素的地址。 - 如果
p指向一个数组元素,p -@ n会计算第n个前序元素的地址。
还有其他有用的非运算符函数可用,例如指针差值和比较,请参阅 Ctypes 文档。
23.5 把函数传给 C(Passing Functions to C)
把 OCaml 函数值传给 C 也很直接。C 标准库函数 qsort 使用作为函数指针传入的比较函数,对元素数组排序。qsort 的签名如下:
void qsort(void *base, size_t nmemb, size_t size,
int(*compar)(const void *, const void *));
C 程序员经常使用 typedef 让涉及函数指针的类型定义更易读。使用 typedef 后,qsort 的类型看起来稍微顺眼一些:
typedef int(compare_t)(const void *, const void *);
void qsort(void *base, size_t nmemb, size_t size, compare_t *);
这也恰好能贴近对应的 Ctypes 定义。由于类型描述是普通值,我们可以直接用 let 代替 typedef,得到可工作的 qsort OCaml 绑定:
# open Core open Ctypes open PosixTypes open Foreign open Ctypes_static;;
# let compare_t = ptr void @-> ptr void @-> returning int;;
val compare_t : (unit Ctypes_static.ptr -> unit Ctypes_static.ptr -> int) fn =
Function (Pointer Void,
Function (Pointer Void, Returns (Primitive Ctypes_primitive_types.Int)))
# let qsort =
foreign "qsort"
(ptr void @-> size_t @-> size_t @-> funptr compare_t
@-> returning void);;
val qsort :
unit Ctypes_static.ptr ->
size_t ->
size_t -> (unit Ctypes_static.ptr -> unit Ctypes_static.ptr -> int) -> unit =
<fun>
我们只使用一次 compare_t(在 qsort 定义中),因此如果你愿意,也可以在 OCaml 代码中内联它。正如类型所示,得到的 qsort 值是高阶函数,因为第四个参数本身就是一个函数。
使用 Ctypes 创建的数组比 C 数组拥有更丰富的运行期结构,因此不需要到处传递大小信息。此外,可以用 OCaml 多态来替代不安全的 void ptr 类型。
23.5.1 示例:命令行快速排序(Example: A Command-Line Quicksort)
下面是一个命令行工具,它使用 qsort 绑定对标准输入中提供的所有整数排序:
open Core
open Ctypes
open PosixTypes
open Foreign
let compare_t = ptr void @-> ptr void @-> returning int
let qsort =
foreign
"qsort"
(ptr void
@-> size_t
@-> size_t
@-> funptr compare_t
@-> returning void)
let qsort' cmp arr =
let open Unsigned.Size_t in
let ty = CArray.element_type arr in
let len = of_int (CArray.length arr) in
let elsize = of_int (sizeof ty) in
let start = to_voidp (CArray.start arr) in
let compare l r = cmp !@(from_voidp ty l) !@(from_voidp ty r) in
qsort start len elsize compare
let sort_stdin () =
let array =
In_channel.input_line_exn In_channel.stdin
|> String.split ~on:' '
|> List.map ~f:int_of_string
|> CArray.of_list int
in
qsort' Int.compare array;
CArray.to_list array
|> List.map ~f:Int.to_string
|> String.concat ~sep:" "
|> print_endline
let () =
Command.basic_spec
~summary:"Sort integers on standard input"
Command.Spec.empty
sort_stdin
|> Command_unix.run
按常规方式用 dune 编译它,用一些输入数据测试它,并构建推断出的接口,以便更仔细地查看:
(executable
(name qsort)
(libraries core ctypes-foreign core_unix.command_unix))
$ echo 2 4 1 3 | dune exec ./qsort.exe
1 2 3 4
推断出的 mli 展示了原始 qsort 绑定和包装函数 qsort' 的类型。
$ ocaml-print-intf qsort.ml
val compare_t :
(unit Ctypes_static.ptr -> unit Ctypes_static.ptr -> int) Ctypes.fn
val qsort :
unit Ctypes_static.ptr ->
PosixTypes.size_t ->
PosixTypes.size_t ->
(unit Ctypes_static.ptr -> unit Ctypes_static.ptr -> int) -> unit
val qsort' : ('a -> 'a -> int) -> 'a Ctypes.CArray.t -> unit
val sort_stdin : unit -> unit
qsort' 包装函数比原始绑定具有更规范的 OCaml 接口。它接收一个比较器函数和一个 Ctypes 数组,并返回 unit。
使用 qsort' 给数组排序很直接。示例代码把标准输入读成列表,将其转换成 C 数组,把它传给 qsort,然后把结果输出到标准输出。再次提醒,不要把 Ctypes.Array 模块和 Core.Array 模块混淆:前者之所以在作用域内,是因为我们在文件开头打开了 Ctypes。
注:已分配 Ctypes 值的生命周期
通过 Ctypes 分配的值,例如使用
allocate、Array.make等分配的值,只要仍然可以从 OCaml 值访问到,就不会被垃圾回收。它们占用的系统内存在变得不可达时释放,释放通过向垃圾回收器(GC)注册的终结器函数完成。不过,Ctypes 值的可达性定义与普通 OCaml 值略有不同。分配函数会返回一个由 OCaml 管理的、指向该值的指针;只要某个派生指针仍然可被 GC 访问,该值就不会被回收。
“派生”指的是通过算术从原始指针计算出的指针,因此对数组元素或结构体字段的可达引用会保护整个对象不被回收。
前述规则的一个推论是,写入 C 堆中的指针不会影响可达性。例如,如果你有一个由 C 管理的结构体指针数组,那么还需要某种额外方式来保留这些结构体本身,防止它们被回收。可以在 OCaml 侧通过全局值数组做到这一点,让它们在不再需要之前保持存活。
传给 C 的函数也有类似的生命周期注意事项。在 OCaml 侧,运行期创建的函数在变得不可达时可能会被回收。正如我们所见,传给 C 的 OCaml 函数会被转换成函数指针,而写入 C 堆中的函数指针不会影响其引用的 OCaml 函数的可达性。对于
qsort来说事情很简单,因为比较函数只在调用qsort本身期间使用。不过,其他 C 库可能会把函数指针存储在全局变量或其他位置;这种情况下,你需要小心,确保传给它们的 OCaml 函数不会过早被垃圾回收。
23.6 进一步了解 C 绑定(Learning More About C Bindings)
Ctypes 发行版包含许多更大规模的示例,包括:
- 对 POSIX
ftsAPI 的绑定,它更全面地演示了 C 回调 - 比本章开头示例更完整的 Ncurses 绑定
- 覆盖完整库的综合测试套件,可以为你自己的绑定提供有用片段
本章其实并不要求你理解 OCaml 的内部细节。Ctypes 会尽最大努力让函数绑定变得容易,但本部分余下章节也会在第 24 章“值的内存表示(Memory Representation of Values)”和第 25 章“理解垃圾回收器(Understanding the Garbage Collector)”中补充与 OCaml 内存布局和自动内存管理交互有关的内容。
Ctypes 让 OCaml 程序能够访问值的 C 表示,使你免于处理 OCaml 值表示的细节,并引入了一个隐藏外部调用细节的抽象层。虽然这覆盖了很多情况,但有时仍需要看穿这层抽象,以便更细粒度地控制两种语言之间交互的细节。
你可以在几个地方找到关于 C 接口的更多信息:
- 标准 OCaml 外部函数接口允许你通过编写操作 OCaml 值表示的 C 函数,从边界的另一侧把 OCaml 和 C 粘合在一起。标准接口的细节可以在 OCaml 手册以及Developing Applications with Objective Caml一书中找到。
- Florent Monnier 维护了一个优秀的在线教程,提供如何从 C 调用 OCaml 函数的示例。它覆盖了很多种 OCaml 数据类型,以及 C 和 OCaml 之间更复杂的回调。
23.6.1 结构体内存布局(Struct Memory Layout)
C 语言允许实现方在如何把 struct 布局到内存中时拥有一定自由度。为了满足宿主平台的内存对齐要求,成员之间和 struct 末尾可能存在填充。Ctypes 使用适合平台的大小和对齐信息来复制 struct 布局过程。只要你按照与正在绑定的 C 库相同的顺序、相同的类型声明 struct 字段,OCaml 和 C 就会对 struct 布局有一致理解。
不过,当库接口中没有完整指定 struct 字段时,这种方法可能会带来困难。接口可能列出结构体字段但不指定顺序,或者只在某些平台上提供某些字段,又或者出于性能原因在 struct 定义中插入未文档化字段。例如,本章使用的 struct timeval 定义准确描述了常见平台上的 struct 布局,但在一些更罕见架构上,实现会包含额外填充成员,这会导致示例出现奇怪行为。
Ctypes 的 Cstubs 子包解决了这个问题。它不会简单假设用户给出的 struct 定义准确反映 C 库中使用的 struct 实际定义,而是生成代码,使用 C 库头文件发现 struct 布局。好消息是,你编写的代码不需要做太大改动。Cstubs 为你已经用来描述 struct timeval 的 field 和 seal 函数提供了替代实现;这些实现不会计算适合平台的成员偏移和大小,而是直接从 C 获取它们。
使用 Cstubs 的细节可以在在线文档中找到,其中也包括与 autoconf 平台可移植性指令集成的说明。