前言
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 專案。