類型規格與 Erlang

PLT 是最好的三明治

a BLT sandwich

回到類型(或缺乏類型),我介紹了 Dialyzer,這是一個用於在 Erlang 中尋找類型錯誤的工具。本章將完全聚焦於 Dialyzer 以及如何實際使用 Erlang 找出一些類型錯誤,以及如何使用該工具來尋找其他類型的差異。我們將從了解為什麼創建 Dialyzer 開始,然後了解其背後的指導原則及其查找與類型相關的錯誤的能力,最後,我們將提供一些實際使用的範例。

Dialyzer 是一個非常有效的 Erlang 程式碼分析工具。它用於尋找各種差異,例如永遠不會執行的程式碼,但其主要用途通常是尋找 Erlang 程式碼庫中的類型錯誤。

在深入了解它之前,我們將建立 Dialyzer 的持久查找表 (PLT),它是 Dialyzer 可以識別的關於標準 Erlang 發行版中包含的應用程式和模組,以及 OTP 之外程式碼的所有詳細資訊的彙編。編譯所有內容需要相當長的時間,特別是如果您在沒有透過 HiPE 進行原生編譯的平台上(也就是 Windows)或在舊版本的 Erlang 上執行它。幸運的是,隨著時間的推移,速度往往會加快,並且新版本(R15B02 以後)中的最新 Erlang 版本正在使用並行 Dialyzer 來使速度更快。在終端機中輸入以下指令,並讓它執行它需要的時間

$ dialyzer --build_plt --apps erts kernel stdlib crypto mnesia sasl common_test eunit
Compiling some key modules to native code... done in 1m19.99s
Creating PLT /Users/ferd/.dialyzer_plt ...
eunit_test.erl:302: Call to missing or unexported function eunit_test:nonexisting_function/0
Unknown functions:
compile:file/2
compile:forms/2
...
xref:stop/1
Unknown types:
compile:option/0
done in 6m39.15s
done (warnings were emitted)

此命令透過指定我們要包含在其中的 OTP 應用程式來建立 PLT。如果您願意,可以忽略警告,因為 Dialyzer 在尋找類型錯誤時可以處理未知函數。當我們稍後在本章討論其類型推斷演算法如何運作時,就會明白原因。某些 Windows 使用者會看到一條錯誤訊息,指出「需要設定 HOME 環境變數,以便 Dialyzer 知道在哪裡找到預設的 PLT」。這是因為 Windows 並非總是設定 HOME 環境變數,而 Dialyzer 不知道將 PLT 放在哪裡。將變數設定為您希望 Dialyzer 放置其檔案的任何位置。

如果您願意,可以透過將它們新增到 --apps 後面的序列中來新增 sslreltool 之類的應用程式,或者如果您的 PLT 已經建立,則透過呼叫

$ dialyzer --add_to_plt --apps ssl reltool

如果您想將自己的應用程式或模組新增到 PLT,可以使用 -r Directories 來執行,它會尋找所有 .erl.beam 檔案(只要它們是用 debug_info 編譯的)以將它們新增到 PLT。

此外,Dialyzer 允許您透過在您執行的任何命令中使用 --plt Name 選項來指定多個 PLT,並選擇特定的 PLT。或者,如果您建立了許多不相交的 PLT,其中 PLT 之間不共用任何包含的模組,您可以使用 --plts Name1 Name2 ... NameN 來「合併」它們。當您希望系統中為不同的專案或不同的 Erlang 版本設定不同的 PLT 時,這特別有用。這樣做的缺點是,從合併的 PLT 獲得的資訊不如所有資訊最初都包含在單個 PLT 中那麼精確。

在 PLT 還在建置時,我們應該熟悉 Dialyzer 尋找類型錯誤的機制。

成功類型化

正如大多數其他動態程式設計語言的情況一樣,Erlang 程式始終面臨遭受類型錯誤的風險。程式設計師將一些不應該有的引數傳遞給函數,也許他忘記正確測試。程式部署後,一切似乎都還好。然後在凌晨四點,您公司的運營人員的手機開始響起,因為您的軟體不斷崩潰,以至於主管無法應付您的錯誤所造成的巨大負擔。

Atlas lifting a rock with bad practice terms such as 'no tests', 'typos', 'large messages', 'bugs', etc.

第二天早上,您來到辦公室,發現您的電腦已被重新格式化,您的汽車被刮花,您的提交權限已被撤銷,這一切都是因為運營人員受夠了您意外地控制了他的工作時間表。

透過具有靜態類型分析器來驗證程式的編譯器,可以避免整個災難。雖然 Erlang 並不像其他動態語言那樣渴望類型系統,但由於其對執行階段錯誤的反應性方法,從早期類型相關的錯誤發現所提供的額外安全性中受益絕對是件好事。

通常,具有靜態類型系統的語言會以這種方式設計。語言的語義在它們允許和不允許的方面受到其類型系統的嚴重影響。例如,像這樣的函數

foo(X) when is_integer(X) -> X + 1;
foo(X) -> list_to_atom(X).

大多數類型系統都無法正確表示上述函數的類型。它們可以看到它可以接受整數或列表,並返回整數或原子,但它們不會追蹤函數的輸入類型和輸出類型之間的依賴關係(條件類型和交集類型能夠做到,但它們可能很冗長)。這意味著編寫這樣的函數(在 Erlang 中是完全正常的)可能會在這些函數稍後在程式碼中使用時,導致類型分析器出現一些不確定性。

一般來說,分析器實際上會想要證明在執行階段不會有類型錯誤,就像在數學上證明一樣。這表示在某些情況下,類型檢查器為了消除可能導致崩潰的不確定性,會不允許某些實際有效的操作。

實作這樣的類型系統可能意味著強迫 Erlang 更改其語義。問題在於,當 Dialyzer 出現時,Erlang 已經在非常大的專案中被廣泛使用。要讓任何像 Dialyzer 這樣的工具被接受,它都需要尊重 Erlang 的哲學。如果 Erlang 在其類型中允許只能在執行階段解決的純粹廢話,那就這樣吧。類型檢查器沒有權利抱怨。沒有程式設計師喜歡一個工具告訴他程式無法執行,即使它已經在生產環境中執行幾個月了!

那麼,另一種選擇是擁有一個不會證明沒有錯誤的類型系統,而是在盡最大努力偵測它可以偵測到的任何錯誤。您可以使這樣的偵測非常好,但它永遠不會是完美的。這是一個需要權衡的取捨。

因此,Dialyzer 的類型系統決定不在類型方面證明程式沒有錯誤,而只是盡可能多地找到錯誤,而絕不會與現實世界中發生的情況相矛盾

我們的主要目標是揭示 Erlang 程式碼中隱含的類型資訊,並使其在程式中明確可用。由於典型 Erlang 應用程式的大小,類型推斷應該是完全自動的,並忠實地尊重該語言的操作語義。此外,它不應強加任何形式的程式碼重寫。原因很簡單。為了滿足類型推斷器而重寫由數十萬行程式碼組成的通常安全攸關的應用程式,並不是一個會很成功的選項。然而,大型軟體應用程式必須維護,而且通常不是由其原始作者維護。透過自動顯示已經存在的類型資訊,我們提供可以隨著程式演變的自動化文件,而不會腐爛。我們也認為在精確度和可讀性之間取得平衡非常重要。最後但同樣重要的是,推斷的類型永遠不應該是錯誤的。

