編碼 · 程式語言
Erlang 入門教學
簡答
Erlang 是愛立信為必須持續運作的系統(如電話交換機與訊息伺服器)所創造的函數式並行語言。程式運行在 BEAM 虛擬機上,由許多輕量行程組成,行程之間以訊息溝通,失敗時由 supervisor 重新啟動。本頁涵蓋編譯模組、型別、模式比對、遞迴、串列與 map、行程、OTP 與錯誤處理。
Erlang 是什麼、可以用來做什麼
Erlang 是一種函數式、動態型別的語言,1980 年代由愛立信為必須不間斷運作的電話交換機而設計,1998 年以開放原始碼釋出。Erlang 程式運行在 BEAM 虛擬機上,由許多互不共享狀態、以訊息溝通的輕量行程組成,並附有 OTP(Open Telecom Platform),這是一套用來建構容錯系統的函式庫與設計原則。
Erlang 用於電信設備、訊息平台(WhatsApp 的後端就是以 Erlang 打造而聞名)、RabbitMQ 等訊息代理、Riak 與 CouchDB 等資料庫,以及支付與物聯網系統。Elixir 與 Gleam 也運行在 BEAM 上,可直接使用 Erlang 的函式庫。
安裝 Erlang 並執行 Hello, world
從 erlang.org 或透過套件管理工具安裝 Erlang/OTP(例如 brew install erlang 或 apt install erlang)。程式碼放在模組中;每個 name.erl 檔案定義模組 name,並列出它匯出的函式。
% hello.erl
-module(hello).
-export([main/0]).
main() ->
io:format("Hello, world!~n").erlc hello.erl # compiles to hello.beam
erl -noshell -s hello main -s init stop
# Hello, world!
erl # interactive shell; c(hello). then hello:main().註解以 % 開頭。格式字串中的 ~n 代表換行,~p 會印出任何 term。在 shell 中,每個運算式都以句點結尾。
標點符號、變數與基本型別
標點符號是 Erlang 帶給初學者最大的驚訝:逗號分隔運算式,分號分隔子句,句點則結束一個函式或 shell 指令。變數以大寫字母或底線開頭,而且只能綁定一次。
1> Name = "Erlang".
"Erlang"
2> Year = 1986, Next = Year + 1.
1987
3> Year = 2000.
** exception error: no match of right hand side value 2000
4> 10 / 4.
2.5
5> {10 div 4, 10 rem 4}.
{2,2}
6> is_atom(ok).
true
7> <<"binary string">>.
<<"binary string">>| 型別 | 範例 | 說明 |
|---|---|---|
| integer | 42, 16#FF | 任意精度;十六進位用 16# |
| float | 3.14 | 64 位元雙精度 |
| atom | ok, error, 'Hello' | 有名稱的常數,小寫開頭或加引號 |
| tuple | {ok, 42} | 大小固定的一組值 |
| list | [1, 2, 3] | 鏈結串列;像 "abc" 這樣的字串其實是整數串列 |
| map | #{name => "Ann"} | 鍵值儲存結構 |
| binary | <<"text">> | 位元組序列,是儲存文字的高效方式 |
true 與 false 是 atom,不是獨立的型別。= 是比對而不是賦值,所以第 3 行會失敗:Year 已經綁定為 1986。
模式比對、case 與 guard
模式比對可以拆解 tuple、串列與 map,並選擇要執行哪個子句。以 when 撰寫的 guard 可為子句加上條件。case 與 if 都是會回傳值的運算式。
-module(match).
-export([describe/1, grade/1, demo/0]).
describe({ok, Value}) -> io_lib:format("ok: ~p", [Value]);
describe({error, Reason}) -> io_lib:format("error: ~p", [Reason]);
describe(_Other) -> "unknown".
grade(Score) when Score >= 90 -> a;
grade(Score) when Score >= 70 -> b;
grade(_) -> c.
demo() ->
[First | Rest] = [1, 2, 3],
#{name := Name} = #{name => "Ann", age => 31},
Size = case length(Rest) of
0 -> empty;
N when N < 5 -> small;
_ -> large
end,
io:format("~p ~p ~s ~p ~p~n", [First, Rest, Name, Size, grade(75)]),
io:format("~s~n", [describe({error, timeout})]).
% match:demo() prints:
% 1 [2,3] Ann small b
% error: timeout除了最後一個子句外,每個子句都以 ; 結尾,最後一個以 . 結尾。如果沒有任何子句符合,行程會引發 function_clause 或 case_clause 錯誤。
函式、遞迴與高階函式
函式由名稱與元數識別,寫成 name/Arity。Erlang 沒有迴圈;重複的工作靠遞迴,而尾遞迴函式只使用固定的堆疊空間。匿名函式寫成 fun ... end。
-module(fns).
-export([factorial/1, sum/1, demo/0]).
factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).
sum(List) -> sum(List, 0). % public wrapper
sum([], Acc) -> Acc; % tail-recursive helper sum/2
sum([H | T], Acc) -> sum(T, Acc + H).
demo() ->
Double = fun(X) -> X * 2 end,
io:format("~p~n", [factorial(20)]), % 2432902008176640000
io:format("~p~n", [sum([1, 2, 3, 4])]), % 10
io:format("~p~n", [Double(21)]), % 42
io:format("~p~n", [lists:map(Double, [1, 2, 3])]), % [2,4,6]
io:format("~p~n", [[X * X || X <- [1, 2, 3, 4, 5], X rem 2 =:= 1]]). % [1,9,25]最後一行使用了串列生成式。請留意比較運算子:=:= 與 =/= 用來檢查完全相等與不相等,== 與 /= 則以數值比較(所以 1 == 1.0 為真),而小於等於寫成 =<,不是 <=。
串列、map 與標準函式庫
lists 模組提供 map、filter、foldl、sort、reverse 等許多函式;maps 處理 map;string 與 binary 處理文字。完整參考資料在 OTP 文件中。
1> L = [5, 3, 8, 1].
[5,3,8,1]
2> lists:sort(L).
[1,3,5,8]
3> lists:filter(fun(X) -> X > 2 end, L).
[5,3,8]
4> lists:foldl(fun(X, Acc) -> X + Acc end, 0, L).
17
5> M = #{ann => 31, bo => 25}, map_size(M).
2
6> M2 = M#{cy => 40}, map_size(M2).
3
7> maps:get(bo, M2).
25
8> maps:get(zed, M2, none).
noneM#{cy => 40} 會建立一個新的 map;M 本身絕不會改變。若要更新一個必須已存在的鍵,請用 := 取代 =>。
行程與訊息傳遞
行程是 Erlang 的招牌特色。spawn 啟動新行程,! 傳送訊息,receive 等待訊息,並以模式比對來配對訊息。行程成本很低,因此系統可以同時執行非常多個。透過 link 與 monitor,一個行程可以得知另一個行程已終止,OTP 的 supervisor 就是用這個機制自動重啟失敗的工作行程。
-module(counter).
-export([start/0, loop/1]).
start() ->
Pid = spawn(counter, loop, [0]),
Pid ! {add, 5},
Pid ! {add, 10},
Pid ! {get, self()},
receive
{count, N} -> io:format("count = ~p~n", [N]) % count = 15
after 1000 ->
io:format("timeout~n")
end.
loop(Count) ->
receive
{add, N} -> loop(Count + N);
{get, From} -> From ! {count, Count}, loop(Count)
end.這個手寫的迴圈,正是 OTP 的 gen_server behaviour 所泛化的做法。在正式環境的程式碼中,你會寫一個 gen_server 模組,並把它放在 supervisor 之下。
錯誤處理與「let it crash」
Erlang 有三類例外:error、throw 與 exit。可預期的失敗通常以 {ok, Value} 或 {error, Reason} 元組回傳。非預期的失敗往往完全不攔截:行程直接當掉,再由它的 supervisor 把它重啟到已知的良好狀態。真的需要攔截時,使用 try ... catch。
1> file:read_file("missing.txt").
{error,enoent}
2> try 1 / 0 catch error:badarith -> division_failed end.
division_failed
3> try throw(my_reason) catch throw:R -> {caught, R} end.
{caught,my_reason}
4> case file:read_file("missing.txt") of {ok, Bin} -> Bin; {error, Why} -> Why end.
enoent完整的小程式:字詞頻率統計
這個模組用 map 統計字串中的字詞,依次數排序,並印出最常見的三個。它結合了字串函式、lists:foldl、map 與自訂排序。
-module(wordfreq).
-export([top/2, main/0]).
top(Text, N) ->
Words = string:lexemes(string:lowercase(Text), " .,;:!?\n"),
Counts = lists:foldl(fun(W, Acc) ->
maps:update_with(W, fun(C) -> C + 1 end, 1, Acc)
end, #{}, Words),
Sorted = lists:sort(fun({W1, C1}, {W2, C2}) ->
{-C1, W1} =< {-C2, W2}
end, maps:to_list(Counts)),
lists:sublist(Sorted, N).
main() ->
Text = "The cat and the dog. The dog sat, and the cat ran.",
[io:format("~s: ~p~n", [W, C]) || {W, C} <- top(Text, 3)],
ok.
% the: 4
% and: 2
% cat: 2接下來可以學什麼
- erlang.org 上的 Erlang/OTP 文件,包括 Getting Started 指南與 OTP Design Principles。
- Learn You Some Erlang for Great Good!,一本可免費線上閱讀的書。
- 標準建置工具 rebar3,以及與 Elixir 共用的套件管理工具 Hex。
- 開始建構真正的服務後,請查閱
gen_server與supervisor的參考頁面。
常見問題
Erlang 現在還有人用嗎?
有。Erlang 仍然運行著電信系統、訊息服務與 RabbitMQ,而 BEAM 生態系也透過可使用 Erlang 函式庫的 Elixir 與 Gleam 持續成長。
Erlang 的 OTP 是什麼?
OTP 是隨 Erlang 一起提供的一套函式庫、工具與設計原則。它提供 gen_server、supervisor 與 application 等 behaviour,用來組織容錯的程式。
該學 Erlang 還是 Elixir?
Elixir 的語法較親切,網頁生態系也更大;Erlang 則較精簡,OTP 與許多核心函式庫都是用它寫成的。兩者運行在同一個虛擬機上,懂其中一個,就很容易讀懂另一個。
let it crash 是什麼意思?
你不必在行程內處理每一種可能的錯誤,而是讓行程失敗,並依賴 supervisor 把它重啟到乾淨的狀態。錯誤處理的程式碼因此保持精簡,系統也能自動復原。
為什麼 Erlang 的變數要大寫?
Erlang 用大小寫來區分變數與 atom:Name 是變數,name 則是 atom,也就是常數值。