模組

什麼是模組

A box with functions written on it

使用互動式 Shell 通常被認為是使用動態程式語言的重要部分。它可以測試各種程式碼和程式。Erlang 的大多數基本資料類型都是直接使用,甚至不需要打開文字編輯器或儲存檔案。你可以丟下鍵盤,到外面玩球,然後結束這一天,但如果你就此停止,你就會是個糟糕的 Erlang 程式設計師。程式碼需要儲存起來才能使用!

這就是模組的用途。模組是一堆函數,它們在單一檔案中,以單一名稱重新組合。此外,Erlang 中的所有函數都必須在模組中定義。你已經使用過模組,可能還沒有意識到。前一章提到的 BIF,像是 hdtl,實際上都屬於 erlang 模組,所有算術、邏輯和布林運算符也是如此。erlang 模組的 BIF 與其他函數的不同之處在於,當你使用 Erlang 時,它們會自動匯入。你將會使用的模組中定義的其他所有函數,都需要以 Module:Function(Arguments) 的形式呼叫。

你可以自己看看

1> erlang:element(2, {a,b,c}).
b
2> element(2, {a,b,c}).
b
3> lists:seq(1,4).
[1,2,3,4]
4> seq(1,4).
** exception error: undefined shell command seq/2

在這裡,list 模組的 seq 函數沒有自動匯入,而 element 卻有。錯誤 'undefined shell command' 來自於 shell 尋找像 f() 這樣的 shell 命令,但找不到它。erlang 模組中有一些函數不會自動匯入,但它們不常被使用。

邏輯上,你應該將關於相似事物的函數放在單一模組內。關於列表的常見操作會放在 lists 模組中,而執行輸入和輸出(例如寫入終端機或檔案)的函數會重新組合到 io 模組中。你將會遇到的唯一不遵守該模式的模組之一是前面提到的 erlang 模組,它具有執行數學運算、轉換、處理多處理、擺弄虛擬機器設定等的函數。它們沒有共同點,除了是內建函數之外。你應該避免建立像 erlang 這樣的模組,而是專注於乾淨的邏輯分離。

模組宣告

A scroll with small text on it

在撰寫模組時,你可以宣告兩種東西:函數屬性。屬性是描述模組本身的元資料,例如其名稱、應該對外界可見的函數、程式碼的作者等等。這種元資料很有用,因為它可以提示編譯器應該如何執行其工作,而且還可以讓人們從編譯後的程式碼中檢索有用的資訊,而無需查閱原始碼。

目前在世界各地的 Erlang 程式碼中使用了大量的模組屬性;事實上,你甚至可以宣告你自己的屬性來做任何你想做的事情。有一些預先定義的屬性在你的程式碼中會比其他屬性更常出現。所有模組屬性都遵循 -Name(Attribute). 的形式。只有其中一個屬性是你的模組可以編譯所必須的。

-module(名稱).
這永遠是檔案的第一個屬性(和陳述式),而且有充分的理由:它是目前模組的名稱,其中 名稱 是一個原子。這是你將用來從其他模組呼叫函數的名稱。呼叫是使用 M:F(A) 的形式進行,其中 M 是模組名稱,F 是函數,A 是參數。

已經可以開始寫程式碼了!我們的第一個模組將會非常簡單且無用。開啟你的文字編輯器並輸入以下內容,然後將其儲存為 useless.erl

-module(useless).

這行文字是一個有效的模組。真的!當然,沒有函數它是無用的。讓我們先決定將哪些函數從我們的 'useless' 模組匯出。為此,我們將使用另一個屬性

-export([Function1/Arity, Function2/Arity, ..., FunctionN/Arity]).
這用於定義模組的哪些函數可以被外界呼叫。它接受一個帶有各自參數個數的函數列表。函數的參數個數是一個整數,表示可以傳遞給該函數的參數數量。這是至關重要的資訊,因為模組內定義的不同函數只有在它們具有不同的參數個數時才能共享相同的名稱。因此,函數 add(X,Y)add(X,Y,Z) 會被認為是不同的,並以 add/2add/3 的形式撰寫。