正如 Dialyzer 背後的成功類型化論文所解釋的那樣,像 Erlang 這樣的語言的類型檢查器應該在沒有類型宣告的情況下工作(儘管它接受提示),應該簡單易讀,應該適應語言(而不是相反),並且只會抱怨會保證崩潰的類型錯誤。

因此,Dialyzer 在每次分析時都會樂觀地假設所有函數都很好。它會將它們視為總是成功,接受任何東西,並且可能返回任何東西。無論如何使用未知函數,這都是使用它的好方法。這就是為什麼在產生 PLT 時,有關未知函數的警告不是什麼大問題。無論如何都很好;當談到類型推斷時,Dialyzer 是一個天生的樂觀主義者。

隨著分析的進行,Dialyzer 會越來越了解您的函數。當它這樣做時,它可以分析程式碼並看到一些有趣的事情。假設您的某個函數在其兩個引數之間有一個 + 運算子,並且它返回加法的值。Dialyzer 不再假設函數接受任何東西並返回任何東西,而是現在會期望引數為數字(整數或浮點值),並且返回的值也將類似地為數字。此函數將具有與其相關聯的基本類型,表示它接受兩個數字並返回一個數字。

現在假設您的某個函數使用原子和數字呼叫上面描述的函數。Dialyzer 會考慮程式碼並說「等一下,你不能將原子和數字與 + 運算子一起使用!」然後它會驚慌失措,因為在函數之前可以返回一個數字的地方,它無法根據您的使用方式返回任何東西。

但是,在更一般的情況下,您可能會看到 Dialyzer 對您知道有時會導致錯誤的許多事情保持沉默。以一段看起來有點像這樣的程式碼片段為例

main() ->
    X = case fetch() of
        1 -> some_atom;
        2 -> 3.14
    end,
    convert(X).

convert(X) when is_atom(X) -> {atom, X}.

這段程式碼假設存在一個 fetch/0 函數,該函數返回 12。基於此,我們要麼返回原子,要麼返回浮點數。

從我們的角度來看,似乎在某個時間點,對 convert/1 的呼叫將會失敗。當 fetch() 返回 2 時,我們可能會在那裡期望出現類型錯誤,這會將浮點數值傳送到 convert/1。Dialyzer 不這麼認為。請記住,Dialyzer 很樂觀。它對您的程式碼有象徵性的信心,而且由於有可能在某個時間點對 convert/1 的函數呼叫成功,Dialyzer 將會保持沉默。在這種情況下,不會報告類型錯誤。

類型推斷與差異

為了實際示範以上的原則,我們來試著對幾個模組執行 Dialyzer。這些模組分別是 discrep1.erldiscrep2.erldiscrep3.erl。它們彼此之間是演進的關係。以下是第一個模組:

-module(discrep1).
-export([run/0]).

run() -> some_op(5, you).

some_op(A, B) -> A + B.

這個模組中的錯誤很明顯。你不能將 5 加到 you 這個原子上。假設 PLT 已經建立,我們可以對這段程式碼執行 Dialyzer。

