檢視原始碼 常見注意事項
本節列出一些需要注意的結構。
運算符 ++
++
運算符會複製其左側運算元。如果我們在 Erlang 中自行實作,就可以清楚地看到這一點
my_plus_plus([H|T], Tail) ->
[H|my_plus_plus(T, Tail)];
my_plus_plus([], Tail) ->
Tail.
我們必須小心在迴圈中使用 ++
的方式。首先是如何**不**使用它
請勿這樣做
naive_reverse([H|T]) ->
naive_reverse(T) ++ [H];
naive_reverse([]) ->
[].
由於 ++
運算符會複製其左側運算元,因此不斷增長的結果會被重複複製,導致時間複雜度為二次方。
另一方面,像這樣在迴圈中使用 ++
是完全沒問題的
可以
naive_but_ok_reverse(List) ->
naive_but_ok_reverse(List, []).
naive_but_ok_reverse([H|T], Acc) ->
naive_but_ok_reverse(T, [H] ++ Acc);
naive_but_ok_reverse([], Acc) ->
Acc.
每個列表元素只會被複製一次。不斷增長的結果 Acc
是右側運算元,它**不會**被複製。
有經驗的 Erlang 程式設計師可能會寫成如下形式
應該這樣做
vanilla_reverse([H|T], Acc) ->
vanilla_reverse(T, [H|Acc]);
vanilla_reverse([], Acc) ->
Acc.
原則上,這樣效率會稍微高一些,因為列表元素 [H]
在被複製和丟棄之前不會被建立。實際上,編譯器會將 [H] ++ Acc
重寫為 [H|Acc]
。
Timer 模組
使用 erlang:send_after/3
和 erlang:start_timer/3
建立計時器,會比使用 STDLIB 中 timer
模組提供的計時器更有效率。
timer
模組使用單獨的程序來管理計時器。在 Erlang/OTP 25 之前,這種管理開銷相當大,而且會隨著計時器數量的增加而增加,特別是當它們是短暫的計時器時,因此計時器伺服器程序很容易過載且無響應。在 Erlang/OTP 25 中,timer 模組經過改進,移除了大部分管理開銷和由此產生的效能損失。儘管如此,計時器伺服器仍然是一個單一的程序,它在某些時候可能會成為應用程式的瓶頸。
timer
模組中不管理計時器的函式(例如 timer:tc/3
或 timer:sleep/1
)不會呼叫計時器伺服器程序,因此無害。
意外複製和共享遺失
當使用 fun 生成一個新程序時,可能會意外地將比預期更多的資料複製到該程序。例如
請勿這樣做
accidental1(State) ->
spawn(fun() ->
io:format("~p\n", [State#state.info])
end).
fun 中的程式碼會從記錄中提取一個元素並印出。記錄的其餘部分 state
並未使用。但是,當執行 spawn/1
函式時,整個記錄會被複製到新建立的程序中。
同樣的問題也可能發生在 map 上
請勿這樣做
accidental2(State) ->
spawn(fun() ->
io:format("~p\n", [map_get(info, State)])
end).
在以下範例中(實作 gen_server
行為的模組的一部分),建立的 fun 會被傳送到另一個程序
請勿這樣做
handle_call(give_me_a_fun, _From, State) ->
Fun = fun() -> State#state.size =:= 42 end,
{reply, Fun, State}.
這種不必要的複製有多糟糕取決於記錄或 map 的內容。
例如,如果 state
記錄像這樣初始化
init1() ->
#state{data=lists:seq(1, 10000)}.
一個包含 10000 個元素(或約 20000 個堆積字)的列表將被複製到新建立的程序。
不必要地複製包含 10000 個元素的列表可能已經夠糟糕了,但如果 state
記錄包含**共享子項**,情況可能會更糟。以下是一個包含共享子項的簡單範例
{SubTerm, SubTerm}
當一個項被複製到另一個程序時,子項的共享會遺失,而複製後的項可能會比原始項大很多倍。例如
init2() ->
SharedSubTerms = lists:foldl(fun(_, A) -> [A|A] end, [0], lists:seq(1, 15)),
#state{data=Shared}.
在呼叫 init2/0
的程序中,state
記錄中 data
欄位的大小將為 32 個堆積字。當記錄被複製到新建立的程序時,共享會遺失,而複製後的 data
欄位的大小將為 131070 個堆積字。有關共享遺失的更多詳細資訊可以在後續章節中找到。
為避免這個問題,在 fun 之外只提取實際使用的記錄欄位
應該這樣做
fixed_accidental1(State) ->
Info = State#state.info,
spawn(fun() ->
io:format("~p\n", [Info])
end).
同樣地,在 fun 之外只提取實際使用的 map 元素
應該這樣做
fixed_accidental2(State) ->
Info = map_get(info, State),
spawn(fun() ->
io:format("~p\n", [Info])
end).
list_to_atom/1
Atom 不會被垃圾回收。一旦建立 atom,它就永遠不會被移除。如果達到 atom 數量的限制(預設為 1,048,576),模擬器將會終止。
因此,在持續運行的系統中,將任意輸入字串轉換為 atom 可能很危險。如果只允許某些定義明確的 atom 作為輸入,則可以使用 list_to_existing_atom/1
或 binary_to_existing_atom/1
來防止阻斷服務攻擊。(所有允許的 atom 必須事先建立,例如,在模組中使用它們並載入該模組。)
使用 list_to_atom/1
建構一個傳遞給 apply/3
的 atom 是相當耗費效能的。
請勿這樣做
apply(list_to_atom("some_prefix"++Var), foo, Args)
length/1
計算列表長度的時間與列表的長度成正比,這與 tuple_size/1
、byte_size/1
和 bit_size/1
不同,後者都在恆定時間內執行。
通常,無需擔心 length/1
的速度,因為它在 C 中高效地實作。在時間要求嚴苛的程式碼中,如果輸入列表可能非常長,您可能需要避免使用它。
length/1
的某些用法可以用模式比對來取代。例如,以下程式碼
foo(L) when length(L) >= 3 ->
...
可以重寫為
foo([_,_,_|_]=L) ->
...
一個細微的差異是,如果 L
是一個不當的列表,length(L)
會失敗,而第二個程式碼片段中的模式則會接受一個不當的列表。
setelement/3
setelement/3
會複製它修改的元組。因此,在迴圈中使用 setelement/3
更新元組每次都會建立一個新的元組副本。
元組被複製的規則有一個例外。如果編譯器清楚地看到破壞性地更新元組會產生與複製元組相同的結果,則對 setelement/3
的呼叫會被特殊的破壞性 setelement
指令取代。在以下程式碼序列中,第一個 setelement/3
呼叫會複製元組並修改第九個元素
multiple_setelement(T0) when tuple_size(T0) =:= 9 ->
T1 = setelement(9, T0, bar),
T2 = setelement(7, T1, foobar),
setelement(5, T2, new_value).
接下來的兩個 setelement/3
呼叫會就地修改元組。
為了應用最佳化,所有下列條件都必須為真
- 元組參數必須已知為已知大小的元組。
- 索引必須是整數文字,而不是變數或表達式。
- 索引必須以遞減順序給定。
- 在呼叫
setelement/3
之間不能有其他函式的呼叫。 - 從一個
setelement/3
呼叫傳回的元組只能在後續的setelement/3
呼叫中使用。
如果程式碼無法以 multiple_setelement/1
範例中的方式結構化,則修改大型元組中多個元素的最佳方法是將元組轉換為列表,修改列表,然後將其轉換回元組。
size/1
size/1
會傳回元組和二進制檔案的大小。
使用 BIF tuple_size/1
和 byte_size/1
會為編譯器和執行階段系統提供更多最佳化的機會。另一個優點是這些 BIF 為 Dialyzer 提供更多型別資訊。
使用 NIF
將 Erlang 程式碼重寫為 NIF 以使其更快應該被視為最後的手段。
在每次 NIF 呼叫中執行太多工作會降低 VM 的反應速度。執行太少的工作可能意味著 NIF 中更快處理的速度增益會被呼叫 NIF 和檢查參數的開銷所抵消。
在編寫 NIF 之前,請務必閱讀有關 長時間運行的 NIF 的資訊。