注意: 匯出的函數代表模組的介面。定義一個介面嚴格揭露其使用所必要的內容,而不是更多,這一點非常重要。這樣做可以讓你擺弄實作的所有其他 [隱藏] 細節,而不會破壞可能依賴你模組的程式碼。

我們無用的模組將首先匯出一個名為 'add' 的有用函數,它將接受兩個參數。以下 -export 屬性可以在模組宣告之後新增

-export([add/2]).

現在寫下函數

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

函數的語法遵循 Name(Args) -> Body. 的形式,其中 Name 必須是一個原子,而 Body 可以是一個或多個以逗號分隔的 Erlang 表達式。函數以句點結束。請注意,Erlang 不使用 'return' 關鍵字。'Return' 是無用的!相反,函數中要執行的最後一個邏輯表達式將自動將其值傳回給呼叫者,而無需你提及它。

新增以下函數(沒錯,每個教學都需要一個 'Hello world' 範例!即使在第四章也是如此!),不要忘記將其新增到 -export 屬性。

%% Shows greetings.
%% io:format/1 is the standard function used to output text.
hello() ->
    io:format("Hello, world!~n").

我們從這個函數中看到的是,註解是單行的,並且以 % 符號開頭(使用 %% 純粹是風格問題)。hello/0 函數也示範了如何在你的程式碼中從外部模組呼叫函數。在這種情況下,io:format/1 是輸出文字的標準函數,如註解中所寫。

最後一個函數將被新增到模組,它同時使用函數 add/2hello/0

greet_and_add_two(X) ->
	hello(),
	add(X,2).
A box being put in another one

不要忘記將 greet_and_add_two/1 新增到匯出的函數列表中。對 hello/0add/2 的呼叫不需要在它們前面加上模組名稱,因為它們是在模組本身中宣告的。

如果你想以與 add/2 或模組內定義的任何其他函數相同的方式呼叫 io:format/1,你可以在檔案開頭新增以下模組屬性:-import(io, [format/1]).。然後你可以直接呼叫 format("Hello, World!~n").。更一般而言,-import 屬性遵循此規則

-import(Module, [Function1/Arity, ..., FunctionN/Arity]).

當程式設計師撰寫程式碼時,匯入函數只不過是快捷方式。Erlang 程式設計師通常不鼓勵使用 -import 屬性,因為有些人認為這會降低程式碼的可讀性。在 io:format/2 的情況下,也存在函數 io_lib:format/2。要找出使用的是哪一個,意味著要到檔案頂部查看它是從哪個模組匯入的。因此,將模組名稱保留在其中被認為是一種良好的作法。通常,你會看到匯入的唯一函數來自 lists 模組:它的函數比大多數其他模組的函數使用頻率更高。

你的 useless 模組現在應該看起來像下面的檔案

-module(useless).
-export([add/2, hello/0, greet_and_add_two/1]).

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

%% Shows greetings.
%% io:format/1 is the standard function used to output text.
hello() ->
    io:format("Hello, world!~n").

greet_and_add_two(X) ->
    hello(),
    add(X,2).

我們完成了「無用」模組。你可以將檔案以 useless.erl 的名稱儲存。檔案名稱應該是 -module 屬性中定義的模組名稱,後跟 '.erl',這是標準的 Erlang 原始碼擴展名。

在示範如何編譯模組並最終嘗試其所有令人興奮的函數之前,我們將了解如何定義和使用巨集。Erlang 巨集與 C 的 '#define' 陳述式非常相似,主要用於定義簡短的函數和常數。它們是簡單的表達式,由文字表示,在程式碼編譯為 VM 之前會被取代。此類巨集主要用於避免讓魔法值在你的模組中到處亂竄。巨集被定義為形式為:-define(MACRO, some_value). 的模組屬性,並且在模組中定義的任何函數內使用為 ?MACRO。'函數' 巨集可以寫成 -define(sub(X,Y), X-Y).,並像 ?sub(23,47) 那樣使用,之後會被編譯器取代為 23-47。有些人會使用更複雜的巨集,但基本語法保持不變。

