類型(或缺乏類型)

強悍的動態型別

當你在輸入來自起步(來真的)的範例,然後是來自模組函數中的語法的模組和函數時,你可能已經注意到我們從來不需要寫變數的型別或是函數的型別。當進行模式匹配時,我們寫的程式碼不需要知道它將與什麼匹配。元組 {X,Y} 可以與 {atom, 123} 以及 {"一個字串", <<"二進位資料!">>}{2.0, ["字串", "和", atoms]} 或任何東西匹配。

當它無法運作時,你的面前會拋出一個錯誤,但這只會在執行程式碼後才會發生。這是因為 Erlang 是動態型別的:每個錯誤都會在執行時被捕獲,而且編譯器不會總是在編譯模組時對你大吼大叫,即使程式碼可能會導致失敗,就像在起步(來真的)中的 "llama + 5" 範例一樣。

A knife slicing ham.

靜態和動態型別的支持者之間的一個經典摩擦點與所寫軟體的安全性有關。一個經常被提出的觀點是,具有編譯器強制執行的高品質靜態型別系統可以在你執行程式碼之前,捕獲大多數潛在的錯誤。因此,靜態型別語言被認為比其動態對應物更安全。雖然與許多動態語言相比,這可能是真的,但 Erlang 持不同意見,並且肯定有記錄可以證明這一點。最好的例子是經常報導的 Ericsson AXD 301 ATM 交換機提供的九個九(99.9999999%)的可用性,該交換機包含超過 100 萬行的 Erlang 程式碼。請注意,這並不表示基於 Erlang 的系統中的任何組件都沒有失敗,而是指一般的交換機系統在 99.9999999% 的時間內可用,包括計劃的停機時間。這部分是因為 Erlang 的建構理念是其中一個組件的失敗不應影響整個系統。來自程式設計師的錯誤、硬體故障或[某些]網路故障都被考慮在內:該語言包含一些功能,可以讓你將程式分佈到不同的節點、處理意外的錯誤,並且永不停止執行。

簡而言之,雖然大多數語言和型別系統旨在使程式沒有錯誤,但 Erlang 使用的策略是假設錯誤無論如何都會發生,並確保涵蓋這些情況:Erlang 的動態型別系統並不是程式可靠性和安全性的障礙。這聽起來像是許多預言性的說法,但你將在後面的章節中看到它是如何做到的。

注意:動態型別在歷史上被選擇的原因很簡單;最初實現 Erlang 的人大多來自動態型別語言,因此,讓 Erlang 具有動態型別對他們來說是最自然的选择。

Erlang 也是強型別的。弱型別語言會在術語之間進行隱式型別轉換。如果 Erlang 是弱型別的,我們可能會執行 6 = 5 + "1" 的操作。但在實踐中,會拋出參數錯誤的例外。

1> 6 + "1".
** exception error: bad argument in an arithmetic expression
     in operator  +/2
        called as 6 + "1"

當然,有時候你可能想要將一種資料轉換為另一種資料:將常規字串更改為位元字串以儲存它們,或將整數更改為浮點數。Erlang 標準函式庫提供了一些函數來執行此操作。

型別轉換

與許多語言一樣,Erlang 會通過將術語轉換為另一種術語來改變術語的型別。這是借助內建函數完成的,因為許多轉換無法在 Erlang 本身中實現。這些函數中的每一個都採用 <type>_to_<type> 的形式,並在 erlang 模組中實現。以下是一些示例

1> erlang:list_to_integer("54").
54
2> erlang:integer_to_list(54).
"54"
3> erlang:list_to_integer("54.32").
** exception error: bad argument
     in function  list_to_integer/1
        called as list_to_integer("54.32")
4> erlang:list_to_float("54.32").
54.32
5> erlang:atom_to_list(true).
"true"
6> erlang:list_to_bitstring("hi there").
<<"hi there">>
7> erlang:bitstring_to_list(<<"hi there">>).
"hi there"

等等。我們在這裡遇到了語言的缺陷:因為使用了 <type>_to_<type> 的方案,所以每次在語言中新增一個新類型時,都需要新增大量的轉換 BIF!以下是已經存在的所有列表

atom_to_binary/2、atom_to_list/1、binary_to_atom/2、binary_to_existing_atom/2、binary_to_list/1、bitstring_to_list/1、binary_to_term/1、float_to_list/1、fun_to_list/1、integer_to_list/1、integer_to_list/2、iolist_to_binary/1、iolist_to_atom/1、list_to_atom/1、list_to_binary/1、list_to_bitstring/1、list_to_existing_atom/1、list_to_float/1、list_to_integer/2、list_to_pid/1、list_to_tuple/1、pid_to_list/1、port_to_list/1、ref_to_list/1、term_to_binary/1、term_to_binary/2 和 tuple_to_list/1。

這有很多轉換函數。我們將在本本書中看到大部分(如果不是全部)這些類型,儘管我們可能不需要所有這些函數。

保護資料類型

Erlang 的基本資料類型很容易識別:元組具有大括號、列表具有方括號、字串用雙引號括起來等等。因此,使用模式匹配可以強制執行特定的資料類型:一個接受列表的 head/1 函數只能接受列表,否則匹配([H|_])將會失敗。

