OCaml 常被介绍成“一门静态类型的函数式语言”。这句话没有错,却很容易让人以为它只是把 Kotlin 的 fundata classwhen 换了一套写法。

真正的差别更深:Kotlin 通常从对象、职责与生命周期出发组织程序,函数式能力被嵌入这套模型;OCaml 更习惯从数据的形状、允许的状态与状态之间的变换出发,让类型推导和模式匹配检查这些变换是否完整。

这并不意味着 OCaml 没有对象、异常或可变状态。它们都存在,只是不再占据语言的中心。学习 OCaml 最有价值的部分,也不是记住 letmatch,而是重新思考:一个问题究竟应该被建模成对象之间的协作,还是一组封闭数据上的变换。

OCaml 是什么

OCaml 属于 ML 语言家族,继承了 ML 最经典的一组设计:

  • 静态类型与强大的局部类型推导;
  • 代数数据类型与穷尽模式匹配;
  • 函数是一等值,并默认支持柯里化;
  • 数据默认不可变,但允许受控使用可变状态;
  • 参数化多态通常不需要显式声明类型参数;
  • 模块、签名与 Functor 组成独立于对象系统的抽象层;
  • 编译为原生代码,也可生成字节码运行。

它是一门多范式语言。除了函数式核心,OCaml 也提供命令式循环、引用、可变字段、数组、异常和对象系统。不过,与其问“OCaml 能不能像 Kotlin 那样写”,更重要的问题是:哪些需求根本不需要先引入 class、interface 和对象身份?

第一处思维切换:一切从表达式开始

在 Kotlin 中,ifwhen 已经是表达式,因此这一点并不陌生:

Kotlin
val label = if (score >= 60) "pass" else "fail"

OCaml 把这种思路贯彻得更彻底。函数体、条件分支、代码块和模式匹配都通过求值得到结果:

OCaml
let label score =
  if score >= 60 then "pass" else "fail"

这里没有显式 return,也通常不写返回类型。最后一个表达式的值就是函数结果,编译器从比较、字面量和分支中推导出:

TEXT
val label : int -> string

这不只是省略类型标注。它会推动代码自然地写成“输入经过若干变换得到输出”,而不是“创建容器、逐步修改变量、最后取出结果”。

例如,Kotlin 常见的集合管道:

Kotlin
val total = orders
    .filter { it.paid }
    .sumOf { it.amount }

在 OCaml 中同样可以写成管道,但数据流方向由 |> 明确表达:

OCaml
let paid_total orders =
  orders
  |> List.filter (fun order -> order.paid)
  |> List.fold_left (fun total order -> total + order.amount) 0

OCaml 并没有垄断这种风格;Kotlin 的标准库也很擅长集合变换。区别在于,Kotlin 允许开发者随时退回面向对象和可变集合,而 OCaml 的核心库、类型系统和惯用 API 会持续把代码推向组合与变换。

类型推导:不是动态类型,也不是少写几个冒号

OCaml 使用 Hindley–Milner 风格的类型推导。局部代码通常不需要标注类型,但每个表达式仍然拥有确定的静态类型。

OCaml
let twice f value =
  f (f value)

编译器可以推导出:

TEXT
val twice : ('a -> 'a) -> 'a -> 'a

'a 是类型变量。这个签名表示:只要 f 接受某种类型并返回同一种类型,twice 就能作用于该类型。它可以处理整数、字符串,也可以处理用户定义的数据,而函数本身不需要声明泛型参数。

对应的 Kotlin 写法需要显式引入类型参数:

Kotlin
fun <T> twice(f: (T) -> T, value: T): T =
    f(f(value))

Kotlin 也有优秀的局部推导,但公共函数的参数类型必须明确,泛型关系通常由开发者写出。OCaml 更接近“先写出关系,再由编译器求解最一般的类型”。

