# 第7章 錯誤處理
| 翻譯: | 丁豪 |
|-----|-----|
| 校對: | 連城 |
即便是Erlang程序員也難免會寫出有問題的程序。代碼中的語法錯誤(和一些語義錯誤)可以借助編譯器檢測出來,但程序仍可能含有邏輯錯誤。對需求理解的偏差或對需求實現的不完備所造成的邏輯錯誤只能通過大量的一致性測試來檢測。其他的錯誤則以運行時錯誤的形式出現。
函數是在Erlang進程中執行的。函數可能出于多種原因而失敗,比如:
- 一次匹配操作失敗
- 使用錯誤的參數調用BIF
- 我們可能打算對一個算術表達式求值,然而其中的一個項式并不是數值
Erlang本身當然無法修正這些情況,但它為程序員提供了一些檢測和處理失敗情況的機制。借助這些機制,程序員可以設計出健壯和容錯的系統。Erlang具備如下機制:
- 監視表達式的求值
- 監視其他進程的行為
- 捕獲對未定義函數的求值
### Catch和Throw
catch和throw提供了一種表達式求值的監視機制,可以用于
- 處理順序代碼中的錯誤(catch)
- 函數的非本地返回(catch結合throw)
表達式求值失敗(如一次匹配失敗)的一般后果是導致求值進程的異常退出。通過以下方式可以借助catch來更改這個默認行為:
~~~
catch Expression
~~~
若表達式的求值過程沒有發生錯誤,則catchExpression返回Expression的值。于是catchatom_to_list(abc)會返回[97,98,99]、catch22會返回22。
若求值過程失敗,catchExpression將返回元組{'EXIT',Reason},其中Reason是用于指明錯誤原因的原子式(參見第??節)。于是catchan_atom-2會返回{'EXIT',badarith}、catchatom_to_list(123)會返回{'EXIT',badarg}。
函數執行結束后,控制流程便返還者。throw/1可以令控制流程跳過調用者。如果我們像上述的那樣計算catchExpression,并在Expression的求值過程中調用throw/1,則控制流程將直接返回至catch。注意catch可以嵌套;在嵌套的情況下,一次失敗或throw將返回至最近的catch處。在catch之外調用throw/1將導致運行時錯誤。
下面的例子描述了catch和throw的行為。定義函數foo/1:
~~~
foo(1) ->
hello;
foo(2) ->
throw({myerror, abc});
foo(3) ->
tuple_to_list(a);
foo(4) ->
exit({myExit, 222}).
~~~
假設在不使用catch的情況下,一個進程標識為Pid的進程執行了這個函數,則:
foo(1)
> 返回hello。
foo(2)
> 執行throw({myerror,abc})。由于不在catch的作用域內,執行foo(2)的進程將出錯退出。
foo(3)
> 執行foo(3)的進程執行BIF tuple_to_list(a)。這個BIF用于將元組轉換為列表。在這個例子中,參數不是元組,因此該進程將出錯退出。
foo(4)
> 執行BIF exit/1。由于不在catch的范圍內,執行foo(4)的函數將退出。很快我們就會看到參數{myExit,222}的用途。
foo(5)
> 執行foo(5)的進程將出錯退出,因為函數foo/1的首部無法匹配foo(5)。
現在讓我們來看看在catch的作用域內對foo/1以相同的參數進行求值會發生什么:
~~~
demo(X) ->
case catch foo(X) of
{myerror, Args} ->
{user_error, Args};
{'EXIT', What} ->
{caught_error, What};
Other ->
Other
end.
~~~
demo(1)
> 像原來一樣執行hello。因為沒有任何失敗發生,而我們也沒有執行throw,所以catch直接返回foo(1)的求值結果。
demo(2)
> 求值結果為{user_error,abc}。對throw({myerror,abc})的求值導致外圍的catch返回{myerror,abc}同時case語句返回{user_error,abc}。
demo(3)
> 求值結果為{caught_error,badarg}。foo(3)執行失敗導致catch返回{'EXIT',badarg}。
demo(4)
> 求值結果為{caught_error,{myexit,222}}。
demo(5)
> 求值結果為{caught_error,function_clause}。
注意,在catch的作用域內,借助{'EXIT',Message},你能夠很容易地“偽造”一次失敗——這是一個**設計決策**[[1]](#)。
### 使用catch和throw抵御不良代碼
下面來看一個簡單的Erlang shell腳本:
~~~
-module(s_shell).
-export([go/0]).
go() ->
eval(io:parse_exprs('=> ')), % '=>' is the prompt
go().
eval({form,Exprs}) ->
case catch eval:exprs(Exprs, []) of % Note the catch
{'EXIT', What} ->
io:format("Error: ~w!~n", [What]);
{value, What, _} ->
io:format("Result: ~w~n", [What])
end;
eval(_) ->
io:format("Syntax Error!~n", []).
~~~
標準庫函數io:parse_exprs/1讀取并解析一個Erlang表達式,若表達式合法,則返回{form,Exprs}。
正確情況下,應該匹配到第一個子句eval({form,Expr})并調用庫函數eval:exprs/2對表達式進行求值。由于無法得知表達式的求值過程是否為失敗,我們在此使用catch進行保護。例如,對1-a進行求值將導致錯誤,但在catch內對1-a求值就可以捕捉這個錯誤[[2]](#)。借助catch,在求值失敗時,case子句與模式{'EXIT',what}匹配,在求值成功時則會與{value,What,_}匹配。
### 使用catch和throw實現函數的非本地返回
假設我們要編寫一個用于識別簡單整數列表的解析器,可以編寫如下的代碼:
~~~
parse_list(['[',']' | T])
{nil, T};
parse_list(['[', X | T]) when integer(X) ->
{Tail, T1} = parse_list_tail(T),
{{cons, X, Tail}, T1}.
parse_list_tail([',', X | T]) when integer(X) ->
{Tail, T1} = parse_list_tail(T),
{{cons, X, Tail}, T1};
parse_list_tail([']' | T]) ->
{nil, T}.
~~~
例如:
~~~
> parse_list(['[',12,',',20,']']).
{{cons,12,{cons,20,nil}},[]}
~~~
要是我們試圖解析一個非法的列表,就會導致如下的錯誤:
~~~
> try:parse_list(['[',12,',',a]).
!!! Error in process <0.16.1> in function
!!! try:parse_list_tail([',',a])
!!! reason function_clause
** exited: function_clause **
~~~
如果我們想在跳出遞歸調用的同時仍然掌握是哪里發生了錯誤,可以這樣做:
~~~
parse_list1(['[',']' | T]) ->
{nil, T};
parse_list1(['[', X | T]) when integer(X) ->
{Tail, T1} = parse_list_tail1(T),
{{cons, X, Tail}, T1};
parse_list1(X) ->
throw({illegal_token, X}).
parse_list_tail1([',', X | T]) when integer(X) ->
{Tail, T1} = parse_list_tail1(T),
{{cons, X, Tail}, T1};
parse_list_tail1([']' | T]) ->
{nil, T};
parse_list_tail1(X) ->
throw({illegal_list_tail, X}).
~~~
現在,如果我們在catch里對parse_list/1求值,將獲得以下結果:
~~~
> catch parse_list1(['[',12,',',a]).
{illegal_list_tail,[',',a]}
~~~
通過這種方式,我們得以從遞歸中直接退出,而不必沿著通常的遞歸調用路徑逐步折回。
### 進程終止
當一個進程的進程執行函數(通過spawn/4創建進程時第3個參數所指定的函數)執行完畢,或是(在catch之外)執行exit(normal),便會正常退出。參見程序7.1:
test:start()
> 創建一個注冊名為my_name的進程來執行test:process()。
程序7.1
~~~
-module(test).
-export([process/0, start/0]).
start() ->
register(my_name, spawn(test, process, [])).
process() ->
receive
{stop, Method} ->
case Method of
return ->
true;
Other ->
exit(normal)
end;
Other ->
process()
end.
~~~
my_name!{stop,return}
> 令test:process()返回true,接著進程正常終止。
my_name!{stop,hello}
> 也會令進程正常終止,因為它執行了BIF exit(normal)。
任何其它的消息,比如my_name!any_other_message都將令進程遞歸執行test:process()(采用尾遞歸優化的方式,參見第??章)從而避免進程終止。
若進程執行BIF exit(Reason),則進程將異常終止。其中Reason是**除了**原子式normal以外的任意的Erlang項式。如我們所見,在catch上下文中執行exit(Reason)不會導致進程退出。
進程在執行到會導致運行時失敗的代碼(如除零錯誤)時,也會異常終止。后續還會討論各種類型的運行時失敗。
### 鏈接進程
進程可以互相監視。這里要引入兩個概念,進程**鏈接**和EXIT信號。在執行期間,進程可以與其他進程(和端口,參見??章節)建立鏈接。當一個進程終止(無論正常或非正常終止)時,一個特殊的EXIT信號將被發送到所有與即將終止的進程相鏈接的進程(及端口)。該信號的格式如下:
~~~
{'EXIT', Exiting_Process_Id, Reason}
~~~
Exiting_Process_Id是即將終止的進程的進程標識,Reason可以是任意的Erlang項式。
收到Reason不是原子式normal的EXIT信號時,信號接收進程的默認動作是立即終止并,同時向當前與之鏈接的進程發送EXIT信號。默認情況下,Reason為原子式normal的EXIT信號將被忽略。
EXIT信號的默認處理方式行為可以被覆寫,以允許進程在接收到EXIT信號時采取任意必要的動作。
### 創建和刪除鏈接
進程可以鏈接到其它進程和端口。進程間的鏈接都是雙向的,也就是說,如果進程A鏈接到進程B,那么進程B也會自動鏈接到進程A。
通過執行BIF link(Pid)便可創建鏈接。調用link(Pid)時,若調用進程和Pid之間已經存在鏈接,則不會產生任何影響。
進程終止時,它所持有的鏈接都將被刪除。也可以通過執行BIF unlink(Pid)顯式刪除鏈接。由于所有鏈接都是雙向的,刪除這一端到另一端的鏈接的同時,另一端的到這一端的鏈接也會被刪除。若調用進程和Pid之間原本就沒有鏈接,unlink(Pid)不會產生任何影響。
BIF spawn_link/3在創建新進程的同時還會在調用進程和新進程間建立鏈接。其行為可以定義為:
~~~
spawn_link(Module, Function, ArgumentList) ->
link(Id = spawn(Module, Function, ArgumentList)),
Id.
~~~
只不過spawn和link是原子方式執行的。這是為了避免調用進程在執行link之前就被EXIT信號殺死。嘗試向一個不存在的進程發起鏈接將導致信號{'EXIT',Pid,noproc}被發送至link(Pid)的調用進程。
程序7.2中,函數start/1建立了若干以鏈式互聯的進程,其中第一個進程的注冊名為start(參見圖7.1)。函數test/1向該注冊進程發送消息。每個進程不斷打印自己在鏈中的位置及收到的消息。消息stop令鏈中最后一個進程執行BIF exit(finished),該BIF將導致該進程異常終止。
程序7.2
~~~
-module(normal).
-export([start/1, p1/1, test/1]).
start(N) ->
register(start, spawn_link(normal, p1, [N - 1])).
p1(0) ->
top1();
p1(N) ->
top(spawn_link(normal, p1, [N - 1]),N).
top(Next, N) ->
receive
X ->
Next ! X,
io:format("Process ~w received ~w~n", [N,X]),
top(Next,N)
end.
top1() ->
receive
stop ->
io:format("Last process now exiting ~n", []),
exit(finished);
X ->
io:format("Last process received ~w~n", [X]),
top1()
end.
test(Mess) ->
start ! Mess.
~~~
我們啟動三個進程(參見圖7.1(a))
~~~
> normal:start(3).
true
~~~

圖7.1 進程退出信號的傳遞
然后向第一個進程發送消息123:
~~~
> normal:test(123).
Process 2 received 123
Process 1 received 123
Last process received 123
123
~~~
再向第一個進程發送消息stop:
~~~
> normal:test(stop).
Process 2 received stop
Process 1 received stop
Last process now exiting
stop
~~~
這條消息順著進程鏈傳遞下去,我們將看到它最終導致鏈中最后一個進程的終止。這會引發一個發送給倒數第二個進程的EXIT信號,致其異常終止(圖7.1(b)),接著又向第一個進程發送EXIT信號(圖7.1(c)),于是注冊進程start也異常終止(圖 7.1(d))。
若這時再向注冊進程start發送一條新消息,將由于目標進程不存在而失敗:
~~~
> normal:test(456).
!!! Error in process <0.42.1> in function
!!! normal:test(456)
!!! reason badarg
** exited: badarg **
~~~
### 運行時失敗
如前所述,catch作用域以外的運行時失敗將導致進程的異常終止。進程終止時,將向與其鏈接的所有進程發送EXIT信號。這些信號包括一個指明失敗原因的原子式。常見的失敗原因如下:
badmatch
> 匹配失敗。例如,嘗試匹配1=3的進程將終止并向鏈接進程發送EXIT信號{'EXIT',From,badmatch}。
badarg
> BIF調用參數錯誤。例如,執行atom_to_list(123)將導致調用進程終止,并向鏈接進程發送EXIT信號{'EXIT',From,badarg}。因為123不是原子式。
case_clause
> > 缺少匹配的case語句分支。例如,若進程執行:
> >
~~~
M = 3,
case M of
1 ->
yes;
2 ->
no
end.
~~~
> 則進程將終止,并向所有鏈接進程發送EXIT信號{'EXIT',From,case_clause}。
if_clause
> > 缺少匹配的if語句分支。例如,若進程執行:
> >
~~~
M = 3,
if
M == 1 ->
yes;
M == 2 ->
no
end.
~~~
> 則進程將終止,并向所有鏈接進程發送EXIT信號{'EXIT',From,if_clause}。
function_clause
> > 缺少能夠匹配函數調用參數列表的函數首部。例如,對如下的foo/1定義調用foo(3):
> >
~~~
foo(1) ->
yes;
foo(2) ->
no.
~~~
> 則調用進程終止,并向所有鏈接進程發送EXIT信號{'EXIT',From,function_clause}。
undef
> 嘗試執行未定義函數的進程將終止并向所有鏈接進程發送{'EXIT',From,undef}(參見第??節)。
badarith
> 執行非法算術表達式(如,1+foo)將導致進程終止,并向所有鏈接進程發送{'EXIT',Pid,badarith}。
timeout_value
> receive表達式中出現非法超時值;如超時值既不是整數也不是原子式infinity。
nocatch
> 執行了throw語句卻沒有對應的catch。
### 自定義默認的信號接收動作
BIF process_flag/2可用于自定義進程接收到EXIT信號時所采取的默認行為。如下所述,執行process_flag(trap_exit,true)將改變默認行為,而process_flag(trap_exit,false)重新恢復默認行為。
如前所述,EXIT信號的格式如下:
~~~
{'EXIT', Exiting_Process_Id, Reason}
~~~
調用了process_flag(trap_exit,true)的進程接收到其他進程發送的EXIT信號后**不再**會**自動**終止。所有EXIT信號,包括Reason為原子式normal的信號,都將被轉換為消息,進程可以以接收其他消息同樣的方式來接收這些消息。程序7.3說明了進程如何互相鏈接以及執行了process_flag(trap_exit,true)的進程如何接收EXIT信號。
~~~
-module(link_demo).
-export([start/0, demo/0, demonstrate_normal/0, demonstrate_exit/1,
demonstrate_error/0, demonstrate_message/1]).
start() ->
register(demo, spawn(link_demo, demo, [])).
demo() ->
process_flag(trap_exit, true),
demo1().
demo1() ->
receive
{'EXIT', From, normal} ->
io:format(
"Demo process received normal exit from ~w~n",
[From]),
demo1();
{'EXIT', From, Reason} ->
io:format(
"Demo process received exit signal ~w from ~w~n",
[Reason, From]),
demo1();
finished_demo ->
io:format("Demo finished ~n", []);
Other ->
io:format("Demo process message ~w~n", [Other]),
demo1()
end.
demonstrate_normal() ->
link(whereis(demo)).
demonstrate_exit(What) ->
link(whereis(demo)),
exit(What).
demonstrate_message(What) ->
demo ! What.
demonstrate_error() ->
link(whereis(demo)),
1 = 2.
~~~
示例代碼的啟動方式如下:
~~~
> link_demo:start().
true
~~~
link_demo:start()以函數demo/0啟動一個進程并用名字demo進行注冊。demo/0關閉EXIT信號的默認處理機制并調用demo1/0等待新消息的到來。
我們來考察一次正常退出過程:
~~~
> link_demo:demonstrate_normal().
true
Demo process received normal exit from <0.13.1>
~~~
執行demonstrate_normal/0的進程(在這個例子中該進程由Erlang shell創建)尋找注冊進程demo的進程標識并與之建立鏈接。函數demostrate_normal/0沒有別的子句,它的執行進程無事可做因而正常終止,從而引發信號:
~~~
{'EXIT', Process_Id, normal}
~~~
該信號被發送到注冊進程demo。注冊進程demo正在等待EXIT信號,因此它將之轉換為一條消息,該消息在函數demo1/0內被接收,并輸出文本(參見圖7.2):
~~~
Demo process received normal exit from <0.13.1>
~~~
接著demo1/0繼續遞歸調用自身。

圖7.2 正常退出信號
下面再來考察一次異常退出過程:
~~~
> link_demo:demonstrate_exit(hello).
Demo process received exit signal hello from <0.14.1>
** exited: hello **
~~~
和demonstrate_normal/0相同,demonstrate_exit/1創建一個到注冊進程demo的鏈接。該例中,demonstrate_exit/1通過exit(hello)調用BIF exit/1。這導致demostrate_exit/1的執行進程異常終止,并將信號:
~~~
{'EXIT', Process_Id, hello}
~~~
發送給注冊進程demo(參見圖7.3)。注冊進程demo將該信號轉換為消息,并在函數demo1/0內被接收,從而輸出文本:
~~~
Demo process received exit signal hello from <0.14.1>
~~~
接著demo1/0繼續遞歸調用自身。

圖7.3 執行exit(hello)
下一個案例中(如圖7.4)我們將看到link_demo:demonstrate_normal()和link_demo:demonstrate_exit(normal)是等同的:
~~~
> link_demo:demonstrate_exit(normal).
Demo process received normal exit from <0.13.1>
** exited: normal **
~~~

圖7.4 執行exit(normal)
下一個案例將展示出現運行時錯誤時,會發生什么事:
~~~
> link_demo:demonstrate_error().
!!! Error in process <0.17.1> in function
!!! link_demo:demonstrate_error()
!!! reason badmatch
** exited: badmatch **
Demo process received exit signal badmatch from <0.17.1>
~~~
向前面一樣,link_demo:demonstrate_error/0創建一個到注冊進程demo的鏈接。link_demo:demonstrate_error/0錯誤地試圖匹配1=2。 該錯誤導致link_demo:demonstrate_error/0的執行進程異常終止,并發送信號{'EXIT',Process_Id,badmatch}至注冊進程demo(參見圖7.5)。

圖7.5 匹配錯誤導致的進程失敗
下一個案例中我們簡單地向正在等待消息的注冊進程demo發送消息hello:
~~~
> link_demo:demonstrate_message(hello).
Demo process message hello
hello
~~~
沒有鏈接被創建,也就沒有EXIT信號被發送或被接收。
通過以下調用來結束這個示例:
~~~
> link_demo:demonstrate_message(finished_demo).
Demo finished
finished_demo
~~~
### 未定義函數和未注冊名稱
最后一類錯誤關注的是當進程試圖執行一個未定義的函數或者給一個未注冊的名稱發送消息時會發生什么。
### 調用未定義函數
如果進程嘗試調用Mod:Func(Arg0,...,ArgN),而該函數未被定義,則該調用被轉換為:
~~~
error_handler:undefined_function(Mod, Func, [Arg0,...,ArgN])
~~~
假設模塊error_handler已經被加載(標準發行版中預定義了error_handler模塊)。error_handler模塊可以被定義為程序7.4。
程序 7.4
~~~
-module(error_handler).
-export([undefined_function/3]).
undefined_function(Module, Func, Args) ->
case code:is_loaded(Module) of
{file,File} ->
% the module is loaded but not the function
io:format("error undefined function:~w ~w ~w",
[Module, Func, Args]),
exit({undefined_function,{Module,Func,Args}});
false ->
case code:load_file(Module) of
{module, _} ->
apply(Module, Func, Args);
{error, _} ->
io:format("error undefined module:~w",
[Module]),
exit({undefined_module, Module})
end
end.
~~~
如果模塊Mod已經被加載,那么將導致一個運行時錯誤。如果模塊尚未加載,那么首先嘗試加載該模塊,若加載成功,再嘗試執行先前調用的函數。
模塊code了解哪些模塊已被加載,同時也負責代碼加載。
### 自動加載
編譯過的函數無需再顯式地編譯或“加載”相關模塊即可直接用于后續的會話。模塊中的**導出**函數被第一次調用時,該模塊將(通過上述的機制)被自動加載。
要實現自動加載,必須滿足兩個條件:首先,包含Erlang模塊的源碼文件必須與模塊同名(擴展名必須為.erl);其次,系統使用的默認搜索路徑必須能定位到該未知模塊。
### 向未注冊名稱發送消息
嘗試向一個不存在的注冊進程發送消息時會觸發error_handler:unregistered_name(Name,Pid,Message)調用。其中Name是不存在的注冊進程的名稱,Pid是發送消息的進程標識,Message是發送給注冊進程的消息。
### 自定義缺省行為
執行BIF process_flag(error_handler,MyMod)可以用模塊MyMod替換默認的error_handler。這使得用戶得以定義他們(私有)的錯誤處理器,用以處理針對未定義函數的調用以及以為注冊進程名稱為目標的消息發送。該功能僅對執行調用的進程**自身**有效。定義非標準的錯誤處理器時必須注意:如果你在替換標準錯誤處理器時犯了什么錯誤,系統可能會失控!
也可以通過加載一個新版本的error_handler模塊來更改默認行為。這么做會影響到所有的進程(定義了私有錯誤處理器的進程出外),因此非常危險。
### Catch和退出信號捕獲
在catch作用域內求值和捕獲進程退出信號是兩種完全不同的錯誤處理機制。退出信號的捕獲影響的是一個進程從其他進程處收到EXIT信號時的動作。catch只影響當前進程中由catch保護的表達式的求值。
執行程序7.5里的tt:test()會創建一個進程,這個進程匹配N(它的值是1)和2。這會失敗的,引發信號{'EXIT',Pid,badmatch}被發送到執行tt:test()并且正在等待一個信號的進程。如果這個進程沒有正在捕獲exits,它也會非正常終止。
程序 7.5
~~~
-module(tt).
-export([test/0, p/1]).
test() ->
spawn_link(tt, p,[1]),
receive
X ->
X
end.
p(N) ->
N = 2.
~~~
調用程序7.5中的tt:test()將創建一個以2對N(值為1)作匹配的鏈接進程。這會失敗,并導致信號{'EXIT',Pid,badmatch}被發送至調用tt:test()的進程,該進程正在等待消息。要是這個進程不捕獲退出信號,它就會異常退出。
如果我們執行的不是tt:test()而是catchtt:test(),結果一樣:catch作用域外的另一個進程會發生匹配失敗。在spawn_link(tt,p,[1])之前加上process_flag(trap_exit,true),tt:test()就會將收到的{'EXIT',Pid,badmatch}信號轉換為一條消息。
腳注
| [[1]](#) | 這不是bug或未錄入文檔的功能! |
|-----|-----|
| [[2]](#) | 這個錯誤可能導致當前shell崩潰。如何避免這個錯誤是留給讀者的練習。 |
|-----|-----|