Hi, My name is Tuple

但是,我們在數值方面遇到了一個問題,因為我們無法指定範圍。因此,我們在關於溫度、駕駛年齡等的函數中使用了保護。我們現在又遇到了另一個障礙。我們如何編寫一個保護,以確保模式與單個特定類型的資料(例如數字、原子或位元字串)匹配?

有一些專門用於此任務的函數。它們將接受單個參數,如果類型正確,則返回 true,否則返回 false。它們是保護表達式中允許使用的少數函數的一部分,並被命名為型別測試 BIF

is_atom/1           is_binary/1         
is_bitstring/1      is_boolean/1        is_builtin/3        
is_float/1          is_function/1       is_function/2       
is_integer/1        is_list/1           is_number/1         
is_pid/1            is_port/1           is_record/2         
is_record/3         is_reference/1      is_tuple/1          

它們可以像任何其他保護表達式一樣使用,只要允許使用保護表達式即可。你可能想知道為什麼沒有函數可以僅給出正在評估的術語的類型(類似於 type_of(X) -> Type)。答案很簡單。Erlang 是關於為正確的情況編程:你只為你所知道會發生的事情和你所期望的事情編程。其他所有事情都應該盡快導致錯誤。儘管這聽起來可能很瘋狂,但你在錯誤和例外中獲得的解釋有望使事情更清楚。在那之前,請相信我。

注意:型別測試 BIF 佔保護表達式中允許的函數的一半以上。其餘的也是 BIF,但不代表型別測試。這些是
abs(Number)、bit_size(Bitstring)、byte_size(Bitstring)、element(N, Tuple)、float(Term)、hd(List)、length(List)、node()、node(Pid|Ref|Port)、round(Number)、self()、size(Tuple|Bitstring)、tl(List)、trunc(Number)、tuple_size(Tuple)。

函數 node/1self/0 與分散式 Erlang 和程序/actor 相關。我們最終會使用它們,但在此之前,我們還有其他主題需要涵蓋。

Erlang 資料結構似乎相對有限,但列表和元組通常足以構建其他複雜的結構,而無需擔心任何事情。例如,二元樹的基本節點可以表示為 {node, Value, Left, Right},其中 LeftRight 要么是相似的節點,要么是空元組。我也可以將自己表示為

{person, {name, <<"Fred T-H">>},
         {qualities, ["handsome", "smart", "honest", "objective"]},
         {faults, ["liar"]},
         {skills, ["programming", "bass guitar", "underwater breakdancing"]}}.

這表明,通過巢狀元組和列表並填充資料,我們可以獲得複雜的資料結構,並構建函數來對它們進行操作。

更新
R13B04 版本添加了 BIF binary_to_term/2,它可以讓你以與 binary_to_term/1 相同的方式反序列化資料,只是第二個參數是選項列表。如果你傳入 [safe],則如果二進位包含未知的原子或匿名函數,則不會對其進行解碼,這可能會耗盡記憶體。

給型別狂熱者

A sign for homeless people: 'Will dance for types'

本節旨在讓由於某種原因而無法在沒有靜態型別系統的情況下生存的程式設計師閱讀。它將包含一些更進階的理論,並且並非所有人都能理解所有內容。我將簡要描述用於在 Erlang 中進行靜態型別分析的工具、定義自訂型別以及通過這種方式獲得更多安全性。這些工具將在本書的後面部分進行描述,以便任何人都可以理解,因為使用它們中的任何一個來編寫可靠的 Erlang 程式並不是必需的。由於我們稍後會展示它們,因此我將提供非常少的關於安裝、執行它們等的詳細資訊。再次聲明,本節適用於那些真的不能在沒有進階型別系統的情況下生存的人。

多年來,有人嘗試在 Erlang 的基礎上構建型別系統。其中一個嘗試發生在 1997 年,由 Glasgow Haskell 編譯器的主要開發人員之一 Simon Marlow 和 Haskell 設計的參與者、並為 monad 背後的理論做出貢獻的 Philip Wadler 進行(閱讀關於該型別系統的論文)。Joe Armstrong 後來評論了這篇論文

有一天,Phil 打電話給我,宣布 a) Erlang 需要一個型別系統,b) 他已經編寫了一個型別系統的小型原型,以及 c) 他有一年的休假,並且將為 Erlang 編寫一個型別系統,「我們有興趣嗎?」,答案是「是」。

Phil Wadler 和 Simon Marlow 研究型別系統超過一年,研究結果發表在 [20] 中。該專案的結果有些令人失望。首先,只有該語言的一個子集可以進行型別檢查,主要遺漏是缺少流程型別和流程間訊息的型別檢查。

流程和訊息都是 Erlang 的核心功能之一,這可以解釋為什麼該系統從未添加到該語言中。其他嘗試對 Erlang 進行型別檢查的嘗試都失敗了。HiPE 專案(嘗試使 Erlang 的效能更好)的努力產生了 Dialyzer,這是一種至今仍在使用的靜態分析工具,它具有自己的型別推斷機制。

