模塊是 Erlang 中的基本代碼結構體。模塊可以包含大量的函數,但只有模塊導出列表中的函數才能從模塊外部調用。
從模塊外部來看,模塊的復雜性跟模塊可導出的函數數量有關。只導出一兩個函數的模塊通常要比那些能導出幾十個函數的模塊更易于人們理解。
對于使用者來說,可導出/非導出函數的比率較低的模塊是比較易于接受的,因為他們只需理解模塊可導出函數的功能即可。
另外,模塊代碼的作者或者維護人員還可以采取任何適當的方式,在保持外部接口不變的前提下改變模塊的內部結構。
如果模塊需要調用很多不同模塊中的函數,那么它就難以維護,相比之下,僅調用有限幾個模塊函數的模塊能更輕松地得到維護。
這是因為,每次我們改變模塊接口時,都要檢查代碼中所有調用該模塊的位置。降低模塊間的依賴性,可以使這些模塊的維護變得簡單。
減少給定模塊所調用的不同模塊數目,也可以簡化系統(tǒng)結構。
同時也應注意,模塊間調用依賴性結構最好呈現(xiàn)樹狀結構,而不要出現(xiàn)循環(huán)結構。例如下圖所示的樹狀結構:
http://wiki.jikexueyuan.com/project/erlang-programming-rules/images/3.2_module_dep_ok.png" alt="module-dep-ok" />
最好不要是這樣的結構:
http://wiki.jikexueyuan.com/project/erlang-programming-rules/images/3.2_module_dep_bad.png" alt="3.2_module_dep_bad" />
應將常用代碼放入庫中。庫應該是相關函數的集合。應該努力確保庫包含同樣類型的函數。比如,若 lists 庫只包含操縱列表的函數,那么這就是一種非常不錯的設計;而如果 lists_and_maths 庫中既含有操縱列表的函數,又含有用于數學運算的函數,那么就是一種非常糟糕的設計。
庫函數應最好沒有副作用。庫中若包含帶有副作用的函數,則會限制它的可重用性。
在解決某個問題時,往往需要結合使用整潔與臟亂的代碼。最好將整潔的代碼與臟亂代碼分別放入單獨的模塊中。
臟亂代碼是指那些做“臟活”的代碼。比如說:
erlang:process_info/1 用于特殊目的。 應該努力增加整潔代碼,減少混亂代碼。隔離混亂代碼與清晰注釋,或將代碼中存在的所有副作用和問題記錄下來。
不要事先假設函數為何被調用,或者調用者希望如何處理結果。
例如,假設我們調用一個例程,它的某些參數可能是無效的。在實現(xiàn)該例程時,不需要知道當參數無效時,函數調用者會希望采用的行為。
因此我們不應該這樣寫函數:
do_something(Args) ->
case check_args(Args) of
ok ->
{ok, do_it(Args)};
{error, What} ->
String = format_the_error(What),
io:format("* error:~s\n", [String]), %% Don't do this
error
end.
而應該這樣寫函數:
do_something(Args) ->
case check_args(Args) of
ok ->
{ok, do_it(Args)};
{error, What} ->
{error, What}
end.
error_report({error, What}) ->
format_the_error(What).
在第一段代碼中,錯誤字符串經常打印在標準輸出中;而第二段代碼則為程序返回一個錯誤描述符,程序可以決定如何處理錯誤描述符。
通過調用 error_report/1,函數可以將錯誤描述轉化為一個可輸出的字符串并在需要時將其打印出來。但這可能并非是預期行為——無論如何,對結果的處理決策應由調用方來決定。
如果在代碼的兩個或多個位置處出現(xiàn)了同樣模式的代碼,則最好將這種代碼單獨編寫為一個常用的函數,然后通過調用該函數來解決問題,而不要讓同樣模式的代碼散布在多個位置。維護復制的代碼會需要付出更大的精力。
如果代碼的兩個或多個位置處具有相似模式的代碼(比如,功能基本相同),那么就值得稍微研究一下,想一想是否不用怎么改變問題本身,就能使代碼適用于不同的情況,然后還可以編寫少量的額外代碼來描述并應對不同情況之間的差別。
總之,盡量避免使用“復制”或“粘貼”來編程,要記得使用函數!
采用“由上至下”的方式來編寫程序,而不要采用“由下到上”的方式(一開始就處理細節(jié))。采用由上至下的方式,方便隨后逐步實現(xiàn)細節(jié),并能最終優(yōu)化原始函數。代碼將獨立于表示形式之外,因為在設計較高層次的代碼時,是不知道表示形式的。
不要一開始就試圖優(yōu)化代碼。首先要保證代碼的正確性,而后(如果需要的情況下)再追求代碼的執(zhí)行效率(在保證正確性的前提下)。
系統(tǒng)的反應方式應該以讓用戶感到“驚訝最少”為宜,比如,當用戶在執(zhí)行一定行為時,應該能預知發(fā)生的結果,而不應該為實際結果而感到驚訝。
這一點跟一致性有關。在具有一致性的系統(tǒng)中,多個模塊的執(zhí)行方式應該保持一致,易于理解;而在有些不一致的系統(tǒng)中,每個模塊都各行其是。
如果某個函數的執(zhí)行方式讓你感到驚訝,或者是該函數解決的是另一個問題,或者是函數名起錯了。
Erlang 的有些原語具有一定的副作用。使用這些原語的函數將無法輕易地重用,因為這些原語會永久改變函數的環(huán)境,所以在調用這種例程前,要清楚了解進程的確切狀態(tài)。
盡量利用無副作用的代碼來編程。
盡量編寫純凈的函數。
收集具有副作用的函數,清晰地注釋它們的所有副作用。
只需稍加留心,絕大多數代碼都可以用無副作用的方式來編寫,從而使系統(tǒng)的維護、測試變得非常容易,其他人也更容易理解系統(tǒng)。
以下這個小例子會更容易闡述這一點。在下例中,為了實現(xiàn)隊列,定義了一個叫做 queue 的小模塊:
-module(queue).
-export([add/2, fetch/1]).
add(Item, Q) ->
lists:append(Q, [Item]).
fetch([H|T]) ->
{ok, H, T};
fetch([]) ->
empty.
上述代碼將隊列實現(xiàn)為列表的形式。不過遺憾的是,用戶在使用該模塊時必須知道隊列已經被表現(xiàn)為列表形式。通常用到該模塊的程序可能含有以下代碼段:
NewQ = [], % 不要這樣做
Queue1 = queue:add(joe, NewQ),
Queue2 = queue:add(mike, Queue1), ....
這很糟糕,因為用戶(a)需要知道隊列被表現(xiàn)為列表,而且(b)實現(xiàn)者無法改變隊列的內部表現(xiàn)(從而使他們以后可能想編寫一個更好的模塊)。
所以,最好像下面這樣:
-module(queue).
-export([new/0, add/2, fetch/1]).
new() ->
[].
add(Item, Q) ->
lists:append(Q, [Item]).
fetch([H|T]) ->
{ok, H, T};
fetch([]) ->
empty.
現(xiàn)在,我們就能像下面這樣來調用該模塊了:
NewQ = queue:new(),
Queue1 = queue:add(joe, NewQ),
Queue2 = queue:add(mike, Queue1), ...
這樣做,不僅改正了前面談到的問題,而且效率更好。假設用戶想知道隊列長度,那么他們很可能會忍不住像下面這樣來調用模塊:
Len = length(Queue) % 不要這樣做
因為他們知道隊列被表現(xiàn)為列表的形式。所以再次說明,這是一種非常丑陋的編程實踐,會讓代碼變得難以維護和理解。如果用戶想知道隊列長度,那就必須給模塊加入一個長度函數,如下所示:
-module(queue).
-export([new/0, add/2, fetch/1, len/1]).
new() -> [].
add(Item, Q) ->
lists:append(Q, [Item]).
fetch([H|T]) ->
{ok, H, T};
fetch([]) ->
empty.
len(Q) ->
length(Q).
現(xiàn)在用戶可以安全地調用 queue:len(Queue) 了。
現(xiàn)在我們可以認為已經將隊列的所有細節(jié)都抽象出來了(隊列實際上被稱為“抽象數據結構”)。
那我們還干嘛那么麻煩?通過對實現(xiàn)的內部細節(jié)予以抽象處理這條編程實踐,對于那些會調用改變模塊中函數的模塊,我們完全可以在不改變它們代碼的前提下改變實現(xiàn)。因此,關于隊列這個例子,還有一個更好的實現(xiàn)方式,如下所示:
-module(queue).
-export([new/0, add/2, fetch/1, len/1]).
new() ->
{[],[]}.
add(Item, {X,Y}) -> % 加速元素的添加
{[Item|X], Y}.
fetch({X, [H|T]}) ->
{ok, H, {X,T}};
fetch({[], []) ->
empty;
fetch({X, []) ->
% 只在有時才執(zhí)行這種復雜繁重的運算
fetch({[],lists:reverse(X)}).
len({X,Y}) ->
length(X) + length(Y).
確定性程序(deterministic program)指的是,不管運行多少次,行為都能保持一致的程序。非確定性程序有時會產生不同的運行結果。從調試的角度來看,也應盡量保持程序的確定性,因為錯誤可以重現(xiàn)出來,有助于調試。
例如,某個進程必須開啟 5 個并行的進程,然后檢查這些進程是否正確開啟。另外,無需考慮這 5 個進程開啟的順序。
我們當然可以并行開啟 5 個進程,然后檢查它們是否正確開啟。但是,最好能同時開啟它們,然后再檢查某一進程是否能在下一進程開啟之前正確開啟。
防范型程序是指那種開發(fā)者不信任輸入到系統(tǒng)中的數據的程序。總之,開發(fā)人員不應該測試函數輸入數據的正確性。系統(tǒng)中的絕大多數代碼應該信任輸入數據。只有少量的一部分代碼才應該執(zhí)行數據檢查,而這通常是發(fā)生在數據首次被輸入到“系統(tǒng)”中的時候,一旦數據進入系統(tǒng),就應該認定該數據是正確的。
比如:
%% Args: Option is all|normal
get_server_usage_info(Option, AsciiPid) ->
Pid = list_to_pid(AsciiPid),
case Option of
all -> get_all_info(Pid);
normal -> get_normal_info(Pid)
end.
如果 Option 不是 normal 或 all,函數就會崩潰,本該如此。調用者應負責提供正確的輸入數據。
應該通過使用設備驅動將硬件從系統(tǒng)中隔離出來。設備驅動應該實現(xiàn)硬件接口,使得硬件看起來像是 Erlang 的進程。應讓硬件的外在特征和行為像是普通的 Erlang 進程。硬件應該能夠接受并發(fā)送普通的 Erlang 消息,并在出現(xiàn)錯誤時采用通??衫斫獾姆绞接枰曰貞?。
假設有一個程序,功能是打開文件,對文件執(zhí)行一些操作,以及關閉文件。編碼如下:
do_something_with(File) ->
case file:open(File, read) of,
{ok, Stream} ->
doit(Stream),
file:close(Stream) % The correct solution
Error -> Error
end.
請注意在同一個例程中,打開文件(file:open)與關閉文件(file:close)的對稱性。下面的解決方案就比較難以實行,讓人搞不懂究竟關閉哪個文件。所以不要像這樣編程。
do_something_with(File) ->
case file:open(File, read) of,
{ok, Stream} ->
doit(Stream)
Error -> Error
end.
doit(Stream) ->
....,
func234(...,Stream,...).
...
func234(..., Stream, ...) ->
...,
file:close(Stream) %% Don't do this