到目前为止,这套课程已经覆盖:

  • 函数式思维与 Kotlin 对照;
  • 类型推导、ADT 与模式匹配;
  • 柯里化、组合与错误上下文;
  • module、signature 与 Functor;
  • polymorphic variant、GADT 与存在类型;
  • 结构化对象系统;
  • 可变状态、异常与资源安全;
  • OCaml 5 effects、Domains 与并发;
  • 运行时、GC 和性能;
  • opam、Dune、测试、文档与 PPX。

最后还差系统边界。真实项目总会遇到 C library、操作系统 API、连续内存、动态链接、容器和发布环境。FFI 也是最容易让语言内部安全保证失效的地方,因此不能只展示一句 external 就结束。

external 声明连接 OCaml 与 C

OCaml 侧可以声明一个外部函数:

OCaml
external add_int :
  int -> int -> int
  = "caml_add_int"

C 侧导出对应符号:

C
#include <caml/mlvalues.h>

CAMLprim value
caml_add_int(value left, value right)
{
  return Val_long(
    Long_val(left) + Long_val(right)
  );
}

value 是 OCaml 运行时值表示。普通 OCaml int 使用 tagged immediate,因此需要 Long_val 解码、Val_long 编码。

这段函数不分配 OCaml 堆对象,也不调用回 OCaml,因此非常简单。一旦涉及字符串、tuple、异常、回调或分配,就必须遵守 GC root 和运行时锁规则。

Dune 编译 foreign stubs

LISP
(library
 (name native_math)
 (foreign_stubs
  (language c)
  (names add_stubs)))

add_stubs.c 位于同一目录,Dune 会调用 C compiler,并把对象文件链接进 library。

依赖外部系统库时还要提供 include path 与 link flags。优先使用 pkg-config、Dune configurator 或依赖包已有的 discovery 机制,不要把 /usr/local/lib 等机器路径写死在项目里。

GC root:C 局部变量不是自动可追踪的

如果 C stub 可能触发 OCaml 分配,GC 可能移动 minor heap 中的对象。C 栈上的裸 value 变量如果没有注册为 root,移动后会留下失效地址。

典型 stub:

C
#include <caml/alloc.h>
#include <caml/memory.h>
#include <caml/mlvalues.h>

CAMLprim value
caml_make_pair(value left, value right)
{
  CAMLparam2(left, right);
  CAMLlocal1(pair);

  pair = caml_alloc(2, 0);
  Store_field(pair, 0, left);
  Store_field(pair, 1, right);

  CAMLreturn(pair);
}

关键宏:

  • CAMLparam* 注册传入的 OCaml value;
  • CAMLlocal* 注册本地 value;
  • CAMLreturn 在返回前解除 root frame;
  • Store_field 执行正确写入与必要屏障。

只要代码路径可能分配,就不能省略 root 管理。包括:

  • caml_alloc
  • 构造 OCaml string;
  • 调用 OCaml closure;
  • 抛 OCaml exception;
  • 某些运行时 helper。

FFI bug 往往不会立即崩溃,而是在特定 GC 时机、优化级别或并行负载下表现为随机内存损坏。

string 与 bytes 不是普通 C 缓冲区

取得 string 数据时必须同时取得长度,不能假设 NUL 结尾,也不能把任意二进制内容当 C string。

OCaml string 在语言层不可变。C stub 不应绕过这一语义修改其内容。需要可写缓冲时使用 bytes、Bigarray 或 C 自己拥有的内存,并明确复制与生命周期。

把 C 返回的 char * 转为 OCaml string 时还要决定:

  • 谁拥有原指针;
  • 何时释放;
  • 是否包含 NUL;
  • 编码是什么;
  • 长度是否可信;
  • 分配失败如何传播。

Unicode 不是由 FFI 自动解决的。OCaml string 是字节序列,文本编码属于应用协议。

Bigarray:共享连续数值内存

Bigarray 为多维、固定元素类型的连续数据提供表示,适合与 C/Fortran、mmap 和数值库互操作。

OCaml:

OCaml
open Bigarray

let values =
  Array1.create
    float64
    c_layout
    1024

