Zigのエラーはなぜシンプルなenumなのか、の哲学
Zig言語を本格的に使っていこうと思ったとき、個人的に最大の懸念だったのが、以下の記事でも言及されている、エラーに追加情報を含むことができないこと。
Zigのエラーというのは、現時点で最新のv0.11.0-devにおいても、シンプルなenumであり、例えばJSONパーサーを書いたとして、もしJSONにミスがあったとき、エラー内容にJSONファイルのエラー位置などの追加情報を含めることができない[1]。
例えば実際のZigのコードだとこんな感じ。
const ParserError = error{
AccessDenied,
FileNotFound,
ParseError,
};
fn json_parse() !void {
return ParserError.ParseError;
}
pub fn main() !void {
try json_parse();
}
// $ zig run error-test.zig
//
// error: ParseError
// error-test.zig:9:5: 0x1026186d3 in json_parse (error-test)
// return ParserError.ParseError;
// ^
// error-test.zig:13:5: 0x10261886f in main (error-test)
// try json_parse();
// ^
こう聞くと、エラー処理の欠陥ではないか、と素直に思ってしまうのだけれど、実は一種の哲学があるのだということを調べていて知った。
要約すると、言語処理やライブラリ開発に関する、開発サイドに必要なエラーと、実際にライブラリやアプリケーションを利用する、エンドユーザに対するエラーというのは、明確に異なるというもの。
例えば前述のJSONパーサーのエラーというのは、アプリケーションがその利用者(エンドユーザ)に対して、誤っているよ、と教えるもので、その情報の中に、それを処理したライブラリやアプリケーションが何行目でエラーしたとかといった類のエラー情報は必要ない。
そしてZigが扱うエラーというのは、あくまでライブラリ・アプリケーションの開発者が必要なエラーであって、エンドユーザのためのエラー情報を含めることによってメモリやスタックを過剰に消費するべきではない、という主張。
……なるほど、これなら一応納得ができる。
もちろん、エンドユーザに対するエラー処理をする方法も記事中では紹介してあるけれど全文ではなかったので、同じ趣旨の内容のzig.newsの記事からコードをそのまま引用すると以下の通り。
(【6/8追記】さらに良い方法が更新されていました: https://zig.news/ityonemo/error-payloads-updated-367m )
const std = @import("std");
const CustomError = error{NumberError};
pub fn raise(message: []const u8, value: u64, opts: anytype) CustomError {
comptime if (has_error(@TypeOf(opts))) {
opts.error_payload.message = message;
opts.error_payload.value = value;
};
return CustomError.NumberError;
}
fn has_error(comptime T: type) bool {
return @hasField(T, "error_payload");
// consider checking for error allocator, too, if necessary.
}
fn add_one(number: u64, opts: anytype) CustomError!u64 {
if (number == 42) {
return raise("bad number encountered", number, opts);
} else {
return number + 1;
}
}
pub fn main() !void {
std.debug.print("no error: {}\n", .{try add_one(1, .{})});
_ = add_one(42, .{}) catch trap1: {
std.debug.print("errored without payload! \n", .{});
// we need this to have a matching return type
break :trap1 0;
};
// here we define the payload we'd like to retrieve
const Payload = struct {
message: []const u8 = undefined,
value: u64 = undefined,
};
var payload = Payload{};
var opts = .{ .error_payload = &payload };
std.debug.print("no error: .{}\n", .{try add_one(1, opts)});
_ = add_one(42, opts) catch trap2: {
std.debug.print("errored with payload: {s}, value {}! \n", .{ payload.message, payload.value });
break :trap2 0;
};
}
opts.error_payload
がエラー追加情報。ただopts
の型がanytype
になっていてかつ内部に参照を含むので、コードが分かりづらい気もするのだけれど、それは好きに修正できる問題なので、Redditの主張で言われているところの「醜い (uglier) が、必要に応じて診断情報 (Diagnostics) を返せる」という趣旨には合っている。
一方で、Zigユーザの中にもやっぱり納得しがたいという層は一定数いるようで、今後v0.12.0以降において、もしかするとunionを使ったエラー情報を含むエラー処理が実装されるかもしれない。
ちなみに、蛇足ではあるけれど、Zigのエラー処理の流れ自体はとても美しいと自分は感じていて、例えばRustでは安易にコードを書くとすぐpanicを起こすコードになってしまう (unwrap
等) [2]のが、Zigの主なエラー処理であるtry
(Rustの?
に当たる) ではエラーがボトムアップされて美しくトップレベルに集まっていき、上位のモジュールでそれを処理することができる。
エラーが何の型であるなど煩わしいことを気にしなくてもこのボトムアップが実現できるし[2:1]、エラー型の結合なども簡単なので、とてもよく設計されている。これはtry catchでエラーを処理するという慣例をうまく使っているし、行頭のtry
は目立つのでコードも見やすい。[3]
なお、エラー時にpanicができないということもなくて、以下の動画で解説されているように、unreachable
や@compileError
、@panic
などを使うことで、必要に応じてpanicを起こすこともできる。
-
スタックトレースができないという意味ではないので注意。(参考: https://ziglang.org/documentation/master/#Error-Return-Traces) ↩︎
-
Rustにも
?
があるのに自然とunwrap
を書いてしまうのは、多くのサンプルコードがそうなっているのもあるけれど、型を気にしなければならないのがたぶん主な理由。Zigでは!
だけでエラーを返す型が明示できるのもシンプルで好き。 ↩︎ ↩︎ -
Rustのthiserrorのように、Zigでも型名以外のエラーの意味が分かる文字情報が含められるようになったらいいなと素朴に思うけれど、それこそメモリやスタックを過剰に消費するので、実装されるとしても別ライブラリかな。(参考: https://zenn.dev/hideoka/articles/e2408b1eb8ee3f) ↩︎
Discussion