这种能力带来一种很特别的反馈方式:当实现被意外写窄,推导出的签名会直接暴露问题。类型不是附在实现外面的说明,而是实现结构的计算结果。

但类型推导不是魔法。可变状态、对象、子类型和某些高阶组合会让推导复杂化;OCaml 还有著名的 value restriction,用来避免多态与可变状态组合后破坏类型安全。后续理解高级类型错误时,不能只依赖“编译器应该猜得到”。

代数数据类型:先定义所有可能,再写行为

Kotlin 开发者通常使用 enum classdata classsealed interface 表达状态:

Kotlin
sealed interface LoadState {
    data object Idle : LoadState
    data object Loading : LoadState
    data class Ready(val content: String) : LoadState
    data class Failed(val reason: String) : LoadState
}

OCaml 的 variant 直接表达同一个“和类型”:

OCaml
type load_state =
  | Idle
  | Loading
  | Ready of string
  | Failed of string

每个构造器既标识分支,也可以携带该分支独有的数据。状态和数据被放在同一个封闭定义中,因此不需要用多个 nullable 字段和布尔值维持约定。

处理它时使用模式匹配:

OCaml
let render = function
  | Idle -> "尚未加载"
  | Loading -> "加载中"
  | Ready content -> content
  | Failed reason -> "失败:" ^ reason

如果漏掉 Failed,编译器会给出非穷尽匹配警告;如果以后增加 Cancelled,需要理解全部状态的匹配点也会暴露出来。

Kotlin 的 sealed hierarchy 加 when 已经相当接近这一模型,但仍有三点差异:

  1. OCaml 的 variant 是语言最基础的数据建模工具,不是从 class hierarchy 收缩而来的特殊用法;
  2. 构造器通常比类更轻,不需要为每个状态声明对象层级;
  3. 模式匹配不仅匹配类型,还能同时解构 tuple、record、list 与嵌套 variant。
OCaml
type user = {
  id : int;
  name : string;
}

type response =
  | Ok of user
  | Error of int * string

let message = function
  | Ok { name; _ } -> "你好," ^ name
  | Error (404, _) -> "用户不存在"
  | Error (code, reason) ->
      Printf.sprintf "错误 %d:%s" code reason

思维上的关键变化是:不要先问“这个行为属于哪个类”,而要先问“输入一共有哪几种形状,每种形状应该产生什么结果”。

optionresult:缺失和失败都是普通数据

Kotlin 将可空性直接放进类型系统,String? 比 Java 的裸 null 安全得多。OCaml 通常不用特殊的可空引用表示缺失,而是使用普通 variant:

OCaml
type 'a option =
  | None
  | Some of 'a

例如:

OCaml
let find_name id users =
  users
  |> List.find_opt (fun user -> user.id = id)
  |> Option.map (fun user -> user.name)

Kotlin 的相近写法是:

Kotlin
fun findName(id: Int, users: List<User>): String? =
    users.find { it.id == id }?.name

两者都能让调用方看到“可能没有值”。差别在于,Kotlin 为 nullable 提供了 ?.?: 等专用语法;OCaml 把缺失视为一个普通代数数据类型,因此使用模式匹配或 Option.mapOption.bind 组合。

可能失败且需要携带错误时,result 比异常更适合进入类型签名:

OCaml
type create_error =
  | Empty_name
  | Duplicate_id of int

let create_user users id name =
  if String.trim name = "" then
    Error Empty_name
  else if List.exists (fun user -> user.id = id) users then
    Error (Duplicate_id id)
  else
    Ok { id; name }

这与 Kotlin 中显式定义 sealed error、返回 Result 风格容器的目标相同。只是 Kotlin 的标准 Result<T> 固定封装 Throwable,业务错误通常还要自己建立类型;OCaml 的 ('value, 'error) result 同时参数化成功和失败,可以直接保留领域错误的精确类型。

