有没有办法在 OCaml 类型系统中嵌入单元处理逻辑?

Is there a way to embed unit-handling logic in the OCaml type system?

这可能是不可能的,但我觉得我可能会学到一些东西,或者可能有一种截然不同的方法来实现它。

我正在编写一些包含一些物理模拟元素的代码,而且我可能会处理一堆不同的单元。我觉得让类型系统在这方面为我做一些工作是值得的,这样我就不能,例如,将质量添加到距离或类似的东西。

这很容易:

module Mass : sig 
  type t
  val sum : t -> t -> t
  ...
end = struct
  type t = int
  let sum = +
  ...
end
module Distance : sig 
  type t
  val sum : t -> t -> t
  ...
end = struct
  type t = int
  let sum = +
  ...
end

现在编译器会阻止我尝试混合两者(对吗?)。类似的东西也应该可以用来表达相同类型的单位(比如,磅与千克),甚至可以让我免受一些精度或溢出错误的影响。到目前为止很容易。 interesting/difficult 部分是我想制作一个流畅的框架来处理单位组合,例如米/秒平方等。

我可以通过玩函子来接近:

module MakeProduct(F : UnitT)(S: UnitT) = struct
   type t = (F.t, S.t)
   ...
end
module MakeRatio(Numerator : UnitT)(Denominator: UnitT) = struct
   type t = (Numerator.t, Denominator.t)
   ...
end

然后我就可以

module MetersPerSecondSquared = MakeRatio(MakeRatio(Meter)( Second))(Second)

会有一些非常笨拙的函数名称,但这应该给我一个类型安全的系统,我可以在其中将 25 m/s^2 乘以 5s 并得到 125m/s.

除了笨拙之外,我看到的问题是系统无法识别以不同顺序表达相同事物的类型。例如,我也可以将上面的内容表示为:

MakeRatio(Meter)(Product(Second)(Second))

两者最终应该表达相同的概念,但我不知道有什么方法可以告诉类型系统它们是相同的,或者您仍然可以将第二个乘以 5s并在 m/s.

中得到结果

我想学习的是:

  1. 到底有没有办法让这个想法奏效?
  2. 如果不是,是否有 formal/theoretical 这很难的原因? (只是为了我自己的教育)
  3. 是否有完全不同的方法来干净地处理类型系统中的不同单位?

可以使用正确的编码进行一些有限的类型级运算。然而,任何编码都会受到OCaml类型系统不了解算术的限制,不能被骗去自己证明复杂的算术定理。

一种可能在复杂性和特性之间取得良好折衷的编码是使用一组固定的核心单元(例如 mskg)和一个幻像描述浮点数单位的类型。

module Units: sig
  type 'a t
  val m: <m: ?one; s: ?zero; kg: ?zero> t
end = struct
  type 'a t = float
  let m = 1.
end

这里的类型 <m:'m; s:'s; kg:'kg> Units.t 本质上是一个浮点数,增加了一些描述每个基本单位的单位指数的类型参数 <m:'m; s:'s; kg:'kg>

我们希望这个指数是类型级整数(所以 ?zero 应该是 0 等的类型级编码...)。

一种有用的整数编码是将它们编码为翻译而不是在一元整数之上。 首先,我们可以定义一个一元z(for zero)类型和类型级别的后继函数:

type z = Zero
type 'a succ = S

然后我们可以将zero编码为将整数n映射到n的函数:

type 'n zero = 'n * 'n

one作为将整数n映射到n + 1的函数:

type 'n one = 'n * 'n succ

通过这种编码,我们可以在Unit模块中填写?zero?one占位符:

module Unit: sig
  type +'a t

  (* Generators *)
  val m: <m:_ one; s:_ zero; kg:_ zero> t
  val s: <m:_ zero; s:_ one; kg:_ zero> t
  val kg: <m:_ zero; s:_ zero; kg:_ one> t
  ...
end