由此產生的類型系統是基於「成功類型」(success typings),這與 Hindley-Milner 或軟類型系統的概念不同。「成功類型」的概念很簡單:類型推論不會嘗試找出每個表達式的精確類型,但它會保證它推斷出的類型是正確的,並且它找到的類型錯誤確實是錯誤。

最好的例子來自於函式 and 的實作,它通常會接收兩個布林值,如果它們都為真則返回 'true',否則返回 'false'。在 Haskell 的類型系統中,這會寫成 and :: bool -> bool -> bool。如果 and 函式必須在 Erlang 中實作,它可以透過以下方式完成:

and(false, _) -> false;
and(_, false) -> false;
and(true,true) -> true.

在「成功類型」下,函式推斷的類型會是 and(_,_) -> bool(),其中 _ 表示「任何東西」。原因很簡單:當執行 Erlang 程式並使用參數 false42 呼叫此函式時,結果仍然是 'false'。在模式匹配中使用 _ 通配符使得實際上可以傳遞任何參數,只要其中一個為 'false',函式就可以正常運作。如果你以這種方式呼叫函式,ML 類型系統會崩潰(它的使用者會心臟病發)。但 Erlang 不會。如果你決定閱讀關於「成功類型」實作的論文,這可能會對你更有意義,該論文解釋了這種行為背後的原理。我真的鼓勵任何類型狂熱者閱讀它,這是一個有趣且實用的實作定義。

有關類型定義和函式註解的詳細資訊在 Erlang 增強提案 8 (EEP 8) 中有說明。如果你有興趣在 Erlang 中使用「成功類型」,請查看 TypEr 應用程式和 Dialyzer,它們都是標準發行版本的一部分。要使用它們,請輸入 $ typer --help$ dialyzer --help(Windows 下是 typer.exe --helpdialyzer.exe --help,如果它們可以從您目前的目錄存取)。

TypEr 將被用來為函式產生類型註解。在這個小的 FIFO 實作上使用,它會吐出以下類型註解:

%% File: fifo.erl
%% --------------
-spec new() -> {'fifo',[],[]}.
-spec push({'fifo',_,_},_) -> {'fifo',nonempty_maybe_improper_list(),_}.
-spec pop({'fifo',_,maybe_improper_list()}) -> {_,{'fifo',_,_}}.
-spec empty({'fifo',_,_}) -> bool().
Implementation of fifo (queues): made out of two stacks (last-in first-out).

這幾乎是正確的。應該避免使用不當的列表,因為 lists:reverse/1 不支援它們,但是繞過模組介面的人將能夠通過它並提交一個。在這種情況下,函式 push/2pop/2 可能在導致異常之前仍然可以成功呼叫幾次。這告訴我們要新增 guard 或手動調整我們的類型定義。假設我們新增簽名 -spec push({fifo,list(),list()},_) -> {fifo,nonempty_list(),list()}. 和一個將不當列表傳遞給模組中 push/2 的函式:當在 Dialyzer 中掃描它(它檢查並匹配類型)時,會輸出錯誤訊息 "The call fifo:push({fifo,[1|2],[]},3) breaks the contract '<Type definition here>'"

Dialyzer 只會在程式碼會破壞其他程式碼時發出警告,如果確實如此,它通常是正確的(它也會抱怨更多東西,例如永遠不會匹配的子句或一般不符之處)。多型資料類型也可以使用 Dialyzer 撰寫和分析:hd() 函式可以使用 -spec([A]) -> A. 進行註解並正確分析,儘管 Erlang 程式設計師似乎很少使用這種類型語法。

不要喝太多 Kool-Aid(不要太過相信)
您不能期望 Dialyzer 和 TypEr 做的一些事情是帶有建構子的類型類別、一階類型和遞迴類型。Erlang 的類型只是註解,除非您自己強制執行,否則對實際編譯沒有影響或限制。類型檢查器永遠不會告訴你一個現在可以正常執行(或已經執行了兩年)的程式有類型錯誤,因為它在執行時實際上不會導致任何錯誤(儘管你可能會讓有錯誤的程式碼正常執行...)

雖然遞迴類型是很有趣的東西,但它們不太可能以 TypEr 和 Dialyzer 目前的形式出現(上面的論文解釋了原因)。目前您能做的最好的事情是透過手動新增一到兩個層級來定義您自己的類型以模擬遞迴類型。

它當然不是一個成熟的類型系統,不像 Scala、Haskell 或 Ocaml 等語言提出的那麼嚴格或強大。它的警告和錯誤訊息也通常有點難懂,而且對使用者不太友善。但是,如果您真的無法在動態世界中生存,或希望獲得額外的安全性,它仍然是一個非常好的折衷方案;請把它當作您工具箱中的工具,而不是太多。

更新
自 R13B04 版本起,遞迴類型現在可以作為 Dialyzer 的實驗功能使用。這使得之前的「不要喝太多 Kool-aid」部分錯誤。我感到羞愧。

請注意,類型文件也已成為官方文件(儘管它仍然可能會變更),並且比 EEP8 中找到的更完整。