OCaml 仍然支持异常,标准库和现实工程也会使用异常。函数式编程并不要求消灭异常,而是区分:哪些失败属于可预期的领域结果,哪些失败表示无法在当前层恢复的异常路径。

不可变不是禁令,而是默认方向

OCaml 的 let 绑定本身不可重新赋值:

OCaml
let count = 1
(* count <- 2  不成立:count 不是可变位置 *)

更新 record 时,惯用方式是基于旧值构造新值:

OCaml
type account = {
  id : int;
  balance : int;
}

let deposit amount account =
  { account with balance = account.balance + amount }

Kotlin 的 val 只保证引用不能重新指向别处,不保证对象内部不可变:

Kotlin
val account = MutableAccount(1, 100)
account.balance += 50

即使使用 data classcopy,Kotlin 集合、Java 互操作对象和普通 class 仍经常带来内部可变性。OCaml 也允许 mutable record 字段、ref、数组和哈希表,但可变性需要被明确标出:

OCaml
type counter = {
  mutable value : int;
}

let increment counter =
  counter.value <- counter.value + 1

因此,OCaml 的目标不是“绝不修改内存”,而是让修改集中在可识别的边界内。纯函数负责大部分规则,可变状态用于性能热点、I/O、缓存或确实具有身份的实体。这样更容易局部推理,也更容易测试。

函数是一等值,柯里化是默认调用模型

Kotlin 支持高阶函数、Receiver、inline lambda 和优秀的 DSL,这部分对 OCaml 并不陌生。真正需要适应的是:OCaml 的多参数函数默认被理解为连续接收一个参数的函数。

OCaml
let add x y = x + y

它的类型是:

TEXT
val add : int -> int -> int

箭头向右结合,即 int -> (int -> int)。传入一个参数会得到新函数:

OCaml
let add_tax = add 10
let final_price = add_tax 90

这就是偏应用。Kotlin 当然也能返回函数,但需要更明确地写出函数类型和 lambda:

Kotlin
fun add(x: Int): (Int) -> Int = { y -> x + y }

val addTax = add(10)
val finalPrice = addTax(90)

自动柯里化让小函数组合非常自然,也解释了 OCaml API 为什么经常把最容易固定的配置参数放在前面,把最后才流入的数据放在后面。

同时,OCaml 提供 labeled argument 和 optional argument,避免长参数列表完全依赖位置:

OCaml
let connect ~host ~port ?(tls = true) () =
  (host, port, tls)

let endpoint =
  connect ~host:"ready-jump.top" ~port:443 ()

末尾的 unit 参数 () 常用于确定可选参数何时结束,也用于表示函数主要为了执行效果,而不是消费有意义的输入。

递归与列表:描述结构,而不是控制游标

命令式代码倾向于把循环理解为“维护一个索引并不断修改累加器”。函数式代码更常让函数结构对应数据结构。

OCaml
let rec sum = function
  | [] -> 0
  | head :: tail -> head + sum tail

列表只有两种形状:空列表 [],或由头部与剩余列表组成的 head :: tail。函数恰好覆盖这两种形状。

不过,这个版本不是尾递归。长列表可能消耗大量调用栈;工程代码会使用尾递归累加器或标准库函数:

OCaml
let sum values =
  List.fold_left ( + ) 0 values

这展示了 OCaml 思维的重要边界:递归很自然,但“自然”不等于自动高效。要理解尾调用、分配、持久化数据结构和标准库实现,而不能把函数式写法当成没有成本的数学记号。

模块系统:抽象不等于继承

Kotlin 的主要抽象单位是 class、interface、object 与 package。OCaml 还有一套独立而强大的模块语言。

OCaml
module type STORE = sig
  type key
  type 'a t

  val empty : 'a t
  val put : key -> 'a -> 'a t -> 'a t
  val get : key -> 'a t -> 'a option
end

module type 是签名,描述模块公开的类型与值。具体模块可以隐藏内部表示,只暴露满足签名的能力。抽象类型 key 使调用者无法依赖其真实表示。