編譯程式碼

Erlang 程式碼會被編譯為位元組碼,以便虛擬機器使用。你可以從許多地方呼叫編譯器:當在命令列中時,$ erlc flags file.erl,當在 shell 或模組中時,compile:file(FileName),當在 shell 中時,c() 等。

是時候編譯我們的無用模組並試試看了。開啟 Erlang shell,輸入

1> cd("/path/to/where/you/saved/the-module/").
"Path Name to the directory you are in"
ok

預設情況下,shell 只會在啟動時所在的目錄和標準函式庫中尋找檔案:cd/1 是專門為 Erlang shell 定義的函數,它會告訴 shell 將目錄更改為新的目錄,這樣瀏覽我們的檔案就不那麼麻煩了。Windows 使用者應該記得使用正斜線。完成此操作後,執行以下操作

2> c(useless).
{ok,useless}

如果您有其他訊息,請確認檔案名稱正確、您位於正確的目錄,並且在您的模組中沒有犯任何錯誤。一旦您成功編譯程式碼,您會注意到在您的目錄中,useless.erl 旁邊會新增一個 useless.beam 檔案。這就是編譯後的模組。讓我們來試試看第一個函數。

3> useless:add(7,2).
9
4> useless:hello().
Hello, world!
ok
5> useless:greet_and_add_two(-3).
Hello, world!
-1
6> useless:not_a_real_function().
** exception error: undefined function useless:not_a_real_function/0

這些函數如預期般運作:add/2 加總數字、hello/0 輸出 "Hello, world!",而 greet_and_add_two/1 則兩者都做!當然,您可能會問為什麼 hello/0 在輸出文字後會回傳原子 'ok'。這是因為 Erlang 函數和表達式**總是**必須回傳某些東西,即使在其他語言中可能不需要。因此,io:format/1 回傳 'ok' 表示正常情況,沒有錯誤。

表達式 6 顯示一個錯誤被拋出,因為該函數不存在。如果您忘記匯出一個函數,當您嘗試使用它時,會出現這種錯誤訊息。

注意: 如果您曾經好奇,'.beam' 代表 *Bogdan/Björn's Erlang Abstract Machine*,也就是 VM 本身。Erlang 還有其他虛擬機器存在,但它們現在已經不太使用,成為歷史:JAM (Joe's Abstract Machine,靈感來自 Prolog 的 WAM) 和舊的 BEAM,它試圖將 Erlang 編譯成 C,然後再編譯成原生程式碼。基準測試顯示這種做法沒有什麼好處,因此放棄了這個概念。

存在許多編譯標誌,可以讓您更進一步控制模組的編譯方式。您可以在 Erlang 文件中取得所有標誌的列表。最常見的標誌是

-debug_info
Erlang 工具,例如除錯器、程式碼涵蓋率和靜態分析工具,會使用模組的除錯資訊來執行其工作。
-{outdir,Dir}
預設情況下,Erlang 編譯器會在目前的目錄中建立 'beam' 檔案。這會讓您選擇要將編譯後的檔案放在哪裡。
-export_all
會忽略 -export 模組屬性,而是匯出所有定義的函數。這主要在測試和開發新程式碼時很有用,但不應在生產環境中使用。
-{d,Macro} 或 {d,Macro,Value}
定義一個要在模組中使用的巨集,其中 Macro 是一個原子。這在進行單元測試時更常使用,以確保只有在明確需要時才會建立和匯出模組的測試函數。預設情況下,如果 Value 沒有定義為 tuple 的第三個元素,則 Value 為 'true'。

要使用一些標誌編譯我們的 useless 模組,我們可以執行以下其中一個動作

7> compile:file(useless, [debug_info, export_all]).
{ok,useless}
8> c(useless, [debug_info, export_all]).
{ok,useless}

您也可以很巧妙地從模組內定義編譯標誌,使用模組屬性。為了獲得與表達式 7 和 8 相同的結果,可以將以下程式碼行新增至模組