然后我们可以使用我们的翻译编码通过类型统一来欺骗类型检查器计算加法:

  val ( * ):
    <m:'m_low * 'm_mid; s:'s_low * 's_mid; kg:'kg_low * 'kg_mid>  t ->
    <m:'m_mid * 'm_high; s:'s_mid * 's_high; kg:'kg_mid * 'kg_high>  t ->
    <m:'m_low * 'm_high; s:'s_low * 's_high; kg:'kg_low * 'kg_high>  t

在这里,如果我们查看每个组件上发生的情况,我们实际上是在声明如果我们有一个从 'm_low'm_mid 的翻译以及另一个从 'm_midm_high,这两个翻译的总和就是从'm_low'm_high的翻译。因此,我们在类型级别实现了加法。

把所有东西放在一起,我们最终得到

module Unit: sig
  type +'a t

  (* Generators *)
  (* Floats are dimensionless *)
  val scalar: float -> <m:_ zero; s: _ zero; kg: _ zero> t
  val float: <m:_ zero; s: _ zero; kg: _ zero> t -> float
  (* Base units *)
  val m: <m:_ one; s:_ zero; kg:_ zero> t
  val s: <m:_ zero; s:_ one; kg:_ zero> t
  val kg: <m:_ zero; s:_ zero; kg:_ one> t

  (* Arithmetic operations *)
  val ( + ): 'a t -> 'a t -> 'a t
  val ( * ):
    <m:'m_low * 'm_mid; s:'s_low * 's_mid; kg:'kg_low * 'kg_mid>  t ->
    <m:'m_mid * 'm_high; s:'s_mid * 's_high; kg:'kg_mid * 'kg_high>  t ->
    <m:'m_low * 'm_high; s:'s_low * 's_high; kg:'kg_low * 'kg_high>  t

  val ( / ) :
    <m:'m_low * 'm_high; s:'s_low * 's_high; kg:'kg_low * 'kg_high> t ->
    <m:'m_mid * 'm_high; s:'s_mid * 's_high; kg:'kg_mid * 'kg_high> t ->
    <m:'m_low * 'm_mid ; s:'s_low * 's_mid ; kg:'kg_low * 'kg_mid > t
end = struct
 type +'a t = float
 let scalar x = x let float x = x
 let ( + ) = ( +. ) let ( * ) = ( *. ) let ( / ) = ( /. )
 let m = 1. let s = 1. let kg = 1.
end

然后我们得到了预期的行为:只有具有相同维度的值才能相加(或相减),值相乘是通过添加维度分量来完成的(除法则相反)。例如,这段代码编译正确

open Units
let ( *. ) x y = scalar x * y
let au = 149_597_870_700. *. m
let c  = 299_792_458. *. m / s
let year = 86400. *. (365. *. s)
let ok = float @@ (c * year) / au

而尝试将天文单位添加到年份会产生类型错误

let error = year + au

Error: This expression has type < kg : 'a * 'a; m : 'b * 'b succ; s : 'c * 'c > Unit.t but an expression was expected of type < kg : 'a * 'a; m : 'b * 'b; s : 'd * 'd succ > Unit.t The type variable 'b occurs inside 'b succ

然而,类型错误并不是真正可以理解的...这是使用编码的常见问题。

这种编码的另一个重要限制是我们使用类型变量的统一来进行计算。通过这样做,只要类型变量没有被泛化,我们就会在计算时使用它。这会导致奇怪的错误。例如,这个函数工作正常

let strange_but_ok x y = m * x +  ((y/m) * m) * m

而这个没有类型检查

let strange_and_fail x = m * x +  ((x/m) * m) * m

幸运的是,由于我们的虚拟类型参数是协变的,放宽的值限制将确保大多数时候类型变量按时泛化;并且只有在将不同维度的函数参数混合在一起时才会出现此问题。

这种编码的另一个重要限制是我们只能进行单位的加减乘除运算。例如,用这种表示法计算平方根是不可能的。

解除此限制的一种方法是仍然为单位使用虚拟类型参数,使用类型构造函数表示加法、乘法等,并在这些类型构造函数之间添加一些公理相等性。但是随后用户必须手动证明同一整数的不同表示之间的等价性。