Nim | Result[T, E]型を定義する
NimでResult[T, E]型を定義する
こんにちは。今回はNimでResult[T, E]
型の実装を試みます。
この記事は、Nim Advent Calendar 2021の5日目です。
Result[T, E]
型は、T
型の正常値かE
型の異常値を持つ型です。エラーハンドリングや非同期処理に用いられます[1]。
これを持つ多くの言語[2]では、列挙型を用いて実装されています。しかし、Nimの列挙型enum
は非定数のデータを持つことはできません[3]ので、構造体object
を用いて実装したいと思います。
type
ResultKind {.pure.} = enum
kOk, kErr
Result* [T, E] = object
case kind: ResultKind
of kOk:
ok: T
of kErr:
err: E
これは簡単な構造で、kind
メンバーがkOk
をとるときT
型のok
メンバーを、kErr
をとるときE
型のerr
メンバーを持ちます[4]。とはいえ、kind
メンバーが定まった時点でok
をとるかerr
をとるかが定まるため、実行時にkind
の値を変更することはできません[5]。
Ok
・Err
を実装する
正常値・異常値をそれぞれ代入するようなOk
・Err
を実装します。次のような素直なプロシージャによる実装がまず考えられます。
proc Ok[T, E] (value: T): Result[T, E] =
result = Result[T, E](kind: kOk, ok: value)
proc Err[T, E] (err: E): Result[T, E] =
result = Result[T, E](kind: kErr, err: err)
これはこれで良いのですが、次のようなコードをコンパイルできないことに注意しなければなりません。
proc over100 (num: int): Result[int, string] =
if num >= 100:
Ok(num)
else:
Err("too small")
プロシージャの戻り値はResult[int, string]
であることは明白ですが、Nimの型推論器ではOk
の型変数E
がstring
であるべきと推論することができないからです。
proc over100 (num: int): Result[int, string] =
if num >= 100:
Ok[int, string](num)
else:
Err[int, string]("too small")
このように明示的に型変数を与えることでコンパイルを通せます。
しかし、これは少し煩雑です。
fn sq(x: u32) -> Result<u32, u32> { Ok(x * x) }
Rustでは上のようなコード[6]が許されていますから、NimにおけるResult[T, E]
でも上手く表現したいところです。
そこで、macro
を用いてプロシージャのresult
変数から型情報を入手します。
macro Ok* (value: untyped): untyped =
result = quote do:
proc Ok2 [T, E] (res: Result[T, E], value: T): Result[T, E] =
result = Result[T, E](kind: ResultKind.kOk, ok: value)
result = result.Ok2(`value`)
macro Err* (err: untyped): untyped =
result = quote do:
proc Err2 [T, E] (res: Result[T, E], err: E): Result[T, E] =
result = Result[T, E](kind: ResultKind.kErr, err: err)
result = result.Err2(`err`)
Ok
マクロはOk2
プロシージャとresult
変数への代入に展開されます。Ok2
は、最初に考えた素直な実装と同じです。次に、展開先のプロシージャにおけるresult
変数に対してOk2
を適用します。result
はプロシージャ内で暗黙に定義される変数で、戻り値の型からResult[T, E]
の型変数は定まっていますから、型推論器の代わりに型情報を手に入れることができます。
match
を実装する
Nimにはパターンマッチがありません[7]ので、Result[T, E]
を処理するmatch
を実装します。
macro match* (value, body: untyped): untyped =
expectLen(body, 2)
let branches = [body[0][0], body[1][0]]
var (okExists, errExists) = (0, 0)
expectKind(branches[0], {nnkIdent, nnkOpenSymChoice})
expectKind(branches[1], {nnkIdent, nnkOpenSymChoice})
for branch in branches:
if branch.repr == "Ok": okExists += 1
elif branch.repr == "Err": errExists += 1
else: error("Unexpect Identify " & branch.repr, branch)
if okExists == 2: error("You describes two Ok clauses.", branches[1])
elif errExists == 2: error("You describes two Err clauses.", branches[1])
result = quote:
block:
var res = `value`
template Ok (okName, okBody: untyped): untyped =
proc okProc (okName: auto) =
okBody
if res.kind == ResultKind.kOk:
okProc(res.ok)
template Err (errName, errBody: untyped): untyped =
proc errProc (errName: auto) =
errBody
if res.kind == ResultKind.kErr:
errProc(res.err)
`body`
前半はvalidな抽象構文木であるかのチェックです。後半のresult
変数に代入されたASTがmatch
の展開結果ですが、主にOk
・Err
の定義と呼び出しです。
200.over100.match:
Ok value:
echo value == 200
Err err:
fail()
match
は上のように利用されます。Ok
とErr
の2パターンを両方記述する必要があり、ここでは200.over100
の値が(kind: kOk, ok: 100)
であるため、Ok
テンプレートが展開されたコードでif res.kind == ResultKind.kOk: OkProc(res.ok)
が呼び出され、echo 200 == 200
が実行されます。一方、Err
テンプレートの方ではerrProc
は定義されますが呼び出されません。
エラー伝搬
?
・?.
演算子を用いると、値がkErr
の時に処理を中断して呼び出し元に値を返します。
template `?`* [T, E] (value: Result[T, E]): T =
case value.kind:
of ResultKind.kOk:
value.ok
of ResultKind.kErr:
return
template `?.`* [T] (left: T, right: proc): untyped =
right(?(left))
Nimはユーザー定義演算子において後置演算子を認めないため、?.
中置演算子を用いてコードの順番を入れ替えます。
感想
利用価値の高い型だと思いますが、実現にはかなり黒魔術めいたことをしなければなりません[8]。堅牢なテストによって管理したいところです。
Araqをはじめとしてコアメンバーは例外についてどのように考えているかはかなり気になっています。標準ライブラリからは関数型言語的な機能を好んで提供している雰囲気を感じ取っていますし、そもそもoptions
を提供しています。
組み込みなどメモリパフォーマンスが求められる用途についてはさておき、Nimの実行時エラーはあまり情報量がなくて辛いことがかなりあるので、実行時エラーを出さない方向に進んでもいいんじゃないかなと思いました。
Advent Calender 2021
- 昨日(12/4): Nimでアプリケーション開発をするための設計のベストプラクティス
- 明日(12/6): Nim で TwitterAPIv2 (OAuth1) 叩こうと思ってミスった話
-
Result[T, E]
をどういう用途に使うべきかは各言語ごとに立場が異なる。Nimでは例外やEffect Systemへのサポートが手厚いのでこちらが主流になることはあまりないように思える。 ↩︎ -
Rust、Swiftなど ↩︎
-
困った。
enum
は列挙型であるが序数型クラス(Ordinal
)に属しているため、非定数のデータを持つことはinc
・dec
などOrdinal
な型全般が満たすべき操作を壊してしまう。とはいえ、disjointな値を持たせることができてしまう。Cとの互換性を持たせる以外の理由は恐らくないが、それでもあまりここら辺の感覚が一貫しているようには思えない。 ↩︎ -
受け取った型変数を必ずしも全て使う必要はない。 ↩︎
-
Error: unhandled exception: assignment to discriminant changes object branch; compile with -d:nimOldCaseObjects for a transition period [FieldDefect] ↩︎
-
Fusionにはある。
case
はパターンマッチではない。 ↩︎ -
とはいえ、かなりライトなマクロの使い方だと思う。これくらいならやっても怒らないでほしい。 ↩︎
Discussion