Functor 则是“接收模块并产生模块”的模块级函数,可用于根据一组能力生成新的实现。它和 Kotlin 的泛型类、依赖注入都能解决部分相似问题,但所在层级不同:

  • Kotlin 泛型主要参数化值和类型,运行时对象仍是核心;
  • OCaml Functor 参数化整个模块,包括类型、值和不变量;
  • Kotlin 常通过 interface 加构造器注入实现;
  • OCaml 可以在编译期组合模块,并通过签名隐藏表示。

Functor 不是每段 OCaml 代码都必须使用的高级技巧,却揭示了语言的抽象观:复用未必需要继承,对象也不是封装的唯一载体。

与 Kotlin 的核心差异

维度 Kotlin OCaml
默认组织单位 class、interface、对象与扩展函数 类型、函数与模块
类型推导 局部表达式很强,公共参数通常显式 能推导函数的多态签名与类型关系
数据建模 data class、enum、sealed hierarchy record、tuple、variant
分支处理 when,对 sealed/enum 可检查穷尽性 match 是核心机制,可深度解构数据
缺失值 T? 与专用空安全语法 'a option,作为普通 ADT 组合
失败 异常、Result<T> 或自定义 sealed 类型 异常与 ('a, 'e) result 并存
可变性 val 不等于对象不可变,JVM 生态常见可变对象 绑定和多数数据默认不可变,可变位置显式标记
高阶函数 lambda、Receiver、inline、DSL 函数一等、自动柯里化、偏应用与组合
抽象机制 OOP、泛型、扩展、package 参数化多态、模块签名、Functor,也支持对象
并发思维 协程、结构化并发、Flow,强调异步任务层级 传统核心更强调纯变换;并发与 effects 属于独立层次
运行平台 JVM 为主,也可 Native、JS、Wasm 原生代码与字节码,运行时模型更轻

最容易产生误判的地方是把 OCaml 看成“更纯的 Kotlin”。Kotlin 的优雅来自对现实工程的折中:保留 JVM 对象模型和 Java 互操作,同时吸收函数式表达、空安全与协程。OCaml 的优雅则来自更小、更正交的核心:用少量类型构造描述数据,再用函数和匹配描述变换。

它们都追求表达力,但方向不同:

  • Kotlin 倾向于让复杂工程能力以自然语法进入主流面向对象语言;
  • OCaml 倾向于减少默认模型,让程序从类型和函数关系中生长出来。

用同一个业务问题观察两种思维

假设订单只能从草稿进入已支付或已取消状态,完成后不能再次转换。

Kotlin 可能把状态和行为封装在对象附近:

Kotlin
sealed interface OrderState {
    data object Draft : OrderState
    data class Paid(val transactionId: String) : OrderState
    data class Cancelled(val reason: String) : OrderState
}

data class Order(
    val id: Long,
    val state: OrderState
)

fun Order.pay(transactionId: String): Order =
    when (state) {
        OrderState.Draft -> copy(
            state = OrderState.Paid(transactionId)
        )
        else -> error("order is already finished")
    }

OCaml 更自然地把事件和状态都定义成数据,再把状态转换写成一个普通函数:

OCaml
type order_state =
  | Draft
  | Paid of string
  | Cancelled of string

type event =
  | Pay of string
  | Cancel of string

type transition_error =
  | Already_finished

type order = {
  id : int;
  state : order_state;
}

let transition order event =
  match order.state, event with
  | Draft, Pay transaction_id ->
      Ok { order with state = Paid transaction_id }
  | Draft, Cancel reason ->
      Ok { order with state = Cancelled reason }
  | (Paid _ | Cancelled _), _ ->
      Error Already_finished

