可變性與命令式控制流程
先備知識
命令式與函數式程式設計各有優點,而 OCaml 允許有效率地結合它們。在本教學的第一部分中,我們將介紹可變狀態和命令式控制流程。請參閱第二部分,以了解建議或不建議使用這些功能的範例。
不可變 vs 可變資料
當您使用 let … = …
將值繫結到名稱時,此名稱-值繫結是不可變的,因此無法變更(這是「更改」、「更新」或「修改」的精美術語)指派給名稱的值。
在以下章節中,我們將介紹 OCaml 用於處理可變狀態的語言功能。
參考
有一種特殊的值,稱為參考,其內容可以更新。
# let d = ref 0;;
val d : int ref = {contents = 0}
# d := 1;;
- : unit = ()
# !d;;
- : int = 1
以下是此範例中發生的情況
- 值
{ contents = 0 }
繫結到名稱d
。這是一個正常的定義。與任何其他定義一樣,它是不可變的。但是,d
的contents
欄位中的值0
是可變的,因此可以更新。 - 賦值運算子
:=
用於將d
內的變數值從0
更新為1
。 - 取值運算子
!
讀取d
內的變數值內容。
上面的 ref
識別符號指的是兩個不同的事物
- 函式
ref : 'a -> 'a ref
會建立參考 - 可變參考的型別:
'a ref
賦值運算子
# ( := );;
- : 'a ref -> 'a -> unit = <fun>
賦值運算子 :=
只是一個函式。它接受
- 要更新的參考,以及
- 取代先前內容的值。
更新會以副作用的形式發生,並傳回值 ()
。
取值運算子
# ( ! );;
- : 'a ref -> 'a = <fun>
取值運算子是一個函式,它接受參考並傳回其內容。
請參閱運算子文件,以取得更多關於 OCaml 中一元和二元運算子如何運作的資訊。
在 OCaml 中使用可變資料時,
- 不可能建立未初始化的參考,並且
- 可變內容和參考具有不同的語法和型別:不可能混淆它們。
可變記錄欄位
可以使用 mutable
關鍵字標記記錄中的任何欄位。此類欄位可以更新。
# type book = {
series : string;
volume : int;
title : string;
author : string;
mutable stock : int;
};;
type book = {
series : string;
volume : int;
title : string;
author : string;
mutable stock : int;
}
例如,以下是一家書店如何追蹤其庫存
- 欄位
title
、author
、volume
、series
是常數。 - 欄位
stock
是可變的,因為此值會隨著每次銷售或補貨而變更。
此類資料庫應該有這樣的條目
# let vol_7 = {
series = "Murderbot Diaries";
volume = 7;
title = "System Collapse";
author = "Martha Wells";
stock = 0
};;
val vol_7 : book =
{series = "Murderbot Diaries"; volume = 7; title = "System Collapse";
author = "Martha Wells"; stock = 0}
當書店收到 10 本這些書的送貨時,我們會更新可變的 stock
欄位
# vol_7.stock <- vol_7.stock + 10;;
- : unit = ()
# vol_7;;
- : book =
{series = "Murderbot Diaries"; volume = 7; title = "System Collapse";
author = "Martha Wells"; stock = 10 }
可變記錄欄位使用左箭頭符號 <-
更新。在運算式 vol_7.stock <- vol_7.stock + 10
中,vol_7.stock
的含義取決於其上下文
- 在
<-
的左側,它指的是要更新的可變欄位。 - 在
<-
的右側,它表示可變欄位的內容。
與參考相反,沒有特殊的語法可以取值可變記錄欄位。
備註:參考是單欄位記錄
在 OCaml 中,參考是具有單一可變欄位的記錄
# #show_type ref;;
type 'a ref = { mutable contents : 'a; }
型別 'a ref
是具有單一欄位 contents
的記錄,該欄位使用 mutable
關鍵字標記。
由於參考是單欄位記錄,因此我們可以使用可變記錄欄位更新語法定義函式 create
、assign
和 deref
# let create v = { contents = v };;
val create : 'a -> 'a ref = <fun>
# let assign f v = f.contents <- v;;
val assign : 'a ref -> 'a -> unit = <fun>
# let deref f = f.contents;;
val deref : 'a ref -> 'a = <fun>
# let f = create 0;;
val f : int ref = {contents = 0}
# deref f;;
- : int = 0
# assign f 2;;
- : unit = ()
# deref f;;
- : int = 2
函式
create
的功能與標準函式庫提供的ref
函式相同。assign
的功能與( := )
運算子相同。deref
的作用與( ! )
運算子相同。
陣列
在 OCaml 中,陣列是一種可變的、固定大小的資料結構,可以儲存相同類型元素的序列。陣列使用整數索引,提供常數時間的存取,並允許更新元素。
# let g = [| 2; 3; 4; 5; 6; 7; 8 |];;
val g : int array = [|2; 3; 4; 5; 6; 7; 8|]
# g.(0);;
- : int = 2
# g.(0) <- 9;;
- : unit = ()
# g.(0);;
- : int = 9
向左箭頭符號 <-
用於更新陣列中指定索引位置的元素。陣列索引存取語法 g.(i)
,其中 g
是 array
類型的值,而 i
是一個整數,代表以下其中之一:
- 要更新的陣列位置(當在
<-
的左側時),或是 - 該儲存格的內容(當在
<-
的右側時)。
如需更詳細的陣列討論,請參閱陣列教學。
位元組序列
OCaml 中的 bytes
類型代表一個固定長度、可變的位元組序列。在 bytes
類型的值中,每個元素都有 8 個位元。由於 OCaml 中的字元使用 8 個位元表示,因此 bytes
值是可變的 char
序列。與陣列一樣,位元組序列支援索引存取。
# let h = Bytes.of_string "abcdefghijklmnopqrstuvwxyz";;
val h : bytes = Bytes.of_string "abcdefghijklmnopqrstuvwxyz"
# Bytes.get h 10;;
- : char = 'k'
# Bytes.set h 10 '_';;
- : unit = ()
# h;;
- : bytes = Bytes.of_string "abcdefghij_lmnopqrstuvwxyz"
位元組序列可以使用函數 Bytes.of_string
從 string
值建立。序列中的個別元素可以使用 Bytes.set
和 Bytes.get
依其索引更新或讀取。
您可以將位元組序列視為以下其中之一:
- 無法列印的可更新字串,或
- 沒有用於索引讀取和更新語法糖的
char
陣列。
注意:bytes
類型使用比 char array
更緊湊的記憶體表示方式。在撰寫本教學時,bytes
和 char array
之間有 8 倍的差異。除非多型函數處理陣列需要 array
,否則應始終優先使用前者。
get_char
函數
範例:在本節中,我們比較實現 get_char
函數的兩種方法。此函數會等待直到按下按鍵,並傳回對應的字元,而不會將其回顯。此函數稍後也會在本教學中使用。
我們使用 Unix
模組中的兩個函數來讀取和更新與標準輸入相關聯的終端機屬性
tcgetattr stdin TCSAFLUSH
以記錄的形式傳回終端機屬性(類似於deref
)tcsetattr stdin TCSAFLUSH
更新終端機屬性(類似於assign
)
這些屬性需要正確設定(即關閉回顯並停用標準模式),以便以我們想要的方式讀取。兩種實作中的邏輯相同
- 讀取並記錄終端機屬性
- 設定終端機屬性
- 等待直到按下按鍵,並將其讀取為字元
- 還原初始終端機屬性
- 傳回讀取的字元
我們使用 OCaml 標準程式庫中的 input_char
函數從標準輸入讀取字元。
以下是第一個實作。如果您在 macOS 中工作,請先執行 #require "unix";;
,以避免發生 Unbound module error
。
# let get_char () =
let open Unix in
let termio = tcgetattr stdin in
let c_icanon, c_echo = termio.c_icanon, termio.c_echo in
termio.c_icanon <- false;
termio.c_echo <- false;
tcsetattr stdin TCSAFLUSH termio;
let c = input_char (in_channel_of_descr stdin) in
termio.c_icanon <- c_icanon;
termio.c_echo <- c_echo;
tcsetattr stdin TCSAFLUSH termio;
c;;
val get_char : unit -> char = <fun>
在此實作中,我們更新 termio
的欄位
- 在
input_char
之前,將c_icanon
和c_echo
都設定為false
,以及 - 在
input_char
之後,還原初始值。
以下是第二個實作
# let get_char () =
let open Unix in
let termio = tcgetattr stdin in
tcsetattr stdin TCSAFLUSH
{ termio with c_icanon = false; c_echo = false };
let c = input_char (in_channel_of_descr stdin) in
tcsetattr stdin TCSAFLUSH termio;
c;;
val get_char : unit -> char = <fun>
在此實作中,不會修改呼叫 tcgetattr
所傳回的記錄。使用 { termio with c_icanon = false; c_echo = false }
建立副本。此副本與 termio
值僅在欄位 c_icanon
和 c_echo
上有所不同。
在第二次呼叫 tcsetattr
時,我們會將終端機屬性還原為其初始狀態。
命令式控制流程
OCaml 允許您依序評估運算式,並提供 for
和 while
迴圈來重複執行程式碼區塊。
依序評估運算式
let … in
# let () = print_string "This is" in print_endline " really Disco!";;
This is really Disco!
- : unit = ()
使用 let … in
建構表示兩件事
- 可能會繫結名稱。在此範例中,由於使用了
()
,因此沒有繫結任何名稱。 - 副作用依序發生。繫結的運算式 (
print_string "This is"
) 會先評估,而參照的運算式 (print_endline " really Disco!"
) 會在第二個評估。
分號
單個分號 ;
運算子稱為序列運算子。它允許您依序評估多個運算式,並以最後一個運算式的值作為整個序列的值。
任何先前運算式的值都會被捨棄。因此,除了序列的最後一個運算式之外,使用具有副作用的運算式是有意義的,最後一個運算式可能沒有副作用。
# let _ =
print_endline "Hello,";
print_endline "world!";
42;;
Hello,
world!
- : int = 42
在此範例中,前兩個運算式是 print_endline
函數呼叫,它會產生副作用(列印到主控台),而最後一個運算式只是整數 42
,它會成為整個序列的值。;
運算子用於分隔這些運算式。
備註:即使它稱為序列運算子,分號並不是真正的運算子,因為它不是 unit -> 'a -> 'a
類型的函數。它而是語言的一種建構。它允許在序列運算式的結尾新增分號。
# (); 42; ;;
- : int = 42
在這裡,42 後面的分號會被忽略。
begin … end
運算式
在 OCaml 中,begin … end
和括號是相同的。
假設我們想要編寫一個函數,該函數
- 具有一個包含值 n 的
int
參考參數 - 將參考的內容更新為 2 × (n + 1)
這可以說是很複雜且無法運作的
# let f r = r := incr r; 2 * !r;;
Error: This expression has type unit but an expression was expected of type int
但以下是如何使其運作的方法
# let f r = r := begin incr r; 2 * !r end;;
val f : int ref -> unit = <fun>
錯誤來自於 :=
的指定,其關聯性強於分號 ;
。以下是我們要依序執行的步驟
- 遞增
r
- 計算
2 * !r
- 指定到
r
中
請記住,以分號分隔的序列的值是其最後一個運算式的值。使用 begin … end
將前兩個步驟分組可以修正錯誤。
趣聞:begin … end
和括號實際上是相同的
# begin end;;
- : unit = ()
if … then … else …
和副作用
在 OCaml 中,if … then … else …
是一個運算式。
# 6 * if "foo" = "bar" then 5 else 5 + 2;;
- : int = 42
如果兩個分支的類型都是 unit
,則條件運算式的傳回類型可以是 unit
。
# if 0 = 1 then print_endline "foo" else print_endline "bar";;
bar
- : unit = ()
以上也可以用這種方式表示
# print_endline (if 0 = 1 then "foo" else "bar");;
bar
- : unit = ()
當只有一個分支要執行時,unit
值 ()
可以作為 無操作。
# if 0 = 1 then print_endline "foo" else ();;
- : unit = ()
但是 OCaml 也允許撰寫沒有 else
分支的 if … then …
運算式,這與上述相同。
# if 0 = 1 then print_endline "foo";;
- : unit = ()
在剖析中,條件運算式會比序列分組更多
# if true then print_endline "A" else print_endline "B"; print_endline "C";;
A
C
- : unit = ()
在這裡,; print_endline "C"
是在整個條件運算式之後執行,而不是在 print_endline "B"
之後執行。
如果想要在條件運算式分支中有兩個列印,請使用 begin … end
。
# if true then
print_endline "A"
else begin
print_endline "B";
print_endline "C"
end;;
A
- : unit = ()
以下是您可能會遇到的錯誤
# if true then
print_endline "A";
print_endline "C"
else
print_endline "B";;
Error: Syntax error
在第一個分支中未能分組會導致語法錯誤。分號之前的內容會被剖析為沒有 else
運算式的 if … then …
。分號之後的內容會顯示為懸空 else
。
For 迴圈
for
迴圈是一種 unit
類型的運算式。在這裡,for
、to
、do
和 done
都是關鍵字。
# for i = 0 to 5 do Printf.printf "%i\n" i done;;
0
1
2
3
4
5
- : unit = ()
這裡
i
是迴圈計數器;它會在每次迭代後遞增。0
是i
的第一個值。5
是i
的最後一個值。- 運算式
Printf.printf "%i\n" i
是迴圈的主體。
迭代會評估主體運算式(其中可能包含 i
),直到 i
達到 5
為止。
for
迴圈的主體必須是 unit
類型的運算式
# let j = [| 2; 3; 4; 5; 6; 7; 8 |];;
val j : int array = [|2; 3; 4; 5; 6; 7; 8|]
# for i = Array.length j - 1 downto 0 do 0 done;;
Line 1, characters 39-40:
Warning 10 [non-unit-statement]: this expression should have type unit.
- : unit = ()
當您使用 downto
關鍵字(而不是 to
關鍵字)時,計數器會在迴圈的每次迭代中遞減。
for
迴圈方便迭代和修改陣列
# let sum = ref 0 in
for i = 0 to Array.length j - 1 do sum := !sum + j.(i) done;
!sum;;
- : int = 35
注意: 以下是如何使用迭代器函數執行相同操作
# let sum = ref 0 in Array.iter (fun i -> sum := !sum + i) j; !sum;;
- : int = 35
While 迴圈
while
迴圈是一種 unit
類型的運算式。在這裡,while
、do
和 done
都是關鍵字。
# let i = ref 0 in
while !i <= 5 do
Printf.printf "%i\n" !i;
i := !i + 1;
done;;
0
1
2
3
4
5
- : unit = ()
這裡
!i <= 5
是條件。- 運算式
Printf.printf "%i\n" !i; i := !i + 1;
是迴圈的主體。
只要條件仍然為真,迭代就會執行主體運算式。
在此範例中,只要參考 i
所持有的值小於 5
,while
迴圈就會繼續執行。
使用例外狀況中斷迴圈
擲回 Exit
例外狀況是立即從迴圈中結束的建議方式。
以下範例使用我們稍早定義的 get_char
函數(在範例:get_char
函數一節中)。
# try
print_endline "Press Escape to exit";
while true do
let c = get_char () in
if c = '\027' then raise Exit;
print_char c;
flush stdout
done
with Exit -> ();;
此 while
迴圈會回顯鍵盤上輸入的字元。當讀取 ASCII Escape
字元時,會擲回 Exit
例外狀況,這會終止迭代並顯示 REPL 回覆:- : unit = ()
。
封閉區塊內的參考
在以下範例中,函數 create_counter
會傳回一個隱藏可變參考 n
的封閉區塊。此封閉區塊會擷取定義 n
的環境,並且每次呼叫時都可以修改 n
。n
參考會「隱藏」在封閉區塊內,封裝其狀態。
# let create_counter () =
let n = ref 0 in
fun () -> incr n; !n;;
val create_counter : unit -> unit -> int = <fun>
首先,我們定義一個名為 create_counter
的函數,該函數不接受任何引數。在 create_counter
內部,會使用值 0 初始化參考 n
。此參考將會保存計數器的狀態。接下來,我們定義一個不接受任何引數的封閉區塊 (fun () ->)。封閉區塊會使用 incr n
遞增 n
的值(計數器),然後使用 !n
傳回 n
的目前值。
# let c1 = create_counter ();;
val c1 : unit -> int = <fun>
# let c2 = create_counter ();;
val c2 : unit -> int = <fun>
現在,我們將建立一個閉包 c1
,它封裝了一個計數器。呼叫 c1 ()
將會增加與 c1
相關聯的計數器,並返回其目前的值。類似地,我們建立另一個閉包 c2
,它有自己獨立的計數器。
# c1 ();;
- : int = 1
# c1 ();;
- : int = 2
# c2 ();;
- : int = 1
# c1 ();;
- : int = 3
呼叫 c1 ()
會增加與 c1
相關聯的計數器,並返回其目前的值。由於這是第一次呼叫,計數器從 1 開始。再次呼叫 c1 ()
會再次增加計數器,所以它返回 2。
呼叫 c2 ()
會增加與 c2
相關聯的計數器。由於 c2
有自己獨立的計數器,它從 1 開始。再次呼叫 c1 ()
會增加它的計數器,結果為 3。
可變狀態與副作用的建議
函數式和命令式程式設計風格經常一起使用。然而,並非所有組合方式都能產生良好的結果。在本節中,我們將展示一些與可變狀態和副作用相關的模式和反模式。
好的:函數封裝的可變性
這是一個計算整數陣列總和的函數。
# let sum m =
let result = ref 0 in
for i = 0 to Array.length m - 1 do
result := !result + m.(i)
done;
!result;;
val sum : int array -> int = <fun>
函數 sum
是以命令式風格編寫的,使用可變的資料結構和 for
迴圈。然而,沒有暴露任何可變性。這是一個完全封裝的實作選擇。這個函數可以安全使用;不會有任何問題發生。
好的:應用程式範圍的狀態
有些應用程式在執行時會維護一些狀態。這裡有一些例子
- 讀取-求值-列印迴圈(REPL)。狀態是將值綁定到名稱的環境。在 OCaml 中,環境是僅附加的,但某些其他語言允許替換或移除名稱-值綁定。
- 有狀態協定的伺服器。每個會話都有一個狀態。全域狀態包含所有會話狀態。
- 文字編輯器。狀態包括最近的命令(允許還原)、任何已開啟檔案的狀態、設定和 UI 的狀態。
- 快取。
以下是一個玩具行的編輯器,使用 先前定義的 get_char
函數。它等待標準輸入上的字元,並在檔案結尾、歸位字元或換行符時退出。否則,如果字元是可列印的,它會列印它並將它記錄在用作堆疊的可變清單中。如果字元是刪除碼,則堆疊會彈出,並且最後列印的字元會被刪除。
# let record_char state c =
(String.make 1 c, c :: state);;
val record_char : char list -> char -> string * char list = <fun>
# let remove_char state =
("\b \b", if state = [] then [] else List.tl state);;
val remove_char : 'a list -> string * 'a list = <fun>
# let state_to_string state =
List.(state |> rev |> to_seq |> String.of_seq);;
val state_to_string : char list -> string = <fun>
# let rec loop state =
let c = get_char () in
if c = '\004' || c = '\n' || c = '\r' then raise Exit;
let s, new_state = match c with
| '\127' -> remove_char !state
| c when c >= ' ' -> record_char !state c
| _ -> ("", !state) in
print_string s;
state := new_state;
flush stdout;
loop state;;
val loop : char list ref -> 'a = <fun>
# let state = ref [] in try loop state with Exit -> state_to_string !state;;
在最後這個命令之後,您可以輸入和編輯任何單行文字。然後,按下返回鍵回到 REPL。
這個例子說明了以下幾點
- 函數
record_char
和remove_char
既不更新狀態也不產生副作用。相反,它們各自返回一對值,包含要列印的字串和下一個狀態new_state
。 - I/O 和狀態更新的副作用發生在
loop
函數內。 - 狀態作為參數傳遞給
loop
函數。
這是處理應用程式範圍狀態的一種可能方法。就像 函數封裝的可變性 示例中一樣,感知狀態的程式碼包含在一個狹窄的範圍內;其餘程式碼是純粹的函數式。
注意:在這裡,狀態是被複製的,這不是記憶體有效率的。在記憶體感知的實作中,狀態更新函數會產生一個「差異」(描述狀態舊版本和更新版本之間差異的資料)。
好的:預先計算值
假設您將角度儲存為圓的幾分之一,使用 8 位元無號整數,將它們儲存為 char
值。在這個系統中,64 是 90 度,128 是 180 度,192 是 270 度,256 是整圓,依此類推。如果您需要計算這些值的餘弦值,一種實作可能如下所示
# let char_cos c =
c |> int_of_char |> float_of_int |> ( *. ) (Float.pi /. 128.0) |> cos;;
val char_cos : char -> float = <fun>
然而,可以通過預先計算所有可能的值來實現更快的實作。只有 256 個值,您將在下面的第一個結果之後看到它們的列表
# let char_cos_tab = Array.init 256 (fun i -> i |> char_of_int |> char_cos);;
val char_cos_tab : float array =
# let char_cos c = char_cos_tab.(int_of_char c);;
val char_cos : char -> float = <fun>
好的:記憶化
記憶化技術依賴於與上一節示例相同的想法:從先前計算的值表中查詢結果。
然而,記憶化不是預先計算所有內容,而是使用在呼叫函數時填充的快取。如果提供的參數
- 在快取中找到(它是命中),則返回儲存的結果,否則它們
- 在快取中找不到(它是未命中),則計算結果,將其儲存在快取中,然後返回。
您可以在「OCaml Programming: Correct + Efficient + Beautiful」的 記憶化 章節中找到一個關於記憶化的具體示例和更深入的解釋。
好的:預設使用函數式
預設情況下,OCaml 程式應該以大部分函數式風格編寫。這表示盡可能嘗試避免副作用,並依賴不可變的資料而不是可變的狀態。
可以使用命令式程式設計風格,而不會失去類型和記憶體安全的好處。然而,通常只以命令式風格進行程式設計是沒有意義的。完全不使用函數式程式設計的慣用語會導致非慣用的 OCaml 程式碼。
大多數現有的模組都提供旨在以函數式方式使用的介面。有些模組需要開發和維護 封裝器函式庫,以便在命令式設定中使用,而這種使用在許多情況下會效率低下。
視情況而定:模組狀態
模組可以通過幾種不同的方式暴露或封裝狀態
- 好的:暴露代表狀態的類型,以及狀態建立或重置函數
- 視情況而定:僅暴露狀態初始化,這意味著只有一個單一狀態
- 不好:可變狀態,沒有明確的初始化函數,也沒有指向可變狀態的名稱
例如,Hashtbl
模組提供了第一種類型的介面。它具有 Hashtbl.t
類型,代表可變資料。它也暴露了 create
、clear
和 reset
函數。clear
和 reset
函數返回 unit
。這強烈地向讀者表明它們執行更新可變資料的副作用。
# #show Hashtbl.t;;
type ('a, 'b) t = ('a, 'b) Hashtbl.t
# Hashtbl.create;;
- : ?random:bool -> int -> ('a, 'b) Hashtbl.t = <fun>
# Hashtbl.reset;;
- : ('a, 'b) Hashtbl.t -> unit = <fun>
# Hashtbl.clear;;
- : ('a, 'b) Hashtbl.t -> unit = <fun>
另一方面,模組可能會在內部定義可變資料,從而影響其行為,而不在其介面中暴露它。這是不可取的。
不好:沒有說明文件的變更
這是一個不好的程式碼示例
# let partition p k =
let m = Array.copy k in
let k_len = ref 0 in
let m_len = ref 0 in
for i = 0 to Array.length k - 1 do
if p k.(i) then begin
k.(!k_len) <- k.(i);
incr k_len
end else begin
m.(!m_len) <- k.(i);
incr m_len
end
done;
(Array.truncate k_len k, Array.truncate m_len m);;
Error: Unbound value Array.truncate
注意: 這個例子不會在 REPL 中運行,因為 Array.truncate
函數沒有定義。
為了理解為什麼這是糟糕的程式碼,假設函數 Array.truncate
的類型為 int -> 'a array -> 'a array
。它的行為是 Array.truncate 3 [5; 6; 7; 8; 9]
返回 [5; 6; 7]
,並且返回的陣列在實體上對應於輸入陣列的前 3 個儲存格。
partition
的類型將為 ('a -> bool) -> 'a array -> 'a array * 'a array
,並且可以記錄為
partition p k
返回一對陣列(m, n)
,其中m
是一個包含k
中所有滿足謂詞p
的元素的陣列,而n
是一個包含k
中不滿足p
的元素的陣列。輸入陣列中元素的順序會被保留。
乍看之下,這看起來像是 函數封裝的可變性 的應用。然而,事實並非如此。輸入陣列被修改了。這個函數有一個副作用,它要么是
- 不是預期的,要么是
- 沒有記錄的。
在後一種情況下,函數應該被命名為不同名稱(例如,partition_in_place
或 partition_mut
),並且應該記錄對輸入陣列的影響。
不好:沒有說明文件的副作用
考慮這個程式碼
# module Array = struct
include Stdlib.Array
let copy a =
if Array.length a > 1000000 then Analytics.collect "Array.copy" a;
copy a
end;;
Error: Unbound module Analytics
注意: 此程式碼無法執行,因為沒有名為 Analytics
的模組。分析是遠端監控函式庫。
定義了一個名為 Array
的模組;它覆蓋並包括了 Stdlib.Array
模組。有關此模式的詳細資訊,請參閱 模組包含部分的模組教學。
要了解為什麼這個程式碼不好,請弄清楚 Analytics.collect
是一個建立網路連線以將資料傳輸到遠端伺服器的函數。
現在,新定義的 Array
模組包含一個 copy
函數,它可能具有意想不到的副作用,但只有在要複製的陣列具有一百萬個或更多儲存格時才會發生。
如果您正在編寫具有不明顯副作用的函數,請不要覆蓋現有的定義。相反,請為函數提供描述性名稱(例如,Array.copy_with_analytics
),並記錄存在呼叫者可能不知道的副作用的事實。
不好:副作用取決於求值順序
考慮以下程式碼
# let id_print s = print_string (s ^ " "); s;;
val id_print : string -> string = <fun>
# let s =
Printf.sprintf "%s %s %s"
(id_print "Monday")
(id_print "Tuesday")
(id_print "Wednesday");;
Wednesday Tuesday Monday val s : string = "Monday Tuesday Wednesday "
函數 id_print
返回其未更改的輸入。然而,它有一個副作用:它首先列印它作為參數接收的字串。
在第二行中,我們將 id_print
應用於參數 "Monday"
、"Tuesday"
和 "Wednesday"
。然後,將 Printf.sprintf "%s %s %s "
應用於結果。
由於 OCaml 中函數參數的求值順序沒有明確定義,因此 id_print
副作用發生的順序是不可靠的。在此示例中,參數是從右到左求值的,但這可能會在未來的編譯器版本中更改。
當將參數應用於變體建構子、建立 tuple 值或初始化記錄欄位時,也會出現此問題。在這裡,它在 tuple 值上進行了說明
# let r = ref 0 in ((incr r; !r), (decr r; !r));;
- : int * int = (0, -1)
此表達式的值取決於子表達式的求值順序。由於未指定此順序,因此無法可靠地知道此值是什麼。在撰寫本教學時,求值產生 (0, -1)
,但如果您看到其他內容,則不是錯誤。必須避免這種不可靠的值。
為了確保求值以特定順序發生,請使用將表達式放入序列的方法。請查看按順序評估表達式部分。
結論
可變狀態既不好也不壞。對於可變狀態可以實現更簡單的實作的情況,OCaml 提供了很好的工具來處理它。我們研究了參考、可變記錄欄位、陣列、位元組序列和像 for
和 while
迴圈這樣的命令式控制流程表達式。最後,我們討論了一些關於建議和不建議使用副作用和可變狀態的示例。