C 可以通过 Bigarray API 取得数据指针和维度,而不必把每个 float 装箱进普通 OCaml array。

适合场景:

  • 图像、音频和张量;
  • 网络或文件的大块二进制数据;
  • BLAS、压缩、加密等 native library;
  • memory-mapped file;
  • 零拷贝或少拷贝协议边界。

零拷贝仍有所有权

“能取得同一指针”不代表生命周期自动安全。必须明确:

  • Bigarray 是否拥有底层内存;
  • C 是否会在 OCaml 值回收后继续保存指针;
  • 多 Domain 是否同时读写;
  • 外部库是否异步使用 buffer;
  • shape、stride、alignment 与 endianness 是否一致;
  • finalizer 是否只是最后保险。

若 C 在调用返回后继续持有指针,应建立显式 handle/close 协议,或确保 OCaml root 在整个 native 生命周期保持可达。

custom block:把 native handle 包进 GC

文件描述符、数据库 handle 或 C struct 指针可以包装进 custom block,并配置 finalize、compare、hash 等操作。

但 finalizer 不能替代显式关闭。稳妥接口通常同时提供:

OCaml
type t

val open_resource :
  string -> t

val close :
  t -> unit

val with_resource :
  string ->
  (t -> 'a) ->
  'a

with_resourceFun.protect 保证确定性释放,custom finalizer 只处理遗漏关闭的兜底。

还应定义重复 close 的语义,避免 double free;常见做法是在 custom state 中记录 closed 标记,或让 native API 本身支持幂等释放。

阻塞 C 调用必须释放运行时

一个长时间阻塞的 C 函数若一直占用 OCaml runtime,会阻止同一执行上下文中的其他 OCaml 工作,并破坏并发调度预期。

C stub 可以在进入不会访问 OCaml heap 的阻塞区前释放运行时,再在返回前重新获取。

概念结构(假设 handle 存在 custom block 中):

C
#include <caml/custom.h>
#include <caml/fail.h>
#include <caml/memory.h>
#include <caml/threads.h>

#define Handle_val(v) \
  (*((native_handle **) Data_custom_val(v)))

CAMLprim value
caml_blocking_read(value handle)
{
  CAMLparam1(handle);

  native_handle *h =
    Handle_val(handle);

  caml_release_runtime_system();
  int result = blocking_read(h);
  caml_acquire_runtime_system();

  if (result < 0) {
    caml_failwith("read failed");
  }

  CAMLreturn(Val_int(result));
}

实际 API 名称与适用方式应依据目标 OCaml 版本的 C interface manual。进入释放区后必须遵守严格规则:

  • 不访问或分配 OCaml heap;
  • 不调用需要 runtime 的 OCaml API;
  • 确保 native 指针生命周期稳定;
  • 重新获得 runtime 后才构造返回值或抛异常;
  • 考虑取消、信号与 EINTR;
  • C library 自身必须线程安全。

effect runtime 不能自动异步化任意 C 调用

Eio 或其他 fiber runtime 能调度已适配的非阻塞 I/O,但一个普通 blocking C call 不会因为位于 fiber 中自动变成异步。它可能占住整个 Domain。

常见方案:

  • 使用 native non-blocking API 并注册 event loop;
  • 把阻塞调用放入有界线程池;
  • 使用库已有的 Eio/Lwt/Async adapter;
  • 将长 CPU 调用送到 Domain pool;
  • 提供明确的取消与超时边界。

C 回调 OCaml

C 可以通过已注册 closure 回调 OCaml,但必须保证:

  • 当前线程/Domain 正确注册并持有 runtime;
  • closure 被注册为 global root,不会被 GC 回收;
  • 参数和返回值遵守 root 规则;
  • OCaml exception 不会越过 C ABI 未处理;
  • 回调不会在外部锁持有期间造成死锁;
  • 关闭资源后 C 不再使用旧 callback。

回调生命周期通常比一次普通 stub 更难。优先让调用保持“OCaml 调 C 并同步返回”;确实需要长期 callback 时,用独立 handle 模块集中管理注册和注销。

ctypes:用 OCaml 描述 C 接口

除手写 stub 外,ctypes 允许在 OCaml 中声明 C 类型和函数绑定,并可动态调用或生成 stub。

优点:

  • 减少手写 value 转换;
  • 接口描述集中;
  • 对常见 struct、pointer、function binding 更直观;
  • 可以生成 C 边界代码。

代价:

  • 复杂 ownership 仍需人工建模;
  • callback、union、bit field 与平台 ABI 仍可能困难;
  • 构建过程增加生成阶段;
  • 错误不会因为使用 ctypes 自动消失;
  • 性能敏感路径仍需测量调用开销与复制。

选择 ctypes 还是手写 stub,应根据 API 规模、性能、平台差异和团队能力,而不是把一个视为绝对安全。

与 Rust 互操作

OCaml 可以通过 C ABI 调用 Rust 导出的函数:

Rust
#[unsafe(no_mangle)]
pub extern "C" fn add_int(
    left: i64,
    right: i64,
) -> i64 {
    left + right
}

中间仍需要 OCaml C stub 或 ctypes,把 OCaml value 转成固定 ABI 类型。

Rust 侧必须遵守:

  • panic 不得越过 C ABI;
  • 所有权转移写进明确 create/free 函数;
  • slice 同时传指针与长度;
  • 不让 Rust reference 跨越无证明生命周期;
  • callback 线程与 runtime 注册正确;
  • 错误使用 status code 或显式 result struct;
  • ABI struct 使用 #[repr(C)]
  • allocator 边界一致:谁分配,谁释放。

Rust 能保证 Rust 内部的内存安全,不能证明 OCaml stub 和 C ABI glue 正确。跨语言边界应保持窄、批量、可测试,避免每个元素往返调用。

unboxed 与 untagged FFI

OCaml 支持在特定 external 声明中使用 unboxed/untagged 等属性,减少数字在 FFI 边界的装箱与 tag 转换。

它们属于高级优化:

  • C 函数签名必须与声明完全对应;
  • bytecode 和 native wrapper 可能需要分别提供;
  • 错误 ABI 会直接导致未定义行为;
  • 收益只有在高频边界才明显;
  • 升级编译器时应重新验证。

默认先用正常 value ABI 保证正确,再通过 benchmark 决定是否需要下潜。

异常不能穿过 C ABI

C 不理解 OCaml exception,Rust/C++ 也不应让自身 unwind 越过 OCaml runtime 边界。

每个边界应选择稳定错误协议:

  • 返回 status code;
  • 返回 result-like struct;
  • OCaml stub 在重新持有 runtime 后转换为 exception;
  • 或构造 result 返回给 OCaml。

不要让 C++ exception、Rust panic 与 OCaml exception 相互穿透。跨语言 unwind 通常没有可移植保证。

链接与部署

native executable 仍可能动态依赖:

  • libc;
  • OpenSSL;
  • libffi;
  • 数据库 client;
  • 压缩库;
  • 自定义 C/Rust shared library。

发布前检查:

Shell
ldd ./order-service

macOS 使用:

Shell
otool -L ./order-service

还要确认:

  • 构建与运行系统的 libc/ABI;
  • rpath 与 shared library 搜索路径;
  • CA certificate;
  • timezone database;
  • locale 与编码;
  • 动态加载插件;
  • CPU 指令集最低要求;
  • Domain 数量和容器 CPU quota;
  • GC/runtime 环境变量。

“编译成原生二进制”不等于天然单文件、完全静态或随处可运行。

容器发布

多阶段构建可以把 opam/Dune 工具链留在 builder image,只复制 executable、必要 shared library 和配置到 runtime image。

但过度精简会漏掉:

  • 动态链接器;
  • TLS 根证书;
  • DNS/NSS 组件;
  • timezone;
  • native dependency;
  • 调试符号或 symbol map;
  • 健康检查工具。

发布镜像应在与生产一致的网络、证书和资源限制下运行集成测试,而不是只执行 --help

交叉编译

OCaml compiler、Dune、opam package 与 C dependency 都要支持目标 triple。含 PPX 的项目还有“构建机上运行的预处理器”和“目标机上运行的程序”两套产物,交叉编译比纯 C 小项目更复杂。

需要跨平台发布时,应优先建立目标平台 CI runner 或容器化原生构建,再评估真正 cross compilation。不要等到发布前才发现某个 opam package 只有 host build 假设。

配置、信号与退出

一个可部署服务至少要处理:

  • 配置解析失败;
  • SIGTERM 优雅关闭;
  • 停止接收新请求;
  • 取消 fiber 与等待子任务;
  • 刷新日志与 metrics;
  • 关闭 socket、连接池和文件;
  • 设置明确退出码;
  • 对强制超时保留兜底。

at_exit 可以执行进程正常退出时的回调,但不能覆盖 SIGKILL、崩溃或宿主直接终止。关键数据不能只依赖退出钩子刷新。

可观测性

生产 OCaml 服务需要与其他语言相同的三类信号:

  • logs:结构化字段、request id、exception 与 backtrace;
  • metrics:吞吐、错误、延迟、Domain/fiber/queue、heap 与 GC;
  • traces:跨 HTTP、数据库和消息系统传播 context。

需要额外关注:

  • minor/major allocation;
  • promotion 和 major heap;
  • compaction;
  • Domain utilization;
  • scheduler queue;
  • blocking section;
  • fiber cancellation;
  • FFI 调用耗时。

如果只看 CPU 和 RSS,很难区分业务对象保留、GC 配置、阻塞 native call 与调度器饥饿。

安全边界

FFI 与序列化会绕过许多静态保证。应对外部输入执行:

  • 长度和整数溢出检查;
  • UTF-8/编码验证;
  • 指针与 buffer 生命周期检查;
  • enum/tag 合法性检查;
  • 反序列化 schema 验证;
  • 超时与资源上限;
  • fuzz testing;
  • sanitizer 构建;
  • dependency/security advisory 跟踪。

尤其不要对不可信数据直接使用 Marshal。长期协议应使用明确 schema,并为未知字段、版本迁移和拒绝服务设计边界。

OCaml 真正适合什么

编译器、解释器与静态分析

ADT、模式匹配、GADT、递归结构和模块系统与 AST/IR 天然契合。类型可以表达 pass 前后结构与语言不变量。

规则密集的领域核心

金融、交易、权限、配置求解和协议状态机需要让非法状态难以表示,并对分支变化进行穷尽审计。

对正确性和迭代速度都有要求的 native service

OCaml 提供强静态类型、快速编译、原生发布和 GC 生产率。对内存上限与尾延迟要求不是硬实时的服务,它能在开发效率与性能间取得好平衡。

程序验证与形式化工具周边

OCaml 长期用于证明助手、模型工具和语言基础设施。其类型/模块能力适合表达符号结构,而 C FFI 又能连接成熟 native library。

数据转换与协议实现

不可变数据、parser combinator、result pipeline 与模式匹配适合构建明确的输入验证和转换阶段。

OCaml 不适合什么

主流 Android/JVM 企业生态替代

如果项目核心依赖 Spring、Android SDK、JVM 中间件和公司既有 Kotlin 能力,OCaml 的语言优势通常不足以抵消生态与招聘成本。

硬实时或严格无 GC 系统

OCaml 可以做低延迟优化,但 GC、运行时和不可预测外部调用使它不适合作为硬实时默认选择。Rust/C/Ada 等拥有更直接资源模型。

内核、驱动和极小裸机

OCaml 运行时、GC 与平台支持不是这些场景的自然中心。

以成熟 GUI/移动框架为核心的产品

OCaml 有相关项目和跨语言方案,但生态深度无法与 Swift、Kotlin、C# 或 Web 技术相比。

团队只想使用“更短语法”

若不愿采用 ADT、模块边界、不可变核心和类型驱动设计,最终只会用更小众的语言写命令式业务,承担生态成本却没有获得主要收益。

与 Kotlin、Rust 的最终定位

维度 Kotlin OCaml Rust
默认组织 对象 + 函数式能力 数据 + 函数 + 模块 所有权类型 + trait
运行时 JVM/ART/Native 等 GC native/bytecode runtime 无必需 GC runtime
类型强项 空安全、sealed、Receiver、协程 HM 推导、ADT、module、GADT ownership、lifetime、trait
并发 coroutine 生态成熟 OCaml 5 effects + 多种 runtime async ecosystem + Send/Sync
资源 GC + use/finally GC + Fun.protect RAII + ownership
系统边界 JNI/FFM/Native C FFI/ctypes/Bigarray C ABI/native
最强生态 JVM/Android/服务端 编译器、形式化、特定金融与工具 系统、基础设施、安全
主要代价 JVM 与框架厚度 生态、岗位与高级类型门槛 学习曲线与建模成本

如果你认为 Kotlin 最优雅,OCaml 不会简单推翻这个判断。Kotlin 的优雅在于把现实工程能力压进一致、可发现的语法;OCaml 的优雅在于用更小的核心,把数据关系交给类型推导和模块系统。

Rust 则把 OCaml/Kotlin 仍交给 GC 与纪律的资源关系继续提升到类型层。三者不是一条从低到高的直线,而是把复杂性放在不同位置。

一套完整的生产检查清单

语言与建模

  • 互斥状态使用 variant;
  • 缺失、领域错误和异常职责清楚;
  • mutable state 限制在模块边界;
  • GADT/Functor 只在确有类型关系时使用;
  • object 只服务运行时动态分派。

并发与运行时

  • 区分 fiber、Thread 与 Domain;
  • CPU 任务进入有界并行池;
  • 阻塞 FFI 不占住调度 Domain;
  • 共享可变状态有 Atomic/Mutex 协议;
  • 取消、异常和资源生命周期结构化;
  • 真实负载下测 GC 与尾延迟。

FFI

  • 每个可能分配的 C stub 正确注册 root;
  • 字符串、buffer、指针和 callback 所有权明确;
  • 外部 unwind 不穿过 ABI;
  • custom block 有显式 close;
  • Bigarray 生命周期覆盖 native 使用;
  • sanitizer 与 fuzz test 覆盖边界。

构建与发布

  • opam 依赖和 compiler 版本可重现;
  • Dune build/test/fmt/doc 全部进入 CI;
  • 动态库、证书、timezone 和 ABI 已检查;
  • SIGTERM 与退出清理已测试;
  • metrics/log/trace 覆盖 runtime 和 scheduler;
  • 发布物在干净目标环境验证。

课程到这里结束

这套通用 OCaml 课程已经形成闭环:

  1. 思维模型;
  2. 类型系统;
  3. 函数组合;
  4. 模块系统;
  5. 高级类型;
  6. 对象系统;
  7. 状态、错误与资源;
  8. effects、并发与并行;
  9. 运行时与性能;
  10. 工具链与测试;
  11. FFI、部署与选型。

继续增加“基础章节”只会重复或把垂直领域混进语言主线。之后值得写的内容应独立成专题,例如:

  • 编译器前端与 typed AST;
  • parser combinator 与增量解析;
  • Eio 网络服务实战;
  • 金融领域建模;
  • Coq/Lean/Why3 周边工具;
  • OCaml 与 Rust 混合工程;
  • 高性能序列化与协议栈。

这些都不是理解 OCaml 所必需的下一章,而是选定方向后的深入应用。

结论:选择 OCaml,就是选择问题的形状

OCaml 最经典的价值不是某个孤立特性,而是一套一致关系:

  • 类型推导保持实现简洁且精确;
  • ADT 与模式匹配让状态变化可审计;
  • 函数组合让数据流显式;
  • module/signature/Functor 管理大型类型关系;
  • object 为真正需要动态分派的问题保留出口;
  • effects 与 Domains 把并发和并行带入现代运行时;
  • GC 和 native compiler 在生产率与性能之间折中;
  • opam/Dune/PPX/测试构成工程平台;
  • C FFI 与 Bigarray 连接系统世界。

它不会取代 Kotlin 的生态和工具体验,也不会取代 Rust 对资源安全的静态控制。但在“复杂数据模型、强类型、原生执行、允许 GC”这个交叉区域,OCaml 仍然拥有非常独特的表达密度。

课程在此结束,不是因为 OCaml 已无内容可写,而是因为继续前进已经不再是学习语言本身,而是在选择用它解决哪一种真实问题。

延伸阅读