$ dialyzer discrep1.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
discrep1.erl:4: Function run/0 has no local return
discrep1.erl:4: The call discrep1:some_op(5,'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
discrep1.erl:6: Function some_op/2 has no local return
discrep1.erl:6: The call erlang:'+'(A::5,B::'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
 done in 0m0.62s
done (warnings were emitted)

喔,真是太有趣了,Dialyzer 找到了一些東西。這到底是什麼意思?第一個錯誤是在使用 Dialyzer 時你會經常遇到的。 「Function Name/Arity has no local return」是 Dialyzer 在函數明顯沒有回傳任何值時(除了可能引發異常之外)發出的標準警告,因為它呼叫的某個函數觸發了 Dialyzer 的型別錯誤偵測器,或是函數本身引發了異常。當這種情況發生時,函數可能回傳的值的型別集合為空;它實際上沒有回傳值。這個錯誤會傳播到呼叫它的函數,導致我們得到「no local return」錯誤。

第二個錯誤稍微容易理解一些。它表示呼叫 some_op(5, 'you') 違反了 Dialyzer 偵測到的函數運作所需的型別,也就是兩個數字(number()number())。目前來說,這種表示法可能有點陌生,但我們很快就會詳細介紹。

第三個錯誤又是「no local return」。第一個錯誤是因為 some_op/2 會失敗,而這個錯誤則是因為 + 呼叫會失敗。這就是第四個,也是最後一個錯誤的原因。加號運算子(實際上是函數 erlang:'+'/2)不能將數字 5 加到原子 you 上。

那麼 discrep2.erl 呢?它的程式碼如下:

-module(discrep2).
-export([run/0]).

run() ->
    Tup = money(5, you),
    some_op(count(Tup), account(Tup)).

money(Num, Name) -> {give, Num, Name}.
count({give, Num, _}) -> Num.
account({give, _, X}) -> X.

some_op(A, B) -> A + B.

如果你再次對這個檔案執行 Dialyzer,你會得到和之前類似的錯誤。

$ dialyzer discrep2.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
discrep2.erl:4: Function run/0 has no local return
discrep2.erl:6: The call discrep2:some_op(5,'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
discrep2.erl:12: Function some_op/2 has no local return
discrep2.erl:12: The call erlang:'+'(A::5,B::'you') will never return since it differs in the 2nd argument from the success typing arguments: (number(),number())
 done in 0m0.69s
done (warnings were emitted)

在分析過程中,Dialyzer 可以看穿 count/1account/1 函數的型別。它會推斷出 Tuple 中每個元素的型別,然後找出它們傳遞的值。這樣它就能再次發現錯誤,沒有任何問題。

讓我們用 discrep3.erl 來進一步挑戰:

-module(discrep3).
-export([run/0]).

run() ->
    Tup = money(5, you),
    some_op(item(count, Tup), item(account, Tup)).

money(Num, Name) -> {give, Num, Name}.

item(count, {give, X, _}) -> X;
item(account, {give, _, X}) -> X.

some_op(A, B) -> A + B.

這個版本引入了一個新的間接層級。這個版本不是直接定義 count 和 account 值的函數,而是使用原子,並切換到不同的函數子句。如果我們對它執行 Dialyzer,會得到以下結果:

$ dialyzer discrep3.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis... done in 0m0.70s
done (passed successfully)
A check for 5 cents made to 'YOU!'

哎呀。不知何故,對檔案的新變更使事情變得足夠複雜,導致 Dialyzer 在我們的型別定義中迷失了方向。但錯誤仍然存在。我們稍後會回來了解為什麼 Dialyzer 在這個檔案中找不到錯誤,以及如何修正它。但現在,我們還有幾種執行 Dialyzer 的方法需要探索。

如果我們想對例如我們的 Process Quest 發行版本 執行 Dialyzer,我們可以這樣做:

$ cd processquest/apps
$ ls
processquest-1.0.0  processquest-1.1.0  regis-1.0.0  regis-1.1.0  sockserv-1.0.0  sockserv-1.0.1

所以我們有一堆函式庫。如果我們有許多同名的模組,Dialyzer 會不喜歡,所以我們需要手動指定目錄。

$ dialyzer -r processquest-1.1.0/src regis-1.1.0/src sockserv-1.0.1/src
Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
Proceeding with analysis...
dialyzer: Analysis failed with error:
No .beam files to analyze (no --src specified?)

喔,對了。預設情況下,Dialyzer 會尋找 .beam 檔案。我們需要加入 --src 旗標來告訴 Dialyzer 使用 .erl 檔案進行分析。

$ dialyzer -r processquest-1.1.0/src regis-1.1.0/src sockserv-1.0.1/src --src
Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
Proceeding with analysis... done in 0m2.32s
done (passed successfully)

你會注意到我選擇在所有請求中加入 src 目錄。你可以不使用它來執行相同的搜尋,但這樣 Dialyzer 就會抱怨一堆與 EUnit 測試相關的錯誤,因為一些斷言巨集在程式碼分析方面的運作方式 — 我們實際上並不關心這些。此外,如果你有時會測試失敗,並刻意讓軟體在測試中崩潰,Dialyzer 也會挑剔,你可能不希望它這樣做。

關於型別的型別

discrep3.erl 中所見,Dialyzer 有時可能無法以我們預期的方式推斷出所有型別。那是因為 Dialyzer 無法讀懂我們的心思。為了協助 Dialyzer 執行其任務(主要也是為了幫助我們自己),我們可以宣告型別並註解函數,以便記錄它們,並幫助形式化我們在程式碼中對型別的隱式期望。

Erlang 型別可以是簡單的事物,例如數字 42,以 42 作為型別(與平常沒有什麼不同),或是特定的原子,例如 catmolecule。這些稱為 *單例型別*,因為它們指的是值本身。存在以下單例型別:

'some atom' 任何原子都可以是它自己的單例型別。
42 一個給定的整數。
[] 一個空列表。
{} 一個空 Tuple。
<<>> 一個空二進制。

你可以看到,僅使用這些型別來編寫 Erlang 程式會很麻煩。沒有辦法表示年齡之類的東西,更不用說用單例型別來表示程式中「所有的整數」。而且,即使我們有辦法一次指定許多型別,不手動寫出它們,就想表示「任何整數」之類的東西也是非常麻煩的,而且無論如何這也不是完全可能的。

因此,Erlang 具有 *聯合型別*,它允許你描述一個包含兩個原子的型別;以及 *內建型別*,它們是預先定義的型別,不一定能手動建構,而且它們通常很有用。聯合型別和內建型別通常共用類似的語法,並且它們以 TypeName() 的形式表示。例如,所有可能整數的型別會以 integer() 表示。使用括號的原因是它們讓我們可以區分,例如所有原子的型別 atom() 和特定的 atom 原子。此外,為了使程式碼更清晰,許多 Erlang 程式設計師選擇在型別宣告中引用所有原子,給出 'atom' 而不是 atom。這明確表示 'atom' 應該是一個單例型別,而不是程式設計師忘記括號的內建型別。

以下是語言提供的內建型別表格。請注意,它們並非都具有與聯合型別相同的語法。它們中的一些,例如二進制和 Tuple,具有特殊的語法,使其更易於使用。

any() 任何 Erlang 項。
none() 這是一種特殊的型別,表示沒有項或型別有效。通常,當 Dialyzer 將函數可能的回傳值簡化為 none() 時,表示該函數應該會崩潰。它等同於「這東西不會工作」。
pid() 進程識別符。
port() Port 是檔案描述符的底層表示形式(除非我們深入挖掘 Erlang 函式庫的內部,否則我們很少看到),sockets,或一般來說,允許 Erlang 與外部世界通訊的事物,例如 erlang:open_port/2 函數。在 Erlang Shell 中,它們看起來像 #Port<0.638>
reference() make_ref()erlang:monitor/2 回傳的唯一值。
atom() 一般的原子。
binary() 二進制資料 Blob。
<<_:Integer>> 已知大小的二進制,其中 Integer 是大小。
<<_:_*Integer>> 具有給定單元大小,但長度未指定的二進制。
<<_:Integer, _:_*OtherInteger>> 上述兩種形式的混合,用於指定二進制可以具有最小長度。
integer() 任何整數。
N..M 整數範圍。例如,如果你想表示一年中的月份數,可以定義範圍 1..12。請注意,Dialyzer 保留將此範圍擴展為更大的範圍的權利。
non_neg_integer() 大於或等於 0 的整數。
pos_integer() 大於 0 的整數。
neg_integer() 直到 -1 的整數。
float() 任何浮點數。
fun() 任何種類的函數。
fun((...) -> Type) 任何 arity 的匿名函數,回傳給定的型別。回傳列表的函數可以表示為 fun((...) -> list())
fun(() -> Type) 沒有引數,回傳給定型別的項的匿名函數。
fun((Type1, Type2, ..., TypeN) -> Type) 帶有已知型別的給定數量的引數的匿名函數。例如,可以將處理整數和浮點數值的函數宣告為 fun((integer(), float()) -> any())
[] 一個空列表。
[Type()] 包含給定型別的列表。整數列表可以定義為 [integer()]。或者,可以寫成 list(Type())。型別 list()[any()] 的簡寫。列表有時可能是不正確的(例如 [1, 2 | a])。因此,Dialyzer 使用 improper_list(TypeList, TypeEnd) 宣告不正確列表的型別。不正確列表 [1, 2 | a] 可以鍵入為 improper_list(integer(), atom())。然後,為了使事情更加複雜,有可能存在我們實際上不確定列表是否正確的情況。在這種情況下,可以使用型別 maybe_improper_list(TypeList, TypeEnd)
[Type(), ...] [Type()] 的這個特殊情況表示列表不能為空。
tuple() 任何 Tuple。
{Type1, Type2, ..., TypeN} 已知大小,具有已知型別的 Tuple。例如,二元樹節點可以定義為 {'node', any(), any(), any(), any()},對應於 {'node', LeftTree, RightTree, Key, Value}
A venn diagram. The leftmost circle is a gold ingot, the rightmost one is a shower head. In the center is a pixelated and censored coloured bit

鑑於以上內建型別,現在更容易想像我們將如何為 Erlang 程式定義型別。但仍然缺少一些東西。也許有些事情太模糊,或者不適合我們的需求。你還記得其中一個 discrepN 模組的錯誤提到型別 number()。該型別既不是單例型別,也不是內建型別。那麼它將是一個聯合型別,這意味著我們可以自己定義它。

表示型別聯合的符號是管道符號 (|)。基本上,這讓我們可以說給定型別 TypeName 表示為 Type1 | Type2 | ... | TypeN 的聯合。因此,包含整數和浮點值的 number() 型別可以表示為 integer() | float()。布林值可以定義為 'true' | 'false'。還可以定義僅使用另一種型別的型別。儘管它看起來像是聯合型別,但實際上是 *別名*。

事實上,許多這樣的別名和型別聯合都已為你預先定義。以下是一些:

term() 這等同於 any(),並且在其他工具之前使用 term() 時新增。或者,可以使用 _ 變數作為 term()any() 的別名。
boolean() 'true' | 'false'
byte() 定義為 0..255,它是存在的任何有效位元組。
char() 它定義為 0..16#10ffff,但不清楚此型別是否指的是特定字元標準。它在方法上非常通用,以避免衝突。
number() integer() | float()
maybe_improper_list() 這是一般不正確列表的 maybe_improper_list(any(), any()) 的快速別名。
maybe_improper_list(T) 其中 T 是任何給定的型別。這其實是 maybe_improper_list(T, any()) 的別名。
string() 字串定義為 [char()],即字元的列表。另有 nonempty_string(),定義為 [char(), ...]。可惜的是,目前還沒有僅限於二進位字串的字串型別,但這更多是因為它們是被視為資料塊,需要根據您選擇的型別進行解讀。
iolist() 啊,經典的 iolist。它們被定義為 maybe_improper_list(char() | binary() | iolist(), binary() | [])。您可以看到 iolist 本身就是以 iolist 定義的。Dialyzer 從 R13B04 版本開始支援遞迴型別。在此之前,您無法使用它們,而且像 iolist 這樣的型別只能透過一些艱難的技巧來定義。
module() 這是一個代表模組名稱的型別,目前是 atom() 的別名。
timeout() non_neg_integer() | 'infinity'
node() Erlang 節點的名稱,它是一個 atom。
no_return() 這是 none() 的別名,旨在用於函式的回傳型別。它特別用於註釋(希望)永遠循環,因此永遠不會回傳的函式。

好的,這已經介紹了一些型別。稍早,我提到了表示樹的型別,寫成 {'node', any(), any(), any(), any()}。現在我們對型別有了更多了解,我們可以在模組中宣告它。

在模組中宣告型別的語法是

-type TypeName() :: TypeDefinition.

因此,我們的樹可以定義為

-type tree() :: {'node', tree(), tree(), any(), any()}.

或者,使用特殊的語法,允許使用變數名稱作為型別註解

-type tree() :: {'node', Left::tree(), Right::tree(), Key::any(), Value::any()}.

但是該定義無效,因為它不允許樹為空。可以通過遞迴的方式來建立更好的樹定義,就像我們在「遞迴」章節中的 tree.erl 模組中做的那樣。在該模組中,空樹定義為 {node, 'nil'}。每當我們在遞迴函式中遇到這樣的節點時,我們就會停止。一個常規的非空節點表示為 {node, Key, Val, Left, Right}。將其轉換為型別,可以得到以下形式的樹節點

-type tree() :: {'node', 'nil'}
              | {'node', Key::any(), Val::any(), Left::tree(), Right::tree()}.

這樣,我們就有了可以是空節點或非空節點的樹。請注意,我們可以使用 'nil' 而不是 {'node', 'nil'},Dialyzer 也可以接受。我只是想尊重我們編寫 tree 模組的方式。我們可能還想為另一段 Erlang 程式碼提供型別,但還沒想過...

記錄(records)呢?它們有一個相當方便的語法來宣告型別。為了說明,讓我們想像一個 #user{} 記錄。我們要儲存使用者的姓名、一些特定筆記(使用我們的 tree() 型別)、使用者的年齡、朋友列表和一些簡短的傳記。

-record(user, {name="" :: string(),
               notes :: tree(),
               age :: non_neg_integer(),
               friends=[] :: [#user{}],
               bio :: string() | binary()}).

型別宣告的一般記錄語法是 Field :: Type,如果需要給定預設值,則變為 Field = Default :: Type。在上面的記錄中,我們可以看到姓名需要是字串,筆記必須是樹,年齡是從 0 到無限大的任何整數(誰知道人們可以活多久!)。一個有趣的欄位是 friends[#user{}] 型別表示使用者記錄可以保存零個或多個其他使用者記錄的列表。它也告訴我們,可以透過將其寫為 #RecordName{} 來將記錄用作型別。最後一部分告訴我們,傳記可以是字串或二進位。

此外,為了使型別宣告和定義的風格更統一,人們傾向於新增一個別名,例如 -type Type() :: #Record{}.。我們也可以將 friends 定義更改為使用 user() 型別,最終如下所示

-record(user, {name = "" :: string(),
               notes :: tree(),
               age :: non_neg_integer(),
               friends=[] :: [user()],
               bio :: string() | binary()}).

-type user() :: #user{}.

您會注意到我為記錄的所有欄位定義了型別,但是其中一些欄位沒有預設值。如果我要建立一個使用者記錄實例,例如 #user{age=5},則不會有任何型別錯誤。如果沒有為它們提供預設值,則所有記錄欄位定義都會隱式地新增一個 'undefined' 聯合。對於較早的版本,該宣告將會導致型別錯誤。

函式型別化

雖然我們可以整天整夜地定義型別,用它們填滿一個個檔案,然後列印這些檔案、裝裱起來並感到非常成就,但 Dialyzer 的型別推斷引擎並不會自動使用它們。Dialyzer 並不會根據您宣告的型別來縮小可能或不可能執行的範圍。

那麼,我們宣告這些型別的意義何在呢?文件嗎?部分是。要讓 Dialyzer 理解您宣告的型別,還需要額外步驟。我們需要在我們想要增強的所有函式上加上型別簽名宣告,將我們的型別宣告與模組內的函式連結起來。

5 playing cards, the 3 of clubs, ace of diamonds, 3 of spades, 3 of hearts, 7 of diamonds

我們在本章的大部分內容都花在「這是這個和那個的語法」之類的事情上,但現在是時候實踐了。一個需要型別化的簡單範例可能是撲克牌。有四種花色:黑桃、梅花、紅心和方塊。然後,牌可以編號從 1 到 10(其中 ace 為 1),然後是 Jack、Queen 或 King。

在正常情況下,我們可能會將撲克牌表示為 {Suit, CardValue},這樣我們就可以將黑桃 A 表示為 {spades, 1}。現在,我們可以定義型別來表示它,而不是只是讓它懸在空中

-type suit() :: spades | clubs | hearts | diamonds.
-type value() :: 1..10 | j | q | k.
-type card() :: {suit(), value()}.

suit() 型別只是代表花色的四個原子(atoms)的聯合。牌值可以是從 1 到 10 (1..10) 的任何牌,或者代表人頭牌的 jqkcard() 型別將它們合併為元組。

現在,這三種型別可以用來表示一般函式中的撲克牌,並給我們一些有趣的保證。例如,以下 cards.erl 模組

-module(cards).
-export([kind/1, main/0]).

-type suit() :: spades | clubs | hearts | diamonds.
-type value() :: 1..10 | j | q | k.
-type card() :: {suit(), value()}.

kind({_, A}) when A >= 1, A =< 10 -> number;
kind(_) -> face.

main() ->
    number = kind({spades, 7}),
    face   = kind({hearts, k}),
    number = kind({rubies, 4}),
    face   = kind({clubs, q}).

kind/1 函式應回傳一張牌是人頭牌還是數字牌。您會注意到從未檢查花色。在 main/0 函式中,您可以看到第三次呼叫使用的是 rubies 花色,這顯然不是我們型別中想要的,而且可能也不是 kind/1 函式中想要的。

$ erl
...
1> c(cards).
{ok,cards}
2> cards:main().
face

一切正常運作。情況不應該是這樣。即使執行 Dialyzer 也沒有任何作用。但是,如果我們將以下型別簽名新增到 kind/1 函式

-spec kind(card()) -> face | number.
kind({_, A}) when A >= 1, A =< 10 -> number;
kind(_) -> face.

那麼就會發生更有趣的事情。但是在我們執行 Dialyzer 之前,讓我們先看看它是如何運作的。型別簽名的形式為 -spec FunctionName(ArgumentTypes) -> ReturnTypes.。在上面的規範中,我們說 kind/1 函式接受根據我們建立的 card() 型別的撲克牌作為引數。它也表示該函式會回傳原子 facenumber

對模組執行 Dialyzer 會產生以下結果

$ dialyzer cards.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
cards.erl:12: Function main/0 has no local return
cards.erl:15: The call cards:kind({'rubies',4}) breaks the contract (card()) -> 'face' | 'number'
 done in 0m0.80s
done (warnings were emitted)
A contract, ripped in two, saying 'I will always say the truth no matter what' signed by 'Spec'

真是太有趣了。根據我們的規範,使用具有 rubies 花色的「撲克牌」呼叫 kind/1 不是有效的事情。

在這種情況下,Dialyzer 會尊重我們提供的型別簽名,並且當它分析 main/0 函式時,它會發現其中錯誤地使用了 kind/1。這會引發第 15 行的警告 (number = kind({rubies, 4}),)。從此之後,Dialyzer 會假設型別簽名是可靠的,並且如果程式碼要按照它使用,則邏輯上將無效。合約中的這種違規行為會傳播到 main/0 函式,但是關於它為何失敗,該層級沒有太多可說的;只是它確實失敗了。

注意:只有在定義了型別規範之後,Dialyzer 才會抱怨這一點。在新增型別簽名之前,Dialyzer 無法假設您計劃僅使用 card() 引數來使用 kind/1。有了簽名,它就可以將其用作自己的型別定義。

這是 convert.erl 中一個更有趣的函式型別範例

-module(convert).
-export([main/0]).

main() ->
    [_,_] = convert({a,b}),
    {_,_} = convert([a,b]),
    [_,_] = convert([a,b]),
    {_,_} = convert({a,b}).

convert(Tup) when is_tuple(Tup) -> tuple_to_list(Tup);
convert(L = [_|_]) -> list_to_tuple(L).

在讀取程式碼時,我們很明顯可以知道對 convert/1 的最後兩次呼叫將會失敗。該函式接受一個列表並回傳一個元組,或者接受一個元組並回傳一個列表。但是,如果我們對程式碼執行 Dialyzer,它將找不到任何問題。

那是因為 Dialyzer 推斷出類似於以下的型別簽名

-spec convert(list() | tuple()) -> list() | tuple().

或者,用文字來說,該函式接受列表和元組,並在元組中回傳列表。這是事實,但遺憾的是,這有點真實了。該函式不像型別簽名所暗示的那樣寬容。這是 Dialyzer 坐下來盡量不要說太多而沒有 100% 確定問題的其中一個地方。

為了幫助 Dialyzer 一點,我們可以傳入一個更精細的型別宣告

-spec convert(tuple()) -> list();
             (list()) -> tuple().
convert(Tup) when is_tuple(Tup) -> tuple_to_list(Tup);
convert(L = [_|_]) -> list_to_tuple(L).

此語法不是將 tuple()list() 型別放在單個聯合中,而是允許您使用替代子句宣告型別簽名。如果使用元組呼叫 convert/1,我們期望一個列表,而在另一種情況下則相反。

有了這個更具體的資訊,Dialyzer 現在可以提供更有趣的結果

$ dialyzer convert.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
convert.erl:4: Function main/0 has no local return
convert.erl:7: The pattern [_, _] can never match the type tuple()
 done in 0m0.90s
done (warnings were emitted)

啊,它在那裡找到了錯誤。成功!我們現在可以使用 Dialyzer 來告訴我們我們知道的事情。當然,以這種方式來說,這聽起來沒有用,但是當您正確鍵入函式並犯了一個您忘記檢查的小錯誤時,Dialyzer 將會支持您,這絕對比錯誤記錄系統在晚上叫醒您要好(或者被您的營運人員刮花您的汽車)。

注意:有些人會更喜歡以下用於多子句型別簽名的語法

-spec convert(tuple()) -> list()
      ;      (list()) -> tuple().

這完全相同,但將分號放在另一行,因為它可能更易於閱讀。在撰寫本文時,沒有廣泛接受的標準。

透過以這種方式使用型別定義和規範,我們實際上能夠讓 Dialyzer 找到我們之前的 discrep 模組中的錯誤。請參閱 discrep4.erl 是如何實現的。

型別化練習

我一直在編寫一個佇列模組,用於先進先出 (FIFO) 操作。您應該知道佇列是什麼,因為 Erlang 的信箱是佇列。新增的第一個元素將是第一個彈出的元素(除非我們進行選擇性接收)。該模組的工作原理如我們已經看過幾次的這張圖片中所述

Drawing representing the implementation of a functional queue

為了模擬佇列,我們使用兩個列表作為堆疊。一個列表儲存新元素,另一個列表讓我們從佇列中移除它們。我們總是新增到同一個列表,然後從第二個列表移除。當我們從中移除的列表為空時,我們會反轉我們新增項目的列表,並且它會成為新的移除列表。與使用單個列表來完成這兩項任務相比,這通常可以保證更好的平均效能。

這是 我的模組,我新增了一些型別簽名以使用 Dialyzer 檢查它

-module(fifo_types).
-export([new/0, push/2, pop/1, empty/1]).
-export([test/0]).

-spec new() -> {fifo, [], []}.
new() -> {fifo, [], []}.

-spec push({fifo, In::list(), Out::list()}, term()) -> {fifo, list(), list()}.
push({fifo, In, Out}, X) -> {fifo, [X|In], Out}.

-spec pop({fifo, In::list(), Out::list()}) -> {term(), {fifo, list(), list()}}.
pop({fifo, [], []}) -> erlang:error('empty fifo');
pop({fifo, In, []}) -> pop({fifo, [], lists:reverse(In)});
pop({fifo, In, [H|T]}) -> {H, {fifo, In, T}}.

-spec empty({fifo, [], []}) -> true;
           ({fifo, list(), list()}) -> false.
empty({fifo, [], []}) -> true;
empty({fifo, _, _}) -> false.

test() ->
    N = new(),
    {2, N2} = pop(push(push(new(), 2), 5)),
    {5, N3} = pop(N2),
    N = N3,
    true = empty(N3),
    false = empty(N2),
    pop({fifo, [a|b], [e]}).

我將佇列定義為 {fifo, list(), list()} 形式的元組。您會注意到我沒有定義 fifo() 型別,主要是因為我只是想能夠輕鬆地為空佇列和已填滿的佇列建立不同的子句。empty(...) 型別規範反映了這一點。

注意:您會注意到在函式 pop/1 中,即使其中一個函式子句呼叫了 erlang:error/1,我也沒有指定 none() 型別。

如前所述,型別 none() 是一種表示給定函式不會回傳的型別。如果函式可能失敗或回傳值,則將其鍵入為同時回傳值和 none() 是沒有意義的。none() 型別始終被假設為存在,因此,聯合 Type() | none() 與單獨的 Type() 相同。

在任何時候您要編寫總是會在呼叫時失敗的函式時,例如,如果您自己實作 erlang:error/1,則保證可以使用 none()

現在以上所有的類型規格看起來都合理。為了確保萬無一失,在程式碼審查期間,我會請你和我一起執行 Dialyzer,看看結果。

$ dialyzer fifo_types.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
fifo_types.erl:16: Overloaded contract has overlapping domains; such contracts are currently unsupported and are simply ignored
fifo_types.erl:21: Function test/0 has no local return
fifo_types.erl:28: The call fifo_types:pop({'fifo',nonempty_improper_list('a','b'),['e',...]}) breaks the contract ({'fifo',In::[any()],Out::[any()]}) -> {term(),{'fifo',[any()],[any()]}}
 done in 0m0.96s
done (warnings were emitted)

我真傻。我們出現了一堆錯誤。而且,可惡的是,它們不太容易閱讀。第二個錯誤「函式 test/0 沒有本地回傳」至少是我們知道如何處理的 — 我們將直接跳到下一個錯誤,它應該就會消失。

現在我們先專注於第一個錯誤,也就是關於具有重疊定義域的合約。如果我們進入 fifo_types 的第 16 行,我們會看到這個:

-spec empty({fifo, [], []}) -> true;
           ({fifo, list(), list()}) -> false.
empty({fifo, [], []}) -> true;
empty({fifo, _, _}) -> false.

那麼,所謂的重疊定義域是什麼?我們必須參考數學中定義域和值域的概念。簡單來說,定義域是函數所有可能的輸入值集合,而值域是函數所有可能的輸出值集合。因此,重疊定義域指的是兩個輸入集合有重疊部分。

an url from 'http://example.org/404' with an arrow pointing to the traditional 'broken image' icon, with a caption saying 'an invalid domain leads to an invalid image

為了找出問題的根源,我們必須查看 list()。如果你還記得早先的大表格,list() 幾乎與 [any()] 相同。而且你也會記得,這兩種型別都包含空列表。這就是你的重疊定義域。當 list() 被指定為類型時,它會與 [] 重疊。為了修正這個問題,我們需要將類型簽章替換如下:

-spec empty({fifo, [], []}) -> true;
           ({fifo, nonempty_list(), nonempty_list()}) -> false.

或替代地:

-spec empty({fifo, [], []}) -> true;
           ({fifo, [any(), ...], [any(), ...]}) -> false.

然後再次執行 Dialyzer 將會消除警告。然而,這還不夠。如果有人傳入了 {fifo, [a,b], []} 呢?即使 Dialyzer 可能不會抱怨,對於人類讀者來說,顯而易見的是,上面的類型規格並沒有將這種情況考慮進去。規格與函數的預期用途不符。我們可以提供更多細節,並採用以下方法:

-spec empty({fifo, [], []}) -> true;
           ({fifo, [any(), ...], []}) -> false;
           ({fifo, [], [any(), ...]}) -> false;
           ({fifo, [any(), ...], [any(), ...]}) -> false.

這既可以運作,又具有正確的含義。

接著處理下一個錯誤(我將其分成多行):

fifo_types.erl:28:
The call fifo_types:pop({'fifo',nonempty_improper_list('a','b'),['e',...]})
breaks the contract
({'fifo',In::[any()],Out::[any()]}) -> {term(),{'fifo',[any()],[any()]}}

翻譯成人類語言,這表示在第 28 行,有個對 pop/1 的呼叫,其推斷的類型與我在檔案中指定的類型不符。

pop({fifo, [a|b], [e]}).

這就是呼叫。現在,錯誤訊息表示它識別出了一個不正確的列表(碰巧不是空的),這完全正確;[a|e] 是一個不正確的列表。它還提到這違反了一個合約。我們需要比對以下來自錯誤訊息的類型定義,看看哪一個被破壞了:

{'fifo',nonempty_improper_list('a','b'),['e',...]}
{'fifo',In::[any()],Out::[any()]}
{term(),{'fifo',[any()],[any()]}}

這個問題可以用以下三種方式之一解釋:

  1. 類型簽章正確,呼叫正確,問題在於預期的回傳值。
  2. 類型簽章正確,呼叫錯誤,問題在於給定的輸入值。
  3. 呼叫正確,但類型簽章錯誤。

我們可以立即排除第一個選項。我們實際上沒有對回傳值做任何處理。這留下了第二個和第三個選項。決定取決於我們是否希望將不正確的列表用於我們的佇列。這是程式庫的撰寫者需要做出的判斷,我可以毫不猶豫地說,我沒有打算將不正確的列表用於此程式碼。事實上,你很少會需要不正確的列表。所以答案是第二個,呼叫錯誤。為了解決這個問題,刪除呼叫或修正它。

test() ->
    N = new(),
    {2, N2} = pop(push(push(new(), 2), 5)),
    ...
    pop({fifo, [a, b], [e]}).

然後再次執行 Dialyzer:

$ dialyzer fifo_types.erl
  Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis... done in 0m0.90s
done (passed successfully)

現在這樣就比較合理了。

匯出類型

這一切都很好。我們有類型、有簽章、有額外的安全性和驗證。那麼,如果我們想在另一個模組中使用我們的佇列會發生什麼?如果我們想使用其他常用模組,例如 dictgb_trees 或 ETS 表格呢?我們如何使用 Dialyzer 來找出與它們相關的類型錯誤?

我們可以使用來自其他模組的類型。這樣做通常需要翻閱文件來找到它們。例如,ets 模組的文件具有以下條目:

---
DATA TYPES

continuation()
    Opaque continuation used by select/1 and select/3.

match_spec() = [{match_pattern(), [term()], [term()]}]
    A match specification, see above.

match_pattern() = atom() | tuple()

tab() = atom() | tid()

tid()
    A table identifier, as returned by new/2.
---

這些是 ets 匯出的資料類型。如果我有一個類型規格要接受 ETS 表格、一個鍵,並回傳一個匹配的條目,我可以這樣定義它:

-spec match(ets:tab(), Key::any()) -> Entry::any().

就這樣。

匯出我們自己的類型,其運作方式與我們對函式所做的事情非常相似。我們只需要新增一個 -export_type([TypeName/Arity]). 形式的模組屬性。例如,我們可以透過新增以下程式碼行,從我們的 cards 模組匯出 card() 類型:

-module(cards).
-export([kind/1, main/0]).

-type suit() :: spades | clubs | hearts | diamonds.
-type value() :: 1..10 | j | q | k.
-type card() :: {suit(), value()}.

-export_type([card/0]).
...

從那時起,如果該模組對 Dialyzer 可見(透過將其新增到 PLT 或與任何其他模組同時分析),你就可以在類型規格中將其從任何其他程式碼位元引用為 cards:card()

A VHS tape saying 'mom and dad wedding night', with a caption that says 'some things are better left unseen'

但是,這樣做會產生一個缺點。使用像這樣的類型並不會阻止任何人使用 card 模組來拆解這些類型並玩弄它們。任何人都可以編寫程式碼片段來匹配這些卡片,有點像 {Suit, _} = ...。這並不總是個好主意:它會讓我們在未來無法更改 cards 模組的實作。這是我們尤其希望在表示資料結構的模組(例如 dictfifo_types(如果它匯出了類型))中強制執行的事情。

Dialyzer 允許你以一種方式匯出類型,該方式告訴你的使用者「你知道嗎?我很樂意讓你使用我的類型,但你膽敢偷看它們的內部!」。這是一個將以下宣告替換的問題:

-type fifo() :: {fifo, list(), list()}.

替換為:

-opaque fifo() :: {fifo, list(), list()}.

然後你仍然可以將其匯出為 -export_type([fifo/0])

將類型宣告為*不透明*表示只有定義該類型的模組才有權查看其組成方式並對其進行修改。它禁止其他模組對除了整體以外的值進行模式匹配,保證(如果他們使用 Dialyzer)他們永遠不會因為實作的突然變更而受到影響。

不要喝太多酷愛
有時,不透明資料類型的實作要么不夠強大,無法完成它應該做的事情,要么實際上存在問題(即有錯誤)。Dialyzer 在首先推斷出函式的成功類型之前,不會將函式的規格納入考量。

這表示當你的類型看起來相當通用,而沒有考慮任何 -type 資訊時,Dialyzer 可能會被某些不透明類型弄糊塗。例如,Dialyzer 分析不透明版本的 card() 資料類型後,可能會將其視為 {atom(), any()}。正確使用 card() 的模組可能會看到 Dialyzer 抱怨它們違反了類型合約,即使它們沒有違反。這是因為 card() 類型本身沒有包含足夠的資訊,讓 Dialyzer 可以將這些點連起來並意識到真正發生了什麼。

通常,如果你看到此類錯誤,為你的元組加上標籤會有幫助。從 -opaque card() :: {suit(), value()}. 形式的類型移動到 -opaque card() :: {card, suit(), value()}. 可能會讓 Dialyzer 可以順利處理不透明類型。

Dialyzer 的實作者目前正在努力改進不透明資料類型的實作,並加強它們的推斷。他們還在努力讓使用者提供的規格更加重要,並在 Dialyzer 的分析過程中更好地信任它們,但這仍然是一項正在進行中的工作。

類型化的行為

回到 客戶端和伺服器,我們已經看到可以使用 behaviour_info/1 函式宣告行為。匯出此函式的模組會將其名稱給予行為,而第二個模組可以透過新增 -behaviour(ModName). 作為模組屬性來實作回呼。

例如,gen_server 模組的行為定義是:

behaviour_info(callbacks) ->
    [{init, 1}, {handle_call, 3}, {handle_cast, 2}, {handle_info, 2},
     {terminate, 2}, {code_change, 3}];
behaviour_info(_Other) ->
    undefined.

這個問題在於 Dialyzer 無法檢查該行為的類型定義。事實上,行為模組無法指定它期望回呼模組實作哪種型別,因此 Dialyzer 無法對其做任何處理。

從 R15B 開始,Erlang/OTP 編譯器已升級,使其現在可以處理一個名為 -callback 的新模組屬性。-callback 模組屬性的語法與 spec 類似。當你使用它指定函式類型時,會自動為你宣告 behaviour_info/1 函式,並且規格會以允許 Dialyzer 執行其工作的方式新增到模組中繼資料。例如,以下是從 R15B 開始的 gen_server 的宣告:

-callback init(Args :: term()) ->
    {ok, State :: term()} | {ok, State :: term(), timeout() | hibernate} |
    {stop, Reason :: term()} | ignore.
-callback handle_call(Request :: term(), From :: {pid(), Tag :: term()},
                      State :: term()) ->
    {reply, Reply :: term(), NewState :: term()} |
    {reply, Reply :: term(), NewState :: term(), timeout() | hibernate} |
    {noreply, NewState :: term()} |
    {noreply, NewState :: term(), timeout() | hibernate} |
    {stop, Reason :: term(), Reply :: term(), NewState :: term()} |
    {stop, Reason :: term(), NewState :: term()}.
-callback handle_cast(Request :: term(), State :: term()) ->
    {noreply, NewState :: term()} |
    {noreply, NewState :: term(), timeout() | hibernate} |
    {stop, Reason :: term(), NewState :: term()}.
-callback handle_info(Info :: timeout() | term(), State :: term()) ->
    {noreply, NewState :: term()} |
    {noreply, NewState :: term(), timeout() | hibernate} |
    {stop, Reason :: term(), NewState :: term()}.
-callback terminate(Reason :: (normal | shutdown | {shutdown, term()} |
                               term()),
                    State :: term()) ->
    term().
-callback code_change(OldVsn :: (term() | {down, term()}), State :: term(),
                      Extra :: term()) ->
    {ok, NewState :: term()} | {error, Reason :: term()}.

而且你的任何程式碼都不會因為行為的變更而中斷。但是請注意,模組不能同時使用 -callback 形式和 behaviour_info/1 函式。只能選擇其中一個。這表示,如果你想建立自訂行為,則在 R15 之前的 Erlang 版本中可以使用的內容與在後續版本中可以使用的內容之間存在差異。

好處是,較新的模組將讓 Dialyzer 能夠進行一些分析,以檢查那裡回傳的任何類型的錯誤,以提供協助。

多型類型

哎呀,這是一個多麼響亮的標題。如果你從未聽過*多型類型*(或替代地,*參數化類型*),這聽起來可能有點可怕。幸運的是,它並沒有像名稱所暗示的那樣複雜。

ditto with a beard

需要多型類型的原因來自於當我們對不同的資料結構進行類型化時,有時可能會發現自己想非常具體地了解它們可以儲存的內容。我們可能希望本章稍早的佇列有時可以處理任何內容,有時只處理撲克牌,或者有時只處理整數。在這最後兩種情況下,問題在於我們可能希望 Dialyzer 能夠抱怨我們試圖將浮點數放入我們的整數佇列中,或將塔羅牌放入我們的撲克牌佇列中。

這是不可能透過嚴格地按照我們之前的方式來執行類型來強制執行的。引入多型類型。多型類型是可以透過其他類型「設定」的類型。幸運的是,我們已經知道如何做到這一點的語法。當我說我們可以將整數列表定義為 [integer()]list(integer()) 時,那些都是多型類型。它是一種接受類型作為參數的類型。

為了讓我們的佇列僅接受整數或卡片,我們可以將其類型定義為:

-type queue(Type) :: {fifo, list(Type), list(Type)}.
-export_type([queue/1]).

當另一個模組想要使用 fifo/1 類型時,它需要將其參數化。因此,cards 模組中的新牌組可能會有以下簽名:

-spec new() -> fifo:queue(card()).

而 Dialyzer,如果可能的話,會嘗試分析該模組,以確保它只提交和期望它所處理的佇列中的卡片。

為了示範,我們決定買一個動物園來慶祝我們幾乎完成《Learn You Some Erlang》。在我們的動物園裡,我們有兩種動物:小熊貓和烏賊。誠然,這是一個相當簡陋的動物園,雖然這不應該阻止我們將入場費定得很高。

我們決定自動化餵養動物,因為我們是程式設計師,而程式設計師有時會因為懶惰而喜歡自動化一些東西。經過一番研究,我們發現小熊貓可以吃竹子、一些鳥類、蛋和漿果。我們還發現烏賊可以與抹香鯨搏鬥,所以我們決定用我們的 zoo.erl 模組餵牠們那些東西。

-module(zoo).
-export([main/0]).

feeder(red_panda) ->
    fun() ->
            element(random:uniform(4), {bamboo, birds, eggs, berries})
    end;
feeder(squid) ->
    fun() -> sperm_whale end.

feed_red_panda(Generator) ->
    Food = Generator(),
    io:format("feeding ~p to the red panda~n", [Food]),
    Food.

feed_squid(Generator) ->
    Food = Generator(),
    io:format("throwing ~p in the squid's aquarium~n", [Food]),
    Food.

main() ->
    %% Random seeding
    <<A:32, B:32, C:32>> = crypto:rand_bytes(12),
    random:seed(A, B, C),
    %% The zoo buys a feeder for both the red panda and squid
    FeederRP = feeder(red_panda),
    FeederSquid = feeder(squid),
    %% Time to feed them!
    %% This should not be right!
    feed_squid(FeederRP),
    feed_red_panda(FeederSquid).

這段程式碼使用了 feeder/1,它接受一個動物名稱並返回一個餵食器(一個返回食物項目的函數)。餵食小熊貓應該使用小熊貓餵食器,餵食烏賊應該使用烏賊餵食器。使用像是 feed_red_panda/1feed_squid/1 這樣的函數定義,沒有辦法透過錯誤使用餵食器來發出警報。即使使用運行時檢查,也是不可能做到的。一旦我們提供食物,就太遲了。

1> zoo:main().
throwing bamboo in the squid's aquarium
feeding sperm_whale to the red panda
sperm_whale

噢不,我們的小動物不應該那樣吃東西!也許類型可以幫助我們。以下類型規範可以使用多型類型的能力來幫助我們:

-type red_panda() :: bamboo | birds | eggs | berries.
-type squid() :: sperm_whale.
-type food(A) :: fun(() -> A).

-spec feeder(red_panda) -> food(red_panda());
            (squid) -> food(squid()).
-spec feed_red_panda(food(red_panda())) -> red_panda().
-spec feed_squid(food(squid())) -> squid().

這裡感興趣的是 food(A) 類型。A 是一個自由類型,稍後決定。然後,我們通過執行 food(red_panda())food(squid()) 來限定 feeder/1 的類型規範中的食物類型。食物類型隨後被視為 fun(() -> red_panda())fun(() -> squid()),而不是一些返回未知內容的抽象函數。如果你將這些規範添加到文件中,然後在上面運行 Dialyzer,會發生以下情況:

$ dialyzer zoo.erl
  Checking whether the PLT /Users/ferd/.dialyzer_plt is up-to-date... yes
  Proceeding with analysis...
zoo.erl:18: Function feed_red_panda/1 will never be called
zoo.erl:23: The contract zoo:feed_squid(food(squid())) -> squid() cannot be right because the inferred return for feed_squid(FeederRP::fun(() -> 'bamboo' | 'berries' | 'birds' | 'eggs')) on line 44 is 'bamboo' | 'berries' | 'birds' | 'eggs'
zoo.erl:29: Function main/0 has no local return
 done in 0m0.68s
done (warnings were emitted)

而且錯誤是正確的。多型類型萬歲!

雖然上述方法非常有用,但程式碼中的微小變更可能會對 Dialyzer 能夠找到的內容產生意想不到的後果。例如,如果 main/0 函數有以下程式碼:

main() ->
    %% Random seeding
    <<A:32, B:32, C:32>> = crypto:rand_bytes(12),
    random:seed(A, B, C),
    %% The zoo buys a feeder for both the red panda and squid
    FeederRP = feeder(red_panda),
    FeederSquid = feeder(squid),
    %% Time to feed them!
    feed_squid(FeederSquid),
    feed_red_panda(FeederRP),
    %% This should not be right!
    feed_squid(FeederRP),
    feed_red_panda(FeederSquid).

情況就不一樣了。在用錯誤的餵食器呼叫函數之前,它們首先會用正確的餵食器呼叫。截至 R15B01,Dialyzer 不會發現此程式碼有錯誤。這是因為當正在進行一些複雜的模組本地改進時,Dialyzer 不一定會保留有關匿名函數是否在餵食函數中被呼叫的資訊。

即使這對許多靜態類型愛好者來說有點令人難過,我們也已經被徹底警告過了。以下引文來自描述 Dialyzer 成功類型實作的論文:

成功類型是一個類型簽名,它過度近似函數可以評估為值的類型集合。簽名的域包含函數可以接受作為參數的所有可能值,其範圍包括此域的所有可能返回值。

然而,對靜態類型愛好者來說,這可能看起來很弱,但成功類型具有以下特性:它們捕獲了如果函數以其成功類型不允許的方式使用(例如,通過將函數應用於參數 p ∈/ α),則此應用肯定會失敗。這正是永不「狼來了」的缺陷檢測工具所需的特性。此外,成功類型可用於自動程式文件,因為它們永遠不會錯過捕捉函數的某些可能(無論多麼非本意)的用法。

再次強調,記住 Dialyzer 在其方法中是樂觀的,對於有效率地使用它至關重要。

如果這仍然讓你感到太沮喪,你可以嘗試將 -Woverspecs 選項添加到 Dialyzer:

$ dialyzer zoo.erl -Woverspecs 
   Checking whether the PLT /home/ferd/.dialyzer_plt is up-to-date... yes
     Proceeding with analysis...
     zoo.erl:17: Type specification zoo:feed_red_panda(food(red_panda())) -> red_panda() is a subtype of the success typing: zoo:feed_red_panda(fun(() -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale')) -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale'zoo.erl:23: Type specification zoo:feed_squid(food(squid())) -> squid() is a subtype of the success typing: zoo:feed_squid(fun(() -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale')) -> 'bamboo' | 'berries' | 'birds' | 'eggs' | 'sperm_whale'
 done in 0m0.94s
done (warnings were emitted)

這會警告你,事實上,你的規範對於你的程式碼預期接受的內容來說太嚴格了,並告訴你(儘管是間接的)你應該放鬆你的類型規範,或者更好地驗證你函數中的輸入和輸出,以反映類型規範。

A red panda and a squid sharing a meal (sperm whale, bamboo, eggs and grapes

你是我的類型

Dialyzer 通常會被證明是編寫 Erlang 時的真正朋友,儘管頻繁的嘮叨可能會誘使你直接放棄它。要記住的一件事是,Dialyzer 幾乎永遠不會出錯,而你很可能會出錯。你可能會覺得某些錯誤毫無意義,但與許多類型系統相反,Dialyzer 只有在它知道自己是對的時候才會發聲,而且其程式碼庫中的錯誤很少見。Dialyzer 可能會讓你感到沮喪,迫使你謙虛,但它不太可能是糟糕、不乾淨程式碼的來源。

注意:在撰寫本章時,當我使用更完整的串流模組時,我最終得到了一些令人討厭的 Dialyzer 錯誤訊息。我煩躁到在 IRC 上抱怨它,Dialyzer 不夠好,無法處理我對類型的複雜使用。

傻瓜我。事實證明(毫不意外),錯了,而 Dialyzer 一直都是對的。它不斷告訴我我的 -spec 是錯誤的,而我一直認為它不是。我輸了,Dialyzer 和我的程式碼贏了。我認為這是一件好事。

所以,嘿,這就是《Learn You Some Erlang for great good》的全部內容了!感謝你的閱讀。沒有太多要說的了,但如果你想獲得更多要探索的主題清單以及我的一些一般性建議,你可以閱讀本指南的結論。一路順風!你,並行的皇帝。