两种写法都能做到不可变更新和穷尽匹配。区别在重心:

  • Kotlin 的 Order.pay 强调“订单拥有一个操作”;
  • OCaml 的 transition 强调“状态与事件共同决定新状态”;
  • 当新增事件或状态时,OCaml 往往围绕矩阵式匹配集中展示规则;
  • 当行为依赖对象能力、框架生命周期或 Java API 时,Kotlin 的对象模型通常更自然。

函数式与面向对象并不是胜负关系。它们是在选择变化轴:如果数据分支稳定、操作不断增加,对象分派可能更顺;如果操作相对集中、状态分支需要被完整审计,代数数据类型与模式匹配往往更清晰。

OCaml 的经典优势,也有现实代价

OCaml 最值得学习的能力包括:

  • 用代数数据类型让非法状态更难表示;
  • 用穷尽匹配把需求变化传播到所有处理点;
  • 用不可变数据和纯函数缩小推理范围;
  • 用类型推导获得简洁但精确的多态 API;
  • 用模块签名隐藏表示,用 Functor 组合实现;
  • 在需要时仍能落回可变状态、异常与命令式代码。

它的代价同样明确:

  • 生态规模、岗位数量和通用业务框架远小于 Kotlin/JVM;
  • 错误信息、value restriction、Functor 与高级类型组合存在学习门槛;
  • 缺少 Kotlin 那种由 IntelliJ、Gradle、Spring、Android 与 Java 库构成的巨大工程惯性;
  • 不可变数据会产生分配,递归和高阶组合也需要理解真实成本;
  • 语法简洁不代表架构自动清晰,过度抽象的 Functor 同样可以难以阅读;
  • 面对大量有身份、生命周期和框架回调的对象时,硬套纯函数模型未必更好。

因此,选择 OCaml 的理由不应只是“语法短”或“函数式更高级”。它更适合需要复杂领域建模、编译器与静态分析、验证工具、金融规则、协议实现,或希望用强类型保持核心逻辑可推理的场景。

即使最终仍主要使用 Kotlin,OCaml 也值得学习。它会反过来改善 Kotlin 代码:更愿意使用 sealed 类型表达状态,区分数据与行为,减少随意共享的可变对象,把业务失败显式化,并把副作用推到系统边缘。

建立 OCaml 的阅读顺序

阅读一段 OCaml 代码时,可以按下面的顺序拆解:

  1. 数据有哪些形状:先找 type、variant、record 与 tuple;
  2. 函数的类型关系是什么:阅读或让工具显示推导签名;
  3. 模式是否穷尽:确认每个状态和嵌套结构都被处理;
  4. 数据如何流动:沿 |>、函数调用与返回值追踪变换;
  5. 副作用在哪里:寻找 refmutable、数组更新、异常与 I/O;
  6. 抽象边界在哪里:查看模块签名公开了什么,又隐藏了什么。

对 Kotlin 代码,我们经常先找入口类、接口实现和调用链;对 OCaml,先找类型定义和核心变换函数,通常更快看清系统。

结论:学习的是另一种问题分解法

OCaml 的经典特性并不是彼此孤立的语法点。类型推导让函数关系保持精确,代数数据类型规定数据的全部可能,模式匹配验证处理是否完整,不可变性让变换更容易推理,柯里化促进小函数组合,模块系统则在更高层组织抽象。

这些能力共同形成一种编程思维:

先定义世界中允许存在的数据,再定义数据之间可以发生的变换,最后把副作用限制在明确的边界。

Kotlin 更擅长在成熟对象世界中提供现代、务实而优雅的表达;OCaml 则迫使我们暂时离开对象中心,从类型、函数和变换重新理解程序。

这正是学习 OCaml 的价值。它未必替代 Kotlin,却会改变我们回到 Kotlin 后写 sealed state、领域模型、集合管道和错误处理的方式。

下一章

下一章将深入 OCaml 的类型推导与代数数据类型:从最一般类型、参数化多态和 value restriction 开始,再分析 variant、record、递归类型与模式匹配如何共同构造可验证的领域模型。

延伸阅读