Skip to content

Latest commit

 

History

History
233 lines (188 loc) · 18.6 KB

errors.zh-TW.md

File metadata and controls

233 lines (188 loc) · 18.6 KB

Zerg Null-safety 與錯誤處理(Null-safety & Errors)

兩層失敗——可回復的值與 abort——橋接它們的運算子(? ?? ?. ! raise assert guard),以及如何依型別處理 錯誤。屬於 語言參考 的一部分。亦有 English 版本。

失敗分成兩層,兩層之間各只有一座橋。可回復的失敗是值——「不存在」與預期內的錯誤都是 sum type 的一般值, 而非魔法般的 null;這是你日常工作的那一層。bug 則是一次 abort——溢位、除以零、賭錯的 !、不成立的 assert、 或一個明確的 raise 會 raise 並 unwind stack(見下方 Aborts);它們不出現在任何簽章裡,無法被檢視、也無法續 算。兩層攜帶的是同一個 Err(Error spec),因此在橋上乾淨地交會:raise(與 !、與 assert)把值升級成 abort,guard 把 abort 降級回值。

值那一層是同一個 sum type;依慣例左邊是值、右邊是要被傳遞的東西:

  • Either[X, Y]——一個 X 或一個 Y;兩側必須不同(Either[T, T] 會被拒),且若某注入可同時抵達兩側就是 compile error(改用明確變體建構)。
  • Result[T] = Either[T, Err],其中 Err 是 Error spec(任何實作它的型別)。
  • T? = Either[T, nil];nil 是它的 placeholder 值。

?——傳遞。 x? 拆出左值,或從所在函式提早 return 右值(early return 的語法糖),因此函式必須有相同的 右型別。T? 與 Result 之間沒有隱式橋接:先用 opt.ok_or(err) 或 res.ok() 轉換。

fn load() -> Result[Config] {
    txt := read_file(path)?     # Result[str];Err 會提早 return
    return parse(txt)           # parse -> Result[Config]
}

? 對每一種 carrier 都可用:外圍函式必須回答一個右側相同的 carrier,而右側原封不動地穿過去,所以 Err 在傳播後仍保有它的 kind。

??——預設值。 a ?? b:a 有左值就用它,否則用 b(右值被丟棄);短路、右結合可串接,適用任何 Either。

?.——optional chain(只限 T?)。 a?.b:a 有值就取 .b,否則整串在原地短路成 nil(與 ? 不同, 不會從函式 return);用在任何非 T? 型別都是 compile error。若該欄位本身就是 optional 就會壓平——整串回答的 是那個欄位的型別,而不是巢狀的 T??(那不是這個語言寫得出來的型別)。

!——force-unwrap(值 → abort)。 x! 拆出左值,否則對一個缺席的 optional raise——刻意的「我確定它有值」 逃生口,是從值那層跨進 abort 的一種入口。(邏輯否定用關鍵字 not,所以 postfix ! 空出來給它。)

[not yet] 作為獨立、可 is 測試的錯誤種類的 UnwrapError 尚未建置;今天該 abort 以一般訊息觸發、不在下方 六種分類之列。

port := lookup("PORT") ?? 8080
name := env("NAME") ?? env("USER") ?? "anon"
addr := config?.server?.host ?? "localhost"

raise——以任一 Err abort(值 → abort)。 raise e 把一個 Err 升級成攜帶它的 abort——是「值→abort」的 通用生產側入口,! 只是它的特例。值那層(簽章裡的 Result / Either)留給預期、可回復的失敗;raise 給 不可回復者——壞掉的 invariant、失敗的 assertion、一個「不可能發生」——因此不進任何簽章、只被 guard 攔下。 一個 raise e from cause 形式會把 cause 記成 e 的 unwrap()——一個 nested abort,把底層 Err 包進更高 層的一個、而不遺失它,餵的是每個 Error 都有的那條 cause chain;裸的 raise e 則原封不動攜帶 e。 raise 語句帶有每一種 diverge 都有的後綴 guard——raise e if c,以及 raise e from cause if c ——它是 if c { raise e } 的糖,也正是格式化器的 F401 會把 block 形式改寫成的樣子。它只屬於語句形式: ?? 右側的 raise 不接尾隨 if,否則那個 guard 會讀成 coalesce 的。

