可變性與命令式控制流程

命令式與函數式程式設計各有優點,而 OCaml 允許有效率地結合它們。在本教學的第一部分中,我們將介紹可變狀態和命令式控制流程。請參閱第二部分,以了解建議或不建議使用這些功能的範例。

不可變 vs 可變資料

當您使用 let … = … 將值繫結到名稱時,此名稱-值繫結是不可變的,因此無法變更(這是「更改」、「更新」或「修改」的精美術語)指派給名稱的值。

在以下章節中,我們將介紹 OCaml 用於處理可變狀態的語言功能。

參考

有一種特殊的值,稱為參考,其內容可以更新。

# let d = ref 0;;
val d : int ref = {contents = 0}

# d := 1;;
- : unit = ()

# !d;;
- : int = 1

以下是此範例中發生的情況

  1. { contents = 0 } 繫結到名稱 d。這是一個正常的定義。與任何其他定義一樣,它是不可變的。但是,dcontents 欄位中的值 0可變的,因此可以更新。
  2. 賦值運算子 := 用於將 d 內的變數值從 0 更新為 1
  3. 取值運算子 ! 讀取 d 內的變數值內容。

上面的 ref 識別符號指的是兩個不同的事物

  • 函式 ref : 'a -> 'a ref 會建立參考
  • 可變參考的型別:'a ref

賦值運算子

# ( := );;
- : 'a ref -> 'a -> unit = <fun>

賦值運算子 := 只是一個函式。它接受

  1. 要更新的參考,以及
  2. 取代先前內容的值。

更新會以副作用的形式發生,並傳回值 ()

取值運算子

# ( ! );;
- : '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;
}

例如,以下是一家書店如何追蹤其庫存

  • 欄位 titleauthorvolumeseries 是常數。
  • 欄位 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 關鍵字標記。

由於參考是單欄位記錄,因此我們可以使用可變記錄欄位更新語法定義函式 createassignderef

# 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),其中 garray 類型的值,而 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_stringstring 值建立。序列中的個別元素可以使用 Bytes.setBytes.get 依其索引更新或讀取。

您可以將位元組序列視為以下其中之一:

  • 無法列印的可更新字串,或
  • 沒有用於索引讀取和更新語法糖的 char 陣列。

注意bytes 類型使用比 char array 更緊湊的記憶體表示方式。在撰寫本教學時,byteschar array 之間有 8 倍的差異。除非多型函數處理陣列需要 array,否則應始終優先使用前者。

範例:get_char 函數

在本節中,我們比較實現 get_char 函數的兩種方法。此函數會等待直到按下按鍵,並傳回對應的字元,而不會將其回顯。此函數稍後也會在本教學中使用。

我們使用 Unix 模組中的兩個函數來讀取和更新與標準輸入相關聯的終端機屬性

  • tcgetattr stdin TCSAFLUSH 以記錄的形式傳回終端機屬性(類似於 deref
  • tcsetattr stdin TCSAFLUSH 更新終端機屬性(類似於 assign

這些屬性需要正確設定(即關閉回顯並停用標準模式),以便以我們想要的方式讀取。兩種實作中的邏輯相同

  1. 讀取並記錄終端機屬性
  2. 設定終端機屬性
  3. 等待直到按下按鍵,並將其讀取為字元
  4. 還原初始終端機屬性
  5. 傳回讀取的字元

我們使用 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_icanonc_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_icanonc_echo 上有所不同。

在第二次呼叫 tcsetattr 時,我們會將終端機屬性還原為其初始狀態。

命令式控制流程

OCaml 允許您依序評估運算式,並提供 forwhile 迴圈來重複執行程式碼區塊。

依序評估運算式

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 和括號是相同的。

假設我們想要編寫一個函數,該函數

  1. 具有一個包含值 nint 參考參數
  2. 將參考的內容更新為 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>

錯誤來自於 := 的指定,其關聯性強於分號 ;。以下是我們要依序執行的步驟

  1. 遞增 r
  2. 計算 2 * !r
  3. 指定到 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 類型的運算式。在這裡,fortododone 都是關鍵字。

# for i = 0 to 5 do Printf.printf "%i\n" i done;;
0
1
2
3
4
5
- : unit = ()

這裡

  • i 是迴圈計數器;它會在每次迭代後遞增。
  • 0i 的第一個值。
  • 5i 的最後一個值。
  • 運算式 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 類型的運算式。在這裡,whiledodone 都是關鍵字。

# 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 所持有的值小於 5while 迴圈就會繼續執行。

使用例外狀況中斷迴圈

擲回 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 的環境,並且每次呼叫時都可以修改 nn 參考會「隱藏」在封閉區塊內,封裝其狀態。

# 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_charremove_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 程式碼。

大多數現有的模組都提供旨在以函數式方式使用的介面。有些模組需要開發和維護 封裝器函式庫,以便在命令式設定中使用,而這種使用在許多情況下會效率低下。

視情況而定:模組狀態

模組可以通過幾種不同的方式暴露或封裝狀態

  1. 好的:暴露代表狀態的類型,以及狀態建立或重置函數
  2. 視情況而定:僅暴露狀態初始化,這意味著只有一個單一狀態
  3. 不好:可變狀態,沒有明確的初始化函數,也沒有指向可變狀態的名稱

例如,Hashtbl 模組提供了第一種類型的介面。它具有 Hashtbl.t 類型,代表可變資料。它也暴露了 createclearreset 函數。clearreset 函數返回 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_placepartition_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 提供了很好的工具來處理它。我們研究了參考、可變記錄欄位、陣列、位元組序列和像 forwhile 迴圈這樣的命令式控制流程表達式。最後,我們討論了一些關於建議和不建議使用副作用和可變狀態的示例。

仍然需要幫助嗎?

協助改進我們的文件

所有 OCaml 文件都是開源的。看到有錯誤或不清楚的地方嗎?提交提取請求。

OCaml

創新。社群。安全。