値の不在と失敗を扱う
呼び出し側へ何を伝えたいかで型を選びます。次の三つは別です。
| 伝えたいこと | Nagiの書き方 | 例 |
|---|---|---|
| 値がない場合がある | T?(Option) |
任意の年齢がNone |
| 処理が成功したか、失敗したか | Result[T, E] |
数値変換がErrを返す |
| 通常の業務失敗として扱わない異常 | panic | 配列の範囲外アクセス |
Noneは処理の失敗理由を持ちません。Errは返却値であり、Pythonの例外送出ではありません。コンパイルエラーやビルド環境の失敗も、この三つとは別です。
このページの目次
値がない場合を扱う
数がなければ既定値を使いたいとき、PythonではNoneを調べられます。
def value_or(value, fallback):
if value is None:
return fallback
return value
Nagiではi64?で不在の可能性を表し、両方のcaseを持つmatchで取り出します。次は完全なコードです。
def value_or(value: i64?, fallback: i64) -> i64:
match value:
case Some(number):
return number
case None:
return fallback
def main():
print(value_or(some(42), 0))
print(value_or(None, 0))
出力は42、0です。値があるものを作る関数は小文字のsome(...)、取り出すパターンは大文字のSome(...)です。Noneにpayloadや括弧は付けません。取り出したnumberはcase内で使えるi64です。
この関数で直接value + 1と書くと、i64?はi64ではないためcheckが拒否します。Someの中のnumberを使って計算します。Pythonのif value is not None:による型絞り込みや汎用unwrap APIは未対応です。一言でいうと、不在を処理してから中身を使います。型とmatchの書式も参照してください。
def add_one(value: i64?) -> i64:
return value + 1 # checkはi64?をi64として使うため拒否
SomeとNoneを分けてから計算します。Noneを0として扱うこの例の出力は42、0です。
def add_one(value: i64?) -> i64:
match value:
case Some(number):
return number + 1
case None:
return 0
def main():
print(add_one(some(41)))
print(add_one(None))
Result[T?, E]は「処理が成功したか」と「成功時に値があるか」を組み合わせます。Ok(None)とErr(problem)は違います。
失敗を呼び出し元へ返す
入力したidを確認し、失敗を呼び出し元へ任せたいとします。Pythonではint(text)のValueErrorを捕捉しなければ、呼び出し元へ伝わります。Nagiでは次の関数のように、Resultを返します。これは関数定義の断片です。
def read_id(text: view[str]) -> Result[i64, Error]:
id = try parse_i64(text)
if id < 1:
return error("id must be positive")
return ok(id)
"2"を読ませると成功値2を返し、"oops"は数値変換の失敗、"0"は自分で作った失敗を返します。Result[T, E]は成功値Tか失敗値Eを持ち、Eには組み込みErrorや独自class・enumを使えます。
tryは成功値を取り出し、失敗ならその値を呼び出し元へ返します。Pythonのtry/exceptのように捕捉するブロックではありません。通常のi64を返す関数でtryを使うとcheckが拒否します。上のようにResultを返すか、次のmatchで回復します。呼び出し先と自分の戻り値には同じエラー型Eが必要です。一言でいうと、tryはErrを呼び出し元へ伝えます。
def read_id(text: view[str]) -> i64:
id = try parse_i64(text) # checkはErrを返す型がないため拒否
return id
失敗を呼び出し元へ伝えるなら、この関数の戻り値をResult[i64, Error]にしてtryを使います(上のread_id例)。ここで回復して数値を返すなら、matchでOkとErrの両方を扱います(result.nagi)。
非同期ではvalue = try await operation(...)です。tryの結果はTなので、Resultとして返すならreturn ok(try operation(...))と書きます。異なるエラー型へはmatchか、後述のstd.result.map_errorで変換します。
成功と失敗を分ける
変換できない入力には既定値を返したいとします。Pythonならtry/except ValueErrorで回復します。Nagiではmatchで返却値を分ければ、通常のi64を返せます。result.nagiは次の完全な例です。
def number_or(text: str, fallback: i64) -> i64:
match parse_i64(view(text)):
case Ok(number):
return number
case Err(problem):
print(error_kind(problem))
message = error_message(problem)
assert_true(len(view(message)) > 0)
return fallback
def main():
print(number_or("21", 0))
print(number_or("oops", -1))
result.nagiとして保存したフォルダーで実行します。
nagic run result.nagi
出力は順に21、invalid、-1です。OkとErrは先頭が大文字のパターンです。値を作る関数は小文字のok(...)やerror(...)を使います。
Okだけを書いて失敗を省くとcheckが拒否します。Errにも処理を書き、意図して使わない失敗値だけを_で捨てます。一言でいうと、その場で扱うならmatchで両方を処理します。
case Ok(...)とcase Err(...)を、それぞれ1回ずつ書く。順番はどちらでもよい。- 括弧内の名前には成功値・失敗値の型が付く。使わない値は
case Err(_):などと書く。 - 名前はそのcase内だけで使える。外側で使っている変数名との重複は拒否する。
- matchは対象のResultを消費する。所有文字列などのpayloadもmoveされる。借用payloadの元データはcase内でも借用中として検査する。
- 両方のcaseがreturnすれば、関数の全経路で値を返すものとして検査する。
matchはResult・Option・enumを対象とする文です。Optionはcase Some(value):とcase None:の両方を書きます。match式、ガード、入れ子のパターン、case _は未対応です。Lowでも同じ分岐を使えます。
独自のエラー型
enumは失敗の種類と、それぞれに必要な情報をまとめます。
enum QuantityError:
InvalidNumber
OutOfRange(minimum: i64)
def positive(value: i64) -> Result[i64, QuantityError]:
if value < 1:
return fail(QuantityError.OutOfRange(minimum=1))
return ok(value)
def fallback(problem: QuantityError) -> i64:
match problem:
case QuantityError.InvalidNumber:
return 0
case QuantityError.OutOfRange(minimum):
return minimum
値だけの種類はQuantityError.InvalidNumber、情報を持つ種類はQuantityError.OutOfRange(1)または名前付き引数で作ります。caseには全種類を1回ずつ書きます。payloadは位置順に受け取り、不要な値は_にします。未知の種類、不正な引数、caseの不足・重複はcheckで拒否します。
classもResult[T, MyError]の失敗値に使えます。たとえばclass StorageError:のフィールドにcause: Errorを持たせ、fail(StorageError(cause=problem))で元の原因を保存できます。class・enumのエラー型にJSONやDBの変換は要求しません。組み込みErrorを含むclassやenumのJSON変換は未対応です。
独自エラーのCLIサンプルは、組み込みErrorからの変換、同じエラー型のtry、enumの分岐を試せます。
エラー型を変換する
std.resultのmap_errorは、成功値を保ったままエラーを変換します。
import std.result as result
enum InputError:
InvalidNumber(cause: Error)
def invalid_number(cause: Error) -> InputError:
return InputError.InvalidNumber(cause)
def read_number(text: view[str]) -> Result[i64, InputError]:
return result.map_error(parse_i64(text), invalid_number)
map_errorはResult[T, E]と同期関数fn[E, F]を受け取り、Result[T, F]を返します。型引数は推論されます。名前付き関数か、その同期関数を代入したローカル変数を渡してください。変換関数はOkでは呼ばず、Errで1回だけ呼びます。
入力のResultを消費し、成功値・失敗値をmoveします。map_error自身はcloneやメモリ確保を行いませんが、変換関数の処理によってはメモリを確保します。成功型Tの借用は元データの生存期間を保って扱います。現在、変換後のエラー型Fにviewを含めることはできません。
tryが自動で別のエラー型へ変換するわけではありません。変換後のResultをtryする関数も、同じエラー型Fを返す必要があります。在庫集計CLIではJSONのErrorをInventoryErrorへ変換しています。
Errorを調べる・作る・返し直す
| 書き方 | 意味 |
|---|---|
error("理由") |
入力の失敗を作る。kindはinvalid |
not_found("理由") |
対象なしの失敗を作る。kindはnot_found |
internal_error("理由") |
内部の失敗を作る。kindはinternal |
error_kind(problem) |
kindの名前を所有文字列で取得する |
error_message(problem) |
messageのコピーを所有文字列で取得する |
return fail(problem) |
元のkindとmessageを保ち、Errorをmoveして返す |
成功型は戻り値や変数の型から決まります。fail(problem)は組み込みErrorだけでなく、class・enumの失敗値もmoveします。型の文脈がなければResult[unit, E]です。error_kindとerror_messageは組み込みError専用で、元の値を消費しません。messageにはDBなどの内部情報が含まれる場合があります。
従来の@getなどのHTTPハンドラーはResult[..., Error]を返します。Nagi 0.1.8から使えるstd.http.serverでは独自のエラー型Eを使い、AppまたはrouteのmapperでResponseへ変換します。従来のハンドラーでの組み込みErrorの変換は次のとおりです。
| kind | HTTPステータス |
|---|---|
invalid |
400 |
not_found |
404 |
busy |
503 |
database / internal |
500 |
DB・内部エラーの500応答は{"error":"internal error"}で、詳細はサーバーのログに出します。Result[T?, Error]の成功値がNoneでも404になります。
入力不正・対象なし・DB失敗と、失敗からの回復を試すにはResult APIサンプルを使ってください。サンプルのテストは、起動したサーバーにリクエストを送り、応答を確認します。
検査とpanicの範囲
Resultを式として直接捨てる場合は、外側がownedで包まれていても型検査で拒否します。awaitしていない非同期呼び出しも拒否します。一方、変数へ代入したResultの未使用は検査できません。owned[Result[...]]のtryやmatchにも対応していません。check成功だけで、すべてのエラーを扱ったとは判断できません。
Resultの失敗とpanicは別です。配列の範囲外アクセスや、実行時に除数が0になる整数の/・%は、Resultではなくpanicになります。Pythonのraise/exceptに相当する汎用の例外構文はありません。
Nagi 0.1.10は整数の/・%で除数に直接0を指定するコードをHigh・Lowともcheckで拒否します。Nagi 0.1.11への導入対象の定数検査では、1 / (1 - 1)や同じ関数内で確定した変数の0、符号付き整数の最小値を-1で割る/剰余を求める式も拒否します。新しい定数診断は名前・型検査の成功後に行い、公開配布での利用可否はRelease記録で確認してください。
到達不能な枝の定数式も検査するため、以前はビルドできたdead branchが拒否される場合があります。+・-・*のoverflowは従来どおりdebugでpanic、releaseでwrapします。このprofile差がある値や、関数・extern・field/indexの値は確定値として伝播しません。すべての実行時ゼロ除算を事前に検出する検査ではありません。
Nagi 0.1.10以降のHTTPサーバーは、応答開始前のhandlerでunwindするpanicを詳細のない500へ変換して接続を閉じます。HTTP応答へ変換できても、DBや共有状態の変更は巻き戻しません。回復できる範囲の詳細はHTTPリファレンスを参照してください。
scopeは本体終了後に子taskの結果を確認し、その際にpanicを検出します。Nagi 0.1.8から使えるstd.actorでは、Turn内の業務エラーEは次の状態を保存して返信し、handler自身のErrorやpanicにはSupervisorの再起動方針を適用します。callはResult[Result[R, E], CallError]を返すため、業務エラーと未起動・停止・タイムアウトなどを分けて扱います。サンプルでHTTP応答への変換も試せます。
旧supervisor_demoは固定workerの再起動を試す検証用APIです。どちらもメモリ破壊やprocess abortを回復する機構ではありません。
診断はファイル名、行、該当ソース、理由を表示します。ビルド時も、元の位置を特定できるエラーはNagi・Lowの文や定義の行と関連noteを表示します。生成Rustの詳細はbuild/run --rust-diagnosticsで確認できます。Rustの修正候補はRust向けです。手書きRustや位置を特定できない診断は省略せず、Rust側の表示を使います。厳密な列位置や全Rust診断の対応は未実装です。VS Codeの定義ジャンプは元ソースの列位置も扱います。
実装とテストはResultの型検査・match、独自エラー型、map_error、整数のゼロ除算チェック、scope、actorを参照してください。