此 EEP 引入了 maybe ... end
表達式,作為一種可用於控制流程和基於模式匹配的值錯誤處理的結構。透過使用此結構,可以避免或簡化深度巢狀的 case ... end
表達式,並避免使用例外處理來進行流程控制。
此文件已置於公共領域。
我們提出 maybe ... end
結構,它類似於 begin ... end
,用於將多個不同的表達式分組為單一區塊。但有一個重要的區別是,maybe
區塊不會匯出其變數,而 begin
會匯出其變數。
我們提出一種新的表達式類型(表示為 MatchOrReturnExprs
),它僅在 maybe ... end
表達式中有效
maybe
Exprs | MatchOrReturnExprs
end
MatchOrReturnExprs
的定義形式如下
Pattern ?= Expr
此定義表示 MatchOrReturnExprs
僅允許在 maybe ... end
表達式的最上層使用。
?=
運算子會取得 Expr
返回的值,並根據 Pattern
對其進行模式匹配。
如果模式匹配成功,則 Pattern
中的所有變數都會繫結到本地環境中,且此表達式等效於成功的 Pattern = Expr
呼叫。如果值不匹配,則 maybe ... end
表達式會直接傳回失敗的表達式。
存在一種特殊情況,我們會將 maybe ... end
擴展為以下形式
maybe
Exprs | MatchOrReturnExprs
else
Pattern -> Exprs;
...
Pattern -> Exprs
end
此形式用於捕獲 MatchOrReturnExprs
中不匹配的表達式,以處理失敗的匹配,而不是傳回其值。在這種情況下,未處理的失敗匹配將會引發 else_clause
錯誤,否則與 case_clause
錯誤相同。
此擴展形式有助於在同一個結構中正確識別和處理成功和不成功的匹配,而不會混淆成功路徑和失敗路徑。
根據此處描述的結構,最終的表達式可能看起來像
maybe
Foo = bar(), % normal exprs still allowed
{ok, X} ?= f(Foo),
[H|T] ?= g([1,2,3]),
...
else
{error, Y} ->
{ok, "default"};
{ok, _Term} ->
{error, "unexpected wrapper"}
end
請注意,為了方便模式匹配和更直觀的使用,?=
運算子的結合性規則應低於 =
,使得
maybe
X = [H|T] ?= exp()
end
是有效的 MatchOrReturnExprs
,等效於非中綴形式 '?='('='(X, [H|T]), exp())
,因為反轉優先順序會得出 '='('?='(X, [H|T]), exp())
,這將會在上下文之外建立 MatchOrReturnExp
,並使其無效。
Erlang 在許多程式設計語言中都具有最靈活的錯誤處理功能。該語言支援
throw
、error
、exit
)catch Exp
處理try ... [of ...] catch ... [after ...] end
處理exit/2
和 trap_exit
{ok, Val} | {error, Term}
、{ok, Val} | false
或 ok | {error, Val}
那麼,為什麼我們應該考慮新增更多?這樣做的原因有很多,包括嘗試減少深度巢狀的條件表達式、清理野外發現的一些雜亂模式,以及在實作函式時提供更好的關注點分離。
在 Erlang 中常見的一種模式是 case ... end
表達式的深度巢狀,以檢查複雜的條件。
例如,採用以下取自 Mnesia 的程式碼
commit_write(OpaqueData) ->
B = OpaqueData,
case disk_log:sync(B#backup.file_desc) of
ok ->
case disk_log:close(B#backup.file_desc) of
ok ->
case file:rename(B#backup.tmp_file, B#backup.file) of
ok ->
{ok, B#backup.file};
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
程式碼的巢狀程度很高,以至於必須為變數引入較短的別名 (OpaqueData
重新命名為 B
),而且一半的程式碼只是透明地傳回每個函式所給定的確切值。
相比之下,相同的程式碼可以使用新的結構撰寫如下
commit_write(OpaqueData) ->
maybe
ok ?= disk_log:sync(OpaqueData#backup.file_desc),
ok ?= disk_log:close(OpaqueData#backup.file_desc),
ok ?= file:rename(OpaqueData#backup.tmp_file, OpaqueData#backup.file),
{ok, OpaqueData#backup.file}
end.
或者,為了防止 disk_log
呼叫傳回 ok | {error, Reason}
以外的其他內容,可以使用以下形式
commit_write(OpaqueData) ->
maybe
ok ?= disk_log:sync(OpaqueData#backup.file_desc),
ok ?= disk_log:close(OpaqueData#backup.file_desc),
ok ?= file:rename(OpaqueData#backup.tmp_file, OpaqueData#backup.file),
{ok, OpaqueData#backup.file}
else
{error, Reason} -> {error, Reason}
end.
這些呼叫的語意是相同的,只是現在更容易將焦點放在個別操作的流程以及成功或錯誤路徑上。
人們處理一連串可失敗操作的常見方式包括摺疊函式清單和濫用清單解析。這兩種模式都有嚴重的缺點,使其不理想。
摺疊函式清單使用在 郵寄清單中的文章 中定義的模式
pre_check(Action, User, Context, ExternalThingy) ->
Checks =
[fun check_request/1,
fun check_permission/1,
fun check_dispatch_target/1,
fun check_condition/1],
Args = {Action, User, Context, ExternalThingy},
Harness =
fun
(Check, ok) -> Check(Args);
(_, Error) -> Error
end,
case lists:foldl(Harness, ok, Checks) of
ok -> dispatch(Action, User, Context);
Error -> Error
end.
此程式碼需要逐一宣告函式,確保整個上下文從一個函式傳遞到另一個函式。由於函式之間沒有共用範圍,因此所有函式都必須對所有引數進行操作。
相比之下,可以使用新的結構實作相同的程式碼,如下所示
pre_check(Action, User, Context, ExternalThingy) ->
maybe
ok ?= check_request(Context, User),
ok ?= check_permissions(Action, User),
ok ?= check_dispatch_target(ExternalThingy),
ok ?= check_condition(Action, Context),
dispatch(Action, User, Context)
end.
而且,如果需要在任何兩個步驟之間取得衍生狀態,可以很容易地將其編織進來
pre_check(Action, User, Context, ExternalThingy) ->
maybe
ok ?= check_request(Context, User),
ok ?= check_permissions(Action, User),
ok ?= check_dispatch_target(ExternalThingy),
DispatchData = dispatch_target(ExternalThingy),
ok ?= check_condition(Action, Context),
dispatch(Action, User, Context, DispatchData)
end.
相比之下,清單解析漏洞比較少見。事實上,它主要是理論上的。在 Diameter 測試案例 或 Rebar3 的 PropEr 外掛程式 中可以找到一些關於它如何運作的提示。
它的整體形式在清單解析中使用產生器來傳遞成功路徑
[Res] =
[f(Z) || {ok, W} <- [b()],
{ok, X} <- [c(W)],
{ok, Y} <- [d(X)],
Z <- [e(Y)]],
Res.
此形式的使用率不高,因為它相當晦澀,而且我懷疑大多數人要么是合理地沒有使用它,要么是沒有考慮過它。顯然,新的形式會更乾淨
maybe
{ok, W} ?= b(),
{ok, X} ?= c(W),
{ok, Y} ?= d(X),
Z = e(Y),
f(Z)
end
最重要的是,如果發現錯誤值,還可以傳回錯誤值。
此形式乍看之下不一定很明顯。為了更好地說明它,讓我們看看 OTP 中 release_handler
模組中定義的一些函式
write_releases_m(Dir, NewReleases, Masters) ->
RelFile = filename:join(Dir, "RELEASES"),
Backup = filename:join(Dir, "RELEASES.backup"),
Change = filename:join(Dir, "RELEASES.change"),
ensure_RELEASES_exists(Masters, RelFile),
case at_all_masters(Masters, ?MODULE, do_copy_files,
[RelFile, [Backup, Change]]) of
ok ->
case at_all_masters(Masters, ?MODULE, do_write_release,
[Dir, "RELEASES.change", NewReleases]) of
ok ->
case at_all_masters(Masters, file, rename,
[Change, RelFile]) of
ok ->
remove_files(all, [Backup, Change], Masters),
ok;
{error, {Master, R}} ->
takewhile(Master, Masters, file, rename,
[Backup, RelFile]),
remove_files(all, [Backup, Change], Masters),
throw({error, {Master, R, move_releases}})
end;
{error, {Master, R}} ->
remove_files(all, [Backup, Change], Masters),
throw({error, {Master, R, update_releases}})
end;
{error, {Master, R}} ->
remove_files(Master, [Backup, Change], Masters),
throw({error, {Master, R, backup_releases}})
end.
乍看之下,很難清理此程式碼:有 3 個多節點操作(備份、更新和移動版本資料),每個操作都依賴前一個操作成功。
您還會注意到,每個錯誤都需要特殊處理,在成功或失敗時還原或移除特定操作。這不是一個將值傳入和傳出狹窄範圍的簡單問題。
另一件需要注意的是,此模組整體上(而不僅僅是此處呈現的程式碼片段)使用 throw
表達式來執行非本地傳回。處理這些問題的實際傳回點分佈在檔案中的各個位置:例如,create_RELEASES/4
和 write_releases_1/3
。
case catch Exp of
形式在整個檔案中使用,因為在巢狀結構中,基於值的錯誤流程很麻煩。
因此,讓我們看看如何使用新的結構重構此程式碼
write_releases_m(Dir, NewReleases, Masters) ->
RelFile = filename:join(Dir, "RELEASES"),
Backup = filename:join(Dir, "RELEASES.backup"),
Change = filename:join(Dir, "RELEASES.change"),
maybe
ok ?= backup_releases(Dir, NewReleases, Masters, Backup, Change,
RelFile),
ok ?= update_releases(Dir, NewReleases, Masters, Backup, Change),
ok ?= move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile)
end.
backup_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
case at_all_masters(Masters, ?MODULE, do_copy_files,
[RelFile, [Backup, Change]]) of
ok ->
ok;
{error, {Master, R}} ->
remove_files(Master, [Backup, Change], Masters)
{error, {Master, R, backup_releases}}
end.
update_releases(Dir, NewReleases, Masters, Backup, Change) ->
case at_all_masters(Masters, ?MODULE, do_write_release,
[Dir, "RELEASES.change", NewReleases]) of
ok ->
ok;
{error, {Master, R}} ->
remove_files(all, [Backup, Change], Masters),
{error, {Master, R, update_releases}}
end.
move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
case at_all_masters(Masters, file, rename, [Change, RelFile]) of
ok ->
remove_files(all, [Backup, Change], Masters),
ok;
{error, {Master, R}} ->
takewhile(Master, Masters, file, rename, [Backup, RelFile]),
remove_files(all, [Backup, Change], Masters),
{error, {Master, R, move_releases}}
end.
重寫程式碼的唯一合理方法是將所有三個主要的多節點操作提取到不同的函式中。
改進的地方是
write_release_m
中只需要一個 throw()
,因此將流程控制詳細資訊與特定函式實作分開。作為對照實驗,讓我們嘗試使用我們較短的函式來執行先前的流程
%% Here is the same done through exceptions:
write_releases_m(Dir, NewReleases, Masters) ->
RelFile = filename:join(Dir, "RELEASES"),
Backup = filename:join(Dir, "RELEASES.backup"),
Change = filename:join(Dir, "RELEASES.change"),
try
ok = backup_releases(Dir, NewReleases, Masters, Backup, Change,
RelFile),
ok = update_releases(Dir, NewReleases, Masters, Backup, Change),
ok = move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile)
catch
{error, Reason} -> {error, Reason}
end.
backup_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
case at_all_masters(Masters, ?MODULE, do_copy_files,
[RelFile, [Backup, Change]]) of
ok ->
ok;
{error, {Master, R}} ->
remove_files(Master, [Backup, Change], Masters)
throw({error, {Master, R, backup_releases}})
end.
update_releases(Dir, NewReleases, Masters, Backup, Change) ->
case at_all_masters(Masters, ?MODULE, do_write_release,
[Dir, "RELEASES.change", NewReleases]) of
ok ->
ok;
{error, {Master, R}} ->
remove_files(all, [Backup, Change], Masters),
throw({error, {Master, R, update_releases}})
end.
move_releases(Dir, NewReleases, Masters, Backup, Change, RelFile) ->
case at_all_masters(Masters, file, rename, [Change, RelFile]) of
ok ->
remove_files(all, [Backup, Change], Masters),
ok;
{error, {Master, R}} ->
takewhile(Master, Masters, file, rename, [Backup, RelFile]),
remove_files(all, [Backup, Change], Masters),
throw({error, {Master, R, move_releases}})
end.
三個分散式函式幾乎沒有變化。然而,這種方法的缺點是,我們已將小型函式的實作詳細資訊與其父系的上下文密切相關。這使得很難單獨推理這些函式或在不同的上下文中使用它們。此外,父系函式可能會捕獲不打算用於它的 throws
。
我認為,透過類似的重構使用基於值的流程控制,可以產生更安全、更乾淨的程式碼,而且巢狀層次也大幅減少。因此,應該可以表達更複雜的操作序列,而不會使其更難以閱讀或單獨推理。
這部分是巢狀結構造成的,但也因為我們採用了更具組合性的方法,不需要將本地函式的實作詳細資訊與其整體管線和執行上下文的複雜性聯繫起來。
這也是建構程式碼的最佳方式,以便處理所有例外處理,並在盡可能靠近其來源的位置,以及盡可能遠離整合流程的位置提供所需的上下文。
本節將詳細說明此 EEP 背後的決策制定,包括
else
區塊maybe ... end
作為結構及其範圍?=
這裡有很多內容要涵蓋。
多種語言都具有基於值的例外處理,其中許多語言都具有強烈的函式式傾向。
最著名的例子可能是 Haskell 的 Maybe
Monad,它使用 Nothing
(表示計算沒有傳回任何內容)或 Just x
(它們基於類型的等效於 {ok, X}
)。這兩種型別的聯集表示為 Maybe x
。以下範例取自 Haskell/Understanding monads/Maybe。
這類錯誤的值在函式中會標記如下
safeLog :: (Floating a, Ord a) => a -> Maybe a
safeLog x
| x > 0 = Just (log x)
| otherwise = Nothing
直接使用類型註解,可以透過模式匹配提取值(如果有的話)。
zeroAsDefault :: Maybe Int -> Int
zeroAsDefault mx = case mx of
Nothing -> 0
Just x -> x
這裡要注意的是,只要你無法找到一個值來替換 Nothing
,或者你無法採用不同的分支,你就必須在整個系統的類型中帶著這個不確定性。
這通常是 Erlang 的終點。你擁有相同的可能性(儘管是動態檢查的),以及將無效值轉換為異常的可能性。
相比之下,Haskell 提供了 monad 操作和它的 *do 記號* 來抽象這些事情。
getTaxOwed name = do
number <- lookup name phonebook
registration <- lookup number governmentDatabase
lookup registration taxDatabase
在這個程式碼片段中,即使 lookup
函數返回 Maybe x
類型,do 記號也會抽象掉 Nothing
值,讓程式設計師專注於 Just x
中的 x
部分。儘管程式碼的寫法好像我們可以對離散值進行操作,但函數會自動將其結果重新包裝到 Just x
中,而任何 Nothing
值都會直接跳過操作。
因此,開發人員被迫承認整個函數的流程取決於值是否存在,但他們仍然可以像操作離散值一樣撰寫大部分程式碼。
OCaml 支援例外處理,使用如 raise (Type "value")
來拋出例外,以及 try ... with ...
來處理它們。然而,由於例外不會被類型系統追蹤,維護者引入了 Result
類型。
該類型定義為
type ('a, 'b) result =
| Ok of 'a
| Error of 'b
這讓人想起 Erlang 的 {ok, A}
和 {error, B}
。OCaml 使用者似乎大多使用模式匹配、組合器函式庫和 monad 繫結來處理基於值的錯誤處理,這類似於 Haskell 的用法。
Rust 定義了兩種錯誤類型:不可恢復的錯誤(使用 panic!
)和可恢復的錯誤,使用 Result<T, E>
值。後者是我們感興趣的,並定義為
enum Result<T, E> {
Ok(T),
Err(E),
}
這直觀地轉化為 Erlang 術語 {ok, T}
和 {error, E}
。在 Rust 中處理這些錯誤的簡單方法是透過模式匹配。
let f = File::open("eep.txt");
match f {
Ok(file) => do_something(file),
Err(error) => {
panic!("Error in file: {:?}", error)
},
};
特定的錯誤值必須是類型良好的,並且 Rust 社群似乎仍在辯論關於如何在泛型類型中最好地實現可組合性和註解的實作細節。
然而,他們處理這些錯誤的工作流程已經明確定義。這種模式匹配形式被認為太過繁瑣。為了在錯誤值上自動 panic,加入了 .unwrap()
方法。
let f = File::open("eep.txt").unwrap();
在 Erlang 中,我們可以用以下程式碼來近似:
unwrap({ok, X}) -> X;
unwrap({error, T}) -> exit(T).
F = unwrap(file:open("eep.txt", Opts)).
還有另一種結構可以更直接地將錯誤返回給呼叫程式碼,而不會發生 panic,那就是 ?
運算子。
fn read_eep() -> Result<String, io::Error> {
let mut h = File::open("eep.txt")?;
let mut s = String::new();
h.read_to_string(&mut s)?;
Ok(s)
}
任何遇到 ?
的 Ok(T)
值都會被解包。任何遇到 ?
的 Err(E)
值都會像使用 return
的 match
一樣,按原樣返回給呼叫者。然而,此運算子要求函數的類型簽章使用 Result<T, E>
類型作為回傳值。
在 1.13 版之前,Rust 使用 try!(Exp)
巨集達到相同的效果,但發現它太過繁瑣。比較一下:
try!(try!(try!(foo()).bar()).baz())
foo()?.bar()?.baz()?
Swift 支援例外處理,以及宣告函數可能拋出例外的類型註解和 do ... catch
區塊。
有一個特殊的運算子 try?
,它可以捕獲任何拋出的例外,並將其轉換為 nil
。
func someThrowingFunction() throws -> Int {
// ...
}
let x = try? someThrowingFunction()
這裡的 x
可以是 Int
值或 nil
。資料流程通常透過在條件表達式中使用 let
指派來簡化。
func fetchEep() -> Eep? {
if let x = try? fetchEepFromDisk() { return x }
if let x = try? fetchEepFromServer() { return x }
return nil
}
Go 的錯誤處理相當薄弱。它有 panics 和錯誤值。錯誤值必須被指派(或明確忽略),但它們可以不經檢查,並導致各種問題。
儘管如此,Go 還是公開了在未來版本中 新的錯誤處理計畫,這可能會很有趣。
Go 設計人員主要考慮的是語法上的變更,以減少錯誤處理的繁瑣性,而不是改變其錯誤處理的語義。
Go 程式通常如下處理錯誤:
func main() {
hex, err := ioutil.ReadAll(os.Stdin)
if err != nil {
log.Fatal(err)
}
data, err := parseHexdump(string(hex))
if err != nil {
log.Fatal(err)
}
os.Stdout.Write(data)
}
新的建議機制如下所示:
func main() {
handle err {
log.Fatal(err)
}
hex := check ioutil.ReadAll(os.Stdin)
data := check parseHexdump(string(hex))
os.Stdout.Write(data)
}
check
關鍵字要求隱式檢查第二個回傳值 err
是否等於 nil
。如果它不等於 nil
,則會呼叫最後定義的 handle
區塊。它可以回傳結果以退出函數、修復某些值,或單純地 panic,僅舉幾個例子。
與 Erlang 相比,Elixir 在錯誤處理方面採用稍微不同的語義方法。不鼓勵將例外用於控制流程(而 Erlang 特別使用 throw
),並且引入了 with
巨集。
with {:ok, var} <- some_call(),
{:error, _} <- fail(),
{:ok, x, y} <- parse_name(var)
do
success(x, y, var)
else
{:error, err} -> handle(err)
nil -> {:error, nil}
end
該巨集允許一系列模式匹配,之後會呼叫 do ...
區塊。如果任何模式匹配失敗,則失敗的值會在可選的 else ... end
區段中重新匹配。
這是本文檔中最通用的控制流程,在它可以處理的值方面具有完全的彈性。這樣做部分是因為至少與此處的其他語言相比,Erlang 和 Elixir API 中都沒有關於錯誤或有效值的強烈規範。
這種高度的彈性在某些情況下被批評為有點令人困惑:使用者有可能建立僅限於錯誤的流程、僅限於成功的流程、混合流程,因此 else
子句可能會變得複雜。
發布了 OK 函式庫,以明確將工作流程縮小到定義明確的錯誤。它支援三種形式,第一種是 for
區塊。
OK.for do
user <- fetch_user(1)
cart <- fetch_cart(1)
order = checkout(cart, user)
saved_order <- save_order(order)
after
saved_order
end
它透過在使用 <-
運算子時 *僅* 匹配 {:ok, val}
來保持前進:上面的 fetch_user/1
函數必須返回 {:ok, user}
才能讓程式碼繼續執行。=
運算子允許模式匹配,就像它通常在 Elixir 中所做的一樣。
任何與 {:error, t}
匹配的回傳值都會直接從表達式中返回。after ... end
區段會取得最後回傳的值,如果它還不是 {:ok val}
形式的 tuple,它會將其包裝成該形式。
第二種變體是 try
區塊。
OK.try do
user <- fetch_user(1)
cart <- fetch_cart(1)
order = checkout(cart, user)
saved_order <- save_order(order)
after
saved_order
rescue
:user_not_found -> {:error, missing_user}
end
此變體也會捕獲例外(在 rescue
區塊中),並且不會在 after
區段中重新包裝最終的回傳值。
函式庫的最後一種變體是管道。
def get_employee_data(file, name) do
{:ok, file}
~>> File.read
~> String.upcase
end
此變體的目標是簡單地將可能導致成功或錯誤的操作串在一起。~>>
運算子會匹配並返回一個 {:ok, term}
tuple,而 ~>
運算子會將一個值包裝到一個 {:ok, term}
tuple 中。
在 Erlang 中,true
和 false
是常規原子,它們僅透過在布林表達式中的使用而獲得特殊地位。如果不是因為控制流程結構,很容易認為更多的函數會返回 yes
和 no
。
同樣地,經過多年的使用,undefined
已成為一種預設的「未找到」值。諸如 nil
、null
、unknown
、undef
、false
等值已經被使用過,但格式上的高度一致性最終使社群在一個值上達成共識。
在各種函數的回傳值方面,{ok, Term}
是最常見的用於需要傳遞值的肯定結果,ok
用於只有其自身成功而沒有其他值的肯定結果,而 {error, Term}
最常用於錯誤。模式匹配和斷言確保人們可以輕鬆地透過呼叫自身的結構來知道呼叫是否成功。
然而,許多成功值仍然是較大的 tuple:{ok, Val, Warnings}
、{ok, Code, Status, Headers, Body}
等。這些變化本身沒有問題,但使用 {ok, {Val, Warnings}}
或 {ok, {Code, Status, Headers, Body}}
也可能不會造成太大傷害。
雖然使用更標準的形式可能會導致更容易的通用化和抽象化,這些可以應用於社群範圍的程式碼。透過為基於值的錯誤處理的控制流程選擇特定的格式,我們將明確鼓勵這種形式的標準化。
話雖如此,現有的格式多樣性以及使用的嚴格值較少意味著強制正規化可能會導致未來語言決策中潛在的彈性損失。例如,EEP-54 —- 在此 EEP 的最終修訂之前完成 -— 嘗試向錯誤報告新增新的上下文形式,並且各種函式庫已經依賴於這些更豐富的模式。
因此,OTP 技術委員會的意見是我們應 *不* 正規化錯誤回傳值。因此,已提出一種更接近 Elixir 的 with
的方法,儘管此 EEP 的方法在可接受的表達式序列及其組合方面更通用。
避免對錯誤值和好值進行正規化,引入了需要 else ... end
子區塊來防止邊緣情況。
讓我們看看以下類型的表達式,以解釋為什麼:
maybe
{ok, {X,Y}} ?= id({ok, {X,Y}})
...
end
雖然這種機制可以很好地處理跳過模式,但它在錯誤處理的上下文中存在一些有問題的弱點。
一個例子可以從 OTP pull request 中取得,該 pull request 基於 inet 選項為封包讀取新增了新的回傳值:#1950。
此 PR 為封包接收向先前的形式新增了一個可能的值
{ok, {PeerIP, PeerPort, Data}}
為了使其可以選擇性地取得
{ok, {PeerIP, PeerPort, AncData, Data}}
基於先前設定的 socket 選項。因此,讓我們將其放入當前提案的上下文中
maybe
{ok, {X,Y}} ?= id({ok, {X,Y}}),
{ok, {PeerIP, PeerPort, Data}} ?= gen_udp:recv(...),
...
end
由於我們在任何不匹配的值上強制返回,如果 socket 配置錯誤以返回 AncData
,則整個表達式會在匹配失敗時返回 {ok, {PeerIP, PeerPort, AncData, Data}}
。
基本上,使用 maybe ... end
結構的函式可能會返回一個意料之外但結果良好的值,這看起來像是成功,但實際上卻是完全無法匹配和處理給定的資訊。當資料具有正確的形狀和類型,但一組綁定變數最終決定匹配是否成功(例如,在 UDP socket 的情況下,返回來自錯誤 peer 的值)時,這種情況會變得更加模糊。
在最糟糕的情況下,它可能會讓未經格式化的原始資料在條件管道中流出,而事後卻無法偵測到,尤其是在 maybe ... end
中的後續函式對文字進行轉換時,例如匿名化或清除資料。這可能會非常不安全,而且幾乎不可能進行良好的除錯。
舉例來說:
-spec fetch() -> {ok, iodata()} | {error, _}.
fetch() ->
maybe
{ok, B = <<_/binary>>} ?= f(),
true ?= validate(B),
{ok, sanitize(B)}
end.
如果從 f()
返回的值結果是一個列表(例如,它是一個使用 list
而不是 binary
作為選項的錯誤設定的 socket),則表示式將會提早返回,fetch()
函式仍然會返回 {ok, iodata()}
,但作為呼叫者,您將無法知道它究竟是轉換後的資料還是不匹配的內容。大多數開發人員也不會明顯意識到這可能代表嚴重的安全風險,因為它允許將意外的資料視為乾淨的資料。
這種特定類型的錯誤實際上在 Elixir 中是有可能發生的,但到目前為止,似乎沒有在社群內流傳相關的警告。這個問題應該使用 else
區塊來處理,此提案重新使用 else
區塊來抑制意外的值。
-spec fetch() -> {ok, iodata()} | {error, _}.
fetch() ->
maybe
{ok, B = <<_/binary>>} ?= f(),
true ?= validate(B),
{ok, sanitize(B)}
else
false -> {error, invalid_data};
{error, R} -> {error, R}
end.
在這裡,設定錯誤的 socket 不會導致未經檢查的資料流經您的應用程式;任何無效的使用情況都會被捕獲,如果 B
的值結果是一個列表,則會引發帶有錯誤值的 else_clause
錯誤。
除非該子句是強制性的(在 Elixir 中不是),否則這種額外的匹配級別是完全可選的;開發人員沒有明顯的動機去處理這些錯誤,如果他們這樣做了,則引發的例外將會是 else
區段中遺失的子句,這會模糊其來源和行號。
因此,我們將必須依靠教育和文件(以及類型分析)來防止未來發生此類問題。
使用靜態類型語言中使用的標準化錯誤和返回值時,這些問題不會存在,但由於我們不打算標準化值,因此 else
區塊是必要的變通方法。
maybe ... end
運算式 #錯誤流程的抽象需要定義一個限制流程控制方式的範圍。在選擇 maybe ... end
運算式之前,需要考慮以下項目
maybe ... end
else
關鍵字在前面提到的語言中,似乎出現了兩大錯誤處理類別。
第一組語言似乎在函式級別追蹤其錯誤處理。例如,Go 使用 return
從目前的函式提早返回。Swift 和 Rust 也將其錯誤處理抽象範圍設定在目前的函式,但它們也利用其類型簽名來保留有關正在發生的控制流程轉換的資訊。Rust 使用 Result<T, E>
類型簽名來定義哪些操作是有效的,而 Swift 則要求開發人員要么在本地處理錯誤,要么使用 throws
來註解函式,以明確說明。
另一方面,Haskell 的 do 表示法僅限於特定的運算式,Elixir 的所有機制也是如此。
Erlang、Haskell 和 Elixir 主要使用遞迴作為迭代機制,並且(在 Haskell 的單子結構之外)不支援 return
控制流程;當迭代需要遞迴時,return
(或 break
)在概念上更難以使用:通過退出當前流程「返回」可能無法讓您擺脫程式設計師可能認為的迴圈,例如。
相反地,Erlang 會使用 throw()
例外作為非本地返回的控制流程機制,以及 catch
或 try ... catch
。選擇在函式級別運作的基於值的錯誤處理結構不一定非常有趣,因為幾乎任何遞迴程序仍然需要使用例外。
因此,使用一個專為關注包含基於值的錯誤的操作序列而構建的自包含結構感覺更簡單。
先前嘗試抽象 Erlang 中基於值的錯誤處理的方法,是使用解析轉換來重載特殊結構,以提供特定的工作流程。
例如,fancyflow
函式庫試圖抽象以下程式碼
sans_maybe() ->
case file:get_cwd() of
{ok, Dir} ->
case
file:read_file(
filename:join([Dir, "demo", "data.txt"]))
of
{ok, Bin} ->
{ok, {byte_size(Bin), Bin}};
{error, Reason} ->
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
如同
-spec maybe() -> {ok, non_neg_integer()} | {error, term()}.
maybe() ->
[maybe](undefined,
file:get_cwd(),
file:read_file(filename:join([_, "demo", "data.txt"])),
{ok, {byte_size(_), _}}).
而 Erlando 會取代
write_file(Path, Data, Modes) ->
Modes1 = [binary, write | (Modes -- [binary, write])],
case make_binary(Data) of
Bin when is_binary(Bin) ->
case file:open(Path, Modes1) of
{ok, Hdl} ->
case file:write(Hdl, Bin) of
ok ->
case file:sync(Hdl) of
ok ->
file:close(Hdl);
{error, _} = E ->
file:close(Hdl),
E
end;
{error, _} = E ->
file:close(Hdl),
E
end;
{error, _} = E -> E
end;
{error, _} = E -> E
end.
帶有列表理解中的單子結構
write_file(Path, Data, Modes) ->
Modes1 = [binary, write | (Modes -- [binary, write])],
do([error_m ||
Bin <- make_binary(Data),
Hdl <- file:open(Path, Modes1),
Result <- return(do([error_m ||
file:write(Hdl, Bin),
file:sync(Hdl)])),
file:close(Hdl),
Result]).
這些情況明確地旨在尋找一種撰寫操作序列的方法,其中預定義的語義受特殊上下文約束,但僅限於重載結構,而不是引入新的結構。
相比之下,大多數 Erlang 的控制流程運算式都遵循類似的結構。請參閱以下最常見的運算式
case ... of
Pattern [when Guard] -> Expressions
end
if
Guard -> Expressions
end
begin
Expressions
end
receive
Pattern [when Guard] -> Expressions
after % optional
IntegerExp -> Expressions
end
try
Expressions
of % optional
Pattern [when Guard] -> Expressions
catch % optional
ExceptionPattern [when Guard] -> Expressions
after % optional
Expressions
end
因此,如果我們要添加新的結構,它應該採用以下形式是合乎邏輯的:
<keyword>
...
end
剩下的問題是:選擇哪個關鍵字,以及支援哪些子句。
maybe ... end
#最初,正在考慮使用類似於 Elixir 的 with
運算式的格式
<keyword>
Expressions | UnwrapExpressions
of % optional
Pattern [when Guard] -> Expressions
end
使用這種結構,基本的 <關鍵字> ... end
形式將遵循目前提議的語義,但是 of ...
區段將允許在運算式的任何返回值上進行模式匹配,無論是 {error, Reason}
還是主區段中最後一個運算式返回的任何非例外值。
這種形式與 try ... of ... catch ... end
允許的形式一致:在涵蓋主區段後,可以在同一個結構內完成更多工作。
但是,try ... of ... catch ... end
有一個引入模式和保護的特定原因:受保護的程式碼會影響尾遞迴。
在諸如以下的迴圈中
map_nocrash(_, []) -> [];
map_nocrash(F, [H|T]) ->
try
F(H)
of
Val -> [Val | map_nocrash(F, T)]
catch
_:_ -> map_nocrash(F, T)
end.
如果沒有發生例外,of
區段允許繼續工作,而無需保護超出函式目前範圍的任何內容,也不會因為強制每次迭代都出現在堆疊上而阻止尾遞迴。
基於值的錯誤處理不存在此類問題,儘管 of ... end
區段有時可能很方便,但對於該結構要有用來說,它嚴格來說不是必要的。
剩下要做的就是選擇一個名稱。最初,選擇的 <關鍵字>
值是 maybe
,基於 Maybe 單子。問題是,引入任何新的關鍵字都會對向後相容性造成嚴重的風險。
由於 OTP 團隊現在計畫引入一種新的機制,用於根據模組啟用新的語言功能,因此引入新關鍵字而導致不相容的潛在風險已降低。只有明確使用新語言功能的模組才會受到影響。
例如,所有以下字詞都經過考慮
======= ================= =========================================
Keyword Times used in OTP Rationale
as a function
======= ================= =========================================
maybe 0 can clash with existing used words,
otherwise respects the spirit
option 88 definitely clashes with existing code
opt 68 definitely clashes with existing code
check 49 definitely clashes with existing code
let 0 word is already reserved and free, but
makes no sense in context
cond 0 word is already reserved and free, may
make sense, but would prevent the
addition of a conditional expression
given 0 could work, kind of respects the context
when 0 reserved for guards, could hijack in new
context but may be confusing
begin 0 carries no conditional meaning, mostly
free for overrides
最初,此提案預期使用 maybe
關鍵字
maybe
Pattern <op> Exp,
...
of
Pattern -> Exp % optional
end
但由於上一節中提到的原因,of ...
區段變得不必要。
else
關鍵字 #這裡的第一步是查看所有現有的替代保留關鍵字:of
、when
、cond
、catch
、after
。
這些關鍵字都無法傳達需要該結構的替代子句的感覺,因此我們需要新增一個。else
關鍵字很吸引人,僅僅因為它為日後將其引入 if
運算式中作為保留字打開了大門。
快速查看 OTP 程式碼庫以確保似乎沒有返回 else()
函式,因此通常應該相對安全地使用。
為了形成 MatchOrReturnExprs
,需要一種機制來引入與常規模式匹配具有不同語義的模式匹配。
使用虛假函式呼叫的簡單解析轉換方法將是最基本的方法
begin
match_or_return(Pattern, Exp),
%% variables bound in Pattern are available in scope
...
end
但是,這會在非左側的位置引入模式匹配,並且在不公開解析轉換細節和了解程式碼如何轉換的情況下,使巢狀處理變得非常奇怪。
也可以使用諸如 let <Pattern> = <Exp>
之類的前綴關鍵字。但是,let
的這種用法將不同於它在其他語言中的使用方式,並且會令人困惑。
中綴運算子似乎是一個很好的選擇,因為模式匹配已經以多種形式使用它們
=
用於模式匹配。在錯誤流程中重載它會阻止使用常規匹配
:=
用於映射;使用它可以奏效,但在處理模式中的巢狀映射時肯定會令人困惑
<-
可以理解。它已經將範圍限制於列表和二進制理解,因此不會發生衝突或混淆。該運算子的現有語義暗示了一個文字模式匹配,其作用類似於篩選器,這正是我們想要的。
<=
與 <-
相同,但適用於二進制產生器
?=
可以理解。它是一個新的運算子,與任何現有的運算子都不衝突,並且可以認為是有條件的匹配。(預處理器使用 ?
來表示巨集調用的開始。儘管 =
是有效的巨集名稱,但這不會引起任何歧義,因為不是原子或變數的巨集名稱必須用單引號引起來。因此,?=
不是 =
巨集的有效調用;=
巨集的調用必須寫成 ?'='
。)
<-
或 ?=
運算子最有意義。我們選擇 ?=
,因為 <-
在目前的用法中表示子集或成員,這實際上不是這裡的用途。
為了完整起見,我還檢查了此 EEP 先前版本中的替代運算子,該版本引入了 {ok, T} | {error, R}
的規範性值,該值具有不同的語義
======= ===========================================================
Operator Description
======= ===========================================================
#= No clash with other syntax (maps, records, integers), no
clash with abstract patterns EEP either.
!= No clash with message passing, but is sure to anyone used
to C-style inequality checks
<~ Works with no known conflict; shouldn't clash with ROK's
frame proposals (uses infix ~ and < > as delimiters).
Has the disadvantage to being visually similar to `<-`.
<| Reverse pipe operator. If Erlang were to implement Elixir's
pipe operator, it would probably make sense to implement both
`<|` and `|>` since the "interesting" argument often comes
last.
=~ Regular expression operator in Elixir and Perl.
在解包運算式的預期用法中,?=
運算子需要具有優先級規則,例如:
X = {Y,X} ?= <Exp>
被視為有效的模式匹配操作,其中 X = {Y,X}
是整個左側模式,因此操作優先級為
lhs ?= rhs
而不是
lhs = rhs ?= <...>
在所有其他方面,優先級規則應與 =
相同,以便提供最不令人驚訝的體驗。
在提出此提案時,還考慮了其他方法,但最終都被忽略了。
此文件較早的版本只是簡單地使用
begin
Foo = bar(),
X ?= id({ok, 5}),
[H|T] ?= id({ok, [1,2,3]}),
...
end
通過呼叫 T ?= f()
隱式解包 {ok, T} = f()
,並強制所有可接受的不匹配值採用 {error, T}
的形式。
為了使該形式對大多數現有程式碼有用,它還需要一些每個人(包括我自己)都不太喜歡的魔法,其中如果 f()
的返回值是 ok
,則 _ ?= f()
將隱式成功。
這被認為太過神奇,而且並非一定有很多現有的 Erlang 程式碼會從這種形式中受益,因為在沒有額外值的情況下,成功函數通常會回傳 ok
。為了避免這種神奇感,需要更強制的 {ok, undefined}
形式(以複製 Rust 的 Ok(())
),但這會感覺非常不符合慣例。
with
中類似 Elixir 的模式 #Elixir 的方法相當全面且強大。它不處理成功或錯誤,而是將模式匹配作為一個整體來概括,就像我們在這裡所做的一樣。
唯一不同之處在於 Elixir 的 with
表達式會強制所有條件式優先執行,並使用 do
區塊來處理後續的自由形式表達式。
with dob <- parse_dob(params["dob"]),
name <- parse_name(params["name"])
do
%User{dob: dob, name: name}
else
err -> err
end
本文檔中引入的 Erlang 形式更加通用,因為它允許在整個程式碼中混合 MatchOrReturnExprs
和常規表達式,而無需使用通用的 do
區塊。
Erlang 形式確實暗示了從 AST 形式轉換為 Core Erlang 時可能需要更複雜的重寫規則。雖然最終結果可能看起來完全不像原始程式碼,但應該可以用現有的 Core Erlang 術語純粹地重寫。
cond
和 cond let
#Anthony Ramine 建議研究重用已經保留的 cond
和 let
關鍵字。他提到 Rust 正在規劃基於這些關鍵字的東西,以及如何根據他先前在語言中支援 cond
結構的工作將其移植到 Erlang。
提議的機制看起來會像這樣
cond
X > 5 -> % regular guard
Exp;
f() < 18 -> % function used in guard, as originally planned
Exp;
let {ok, Y} = exp(), Y < 5 ->
Exp
end
只有當 Y
匹配且所有 guard 都成功時,最後一個子句才允許在自己的分支中使用 Y
;如果綁定失敗,則會自動切換到下一個分支。
因此,可以涵蓋更複雜的操作序列,如下所示
cond
let {ok, _} = call1(),
let {ok, _} = call2(),
let Res = call3() ->
Res;
true ->
AlternativeBranch
end
我認為這個機制值得探索,或許可以添加到語言中,但它本身並不能充分解決錯誤處理流程的問題,因為無法輕易地從失敗的操作中提取錯誤。
自動包裝回傳值是 Elixir 的 OK
函式庫所做的事情,也是 Haskell 的 do 表示法所做的事情,但 Rust 和 Swift 都沒有這樣做。
似乎對於可以做什麼沒有非常明確的共識。因此,為了實作的簡單性,直接按原樣回傳值而不進行自動包裝似乎是明智的,特別是因為我們沒有為處理的值規定元組格式。
因此,開發人員可以自由回傳最符合其函數類型簽名的任何值,從而更容易將回傳值與他們擁有的系統整合。
它還允許操作序列在成功時可能回傳 ok
,即使它們的個別函數回傳了諸如 true
之類的值,而不是 {ok, true}
。
這裡建議的例外格式是 {else_clause, Value}
。選擇這種格式是遵循 Erlang/OTP 標準
if_clause
{case_clause, Val}
function_clause
(該值在堆疊追蹤中提供){badmatch, Val}
catch
區塊和 receive
表達式中不匹配的值不會明確引發任何內容由於 case_clause
在功能上是最接近的例外,並且它攜帶一個值,我們選擇在此處複製相同的形式。
之所以選擇 else_clause
而不是 maybe_clause
,是因為 else
區塊在未來可能會用於其他結構中,並且將例外限制為區塊本身的名稱可能更具未來性。
引入了新的關鍵字 maybe
,這可能會與現有使用未加引號的 maybe
作為原子(實際上也包括用作模組或函數名稱)的情況發生衝突。
該計畫是引入這個新功能,同時引入一種機制,讓使用者可以針對每個模組單獨啟用它和其他新功能,因此新的關鍵字 maybe
只會在使用者啟用此功能的模組中產生潛在的不相容性。
有幾個參考實作