-compile([debug_info, export_all]).

然後只需編譯,您就會得到與手動傳遞標誌相同的結果。現在我們已經能夠寫下函數、編譯它們並執行它們,是時候看看我們可以將它們發揮到什麼程度了!

注意: 另一個選項是將您的 Erlang 模組編譯成本機程式碼。並非每個平台和作業系統都提供本機程式碼編譯,但在那些支援它的平台上,它可以使您的程式執行速度更快(根據軼事證據,大約快 20%)。要編譯成本機程式碼,您需要使用 hipe 模組並以下列方式呼叫它:hipe:c(Module,OptionsList). 您也可以在 shell 中使用 c(Module,[native]). 來達到類似的結果。請注意,產生的 .beam 檔案將包含原生程式碼和非原生程式碼,並且原生部分將無法跨平台移植。

更多關於模組的資訊

在繼續學習更多關於撰寫函數和幾乎沒什麼用的程式碼片段之前,我想討論一些未來可能對您有用的其他雜項資訊。

第一個資訊是關於模組的元數據。我在本章開頭提到,模組屬性是描述模組本身的元數據。當我們無法存取原始碼時,我們可以在哪裡找到這些元數據?嗯,編譯器對我們很友善:當編譯一個模組時,它會擷取大多數模組屬性並將它們儲存(連同其他資訊)在 module_info/0 函數中。您可以透過以下方式查看 useless 模組的元數據

9> useless:module_info().
[{exports,[{add,2},
           {hello,0},
           {greet_and_add_two,1},
           {module_info,0},
           {module_info,1}]},
 {imports,[]},
 {attributes,[{vsn,[174839656007867314473085021121413256129]}]},
 {compile,[{options,[]},
           {version,"4.6.2"},
           {time,{2009,9,9,22,15,50}},
           {source,"/home/ferd/learn-you-some-erlang/useless.erl"}]}]
10> useless:module_info(attributes).
[{vsn,[174839656007867314473085021121413256129]}]

上面的程式碼片段也顯示了一個額外的函數 module_info/1,它會讓您取得一個特定的資訊。您可以查看匯出的函數、匯入的函數(在本例中沒有!)、屬性(這是您自訂元數據的存放位置),以及編譯選項和資訊。如果您決定在模組中新增 -author("An Erlang Champ").,它將會與 vsn 放在同一個區段中。當涉及到生產方面的事情時,模組屬性的用途有限,但它們在做一些小技巧來幫助自己時會很有用:我在本書的 測試腳本 中使用它們來註解那些可以更好地進行單元測試的函數;該腳本會查找模組屬性,找到註解的函數,並顯示有關它們的警告。

注意: vsn 是一個自動產生的唯一值,用於區分程式碼的每個版本,不包括註解。它用於程式碼熱載入(在應用程式執行時升級它,而無需停止它)和一些與發布處理相關的工具。如果您願意,您也可以自己指定一個 vsn 值:只需在您的模組中新增 -vsn(VersionNumber) 即可。

A small graph with three nodes: Mom, Dad and You. Mom and Dad are parents of You, and You is brother of Dad. Text under: 'If circular dependencies are digusting in real life, maybe they should be disgusting in your programs too'

另一個值得探討的重點是關於一般模組設計:避免循環相依性!模組 A 不應呼叫模組 B,而模組 B 也呼叫模組 A。這種相依性通常會導致程式碼維護變得困難。事實上,即使它們不是循環相依性,依賴太多模組也會使維護更加困難。您最不希望的就是在半夜醒來,發現一個瘋狂的軟體工程師或電腦科學家正試圖挖出您的眼睛,因為您寫了可怕的程式碼。

由於類似的原因(維護和擔心您的眼睛),通常認為將具有相似角色的函數放在一起是一種良好的做法。啟動和停止應用程式或在某些資料庫中建立和刪除記錄就是這種情況的例子。

嗯,關於那些迂腐的道德勸說就夠了。我們來多探索一下 Erlang 怎麼樣?