☰ OCaml 簡介
第 1 章 核心語言
本手冊的此部分是對 OCaml 語言的入門教學。假設您對傳統語言(例如 C 或 Java)的程式設計有相當的熟悉度,但不需要事先接觸過函數式語言。本章介紹核心語言。第 2 章處理模組系統,第 3 章處理物件導向功能,第 4 章處理具標籤的引數,第 5 章處理多型變體,第 6 章處理多型的限制,而第 8 章提供一些進階範例。
1 基礎
對於 OCaml 的這個概述,我們使用互動式系統,它是從 Unix shell 或 Windows 命令提示字元執行 ocaml 來啟動的。本教學呈現為與互動式系統的會話記錄:以 # 開頭的行代表使用者輸入;系統回應列印在下方,沒有前導的 # 。
在互動式系統下,使用者輸入以 ;; 結尾的 OCaml 片語來回應 # 提示,系統會即時編譯它們、執行它們,並列印求值的結果。片語可以是簡單的表達式,或是識別符號(值或函數)的 let 定義。
# 1 + 2 * 3;;
- : int = 7
# let pi = 4.0 *. atan 1.0;;
val pi : float = 3.14159265358979312
# let square x = x *. x;;
val square : float -> float = <fun >
# square (sin pi) +. square (cos pi);;
- : float = 1.
OCaml 系統會計算每個片語的值和類型。即使函數參數也不需要明確的類型宣告:系統會從它們在函數中的使用方式推斷出它們的類型。另請注意,整數和浮點數是不同的類型,具有不同的運算子:+ 和 * 對整數運算,但 +. 和 *. 對浮點數運算。
# 1.0 * 2;;
Error : 這個表達式的類型是 float,但預期是 int 類型的表達式
遞迴函數使用 let rec 綁定來定義
# let rec fib n = if n < 2 then n else fib (n - 1) + fib (n - 2);;
val fib : int -> int = <fun >
2 資料型態
除了整數和浮點數之外,OCaml 還提供常用的基本資料型態
布林值
# (1 < 2) = false ;;
- : bool = false
# let one = if true then 1 else 2;;
val one : int = 1
字元
# int_of_char '\n';;
- : int = 10
不可變的字元字串
# "Hello" ^ " " ^ "world" ;;
- : string = "Hello world"
# {|這是一個帶引號的字串,這裡,\ 和 " 都不是特殊字元|} ;;
- : string = "這是一個帶引號的字串,這裡,\\ 和 \" 都不是特殊字元"
# {|"\\"|} ="\"\\\\\"" ;;
- : bool = true
# {delimiter|這個|}帶引號的字串的結尾在這裡|delimiter} = "這個|}帶引號的字串的結尾在這裡" ;;
- : bool = true
預定義的資料結構包括元組、陣列和清單。還有用於定義您自己的資料結構的一般機制,例如記錄和變體,稍後將更詳細地介紹;現在,我們專注於清單。清單可以延伸方式給定為以分號分隔的元素括在方括號中的清單,或者透過使用 :: (「cons」) 運算子將元素新增到空清單 [] (發音為「nil」) 的前面來建構。
# let l = ["is" ; "a" ; "tale" ; "told" ; "etc." ];;
val l : string list = ["is" ; "a" ; "tale" ; "told" ; "etc." ]
# "Life" :: l;;
- : string list = ["Life" ; "is" ; "a" ; "tale" ; "told" ; "etc." ]
與所有其他 OCaml 資料結構一樣,清單不需要從記憶體中明確地配置和解除配置:所有記憶體管理在 OCaml 中都是完全自動的。同樣地,沒有明確的指標處理:OCaml 編譯器會在必要時靜默地引入指標。
與大多數 OCaml 資料結構一樣,清單的檢查和解構是透過模式比對來執行的。清單模式的格式與清單表達式完全相同,其中識別符號代表清單中未指定的部分。例如,以下是清單上的插入排序
# let rec sort lst = match lst with [] -> [] | head :: tail -> insert head (sort tail) and insert elt lst = match lst with [] -> [elt] | head :: tail -> if elt <= head then elt :: lst else head :: insert elt tail ;;
val sort : 'a list -> 'a list = <fun > val insert : 'a -> 'a list -> 'a list = <fun >
# sort l;;
- : string list = ["a" ; "etc." ; "is" ; "tale" ; "told" ]
為 sort 推斷的類型 'a list -> 'a list 表示 sort 實際上可以應用於任何類型的清單,並傳回相同類型的清單。類型 'a 是一個類型變數 ,代表任何給定的類型。sort 可以應用於任何類型清單的原因是,比較 (= 、<= 等) 在 OCaml 中是多型的 :它們在任何兩個相同類型的值之間運作。這使得 sort 本身在所有清單類型上都是多型的。
# sort [6; 2; 5; 3];;
- : int list = [2; 3; 5; 6]
# sort [3.14; 2.718];;
- : float list = [2.718; 3.14]
上面的 sort 函數不會修改其輸入清單:它會建構並傳回一個新清單,其中包含與輸入清單相同的元素,並以遞增順序排列。實際上,在 OCaml 中,一旦建構清單就沒有辦法就地修改清單:我們說清單是不可變的 資料結構。大多數 OCaml 資料結構都是不可變的,但少數資料結構(最值得注意的是陣列)是可變的 ,這意味著它們可以隨時就地修改。
OCaml 中具有多個引數的函數的類型表示法是
arg1_type -> arg2_type -> ... -> return_type 。例如,為 insert 推斷的類型 'a -> 'a list -> 'a list 表示 insert 接受兩個引數,一個是任何類型 'a 的元素,另一個是具有相同類型 'a 元素的清單,並傳回相同類型的清單。
3 函數作為值
OCaml 是一種函數式語言:完全數學意義上的函數會受到支援,並且可以像任何其他資料一樣自由地傳遞。例如,這是一個 deriv 函數,它接受任何浮點函數作為引數,並傳回其導函數的近似值
# let deriv f dx = fun x -> (f (x +. dx) -. f x) /. dx;;
val deriv : (float -> float) -> float -> float -> float = <fun >
# let sin' = deriv sin 1e-6;;
val sin' : float -> float = <fun >
# sin' pi;;
- : float = -1.00000000013961143
甚至函數組合也是可定義的
# let compose f g = fun x -> f (g x);;
val compose : ('a -> 'b) -> ('c -> 'a) -> 'c -> 'b = <fun >
# let cos2 = compose square cos;;
val cos2 : float -> float = <fun >
將其他函式作為引數的函式稱為「函式式」(functionals) 或「高階函式」(higher-order functions)。函式式在提供迭代器或類似資料結構的泛型操作時特別有用。例如,標準 OCaml 函式庫提供了一個 List.map 函式式,它會將給定的函式應用於列表的每個元素,並傳回結果的列表。
# List.map (fun n -> n * 2 + 1) [0;1;2;3;4];;
- : int list = [1; 3; 5; 7; 9]
這個函式式以及許多其他列表和陣列函式式都是預先定義的,因為它們通常很有用,但它們沒有什麼神奇之處:它們可以很容易地定義如下。
# let rec map f l = match l with [] -> [] | hd :: tl -> f hd :: map f tl;;
val map : ('a -> 'b) -> 'a list -> 'b list = <fun >
4 記錄 (Records) 和變體 (Variants)
使用者定義的資料結構包括記錄和變體。兩者都使用 type 宣告來定義。在這裡,我們宣告一個記錄類型來表示有理數。
# type ratio = {num: int; denom: int};;
type ratio = { num : int; denom : int; }
# let add_ratio r1 r2 = {num = r1.num * r2.denom + r2.num * r1.denom; denom = r1.denom * r2.denom};;
val add_ratio : ratio -> ratio -> ratio = <fun >
# add_ratio {num=1; denom=3} {num=2; denom=5};;
- : ratio = {num = 11; denom = 15}
記錄欄位也可以透過模式匹配來存取
# let integer_part r = match r with {num=num; denom=denom} -> num / denom;;
val integer_part : ratio -> int = <fun >
由於此模式匹配中只有一種情況,因此直接在記錄模式中展開引數 r 是安全的
# let integer_part {num=num; denom=denom} = num / denom;;
val integer_part : ratio -> int = <fun >
不需要的欄位可以省略
# let get_denom {denom=denom} = denom;;
val get_denom : ratio -> int = <fun >
選擇性地,可以透過以尾隨萬用字元 _ 結束欄位列表來明確指出遺失的欄位:
# let get_num {num=num; _ } = num;;
val get_num : ratio -> int = <fun >
當 = 符號的兩邊相同時,可以透過省略 =field 部分來避免重複欄位名稱
# let integer_part {num; denom} = num / denom;;
val integer_part : ratio -> int = <fun >
這種欄位的簡短表示法在建構記錄時也適用
# let ratio num denom = {num; denom};;
val ratio : int -> int -> ratio = <fun >
最後,可以一次更新記錄的幾個欄位
# let integer_product integer ratio = { ratio with num = integer * ratio.num };;
val integer_product : int -> ratio -> ratio = <fun >
使用此函式式更新表示法,會複製 with 左側的記錄,但會更新右側的欄位。
變體類型的宣告列出該類型值的所有可能形式。每種情況都由一個名稱(稱為建構子)識別,該名稱既用於建構變體類型的值,也用於透過模式匹配檢查它們。建構子名稱會大寫,以將它們與變數名稱(必須以小寫字母開頭)區分開來。例如,以下是一個用於執行混合算術(整數和浮點數)的變體類型
# type number = Int of int | Float of float | Error;;
type number = Int of int | Float of float | Error
此宣告表示 number 類型的值可以是整數、浮點數或表示無效運算結果的常數 Error (例如,除以零)。
列舉類型是變體類型的一種特殊情況,其中所有替代方案都是常數
# type sign = Positive | Negative;;
type sign = Positive | Negative
# let sign_int n = if n >= 0 then Positive else Negative;;
val sign_int : int -> sign = <fun >
若要定義 number 類型的算術運算,我們對涉及的兩個數字使用模式匹配
# let add_num n1 n2 = match (n1, n2) with (Int i1, Int i2) -> if sign_int i1 = sign_int i2 && sign_int (i1 + i2) <> sign_int i1 then Float(float i1 +. float i2) else Int(i1 + i2) | (Int i1, Float f2) -> Float(float i1 +. f2) | (Float f1, Int i2) -> Float(f1 +. float i2) | (Float f1, Float f2) -> Float(f1 +. f2) | (Error, _) -> Error | (_, Error) -> Error;;
val add_num : number -> number -> number = <fun >
# add_num (Int 123) (Float 3.14159);;
- : number = Float 126.14159
變體類型的另一個有趣的範例是內建的 'a option 類型,它表示類型 'a 的值或沒有值
# type 'a option = Some of 'a | None;;
type 'a option = Some of 'a | None
當定義在常見情況下可能失敗的函式時,此類型特別有用,例如
# let safe_square_root x = if x >= 0. then Some(sqrt x) else None;;
val safe_square_root : float -> float option = <fun >
變體類型最常見的用法是描述遞迴資料結構。例如,考慮二元樹的類型
# type 'a btree = Empty | Node of 'a * 'a btree * 'a btree;;
type 'a btree = Empty | Node of 'a * 'a btree * 'a btree
此定義的解讀如下:包含 'a 類型值(任意類型)的二元樹可以是空的,也可以是一個節點,其中包含一個 'a 類型的值和兩個也包含 'a 類型值的子樹,也就是兩個 'a btree 。
二元樹上的運算自然地表示為遞迴函式,這些函式遵循與類型定義本身相同的結構。例如,以下是在有序二元樹中執行查找和插入的函式(元素從左到右遞增)
# let rec member x btree = match btree with Empty -> false | Node(y, left, right) -> if x = y then true else if x < y then member x left else member x right;;
val member : 'a -> 'a btree -> bool = <fun >
# let rec insert x btree = match btree with Empty -> Node(x, Empty, Empty) | Node(y, left, right) -> if x <= y then Node(y, insert x left, right) else Node(y, left, insert x right);;
val insert : 'a -> 'a btree -> 'a btree = <fun >
4.1 記錄和變體消除歧義
(第一次閱讀時可以跳過此子章節)
細心的讀者可能想知道當兩個或多個記錄欄位或建構子共用相同的名稱時會發生什麼
# type first_record = { x:int; y:int; z:int } type middle_record = { x:int; z:int } type last_record = { x:int };;
# type first_variant = A | B | C type last_variant = A;;
答案是,當面對多個選項時,OCaml 會嘗試使用本地可用資訊來消除各種欄位和建構子之間的歧義。首先,如果知道記錄或變體的類型,OCaml 可以明確地選擇對應的欄位或建構子。例如
# let look_at_x_then_z (r:first_record) = let x = r.x in x + r.z;;
val look_at_x_then_z : first_record -> int = <fun >
# let permute (x:first_variant) = match x with | A -> (B:first_variant) | B -> A | C -> C;;
val permute : first_variant -> first_variant = <fun >
# type wrapped = First of first_record let f (First r) = r, r.x;;
type wrapped = First of first_record val f : wrapped -> first_record * int = <fun >
在第一個例子中,(r:first_record) 是一個明確的註解,告訴 OCaml r 的類型是 first_record 。有了這個註解,Ocaml 就知道 r.x 指的是第一個記錄類型的 x 欄位。類似地,第二個例子中的類型註解清楚地讓 OCaml 知道建構子 A 、B 和 C 來自第一個變體類型。相反地,在最後一個例子中,OCaml 自己推斷出 r 的類型只能是 first_record ,因此不需要明確的類型註解。
這些明確的類型註解實際上可以在任何地方使用。大多數時候它們是不必要的,但它們對於引導消除歧義、除錯意外的類型錯誤,或者與後續章節中描述的 OCaml 的一些更進階的功能結合使用都很有用。
其次,對於記錄類型,OCaml 也可以藉由查看表達式或模式中使用到的所有欄位來推斷正確的記錄類型。
# let project_and_rotate {x; y; _} = { x= - y; y = x; z = 0} ;;
val project_and_rotate : first_record -> first_record = <fun >
由於欄位 x 和 y 只能同時出現在第一個記錄類型中,因此 OCaml 推斷出 project_and_rotate 的類型是 first_record -> first_record 。
最後,如果沒有足夠的資訊來消除不同欄位或建構子之間的歧義,OCaml 會在所有本地有效的選擇中選擇最後定義的類型。
# let look_at_xz {x; z} = x;;
val look_at_xz : middle_record -> int = <fun >
在這裡,OCaml 推斷出 {x;z} 的類型可能的選擇是 first_record 和 middle_record ,因為 last_record 類型沒有 z 欄位。然後 OCaml 選擇 middle_record 類型作為這兩種可能性中最後定義的類型。
請注意,這種最後手段的消除歧義是本地的:一旦 OCaml 選擇了一種消除歧義的方法,它就會堅持這種選擇,即使它導致後續的類型錯誤。
# let look_at_x_then_y r = let x = r.x in x + r.y ;;
Error : This expression has type last_record There is no field y within type last_record
# let is_a_or_b x = match x with | A -> true | B -> true ;;
Error : This variant pattern is expected to have type last_variant There is no constructor B within type last_variant
此外,成為最後定義的類型是一個相當不穩定的位置,在新增或移動類型定義之後,或者在開啟模組後(請參閱第2 章),它可能會偷偷地改變。因此,添加明確的類型註解來引導消除歧義比依賴最後定義的類型消除歧義更可靠。
5 命令式功能
儘管到目前為止的所有範例都是以純粹的應用式風格編寫的,但 OCaml 也配備了完整的命令式功能。這包括常用的 while 和 for 迴圈,以及像是陣列的可變資料結構。陣列可以藉由在 [| 和 |] 括號之間列出以分號分隔的元素值來建立,或使用 Array.make 函數配置和初始化,然後稍後藉由賦值來填入。例如,下面的函數將兩個向量(表示為浮點數陣列)按分量相加。
# let add_vect v1 v2 = let len = min (Array.length v1) (Array.length v2) in let res = Array.make len 0.0 in for i = 0 to len - 1 do res.(i) <- v1.(i) +. v2.(i) done ; res;;
val add_vect : float array -> float array -> float array = <fun >
# add_vect [| 1.0; 2.0 |] [| 3.0; 4.0 |];;
- : float array = [|4.; 6.|]
記錄欄位也可以藉由賦值來修改,前提是它們在記錄類型的定義中宣告為 mutable 。
# type mutable_point = { mutable x: float; mutable y: float };;
type mutable_point = { mutable x : float; mutable y : float; }
# let translate p dx dy = p.x <- p.x +. dx; p.y <- p.y +. dy;;
val translate : mutable_point -> float -> float -> unit = <fun >
# let mypoint = { x = 0.0; y = 0.0 };;
val mypoint : mutable_point = {x = 0.; y = 0.}
# translate mypoint 1.0 2.0;;
- : unit = ()
# mypoint;;
- : mutable_point = {x = 1.; y = 2.}
OCaml 沒有內建的變數概念——也就是目前的值可以藉由賦值來更改的識別符號。(let 綁定不是賦值,它引入了一個具有新作用域的新識別符號。)但是,標準函式庫提供了參考,它們是可變的間接儲存格,具有運算子 ! 來擷取參考的目前內容,以及 := 來指定內容。然後可以藉由 let 綁定參考來模擬變數。例如,這是一個對陣列進行原地插入排序的範例。
# let insertion_sort a = for i = 1 to Array.length a - 1 do let val_i = a.(i) in let j = ref i in while !j > 0 && val_i < a.(!j - 1) do a.(!j) <- a.(!j - 1); j := !j - 1 done ; a.(!j) <- val_i done ;;
val insertion_sort : 'a array -> unit = <fun >
參考也可用於編寫在兩次呼叫函數之間維護目前狀態的函數。例如,下面的虛擬隨機數產生器會在參考中保留最後傳回的數字。
# let current_rand = ref 0;;
val current_rand : int ref = {contents = 0}
# let random () = current_rand := !current_rand * 25713 + 1345; !current_rand;;
val random : unit -> int = <fun >
同樣,參考並沒有什麼神奇之處:它們被實作為單一欄位的可變記錄,如下所示。
# type 'a ref = { mutable contents: 'a };;
type 'a ref = { mutable contents : 'a; }
# let ( ! ) r = r.contents;;
val ( ! ) : 'a ref -> 'a = <fun >
# let ( := ) r newval = r.contents <- newval;;
val ( := ) : 'a ref -> 'a -> unit = <fun >
在某些特殊情況下,您可能需要將多型函數儲存在資料結構中,並保持其多型性。執行此操作需要使用者提供的類型註解,因為多型性只會針對全域定義自動引入。但是,您可以明確地為記錄欄位提供多型類型。
# type idref = { mutable id: 'a. 'a -> 'a };;
type idref = { mutable id : 'a. 'a -> 'a; }
# let r = {id = fun x -> x};;
val r : idref = {id = <fun >}
# let g s = (s.id 1, s.id true );;
val g : idref -> int * bool = <fun >
# r.id <- (fun x -> print_string "called id\n" ; x);;
- : unit = ()
# g r;;
called id called id - : int * bool = (1, true )
6 例外
OCaml 提供了例外來表示和處理例外情況。例外也可以用作通用的非本地控制結構,儘管不應該過度使用,因為這會使程式碼更難以理解。例外是使用 exception 建構宣告,並使用 raise 運算子發出訊號。例如,下面這個用於取得列表頭部的函數使用例外來表示給定空列表的情況。
# exception Empty_list;;
exception Empty_list
# let head l = match l with [] -> raise Empty_list | hd :: tl -> hd;;
val head : 'a list -> 'a = <fun >
# head [1; 2];;
- : int = 1
# head [];;
Exception: Empty_list.
整個標準函式庫都使用例外來表示函式庫函式無法正常完成的情況。例如,List.assoc 函數會在 (鍵,資料) 組成的列表中傳回與給定鍵關聯的資料,當該鍵未出現在列表中時,會引發預定義的例外 Not_found 。
# List.assoc 1 [(0, "zero" ); (1, "one" )];;
- : string = "one"
# List.assoc 2 [(0, "zero" ); (1, "one" )];;
Exception: Not_found.
可以使用 try …with 建構來捕獲例外。
# let name_of_binary_digit digit = try List.assoc digit [0, "zero" ; 1, "one" ] with Not_found -> "not a binary digit" ;;
val name_of_binary_digit : int -> string = <fun >
# name_of_binary_digit 0;;
- : string = "zero"
# name_of_binary_digit (-1);;
- : string = "not a binary digit"
with 部分會對例外值進行模式匹配,其語法和行為與 match 相同。因此,可以使用一個 try …with 結構捕獲多個例外。
# let rec first_named_value values names = try List.assoc (head values) names with | Empty_list -> "no named value" | Not_found -> first_named_value (List.tl values) names;;
val first_named_value : 'a list -> ('a * string) list -> string = <fun >
# first_named_value [0; 10] [1, "one" ; 10, "ten" ];;
- : string = "ten"
此外,可以通過捕獲所有例外、執行最終操作,然後重新拋出例外來執行最終化。
# let temporarily_set_reference ref newval funct = let oldval = !ref in try ref := newval; let res = funct () in ref := oldval; res with x -> ref := oldval; raise x;;
val temporarily_set_reference : 'a ref -> 'a -> (unit -> 'b) -> 'b = <fun >
try …with 的另一種替代方案是在模式匹配時捕獲例外。
# let assoc_may_map f x l = match List.assoc x l with | exception Not_found -> None | y -> f y;;
val assoc_may_map : ('a -> 'b option) -> 'c -> ('c * 'a) list -> 'b option = <fun >
請注意,只有在 match …with 之間引發例外時,此結構才有用。例外模式可以與頂層的普通模式組合。
# let flat_assoc_opt x l = match List.assoc x l with | None | exception Not_found -> None | Some _ as v -> v;;
val flat_assoc_opt : 'a -> ('a * 'b option) list -> 'b option = <fun >
但是它們不能嵌套在其他模式中。例如,模式 Some (exception A) 無效。
當例外作為控制結構使用時,使用本地定義的例外來盡可能使其局部化會很有用。例如,使用
# let fixpoint f x = let exception Done in let x = ref x in try while true do let y = f !x in if !x = y then raise Done else x := y done with Done -> !x;;
val fixpoint : ('a -> 'a) -> 'a -> 'a = <fun >
函式 f 不能引發 Done 例外,這消除了整個類別的不當行為函式。
7 惰性表達式
OCaml 允許我們延遲一些計算,直到稍後我們需要該計算結果時再進行。
我們使用 lazy (expr) 來延遲對某些表達式 expr 的求值。例如,我們可以延遲 1+1 的計算,直到我們需要該表達式的結果 2 時才進行。讓我們看看如何初始化惰性表達式。
# let lazy_two = lazy (print_endline "lazy_two evaluation" ; 1 + 1);;
val lazy_two : int lazy_t = <lazy >
我們添加了 print_endline "lazy_two evaluation" ,以查看惰性表達式何時被求值。
lazy_two 的值顯示為 <lazy> ,這表示該表達式尚未求值,並且其最終值未知。
請注意, lazy_two 的類型為 int lazy_t 。但是,類型 'a lazy_t 是一個內部類型名稱,因此在可能的情況下應優先使用類型 'a Lazy.t 。
當我們最終需要惰性表達式的結果時,我們可以對該表達式調用 Lazy.force 來強制其求值。函式 force 來自標準函式庫模組 Lazy 。
# Lazy.force lazy_two;;
lazy_two evaluation - : int = 2
請注意,我們上面的函式調用會印出 “lazy_two evaluation”,然後傳回計算的純值。
現在,如果我們查看 lazy_two 的值,我們會看到它不再顯示為 <lazy> ,而是顯示為 lazy 2 。
# lazy_two;;
- : int lazy_t = lazy 2
這是因為 Lazy.force 會記憶強制表達式的結果。換句話說,對該表達式的每次後續 Lazy.force 調用都會傳回第一次計算的結果,而不會重新計算惰性表達式。讓我們再次強制 lazy_two 。
# Lazy.force lazy_two;;
- : int = 2
這次沒有對表達式求值;請注意,沒有印出 “lazy_two evaluation”。只傳回了初始計算的結果。
惰性模式提供了強制惰性表達式的另一種方法。
# let lazy_l = lazy ([1; 2] @ [3; 4]);;
val lazy_l : int list lazy_t = <lazy >
# let lazy l = lazy_l;;
val l : int list = [1; 2; 3; 4]
我們也可以在模式匹配中使用惰性模式。
# let maybe_eval lazy_guard lazy_expr = match lazy_guard, lazy_expr with | lazy false , _ -> "matches if (Lazy.force lazy_guard = false); lazy_expr not forced" | lazy true , lazy _ -> "matches if (Lazy.force lazy_guard = true); lazy_expr forced" ;;
val maybe_eval : bool lazy_t -> 'a lazy_t -> string = <fun >
只有在計算出 lazy_guard 值為 true 時,才會強制惰性表達式 lazy_expr 。實際上,一個簡單的萬用字元模式(非惰性)永遠不會強制對惰性表達式求值。但是,帶有關鍵字 lazy 的模式(即使是萬用字元模式)始終會強制對延遲的計算求值。
8 表達式的符號處理
我們以一個更完整的範例來結束本介紹,該範例代表了 OCaml 在符號處理中的使用:包含變數的算術表達式的形式化操作。以下變體類型描述了我們將操作的表達式
# type expression = Const of float | Var of string | Sum of expression * expression | Diff of expression * expression | Prod of expression * expression | Quot of expression * expression ;;
type expression = Const of float | Var of string | Sum of expression * expression | Diff of expression * expression | Prod of expression * expression | Quot of expression * expression
首先,我們定義一個函數,用於評估給定環境中的表達式,環境將變數名稱映射到它們的值。為簡單起見,環境以關聯列表表示。
# exception Unbound_variable of string;;
exception Unbound_variable of string
# let rec eval env exp = match exp with Const c -> c | Var v -> (try List.assoc v env with Not_found -> raise (Unbound_variable v)) | Sum(f, g) -> eval env f +. eval env g | Diff(f, g) -> eval env f -. eval env g | Prod(f, g) -> eval env f *. eval env g | Quot(f, g) -> eval env f /. eval env g;;
val eval : (string * float) list -> expression -> float = <fun >
# eval [("x" , 1.0); ("y" , 3.14)] (Prod(Sum(Var "x" , Const 2.0), Var "y" ));;
- : float = 9.42
現在進行真正的符號處理,我們定義表達式相對於變數 dv 的導數
# let rec deriv exp dv = match exp with Const c -> Const 0.0 | Var v -> if v = dv then Const 1.0 else Const 0.0 | Sum(f, g) -> Sum(deriv f dv, deriv g dv) | Diff(f, g) -> Diff(deriv f dv, deriv g dv) | Prod(f, g) -> Sum(Prod(f, deriv g dv), Prod(deriv f dv, g)) | Quot(f, g) -> Quot(Diff(Prod(deriv f dv, g), Prod(f, deriv g dv)), Prod(g, g)) ;;
val deriv : expression -> string -> expression = <fun >
# deriv (Quot(Const 1.0, Var "x" )) "x" ;;
- : expression = Quot (Diff (Prod (Const 0., Var "x" ), Prod (Const 1., Const 1.)), Prod (Var "x" , Var "x" ))
9 美觀列印
如上面的範例所示,隨著表達式變得越來越大,表達式的內部表示(也稱為抽象語法 )很快變得難以閱讀和編寫。我們需要一個列印器和一個解析器,以便在抽象語法和具體語法 之間來回轉換,在表達式的情況下,具體語法是熟悉的代數表示法(例如 2*x+1 )。
對於列印函數,我們會考慮通常的優先順序規則(即 * 的綁定強度高於 + ),以避免列印不必要的括號。為此,我們維護當前的運算子優先順序,並且僅當運算子的優先順序低於當前優先順序時,才在運算子周圍列印括號。
# let print_expr exp = let open_paren prec op_prec = if prec > op_prec then print_string "(" in let close_paren prec op_prec = if prec > op_prec then print_string ")" in let rec print prec exp = match exp with Const c -> print_float c | Var v -> print_string v | Sum(f, g) -> open_paren prec 0; print 0 f; print_string " + " ; print 0 g; close_paren prec 0 | Diff(f, g) -> open_paren prec 0; print 0 f; print_string " - " ; print 1 g; close_paren prec 0 | Prod(f, g) -> open_paren prec 2; print 2 f; print_string " * " ; print 2 g; close_paren prec 2 | Quot(f, g) -> open_paren prec 2; print 2 f; print_string " / " ; print 3 g; close_paren prec 2 in print 0 exp;;
val print_expr : expression -> unit = <fun >
# let e = Sum(Prod(Const 2.0, Var "x" ), Const 1.0);;
val e : expression = Sum (Prod (Const 2., Var "x" ), Const 1.)
# print_expr e; print_newline ();;
2. * x + 1. - : unit = ()
# print_expr (deriv e "x" ); print_newline ();;
2. * 1. + 0. * x + 0. - : unit = ()
10 Printf 格式
在 Printf 模組(請參閱第 2 章)中有一個 printf 函數,可讓您更簡潔地進行格式化輸出。它遵循 C 標準函式庫中 printf 函數的行為。printf 函數採用一個格式字串,該字串將所需輸出描述為散布著指定符(例如 %d 、%f )的文字。接著,這些指定符會按照它們在格式字串中出現的順序,由後續的參數取代。
# Printf.printf "%i + %i is an integer value, %F * %F is a float, %S\n" 3 2 4.5 1. "this is a string" ;;
3 + 2 is an integer value, 4.5 * 1. is a float, "this is a string" - : unit = ()
OCaml 型別系統會檢查參數的型別和指定符是否相容。如果您傳遞的引數型別與格式指定符不符,編譯器會顯示錯誤訊息
# Printf.printf "Float value: %F" 42 ;;
Error : This expression has type int but an expression was expected of type float Hint: Did you mean 42.?
fprintf 函數與 printf 類似,只是它將輸出通道作為第一個引數。 %a 指定符對於定義自訂列印器(針對自訂型別)很有用。例如,我們可以建立一個列印範本,將整數引數轉換為帶符號的十進位制
# let pp_int ppf n = Printf.fprintf ppf "%d" n;;
val pp_int : out_channel -> int -> unit = <fun >
# Printf.printf "Outputting an integer using a custom printer: %a " pp_int 42;;
Outputting an integer using a custom printer: 42 - : unit = ()
這些基於 %a 指定符的列印器的優點在於它們可以組合在一起,逐步建立更複雜的列印器。我們可以定義一個組合器,它可以將 'a 型別的列印器轉換為 'a optional 的列印器
# let pp_option printer ppf = function | None -> Printf.fprintf ppf "None" | Some v -> Printf.fprintf ppf "Some(%a)" printer v;;
val pp_option : (out_channel -> 'a -> unit) -> out_channel -> 'a option -> unit = <fun >
# Printf.fprintf stdout "The current setting is %a. \nThere is only %a\n" (pp_option pp_int) (Some 3) (pp_option pp_int) None ;;
The current setting is Some(3). There is only None - : unit = ()
如果其引數值為 None ,則 pp_option 列印器傳回 None ,否則它會使用提供的列印器來列印 Some 。
以下是如何使用 fprintf 重寫美觀列印器
# let pp_expr ppf expr = let open_paren prec op_prec output = if prec > op_prec then Printf.fprintf output "%s" "(" in let close_paren prec op_prec output = if prec > op_prec then Printf.fprintf output "%s" ")" in let rec print prec ppf expr = match expr with | Const c -> Printf.fprintf ppf "%F" c | Var v -> Printf.fprintf ppf "%s" v | Sum(f, g) -> open_paren prec 0 ppf; Printf.fprintf ppf "%a + %a" (print 0) f (print 0) g; close_paren prec 0 ppf | Diff(f, g) -> open_paren prec 0 ppf; Printf.fprintf ppf "%a - %a" (print 0) f (print 1) g; close_paren prec 0 ppf | Prod(f, g) -> open_paren prec 2 ppf; Printf.fprintf ppf "%a * %a" (print 2) f (print 2) g; close_paren prec 2 ppf | Quot(f, g) -> open_paren prec 2 ppf; Printf.fprintf ppf "%a / %a" (print 2) f (print 3) g; close_paren prec 2 ppf in print 0 ppf expr;;
val pp_expr : out_channel -> expression -> unit = <fun >
# pp_expr stdout e; print_newline ();;
2. * x + 1. - : unit = ()
# pp_expr stdout (deriv e "x" ); print_newline ();;
2. * 1. + 0. * x + 0. - : unit = ()
由於格式字串的建置方式,儲存格式字串需要明確的型別註解
# let str : _ format = "%i 是一個整數值,%F 是一個浮點數,%S\n" ;;
# Printf.printf str 3 4.5 "字串值" ;;
3 是一個整數值,4.5 是一個浮點數,"字串值" - : unit = ()
11 獨立的 OCaml 程式
目前為止給出的所有範例都是在互動式系統下執行的。OCaml 程式碼也可以使用批次編譯器 ocamlc 和 ocamlopt 單獨編譯並非互動式地執行。原始碼必須放在副檔名為 .ml 的檔案中。它由一系列短語組成,這些短語將在執行時按照它們在原始檔中的出現順序進行評估。與互動模式不同,類型和值不會自動列印;程式必須明確呼叫列印函式才能產生輸出。互動範例中使用的 ;; 在為 OCaml 編譯器建立的原始檔中不是必需的,但即使存在語法錯誤,也可以幫助明確標記最上層表達式的結尾。以下是一個範例獨立程式,用於列印兩個數字的最大公因數 (gcd)
(* File gcd.ml *)
let rec gcd a b =
if b = 0 then a
else gcd b (a mod b);;
let main () =
let a = int_of_string Sys.argv.(1) in
let b = int_of_string Sys.argv.(2) in
Printf.printf "%d\n" (gcd a b);
exit 0;;
main ();;
Sys.argv 是一個包含命令列參數的字串陣列。Sys.argv.(1) 因此是第一個命令列參數。上面的程式使用以下 shell 命令進行編譯和執行
$ ocamlc -o gcd gcd.ml
$ ./gcd 6 9
3
$ ./gcd 7 11
1
更複雜的獨立 OCaml 程式通常由多個原始檔組成,並且可以與預編譯的函式庫連結。第 13 章和 16 章說明如何使用批次編譯器 ocamlc 和 ocamlopt 。可以使用第三方建置系統(例如 dune )自動化多檔案 OCaml 專案的重新編譯。
版權所有 © 2024 法國國家資訊與自動化研究所