assert cond——陳述一項主張(值 → abort)。 主張不成立時 raise AssertionError,而它只收一個條件、 別的都不收(GRAMMAR#assert-stmt):訊息才是函式永遠湊不出來的那樣東西,所以由 compiler 從主張所在的位置、主張本身的原始碼文字、以及比較拆開後每個運算元的值寫出來。

AssertionError: parse.zg:41  assert lo == hi
  lo = 1
  hi = 2

所以 assert 是給那種「自己的文字就是解釋」的主張用的。需要解釋而不是展示的主張,寫成 raise ValueError("why") if not cond,那才是 production 形式——也是唯一帶條件的形式,因為 assert 是 唯一不收後綴 if 的 diverge:assert c if d 根本不成立(E2005)。assert 一律被編譯進去、沒有任何旗標 拿得掉,因為有 assertion 與沒有 assertion 的程式會是兩支程式;所以 zerg lint 會把 *_test.zg 以外的 assert 報成 L602——它不是比你要的更弱的檢查,而是更不精確的那一個。

內建的錯誤分類是一棵樹。 這個階段提供固定的六種錯誤,而其中有些是另一些的一種:

ValueError            這個值不是這裡能接受的
├── OverflowError     ……因為它超出了範圍          int(u)、byte(300)、a + b
└── EncodingError     ……因為它不是合法的文字      str 橋接到不合法的 UTF-8
IOError               外界不配合
IndexError            索引落在容器外
KeyError              沒有任何 map 持有這個 key

讀這棵樹的是 in,不是 is。 這裡有三個關係,而每一個都有自己的拼法:

要問的 怎麼寫 對一個 OverflowError
它正好是不是這個 kind e is X is OverflowError
它是不是這個 kind 的成員 e in X in ValueError 也成立
它是從什麼 raise 出來的 e.unwrap() from 指的那一個

所以處理者用 e in ValueError 粗略地接(「值不對,我不在乎為什麼」),或用 e is OverflowError 精確地接,而 粗略的那個不是謊話。Zerg 沒有繼承,這也不是繼承:這棵樹是內建 kind 之間一個固定的關係,而且只往上讀——每一個 OverflowError 都在 ValueError 裡,而手寫 raise 出來的 ValueError 不是 OverflowError。

把它們分開不是為了整齊。一個同時回答其中兩者的述詞,等於兩者都分不出來: raise IOError("outer") from ValueError("inner") 必須仍然跟一個本來就是 ValueError 的錯誤有所區別,而它之所以區 別得出來,只因為「問 cause」是另一個問題。

你從中挑選;自訂錯誤型別(一個實作 Error spec ——message() -> str、unwrap() -> Err?、code() -> byte?,見 內建 spec——的 struct / enum) 尚未支援。每一種都是完整的 Err:帶訊息建構(raise ValueError("bad input"))、放進 Result 右側、也可被 raise、讀 err.message()、用 err is ValueError 與 err in ValueError 測試,而 guard 會把它還原成 Right(err)、message/cause/code 完整。 runtime 自身的內建失敗也 raise 對應的種類,使 library 與 runtime 的錯誤共用一套詞彙:整數解析失敗是 ValueError(超出範圍→OverflowError)、一次 checked 收窄轉換是 OverflowError、I/O 失敗是 IOError、對無效 UTF-8 的 str 橋接是 EncodingError、越界索引是 IndexError、缺少的 map 鍵是 KeyError。Result / Either 攜帶它們,? / ?? / guard 原封不動地穿引;對具體 Either[T, Kind] 的 match 則分辨其種類。abort 契約本身—— 寫到 stderr 的訊息、exit 狀態 1、Kind: message 那一行——見 Conformance。前綴屬於那 一行、不屬於訊息:message() 只回答文字本身,而錯誤走到頂端時種類會被渲染在它前面——手寫的 raise ValueError("bad input") 與 runtime 自己的 IndexError 一視同仁。

code() 已經建了、回答 byte?,而且永遠回答 absent。本編譯器建得出來的 Err 沒有一個帶著 code: 存在的錯誤就是那些內建種類,而 code 屬於使用者自訂的錯誤型別——那才是本段背後沒有實作的那一半。這個方法在 一個空的定義域上是完整的,而不是一個程式碰得到、然後被拒絕的形式;message() 與 unwrap() 依規格回答。

Aborts。 一次 abort——一個內建 runtime fault 或任何你 raise 的 Err——代表 bug,不是預期內的失敗。本章 用到的 fault 名稱裡,今天有十一個具現化成可 is 測試的種類:ValueError、OverflowError、IOError、 EncodingError、IndexError、KeyError、DivideByZeroError,加上並行那章指名的三個——SendOnClosedError、 DeadlockError 與 StopIteration——以及 AssertionError:那是失敗的 assert raise 出來的東西,而且沒有別的 東西會 raise 它。這份獨佔性正是種類勝過訊息之處:zerg test 把不成立的主張報成失敗、把其他抵達測試本體頂端 的東西報成崩潰,而它分辨兩者的方法就是問 e is AssertionError。其餘在語言表面還叫不出名字:UnwrapError、 MatchError 與 AliasError 是 [not yet]——err is AliasError 在兩個編譯器裡都是 E9078 NotImplemented: is AliasError — an is test compares the operand's own type against a named one here, and AliasError names no type to put on the right of it——而它們的 abort 也不帶獨立具現化種類、只有一般 訊息。這道拒收正是把它們擋在下面那個折疊之外的東西:一個分類有、而這個編譯器叫不出名字的種類,不可以讀成 一句自信的「不,它不是那些之一」。

StopIteration 可測試,卻無法建構。 它是唯一一個程式可以放在 is 右邊、卻不可以呼叫的名字: raise StopIteration("…") 在兩個編譯器裡都是 E4063 StopIteration is testable but not constructible,因為它正是 channel 乾淨關閉早已戴上的那個標記——一個能 raise 它的 sender 會讓 consumer 付出什麼代價,見 Concurrency。 StackOverflowError 則是唯一不是 abort 的那一個(見下)。 abort 不可被當控制流攔截:沒有 try/catch、不能檢視是「哪一種」abort、也不能回到出錯處續算。語意上它是一次 會執行 scope 清理的 stack unwind——從 raise 點到它停下之處,每一層 scope 都先跑 它的 defer、再按序釋放,其 Ref 值(channel 與 Ref[T])的 refcount 遞減,與正常的 scope 結束完全相同;絕不是 裸的 abort()。unwind 抵達某條 stack 頂端就讓那條 stack crash:主 stack 結束整個程式,coroutine 的 stack 只結束該 coroutine(spawn 是 fire-and-forget——見 Concurrency)。

一次在另一個 abort 已經在 unwind 時才觸發的 abort——某個 defer、或某個 Ref 的 drop 自己 abort——絕不 放棄那次 unwind:其餘的 defer 照跑,所以清理永不被略過。兩個錯誤以與 raise e from cause 相同的 nesting 合併——較晚的 abort 往外傳、把還在飛的那個記成它的 unwrap() cause——所以兩者皆不遺失、consumer 讀得到整條鏈。 沒有哪個錯誤會無聲勝出,也沒有另一個 suppressed 槽要去查。

一個 StackOverflowError 是唯一不是 abort 的結束方式。Zerg 不做 tail-call 優化——for 才是迴圈、有界 stack 就夠——因此無界遞迴是一個確定的溢位、而不是無聲的卡死,而 runtime 會給那次溢位名字:它的 fault handler 把 StackOverflowError: stack overflow 寫到標準錯誤、行程以狀態 1 結束,今天程式能溢出的兩條 stack(coroutine 的 guard page 與 main 的原生 stack)皆然。

它做不到的是展開。那條會去跑待決 defer 的 stack 正是已經耗盡的那一條,所以 defer 被跳過、沒有 guard 能 把它降級,err is StackOverflowError 也不可寫——根本沒有一個值可以拿去問。它也是 coroutine 無法包住的那一個 結束:一個沒有 handler 的一般 abort 只結束該 coroutine,溢位卻結束整個行程。它落在哪一份契約之外見 Conformance。

[implementation-defined] 一次溢位怎麼被偵測是這個實作的事。一個擁有並自行成長 stack 的 runtime,可以 在 fault 之前就檢查呼叫深度、然後乾淨地 unwind;這一個是接下那個 fault、然後指名它。規範釘住的是「深度是有界 的」以及「碰到界限會指名結束程式」,不是那個察覺它的機制。

一個 DeadlockError——每個 coroutine 都阻塞、無法再前進——現在已是規格所要求的那次乾淨 abort:它 unwind、跑 pending defer,guard 也攔得住。它在 main 的 coroutine 上 raise,而且每一次偵測都會重新 raise、不是只有 一次,所以一個原封不動重試的 guard 會把 deadlock 變成 livelock;兩者為何都是刻意的,見 Concurrency。

guard——把 abort 降級成值(abort → 值)。 guard { … } 執行一個區塊,並把其中任何 abort 具現化成 Err, 因此整個運算式恆為 Result[T](T = 區塊的值型別):正常結果 v 變成 Left(v),攜帶 err 的 abort 變成 Right(err)。

n := guard { parse_int(untrusted) } ?? 0    # 內部溢位變成 Right(err);?? 再給預設值

fn read_config(s: str) -> Result[Config] {
    return guard { risky_parse(s) }         # 內部的 abort 降級成 Right(err)
}

被 guard 的區塊只有一條限制,而且是大聲的。 用 return、break 或 continue 離開區塊, 在兩個編譯器裡都會被拒絕——E9043 NotImplemented: return leaving a guard block — the guard's handler would stay installed on a frame that has returned。handler 在區塊前推入、區塊後彈出, 中途跳走會把 frame 帶走、把 handler 留在上面。寫在 guard 裡面的迴圈不受影響:它的 break 從不跨過那個 frame。

區塊的值是在區塊內綁定的名字是很平常的事,那也正是一個 guard 要裝好幾個 statement 的主要理由—— guard { x := parse(s) check(x) }。那個值是被複製出來、而不是被別名,所以那些 binding 在離開時照樣釋放, 而答案活得比區塊久。

Result[T] 能在簽章裡存活,所以上面的 read_config 就是一個普通函式,而它回傳的東西在呼叫端就是一個普通的 值:交給 ?? 取預設、交給 match 讀兩側(Right(e) 那個 arm 的 e is ValueError 回答種類),或交給 ? 把同一 個右側原封不動地往下一個函式的邊界外傳。

Result 恆被壓平:因為被 raise 的錯誤本身就是 Err,對一個已經產出 Result[U] 的區塊 guard,結果仍是 Result[U]——內部 abort 與回傳的 Right(err) 收斂成同一個 Right(err)。guard 只攔當前 stack 上的 abort; 在區塊內 spawn 出去的 coroutine 有自己的 stack,不受影響。

guard 是從 abort 那層回到值的唯一路徑,與 raise/! 這些入口對稱——一旦 guard,abort 就成了普通 Result,用 既有的 ? / ?? / match 處理,沒有獨立的 handler、也沒有 recover 建構。它在 coroutine 裡沒有特殊意義: 用 guard 包住的 coroutine body 就只是一個產出 Result[T] 的函式,要回報就跟其他值一樣送進 channel。

依型別處理錯誤——is

兩層送出的是同一個被抹除的 Err——值那層的 Right(err) 與 guard 具現化的 abort 無從分辨——所以同一套機制就 能對兩者分派。要對某一種錯誤動作,就用 is 測它的型別(見 型別測試):

match guard { work() } {
    Either.Left(v)  => use(v)
    Either.Right(e) => {
        # 先是分類種類,再來是 guard 具現化的內建 abort,最後是必備的 catch-all
        if e is IOError { rebuild() } else if e is OverflowError { alert(e) } else { report(e.message()) }
    }
}

is 只產出 bool,所以一個分支能用 Error 介面(message / code / unwrap)、但碰不到具體型別自己的欄位 ——值已被抹除、永不重新建構。在一個錯誤上,那正是 is 的用途:上面那十一個具現化種類,是它唯一會從 runtime tag 回答的名字,而分類範圍外的名字得到的是 E9078、而不是一個回答 false 的測試。其他每一個名字都是從運算元自己的 型別回答的編譯期常數,那由 Spec 與 Generics 決定;仍然 裝箱的運算元是唯一在執行期得到答案的:讀的是它被裝箱時對上的那張見證表。這裡可達的錯誤集合在覆蓋上被 視為開放,所以 is 串永遠無法窮盡:catch-all 必備。未命中的錯誤會像任何未覆蓋的 match 一樣 abort——但 MatchError 是 [not yet] 的具現化種類,且因為最後一個 match arm 一律無條件,compiler 今天永不 emit 一個(catch-all 的要求是靜態規則、不是 runtime MatchError)。

這把錯誤處理依「你握不握有封閉集合」分成兩路。需要以值決定錯誤種類時,就讓它保持具體——一個 Either[T, ValueError](從不抹除),其右側由 match 以值讀出。只認得少數幾種時,收下被抹除的 Result[T]、用 is 分派並留 catch-all。於是回傳型別本身就是契約:抹除的 Result 說「分支、用 Error 介面」,具體的 Either 說 「這是我確切的錯誤種類」。(把多種錯誤聚成一個封閉 sum 的自訂 error enum,與上面的自訂 error 型別一同延後。)

[not yet] 內建錯誤種類不能出現在型別位置:fn f() -> Either[int, ValueError] 會回報 E4056 no type named ValueError。那些種類是 runtime 認得的 constructor,不是型別檢查器解析得到的名字,所以剛 才描述的那一分為二裡封閉集合的那一半,今天沒有任何程式寫得出來;兩條路裡存在的是被抹除的 Result[T] 加上一串 is。