OCaml 程式設計指南
這是一組合理的 OCaml 程式設計指南,反映了資深 OCaml 程式設計師之間的共識。
OCaml 原始碼可以使用 OCamlFormat 自動格式化,因此您不必擔心手動格式化。您可以專注於重要的部分,從而加快程式碼審查的速度。儘管如此,一些最佳實踐並非自動化,因此它們記錄在本文中。如果您喜歡手動格式化程式碼,本文末尾有一些格式化指南。
一般指南
保持簡潔易讀
您花費在輸入程式的時間與花費在閱讀程式的時間相比微不足道。這就是為什麼如果您努力優化可讀性,就可以節省大量時間的原因。
您今天「浪費」在獲得更簡單的程式的時間,將在未來無數次的修改和閱讀程式時(從第一次除錯開始)獲得百倍的回報。
程式撰寫定律:一個程式撰寫一次、修改十次、閱讀一百次。因此,簡化其撰寫、始終將未來的修改牢記在心,並且絕不損害可讀性是有益的。
命名複雜的參數
取代
let temp =
f x y z
“large
expression”
“other large
expression” in
...
撰寫
let t =
“large
expression”
and u =
“other large
expression” in
let temp =
f x y z t u in
...
命名匿名函式
在迭代器的參數是一個複雜函式的情況下,也透過 let
繫結定義該函式。取代
List.map
(function x ->
blabla
blabla
blabla)
l
撰寫
let f x =
blabla
blabla
blabla in
List.map f l
理由:更清晰,特別是如果給函式的名稱有意義。
程式設計指南
如何程式設計
始終將您的作品放回工作台上,
然後打磨它並再次打磨它。
撰寫簡潔明瞭的程式
在建立的每個階段重新閱讀、簡化和澄清。動動腦筋!
將程式細分為小函式
小函式更容易掌握。
透過在單獨的函式中定義來分解重複的程式碼片段
以這種方式獲得的程式碼共享有助於維護,因為每個更正或改進都會自動傳播到整個程式。此外,隔離和命名程式碼片段的簡單動作有時可以讓您識別出意想不到的功能。
程式設計時絕不複製貼上程式碼
貼上程式碼幾乎肯定表示引入了程式碼共享預設值,並且忽略了識別和撰寫有用的輔助函式。因此,這表示程式中遺失了一些程式碼共享。遺失程式碼共享意味著您之後在維護時會遇到更多問題。貼上程式碼中的錯誤必須在程式碼每個副本中每次出現錯誤時進行更正!
此外,很難識別出程式碼中重複了二十次的相同十行程式碼。相反地,如果輔助函式定義了這十行程式碼,則很容易查看和找到這些程式碼的使用位置:只需在函式被呼叫的位置即可。如果程式碼在各處複製貼上,則程式會更難以理解。
總之,複製貼上程式碼會導致程式更難以閱讀且更難以維護。必須消除它。
如何註解程式
當遇到困難時,不要猶豫地註解。如果沒有困難,註解就沒有意義。它只會產生不必要的雜訊。
避免在函式主體中註解。最好在函式開頭加上註解,解釋特定演算法的工作原理。再次強調,如果沒有困難,就沒有註解的必要。
避免無益的註解
無益的註解是不增加任何價值的註解,例如,瑣碎的資訊。無益的註解顯然沒有意義;它是一種不必要地分散讀者注意力的麻煩。它通常用於滿足與所謂的軟體計量學相關的一些奇怪標準,即註解數量 / 程式碼行數的比率。這個任意的比率沒有理論或實際的解釋。
絕對避免無益的註解。
一個需要避免的範例,以下註解使用了技術詞彙,因此偽裝成真正的註解,但它沒有其他有意義的資訊
(*
Function print_lambda:
print a lambda-expression given as argument.
Arguments: lam, any lambda-expression.
Returns: nothing.
Remark: print_lambda can only be used for its side effect.
*)
let rec print_lambda lam =
match lam with
| Var s -> printf "%s" s
| Abs l -> printf "\\ %a" print_lambda l
| App (l1, l2) ->
printf "(%a %a)" print_lambda l1 print_lambda l2
在模組介面中的用法
函式的使用必須出現在匯出它的模組介面中,而不是在實作它的程式中。選擇與 OCaml 系統的介面模組中相同的註解,如果需要,稍後會自動提取介面模組的文件。
使用斷言
盡可能多地使用斷言,因為它們可讓您避免冗長的註解,同時允許在執行時進行有用的驗證。
例如,使用斷言可以有效地驗證驗證函式參數的條件。
let f x =
assert (x >= 0);
...
此外,請注意,斷言通常比註解更可取,因為它更值得信賴。斷言會強制執行相關性,因為它會在每次執行時進行驗證,而註解可能會很快過時,使其不利於理解程式。
命令式程式碼中逐行註解
在撰寫困難的程式碼時,特別是在具有大量記憶體修改(資料結構中的物理變更)的高度命令式程式碼的情況下,有時必須在函式主體內進行註解,以解釋此處編碼的演算法實作,或遵循函式必須維護的連續不變修改。再次強調,如果存在困難,則必須進行註解,必要時針對每行程式碼進行註解。
如何選擇識別符號
很難選擇其名稱能表達程式相應部分的含義的識別符號。這就是為什麼您必須特別注意這一點,強調命名的清晰性和規則性。
不要為全域名稱使用縮寫
全域識別符號(包括函式名稱)可以很長,因為了解它們在遠離其定義的位置所服務的目的非常重要。
int_of_string
,而不是 intOfString
)
使用底線分隔單字:(大小寫修改在 OCaml 中具有意義。實際上,大寫的單字保留給建構子和模組名稱。相反,常規變數(函式或識別符號)必須以小寫字母開頭。這些規則阻止在識別符號中適當使用大小寫修改進行單字分隔。第一個單字開始識別符號,因此它必須是小寫,並且禁止選擇 IntOfString
作為函式名稱。
對於具有相同意義的函式參數,永遠給予相同的名稱
如有必要,請在檔案頂端的註解中明確說明此命名法。如果有多個參數具有相同意義,則在它們的名稱後附加數字後綴。
局部識別符號可以簡短,且應在不同函式之間重複使用
這能增強樣式一致性。避免使用外觀可能導致混淆的識別符號,例如 l
或 O
,它們很容易與 1
和 0
混淆。
範例
let add_expression expr1 expr2 = ...
let print_expression expr = ...
當與使用此命名慣例的現有函式庫互動時,一個可接受的例外情況是不使用大寫字母來分隔識別符號中的單字。這讓 OCaml 函式庫的使用者可以更容易地在原始函式庫文件中找到方向。
如何使用模組
細分為模組
您必須將程式細分為具有連貫性的模組。
對於每個模組,您必須明確地編寫介面。
對於每個介面,您必須記錄模組定義的事項:函式、類型、例外等等。
開啟模組
避免使用 open
指令,而是使用限定的識別符號表示法。因此,您會偏好簡短但有意義的模組名稱。
理由:使用不限定的識別符號是含糊不清的,並會導致難以偵測的語意錯誤。
let lim = String.length name - 1 in
...
let lim = Array.length v - 1 in
...
... List.map succ ...
... Array.map succ ...
何時使用開啟模組而不是保持關閉
將修改環境並引入重要函式集的其他版本的模組視為正常開啟。例如,Format
模組會自動提供縮排列印。此模組重新定義了常用的列印函式 print_string
、print_int
、print_float
等,因此當您使用 Format
時,請在檔案頂端系統性地開啟它。
如果您不開啟 Format
,您可能會遺漏列印函式的限定,而這可能是完全靜默的,因為 Format
的許多函式在預設環境 (Stdlib
) 中都有對應的函式。混合使用 Format
和 Stdlib
的列印函式會導致顯示中難以追蹤的細微錯誤。例如
let f () =
Format.print_string "Hello World!"; print_newline ()
是錯誤的,因為它沒有呼叫 Format.print_newline
來刷新美化列印佇列並輸出 "Hello World!"
。而是將 "Hello World!"
卡在美化列印佇列中,而 Stdlib.print_newline
在標準輸出上輸出歸位符號。
如果 Format
正在列印到檔案,而標準輸出是終端機,使用者將很難發現該檔案缺少歸位符號(並且檔案上的顯示內容很奇怪,因為應該由 Format.print_newline
關閉的方塊仍然開啟),同時螢幕上出現了偽造的歸位符號!
出於同樣的原因,開啟大型函式庫,例如具有任意精度整數的函式庫,以避免加重使用它們的程式的負擔。
open Num
let rec fib n =
if n <= 2 then Int 1 else fib (n - 1) +/ fib (n - 2)
理由:如果您必須限定所有識別符號,程式的可讀性會降低。
在類型定義共用的程式中,將這些定義收集到一個或多個不含實作的模組(僅包含類型)中是有利的。然後,可以系統性地開啟匯出共用類型定義的模組。
模式匹配
永遠不要害怕過度使用模式匹配!另一方面,請小心避免不完整的模式匹配建構。小心完成它們,當不必要時(例如,當匹配程式中定義的具體類型時),不要使用「捕獲所有」子句,例如 | _ -> ...
或 | x -> ...
。另請參閱下一節:編譯器警告。
編譯器警告
編譯器警告旨在防止潛在錯誤,這就是為什麼您絕對必須注意它們,並在編譯程式產生此類警告時更正程式。此外,編譯產生警告的程式帶有業餘的氣息,這肯定不適合您自己的工作!
模式匹配警告
必須非常小心地處理有關模式匹配的警告。
- 當然,應該消除具有無用子句的警告。
- 對於不完整的模式匹配,您必須完成相應的模式匹配建構,而不要新增預設情況「捕獲所有」,例如
| _ -> ...
,而是使用未經建構的其餘部分檢查的明確建構函式列表,例如| Cn _ | Cn1 _ -> ...
。
理由:以這種方式編寫並不會更複雜,而且這可以讓程式更安全地發展。實際上,向匹配的資料類型新增新的建構函式將會再次產生警示,這將允許程式設計師新增對應於新建構函式的子句(如果需要)。相反,「捕獲所有」子句會使函式靜默編譯,並且可能會認為該函式是正確的,因為新的建構函式將由預設情況處理。
- 還必須更正由帶有守衛的子句引起的不完整模式匹配。一個典型的情況是抑制多餘的守衛。
let
繫結
解構 「解構 let
繫結」是一種同時將多個名稱繫結到多個表達式的方法。您將所有要繫結的名稱打包到一個集合中,例如元組或列表,然後將所有表達式相應地打包到一個集合表達式中。當評估 let
繫結時,它會解壓縮兩邊的集合,並將每個表達式繫結到其對應的名稱。例如,let x, y = 1, 2
是一個解構 let
繫結,它同時執行繫結 let x = 1
和 let y = 2
。
let
繫結不限於簡單的識別符號定義。您可以將它與更複雜或更簡單的模式一起使用。例如
let
搭配複雜模式
let [x; y] as l = ...
同時定義列表l
及其兩個元素x
和y
。let
搭配簡單模式
let _ = ...
不定義任何內容,它只評估=
符號右側的表達式。
let
必須是完整的
解構 僅當模式匹配是完整的時才使用解構 let
繫結(模式永遠不會匹配失敗)。通常,您將因此限制為產品類型定義(元組或記錄),或僅限於單一情況的變體類型定義。在任何其他時間,請使用明確的 match ... with
建構。
let ... in
:發出警告的解構let
必須由明確的模式匹配取代。例如,不要使用let [x; y] as l = List.map succ (l1 @ l2) in expression
,而是編寫
match List.map succ (l1 @ l2) with
| [x; y] as l -> expression
| _ -> assert false
- 應使用明確的模式匹配和元組重寫使用解構
let
陳述式的全域定義
let x, y, l =
match List.map succ (l1 @ l2) with
| [x; y] as l -> x, y, l
| _ -> assert false
理由:如果您使用一般的解構
let
繫結,則沒有辦法使模式匹配完整。
let _ = ...
序列警告和 當編譯器發出有關循序表達式類型的警告時,您必須明確指出您想要忽略此表達式的結果。為此
- 請使用空繫結並抑制序列警告
List.map f l;
print_newline ()
撰寫
let _ = List.map f l in
print_newline ()
- 您也可以使用預定義的函式
ignore : 'a -> unit
,它會忽略其引數以傳回unit
。
ignore (List.map f l);
print_newline ()
- 無論如何,抑制此警告的最佳方法是瞭解編譯器為何發出此警告。編譯器警告您,是因為您的程式碼計算的結果沒有用處,因為它在計算後就被刪除了。因此,如果有的話,此計算僅針對其副作用執行;因此,它應該傳回
unit
。
大多數時候,警告表示使用了錯誤的函式,可能是混淆了函式的僅副作用版本(其結果不相關的程序)及其功能對應項(其結果有意義)。
在上面提到的範例中,第一種情況佔了上風,程式設計師應該呼叫 iter
而不是 map
,然後簡單地寫入
List.iter f l;
print_newline ()
在實際程式中,可能不存在合適的(僅副作用)函式,因此必須編寫它。通常,將函式的程序部分與功能部分仔細分離可以優雅地解決問題,而最終的程式看起來會更好!例如,您會將有問題的定義轉變為
let add x y =
if x > 1 then print_int x;
print_newline ();
x + y;;
更清晰、獨立的定義,並相應地變更對 add
的舊呼叫。
在任何情況下,請僅在您要忽略結果的情況下使用 let _ = ...
建構。不要系統性地用此建構取代序列。
理由:序列更清楚!比較
e1; e2; e3
與let _ = e1 in let _ = e2 in e3
hd
和 tl
函式
不要使用 hd
和 tl
函式,而是明確地模式匹配列表引數。
理由:這與使用
hd
和tl
一樣簡短且更清晰,使用hd
和tl
必須使用try... with...
保護,以捕獲這些函式可能引發的例外。
迴圈
for
迴圈
若要簡單地遍歷陣列或字串,請使用 for
迴圈。
for i = 0 to Array.length v - 1 do
...
done
如果迴圈複雜或傳回結果,請使用遞迴函式。
let find_index e v =
let rec loop i =
if i >= Array.length v then raise Not_found else
if v.(i) = e then i else loop (i + 1) in
loop 0;;
理由:遞迴函式可讓您簡單地編寫任何迴圈,即使是複雜的迴圈,例如具有多個結束點或具有奇怪的索引步驟(例如,步驟取決於資料值)。
此外,遞迴迴圈避免了使用可變量,可變量的值可以在迴圈的任何部分(甚至在外部)修改。相反,遞迴迴圈會明確地將遞迴呼叫期間容易變更的值作為引數。
while
迴圈
While 迴圈法則:小心!
while
迴圈通常是錯誤的,除非已明確編寫其迴圈不變量。
while
迴圈的主要用途是無限迴圈 while true do ...
。您通常透過例外情況(通常在程式終止時)離開它。
其他 while
迴圈很難使用,除非它們來自演算法課程中的現成程式,並且在其中經過驗證。
理由:
while
迴圈需要一個或多個可變量,因此迴圈條件會變更值,而迴圈最終會終止。若要證明它們的正確性,您必須發現迴圈不變量,這是一項有趣但困難的運動。
例外
不要害怕在你的程式中定義自己的例外,但另一方面,盡可能使用系統預先定義的例外。例如,每個失敗的搜尋函式都應該引發預先定義的 Not_found
例外。請務必使用 try ... with
來處理函式呼叫可能引發的例外。
通常,使用 try ... with _ ->
處理所有例外的情況,會保留給程式的主要函式使用。如果你必須捕獲每個例外以維持演算法的不變性,請務必為例外命名,並在重置不變性後重新引發它。通常
let ic = open_in ...
and oc = open_out ... in
try
treatment ic oc;
close_in ic; close_out oc
with x -> close_in ic; close_out oc; raise x
理由:
try ... with _ ->
會靜默地捕獲所有例外,甚至是那些與手邊計算無關的例外(例如,中斷會被捕獲,並且計算會繼續進行!)。
資料結構
OCaml 的一大優勢是可定義資料結構的強大功能以及操作它們的簡便性。所以你必須充分利用這一點!不要猶豫定義自己的資料結構。特別是,不要系統地用整數表示列舉,也不要用布林值表示兩種情況的列舉。範例
type figure =
| Triangle | Square | Circle | Parallelogram
type convexity =
| Convex | Concave | Other
type type_of_definition =
| Recursive | Non_recursive
理由:布林值通常會阻礙對應程式碼的直觀理解。例如,如果
type_of_definition
是用布林值編碼的,那麼true
代表什麼?「正常」的定義(即,非遞迴)還是遞迴定義?在用整數編碼的列舉類型的情況下,很難限制可接受整數的範圍。必須定義建構函式來確保程式的強制不變性(然後驗證沒有直接建立任何值),或者在程式中添加斷言,並在模式匹配中添加防護。當總和類型的定義優雅地解決這個問題時,這不是好習慣,而且還具有激發模式匹配的全部功能以及編譯器驗證詳盡性的額外好處。
批評:對於二元列舉,可以系統地定義其名稱帶有實現該類型的布林值語義的謂詞。例如,我們可以採用謂詞以字母
p
結尾的慣例。然後,我們不用為type_of_definition
定義新的總和類型,而是使用一個謂詞函式recursivep
,如果定義是遞迴的,則返回true
。解答:這種方法特定於二元列舉,並且不易擴展;此外,它不適合模式匹配。例如,用於編碼為
| Let of bool * string * expression
的定義的典型模式匹配如下所示| Let (_, v, e) as def -> if recursivep def then code_for_recursive_case else code_for_non_recursive_case
或者,如果
recursivep
可以應用於布林值| Let (b, v, e) -> if recursivep b then code_for_recursive_case else code_for_non_recursive_case
將其與顯式編碼進行對比
| Let (Recursive, v, e) -> code_for_recursive_case | Let (Non_recursive, v, e) -> code_for_non_recursive_case
這兩個程式之間的差異很微妙,你可能會認為這只是品味的問題;但是,顯式編碼絕對更能抵抗修改,並且更符合語言的規範。
相反地,當建構子 true
和 false
的解釋很清楚時,沒有必要系統地為布林標誌定義新的類型。那麼以下類型定義的實用性就會受到質疑
type switch = On | Off
type bit = One | Zero
對於以整數表示的列舉類型,當這些整數相對於表示的資料具有明顯的解釋時,也可以接受相同的反對意見。
何時使用可變值
可變值對於簡單而清晰的程式設計非常有用,有時是不可或缺的。但是,你必須謹慎使用它們,因為 OCaml 的普通資料結構是不可變的。它們因其允許的程式設計的清晰度和安全性而受到青睞。
迭代器
OCaml 的迭代器是一項強大而有用的功能。但是,你不應該過度使用它們,也不應該忽視它們。它們由函式庫提供,並且很可能由函式庫的作者正確地設計和考慮,因此重新發明它們是沒有用的。
寫
let square_elements elements = List.map square elements
而不是
let rec square_elements = function
| [] -> []
| elem :: elements -> square elem :: square_elements elements
另一方面,避免寫
let iterator f x l =
List.fold_right (List.fold_left f) [List.map x l] l
即使你得到
let iterator f x l =
List.fold_right (List.fold_left f) [List.map x l] l;;
iterator (fun l x -> x :: l) (fun l -> List.rev l) [[1; 2; 3]]
如果有明確的需要,請務必添加解釋性註解。在我看來,這絕對必要!
如何最佳化程式
最佳化偽法則:先驗地不進行最佳化。
後驗地也不進行最佳化。
最重要的是,以簡單而清晰的方式程式設計。在確定程式的瓶頸之前(通常是在幾個常式之後),不要開始最佳化。然後,最佳化首先在於改變演算法的複雜度。這通常是透過重新定義被操作的資料結構,以及完全重寫產生問題的程式部分來實現的。
理由:程式的清晰度和正確性優先。此外,在一個實質性的程式中,實際上不可能先驗地確定程式中哪些部分的效率最為重要。
如何在類別和模組之間選擇
當你需要繼承時,即資料及其功能的增量改進時,請使用 OCaml 類別。
當你需要模式匹配時,請使用傳統的資料結構(尤其是變體類型)。
當資料結構是固定的,並且它們的功能也固定,或者在使用它們的程式中添加新函數就足夠時,請使用模組。
OCaml 程式碼的清晰度
OCaml 語言包含強大的建構,允許簡單而清晰的程式設計。要獲得清晰程式的主要問題是適當地使用它們。
該語言具有多種程式設計風格(或程式設計範例):命令式程式設計(基於狀態和賦值的概念)、函數式程式設計(基於函數、函數結果和計算的概念)以及物件導向程式設計(基於封裝狀態和一些可以修改狀態的程序或方法的物件的概念)。程式設計師的首要工作是選擇最適合手邊問題的程式設計範例。當使用程式設計範例時,困難在於使用語言建構來表達計算,以最自然和最簡單的方式實現演算法。
風格上的危險
關於程式設計風格,通常可以觀察到兩種對稱的問題行為。一方面,「全部命令式」的方式(系統性地使用迴圈和賦值),另一方面,「純函數式」的方式(從不使用迴圈或賦值)。「100% 物件」的風格肯定會在未來出現。
- 「過於命令式」的危險:
- 使用命令式風格來編寫一個自然是遞迴的函式是一個壞主意。例如,要計算列表的長度,你不應該寫
let list_length l =
let l = ref l in
let res = ref 0 in
while !l <> [] do
incr res; l := List.tl !l
done;
!res;;
而是寫以下簡單而清晰的遞迴函式
let rec list_length = function
| [] -> 0
| _ :: l -> 1 + list_length l
(對於那些會質疑這兩個版本等效性的人,請參閱下面的註解)。
-
命令式世界中另一個常見的「過於命令式」的錯誤是,不系統地選擇簡單的
for
迴圈來迭代向量的元素,而是使用一個或兩個引用的複雜while
迴圈。太多無用的賦值意味著太多出錯的機會。 -
這類程式設計師認為,記錄類型定義中的
mutable
關鍵字應該是隱含的。 -
「過於函數式」的危險:
- 堅持這種教條的程式設計師會避免使用陣列和賦值。在最嚴重的情況下,人們會觀察到完全拒絕編寫任何命令式建構,即使它顯然是解決問題最優雅的方式。
- 典型症狀:用遞迴函式系統地重寫
for
迴圈,在命令式資料結構對任何人來說似乎是強制性的情況下使用列表,將問題的眾多全域參數傳遞給每個函式,即使全域引用完美地避免了這些主要是必須重複傳遞的不變量的虛假參數。 - 這位程式設計師認為,記錄類型定義中的
mutable
關鍵字應該從語言中刪除。
普遍認為難以閱讀的 OCaml 程式碼
OCaml 語言包含強大的建構,允許簡單而清晰的程式設計。但是,這些建構的強大功能也使你可以編寫無謂複雜的程式碼,以至於你得到一個完全無法閱讀的程式。
以下是一些編寫過於複雜程式碼的常見方法
- 使用無用的(因此對可讀性來說很初級)
if then else
,如
let flush_ps () =
if not !psused then psused := true
或(更微妙的)
let sync b =
if !last_is_dvi <> b then last_is_dvi := b
- 用另一個建構來編寫一個建構。例如,透過將匿名函式應用於參數來編寫
let ... in
。你會寫
(fun x y -> x + y)
e1 e2
而不是簡單地寫
let x = e1
and y = e2 in
x + y
-
系統地使用
let in
綁定來編寫序列。 -
將計算和副作用混合,尤其是在函式呼叫中。回想一下,函式呼叫參數的評估順序是未指定的,這意味著你不應該在函式呼叫中混合副作用和計算。但是,當只有一個參數時,你可能會利用這一點在參數內執行副作用,儘管這對讀者來說非常麻煩,但不會危及程式語義。這應該絕對禁止。
-
濫用迭代器和高階函式(即過度使用或使用不足)。例如,最好使用
List.map
或List.iter
,而不是透過使用你自己的遞迴函式來內聯編寫它們的等效項。更糟的是,不要使用List.map
或List.iter
,而是根據List.fold_right
和List.fold_left
編寫它們的等效項。 -
編寫難以閱讀的程式碼的另一種有效方法是混合所有或部分這些方法。例如
(fun u -> print_string "world"; print_string u)
(let temp = print_string "Hello"; "!" in
((fun x -> print_string x; flush stdout) " ";
temp));;
如果你自然地以這種方式編寫程式 print_string "Hello world!"
,請將你的作品提交給Obfuscated OCaml Contest。
管理程式開發
以下是來自資深 OCaml 程式設計師的提示,他們曾在開發編譯器方面提供服務。這些都是由小型團隊開發的大型、複雜程式的良好範例。
如何編輯程式
許多開發人員對在 Emacs 編輯器(通常是 GNU Emacs)中編寫程式懷有一種崇敬之情。該編輯器與語言介面良好,因為它能夠對 OCaml 原始程式碼進行語法著色(以顏色呈現不同類別的單字,例如著色關鍵字)。
以下兩個命令被認為是不可或缺的
CTRL-C-CTRL-C
或Meta-X compile
:從編輯器內部啟動重新編譯(使用make
命令)。CTRL-X-`
:將游標放在檔案中,並放在 OCaml 編譯器發出錯誤訊號的確切位置。
開發人員描述了如何使用這些功能:CTRL-C-CTRL-C
組合重新編譯整個應用程式;然後,在出現錯誤的情況下,連續使用 CTRL-X-`
命令允許更正所有發出訊號的錯誤;最後,循環再次開始,使用 CTRL-C-CTRL-C
啟動新的重新編譯。
其他 Emacs 技巧
ESC-/
命令(dynamic-abbrev-expand)會自動補全游標前方的單字,使用編輯檔案中已存在的單字。這讓您可以始終選擇有意義的識別符,而無需在程式中繁瑣地輸入長名稱,也就是說,輸入前幾個字母後,ESC-/
可以輕鬆補全識別符。如果補全錯誤,每次後續的 ESC-/
都會提供一個替代的補全選項。
在 Unix 環境下,CTRL-C-CTRL-C
或 Meta-X compile
組合鍵,接著輸入 CTRL-X-`
,也可以用來在 OCaml 程式中尋找特定字串的所有出現位置。它不會執行 make
來重新編譯,而是執行 grep
命令。接著,grep
輸出的所有「錯誤訊息」都與 CTRL-X-`
的用法相容,這會自動將您帶到檔案和找到字串的位置。
如何使用互動式系統編輯
在 Unix 環境下:使用行編輯器 ledit
,它提供強大的編輯功能,類似 Emacs 的操作方式(包括 ESC-/
!),以及歷史機制,讓您可以檢索先前輸入的命令,甚至可以從一個會話中檢索到另一個會話的命令。ledit
是用 OCaml 撰寫的,可以從這裡免費下載。
如何編譯
make
工具對於管理程式的編譯和重新編譯是不可或缺的。範例 make
檔案可以在 The Hump 上找到。您也可以參考 OCaml 編譯器的 Makefiles
。
如何以團隊形式開發:版本控制
使用 Git 軟體版本控制系統的使用者,總是讚不絕口其帶來的生產力提升。這個系統支援管理程式開發團隊的開發,同時在團隊成員之間保持一致性,並維護軟體變更的日誌。
Git 也支援多個團隊同時開發,這些團隊可能分散在網際網路連接的不同地點。
一個匿名的 Git 唯讀鏡像 包含 OCaml 編譯器的工作原始碼,以及其他與 OCaml 相關的軟體原始碼。
格式化指南
如果您選擇不使用 OCamlFormat 自動格式化您的原始碼,請在手動格式化時考慮以下樣式指南。
偽空格法則:請不要猶豫在您的程式中使用空格分隔單字。空白鍵是鍵盤上最容易找到的按鍵,請盡可能頻繁地按下它!
分隔符
分隔符號後應始終加上空格,運算符號的兩側應加上空格。為了讓書面文字更容易閱讀,在單字之間使用空格分隔是排版上的一大進步。如果您希望程式易於閱讀,請在您的程式中也這樣做。
如何寫入配對
元組使用括號括起來,其中的逗號(分隔符)後接一個空格:(1, 2)
、let triplet = (x, y, z)
...
普遍接受的例外:
-
定義配對的組成部分:您可以寫成
let x, y = ...
,而不是let (x, y) = ...
。理由:重點在於同時定義多個值,而不是建構一個元組。此外,模式在
let
和=
之間可以很好地呈現。 -
同時匹配多個值:當同時匹配多個值時,可以省略 n 元組周圍的括號。
match x, y with | 1, _ -> ... | x, 1 -> ... | x, y -> ...
理由:重點在於並行匹配多個值,而不是建構一個元組。此外,被匹配的表達式被
match
和with
分隔,而模式則被|
和->
很好地分隔。
如何寫入列表
寫成 x :: l
,在 ::
周圍加上空格(因為 ::
是中綴運算符,因此以空格包圍),並寫成 [1; 2; 3]
(因為 ;
是分隔符,因此後接一個空格)。
如何寫入運算符號
請注意保持運算符號以空格分隔。這不僅會使您的公式更易於閱讀,還可以避免與多字元運算符混淆。(此規則的明顯例外是符號 !
和 .
,它們不會與其參數分隔。)
範例:寫成 x + 1
或 x + !y
。
理由:如果您省略空格,則
x+1
會被理解,但x+!y
會改變其含義,因為+!
會被解釋為多字元運算符。批評:當使用空格反映運算符的相對優先順序時,運算符周圍沒有空格可以提高公式的可讀性。例如,
x*y + 2*z
很明顯地表示乘法的優先順序高於加法。回應:這是一個糟糕的想法,是一種幻想,因為語言中沒有任何東西可以確保空格可以正確反映公式的含義。例如,
x * z-1
表示(x * z) - 1
而不是x * (z - 1)
,正如空格的建議解釋似乎暗示的那樣。此外,多字元符號的問題會讓您無法以一致的方式使用此慣例,也就是說,您不能省略乘法周圍的空格來寫成x*!y + 2*!z
。最後,使用空格是一種微妙且脆弱的慣例,是一種潛意識訊息,在閱讀時很難掌握。如果您想要讓優先順序顯而易見,請使用語言提供的表達方式:寫入括號。額外理由:系統地使用空格包圍運算符可以簡化對中綴運算符的處理,它們不是複雜的特殊情況。實際上,雖然您可以寫成不帶空格的
(+)
,但您顯然不能寫成(*)
,因為(*
會被讀取為註解的開頭。您至少必須寫入一個空格,例如「( *)
」,儘管如果您想避免*)
在某些情況下被讀取為註解的結尾,則在*
之後使用額外的空格絕對更好。如果您採用此處提出的簡單規則,即保持運算符號以空格分隔,則可以輕鬆避免所有這些困難。
事實上,您會很快發現這個規則並不難遵循。空白鍵是鍵盤上最大且位置最佳的按鍵。它是最容易使用的,因為您不會錯過它!
如何寫入長字元字串
使用該行強制執行的慣例縮排長字元字串,並在每行的末尾加上字串連續的指示(行末的 \
字元會省略下一行開頭的空白字元)
let universal_declaration =
"-1- Programs are born and remain free and equal under the law;\n\
distinctions can only be based on the common good." in
...
何時在表達式中使用括號
括號是有意義的。它們表示需要使用不尋常的優先順序,因此應明智地使用它們,而不是隨機地散布在程式中。為此,您應該了解常用的優先順序,即不需要使用括號的運算組合。很幸運的是,如果您了解一點數學或努力遵循以下規則,這並不複雜
算術運算符:與數學中相同的規則
例如:1 + 2 * x
表示 1 + (2 * x)
。
函數應用:與數學中三角函數用法相同的規則
在數學中,您寫成 sin x
表示 sin (x)
。同樣,sin x + cos x
表示 (sin x) + (cos x)
而不是 sin (x + (cos x))
。在 OCaml 中使用相同的慣例:寫成 f x + g x
表示 (f x) + (g x)
。
此慣例推廣到所有(中綴)運算符:f x :: g x
表示 (f x) :: (g x)
,f x @ g x
表示 (f x) @ (g x)
,以及 failwith s ^ s'
表示 (failwith s) ^ s'
,而不是 failwith (s ^ s')
。
比較和布林運算符
比較是中綴運算符,因此適用先前的規則。這就是為什麼 f x < g x
表示 (f x) < (g x)
的原因。由於類型原因(以及沒有其他合理的解釋),表達式 f x < x + 2
表示 (f x) < (x + 2)
。同樣,f x < x + 2 && x > 3
表示 ((f x) < (x + 2)) && (x > 3)
。
布林運算符的相對優先順序與數學中的優先順序相同
儘管數學家傾向於過度使用括號,但布林「或」運算符類似於加法,而「和」運算符類似於乘法。因此,正如 1 + 2 * x
表示 1 + (2 * x)
一樣,true || false && x
表示 true || (false && x)
。
如何分隔程式中的結構
當需要在程式中分隔語法結構時,請使用關鍵字 begin
和 end
作為分隔符,而不是括號。但是,如果您以一致且系統的方式執行此操作,則可以使用括號。
這種對結構的明確分隔主要涉及模式匹配結構或嵌入在 if then else
結構中的序列。
match
結構中的 match
結構
當 match ... with
或 try ... with
結構出現在模式匹配子句中時,絕對有必要分隔此嵌入式結構(否則,外層模式匹配結構的後續子句將自動與內層模式匹配結構相關聯)。例如
match x with
| 1 ->
begin match y with
| ...
end
| 2 ->
...
if
分支內的序列
同樣,出現在條件式 then
或 else
部分的序列必須分隔
if cond then begin
e1;
e2
end else begin
e3;
e4
end
程式的縮排
Landin 的偽法則:將程式的縮排視為它決定程式的含義。
我想補充這個法則:請注意程式中的縮排,因為在某些情況下,它確實定義了程式的含義!
程式的縮排是一門藝術,會引起許多強烈的意見。在這裡,給出了幾個從經驗中汲取且未受到嚴重批評的縮排樣式。
當我認為所採用樣式的理由很明顯時,我已經指出了。另一方面,也記錄了批評意見。
因此,每次您都必須在建議的不同樣式之間進行選擇。
唯一的絕對規則是下面的第一個。
縮排的一致性
選擇一種普遍接受的縮排樣式,然後在整個應用程式中系統地使用它。
頁面寬度
頁面寬度為 80 個欄位。
理由:此寬度可以讓您在所有顯示器上閱讀程式碼,並以清晰的字體列印在標準紙張上。
頁面高度
一個函式應始終適合一個螢幕(約 70 行),在特殊情況下最多兩個,最多三個。超過這個限制是不合理的。
理由:當一個函數超出一個螢幕的範圍時,就應該將其分解為子問題並獨立處理。超過一個螢幕範圍,人們就會迷失在程式碼中。縮排變得難以閱讀,並且難以保持正確。
縮排多少
程式碼中連續兩行之間的縮排通常為 1 或 2 個空格。選擇一個縮排量,並在整個程式中堅持使用。
使用 Tab 停靠點
絕對不建議使用 Tab 字元(ASCII 字元 9)。
理由:在不同的顯示器之間,程式碼的縮排會完全改變。如果程式設計師同時使用 Tab 和空格來縮排程式碼,也可能會完全錯誤。
批評:使用 Tab 的目的是允許讀者透過更改 Tab 停靠點來或多或少地縮排。整體縮排保持正確,讀者很高興能輕鬆自訂縮排量。
答案:這種方法幾乎不可能使用,因為您應該始終使用 Tab 來縮排,這很困難且不自然。
如何縮排運算
當運算子接受複雜的參數時,或在多次呼叫同一運算子的情況下,請以運算子開始下一行,並且不要縮排運算的其餘部分。例如
x + y + z
+ t + u
理由:當運算子開始該行時,很明顯運算會在此行繼續。
當處理此類運算序列中的「大型運算式」時,最好使用 let in
建構定義「大型運算式」,而不是縮排該行。取代
x + y + z
+ “large
expression”
撰寫
let t =
“large
expression” in
x + y + z + t
當使用運算子組合時,您肯定必須綁定過大的運算式,使其無法在一個運算中寫入。取代難以閱讀的運算式
(x + y + z * t)
/ (“large
expression”)
撰寫
let u =
“large
expression” in
(x + y + z * t) / u
這些準則適用於所有運算子。例如
let u =
“large
expression” in
x :: y
:: z + 1 :: t :: u
let ... ;;
定義
如何縮排全域 通常,在模組中全域定義的函數主體會正常縮排。但是,可以特別處理這種情況,以更好地偏移定義。
使用 1 或 2 個空格的規則縮排
let f x = function
| C ->
| D ->
...
let g x =
let tmp =
match x with
| C -> 1
| x -> 0 in
tmp + 1
理由:縮排量沒有例外。
其他慣例也是可以接受的,例如
- 當進行模式比對時,主體會靠左對齊。
let f x = function
| C ->
| D ->
...
理由:分隔模式的垂直線在定義完成時停止,因此仍然很容易繼續進行下一個定義。
批評:與正常縮排相比,這是一個令人不快的例外。
- 主體會在已定義函數的名稱下方對齊。
let f x =
let tmp = ... in
try g x with
| Not_found ->
...
理由:定義的第一行會很好地偏移,因此更容易從一個定義轉到另一個定義。
批評:您太快就會碰到右邊界。
let ... in
建構
如何縮排 由 let
引入的定義之後的運算式會縮排到與關鍵字 let
相同的級別,而引入它的關鍵字 in
會寫在行的末尾
let expr1 = ... in
expr1 + expr1
在有一系列 let
定義的情況下,先前的規則表示。這些定義應放置在相同的縮排級別
let expr1 = ... in
let n = ... in
...
理由:建議將一系列
let ... in
建構比作數學文本中的一組假設,因此所有假設都具有相同的縮排級別。
變化:有些人將關鍵字 in
單獨寫在一行上,以區分計算的最終運算式
let e1 = ... in
let e2 = ... in
let new_expr =
let e1' = derive_expression e1
and e2' = derive_expression e2 in
Add_expression e1' e2'
in
Mult_expression (new_expr, new_expr)
批評:缺乏一致性。
if ... then ... else ...
如何縮排 多個分支
以相同的縮排級別寫入具有多個分支的條件
if cond1 ...
if cond2 ...
if cond3 ...
理由:與模式比對子句的類似處理,全部對齊到相同的 Tab 停靠點。
如果條件的大小和運算式允許,請寫入
if cond1 then e1 else
if cond2 then e2 else
if cond3 then e3 else
e4
如果多個條件分支中的運算式必須被括起來(例如,當它們包含陳述式時),請寫入
if cond then begin
e1
end else
if cond2 then begin
e2
end else
if cond3 then ...
有些人建議另一種處理多個條件的方法:以關鍵字 else
開始每一行
if cond1 ...
else if cond2 ...
else if cond3 ...
理由:
elsif
是許多語言中的關鍵字,因此請使用縮排和else if
來提醒。此外,您無需查看行尾即可知道條件是否繼續或執行另一個測試。批評:在處理所有條件時缺乏一致性。為什麼第一個條件使用特殊情況?
再次強調,請選擇您的風格並有系統地使用它。
單一分支
根據相關運算式的大小,特別是這些運算式的 begin
、end
或 (
)
分隔符號的存在,單一分支可以使用幾種樣式。
當分隔條件分支時,會使用幾種樣式
(
在行尾if cond then ( e1 ) else ( e2 )
或者,在行首第一個
begin
if cond then begin e1 end else begin e2 end
事實上,條件的縮排取決於其運算式的大小。
如果
cond
、e1
和e2
很小,則只需將它們寫在一行上if cond then e1 else e2
如果構成條件的運算式是純函數式的(沒有副作用),我們建議當它們太大而無法容納在一行上時,使用
let e = ... in
將它們綁定在條件內。理由:這樣,您就可以回到單行上的簡單縮排,這是最易讀的。作為一個額外的好處,命名有助於理解。
因此,現在我們考慮的問題是,相關運算式確實有副作用,這使我們無法簡單地使用
let e = ... in
綁定它們。如果
e1
和cond
很小,但e2
很大if cond then e1 else e2
如果
e1
和cond
很大,但e2
很小if cond then e1 else e2
如果所有運算式都很大
if cond then e1 else e2
如果有
( )
分隔符號if cond then ( e1 ) else ( e2 )
e1
需要( )
,但e2
很小的混合情況if cond then ( e1 ) else e2
如何縮排模式比對建構
一般原則
所有模式比對子句都以垂直線引入,包括第一個子句。
批評:第一個垂直線不是強制性的。因此,沒有必要寫入它。
對批評的回答:如果您省略第一個垂直線,則縮排看起來不自然。第一個案例的縮排比正常的新行所需要的縮排要大。因此,這是對正確縮排規則的無用例外。它還堅持不要對整組子句使用相同的語法,而是將第一個子句作為例外,使用稍微不同的語法寫入。最後,美學價值值得懷疑(有些人會說「糟糕」而不是「值得懷疑」)。
將所有模式比對子句與開始每個子句的垂直線對齊,包括第一個子句。
如果子句中的運算式太大而無法容納在一行上,則必須在相應子句的箭頭之後立即斷行。然後從子句模式的開頭開始正常縮排。
模式比對子句的箭頭不應對齊。
match
或 try
對於 match
或 try
,請將子句與建構的開頭對齊
match lam with
| Abs (x, body) -> 1 + size_lambda body
| App (lam1, lam2) -> size_lambda lam1 + size_lambda lam2
| Var v -> 1
try f x with
| Not_found -> ...
| Failure "not yet implemented" -> ...
將關鍵字 with
放在行的末尾。如果前面的運算式超出了一行,請將 with
放在單獨的一行上
try
let y = f x in
if ...
with
| Not_found -> ...
| Failure "not yet implemented" -> ...
理由:單獨一行上的關鍵字
with
表示程式進入建構的模式比對部分。
縮排子句內的運算式
如果模式比對箭頭右側的運算式太大,則在箭頭之後斷行。
match lam with
| Abs (x, body) ->
1 + size_lambda body
| App (lam1, lam2) ->
size_lambda lam1 + size_lambda lam2
| Var v ->
一旦一個運算式溢出,一些程式設計師就會將此規則推廣到所有子句。然後,他們會像這樣縮排最後一個子句
| Var v ->
1
其他程式設計師會更進一步,並將此規則系統地應用於任何模式比對的任何子句。
let rec fib = function
| 0 ->
1
| 1 ->
1
| n ->
fib (n - 1) + fib ( n - 2)
批評:可能不夠緊湊。對於簡單的模式比對(或複雜比對中的簡單子句),該規則並未提高可讀性。
理由:我看不出此規則有任何理由,除非您的薪水與程式碼的行數成正比。在這種情況下,請使用此規則來獲得更多報酬,而不會在您的 OCaml 程式中增加更多錯誤!
匿名函數中的模式比對
與 match
或 try
類似,以 function
開頭的匿名函數的模式比對是根據 function
關鍵字縮排的
map
(function
| Abs (x, body) -> 1 + size_lambda 0 body
| App (lam1, lam2) -> size_lambda (size_lambda 0 lam1) lam2
| Var v -> 1)
lambda_list
具名函數中的模式比對
由 let
或 let rec
定義的函數中的模式比對會產生幾種合理的樣式,這些樣式遵守先前的模式比對規則(顯然排除匿名函數的模式比對)。有關建議的樣式,請參閱上文。
let rec size_lambda accu = function
| Abs (x, body) -> size_lambda (succ accu) body
| App (lam1, lam2) -> size_lambda (size_lambda accu lam1) lam2
| Var v -> succ accu
let rec size_lambda accu = function
| Abs (x, body) -> size_lambda (succ accu) body
| App (lam1, lam2) -> size_lambda (size_lambda accu lam1) lam2
| Var v -> succ accu
模式捕捉建構的不良縮排
函數和案例分析的糟糕縮排。
這包括在先前推到右側的關鍵字 match
或 function
下正常縮排。不要寫入
let rec f x = function
| [] -> ...
...
而是選擇在 let
關鍵字下縮排該行
let rec f x = function
| [] -> ...
...
理由:您會碰到邊界。美學價值值得懷疑。
->
符號的糟糕對齊。
模式比對子句中 仔細對齊模式比對箭頭被認為是不好的做法,如下面的片段所示
let f = function
| C1 -> 1
| Long_name _ -> 2
| _ -> 3
理由:這使得維護程式碼更加困難(添加額外的案例可能會導致所有縮排發生變化,因此我們通常會在當時放棄對齊。在這種情況下,最好一開始就不要對齊箭頭!)。
如何縮排函數呼叫
縮排到函數的名稱
除非函數有許多參數,或是參數非常複雜以致於無法放在同一行,否則不會有問題。你必須根據所選擇的慣例,將函數名稱的運算式縮排(1 或 2 個空格)。將小的參數寫在同一行,並在參數的開頭換行。
盡可能避免使用由複雜運算式組成的參數。在這些情況下,使用 let
結構定義「大型」參數。
理由:沒有縮排問題。如果給予運算式的名稱是有意義的,程式碼會更易讀。
額外理由:如果參數的求值會產生副作用,則
let
綁定實際上是必要的,以明確定義求值順序。
註解
list_length
的命令式和函數式版本
在複雜度方面,list_length
的兩個版本並不完全等效。命令式版本使用恆定的堆疊空間來執行,而函數式版本需要儲存暫停遞迴呼叫的返回位址(其最大數量等於列表參數的長度)。如果你想要檢索執行函數式程式的恆定空間需求,你只需撰寫一個在其尾部進行遞迴的函數(或尾遞迴)。這是一個僅以遞迴呼叫結束的函數(這裡的情況並非如此,因為在遞迴呼叫返回後必須執行 +
的呼叫)。只需使用累加器來儲存中間結果,如下所示:
let list_length l =
let rec loop accu = function
| [] -> accu
| _ :: l -> loop (accu + 1) l in
loop 0 l
這樣一來,你會得到一個程式,其計算特性與命令式程式相同,並且額外具有執行模式匹配和遞迴呼叫來處理屬於遞迴總和資料型別的參數的演算法的清晰度和自然外觀。
致謝
法文原始翻譯:Ruchira Datta。
感謝所有已參與此頁面評論的人:Daniel de Rauglaudre、Luc Maranget、Jacques Garrigue、Damien Doligez、Xavier Leroy、Bruno Verlyck、Bruno Petazzoni、Francois Maltey、Basile Starynkevitch、Toby Moth、Pierre Lescanne。