位元詩人 為什麼 OCaml 不適合數理運算建模?OCaml 運算子重載實例

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

OCaml 以嚴謹的編譯器著稱。但數理運算的程式碼過於笨重,幾乎不會是數理運算建模的首選語言。

本文列出三個簡單的範例,展示為什麼 OCaml 不適合數理運算建模,以及提供改善的方式。

整數運算

let ( ** ) base exp =
  let rec loop base exp acc =
    if 0 = exp then
      acc
    else
      loop base (exp-1) (base*acc)
  in
  loop base exp 1

let _ =
  let a = 7 in
  let b = 3 in
  assert(10 = (a + b));
  assert( 4 = (a - b));
  assert(21 = (a * b));
  assert( 2 = (a / b));
  assert(7*7*7 = (7 ** 3))

整數運算還算簡單,就是乘方運算沒有內建運算子,要自行實做。

浮點數運算

open Float

let float_equal a b = Float.(abs(a -. b) < 0.00001)

let _ =
  let a = 3.6 in
  let b = 2.4 in
  assert(float_equal (a +. b) 6.0);
  assert(float_equal (a -. b) 1.2);
  assert(float_equal (a *. b) 8.64);
  assert(float_equal (a /. b) 1.5);
  assert(float_equal (a ** 2.0) (3.6 *. 3.6))

浮點數運算的摩擦就很明顯了。每個運算子都要刻意寫出來,如果要寫大量數理運算,真的會覺得很煩躁。

如果真的想讓 OCaml 的浮點數運算方便一點,可以先寫一些語法糖,然後再局部引用:

(* Declare Float Algebra DSL *)
module FloatAlgebra = struct
  let (+) x y = x +. y
  let (-) x y = x -. y
  let ( * ) x y = x *. y
  let (/) x y = x /. y
  let (=) x y = Float.abs(x -. y) < 0.00001

  let log x = Float.log x
  let log10 x = Float.log10 x
  let log2 x = Float.log2 x

  let pi = Float.pi
  let e = exp 1.0
end

(* Main program *)
let _ =
  let open FloatAlgebra in
  let circle_area r = pi * r * r in
  let a = 3.6 in
  let b = 2.4 in
  assert(6.0 = (a + b));
  assert(1.2 = (a - b));
  assert(8.64 = (a * b));
  assert(1.5 = (a / b));
  assert(3.6*3.6 = (a ** 2.0));
  assert(pi*10.0*10.0 = circle_area 10.0)

由於是局部引用,不會在全域汙染整數運算子。事實上,這樣的語法糖很適合發展成一套數理運算的 internal DSL。

大數運算

OCaml 沒有內建大數型態,需要透過庫進行大數運算。這裡用 num。由於不支援運算子,我們仿照前一小節的做法,宣告一套小型 internal DSL:

open Big_int
open Printf

(* Declare Big Number Algebra DSL *)
module BigIntAlgebra = struct
  let (+) (a : big_int) (b : big_int) = Big_int.add_big_int a b
  let (-) (a : big_int) (b : big_int) = Big_int.sub_big_int a b
  let ( * ) (a : big_int) (b : big_int) = Big_int.mult_big_int a b
  let (/) (a : big_int) (b : big_int) = Big_int.div_big_int a b
  let (=) (a : big_int) (b : big_int) = Big_int.eq_big_int a b

  let zero = unit_big_int
  let one = unit_big_int
end

(* Big number algebra *)
let fac (n : big_int) =
  let open BigIntAlgebra in
  let rec loop (n : big_int) (acc : big_int) =
    if one = n then
      acc
    else
      loop (n - one) (n*acc)
  in
  loop n one

(* Main program *)
let _ =
  let a = big_int_of_int 10 in
  fac a
  |> string_of_big_int
  |> Printf.printf "%s\n"

當我們在 fac 函式裡進行大數運算時,由於有用自訂的 internal DSL,語法變簡單很多,近似原生型態運算。

數理運算函式庫

如果真的要做數理運算,可以看一下 Lwt 庫。限於篇幅,本節不展示 Lwt 的用法。

理由

OCaml 之所以採取這樣的設計,是有其理由的。整數與浮點數之間的隱式轉換,往往是 subtle bugs 的來源。OCaml 的哲學是邏輯要明確、一致,因此選擇讓數理運算必須顯式處理。代價是大部分開發者不會用 OCaml 來做數理運算建模。

結語

雖然 OCaml 在邏輯關係建模上非常強大,但在數理運算建模上確實顯得笨重。如果只是偶爾需要數理計算,忍一下、寫些程式碼就能過去;但若問題空間以數理運算為主,選擇 MATLAB、Octave、Julia 這類更合適的語言會更省力。

然而,如果仍然希望在 OCaml 裡建立數理模型,本文展示的 internal DSL 做法值得參考。這樣的 DSL 只要建一次,就能長期使用,讓數學公式更自然地嵌入程式碼,減少摩擦。與其抱怨,不如把笨重轉化為語法糖,這樣 OCaml 也能成為一個乾淨而可持續的數理建模環境。

和 Julia 比較

Julia 的設計初衷是為了 heavy computation 與 large data,它的後端優勢無可取代。但如果問題空間只是 small data,或者重點在於公式建模與邏輯結構,那麼 OCaml 搭配 internal DSL 已經能達到「好用到可以長期使用」的程度。

這樣的 DSL 不會讓 OCaml 變成 Julia,但它能讓數學公式更自然地嵌入程式碼,減少摩擦,並且保留 OCaml 在邏輯建模上的強項。對於研究筆記、教學範例、或小規模數值模型,這樣的做法已經足夠實用。

另見

  • ocaml-algebra:將本文的概念實作成可用的 OCaml 專案。
關於作者

位元詩人 (ByteBard) 是資訊領域碩士,專注於從探索到產品的開發過程,並以工具驅動的方式改善專案。喜歡以開源專案作為成果,回饋社群。

主要方向包括:自用工具的打磨 (dogfooding)、編譯器技術在工具開發中的應用,以及將研究轉化為可維護的開源成果。

除了技術之外,也喜歡日本料理和黑咖啡,偶爾自助旅行,將生活中的靈感融入技術隨筆。