zig言語でtomlパーサを書いてみた

数ヶ月前にzig言語というやつの存在を知って、これはちょっと面白そうだなと思ったので、勉強がてらなにかやってみよう、と思っていた。ある日、tomlのパーサはどうだろうかと思い立ってしばらくやっていて(Ghost of Tsushima で作業が中断したりしつつ)、まあまあ出来上がってきたと思うので、現在のところのソースコードをgithubに置いておいた(https://github.com/jmuk/zig-toml)。

というわけで、zig言語をちょっと書いてみた感想を残しておく。なお、利用したのはzig 0.6.0なので、今後いろいろ変わってくる可能性もあることは強調しておきたい。

zig言語のよいところ・興味深いところ

型の扱いがzigでは興味深いところだった。zigはかなりいろんなところでcomptimeというマーカをつけてコンパイル時にコンパイラが事前処理をするようなことができる。これでたとえば、C言語であればマクロ展開させるようなコードも自然に書けるしわかりやすい。

さらに型もコンパイル時の値として扱うことができ、型によって処理を変えるような関数もかんたんに書ける。さらには、型を受け取って型を返す関数によってテンプレート・総称型を実現している。たとえば、

fn List(comptime T: type) type {
    return struct {
        items: []T,
        len: usize,
    };
}

のように書ける。これはちょっとかっこいい。型レベルの情報(たとえば構造体のフィールド名など)はコンパイル時には展開できて、ちょっとしたリフレクション処理のようなこともできる。リフレクションはコンパイル時にしか発生しないので、実行コードはおかしな遅さにはならない。

また、エラーになりうる型、オプショナルな型などはいくつか特別扱いしている点も面白い。null安全性なども担保されているし、Go言語などと違い、エラーの場合にはエラーだけが返るようになっていてわかりやすいし、エラーが値でありながらtry構文などにより帯域脱出っぽいようにも書けるのもよさそう。

zigには(たぶんGoの影響で)defer文があるのだけど、errdeferという「エラーのときだけ実行される」処理というのが書けるのはかなり素晴らしいと思うというか、正直Goにもこれがほしい。これもエラーが特別な値でありエラーになりうる型を特別視しているところとうまく結びついている。

困ったところ、微妙なところ

メモリ管理はやっぱり大変。zigはGCのない手動メモリ管理言語であり、Rustのようなオーナーシップのようなものもない。C言語と同じでメモリ管理は手動で頑張る必要がある。どこでメモリがアロケートされ、誰がオーナーで、どの操作でオーナーシップが移りうるのか、といったことは言語レベルでいっさいサポートがない。こういう言語で書くのは久しぶりで、面倒さはやっぱりある(defer / errdefer でかなり軽減されているはずだが、それでも)。

メモリ管理については、zigはアロケータというオブジェクトが標準で定義されていて、好きなアロケータを扱えるようになっている。これはいい面もある。ある種のオブジェクトをひとまとめのアロケータで管理してまるごと破棄したり、その範囲を越えたアロケーションを禁止したりできるし、たとえば、既存のアロケータをラップしたリーク検知アロケータも標準から提供されていて、これを使ってテストコードを書けばコードのメモリリークはかなり容易に検出ができる(ただ、どこでリークしたかという情報までは取れないので修正の厄介さはそれほど軽減されていない気がするが……)。

一方これは、どのアロケータがどのオブジェクトのデータを割り当てたのかということがまったく自明でないことを意味する。たとえばツリーのようなデータ構造が不要になったので破棄したくなったとして、再帰的にノードをたどっていって順次廃棄していくというコードを書くだろう。でも、ノードによって異なるアロケータから割り当てられていたということも原理的にはありうるから、ノードごとにただしいアロケータに対して廃棄するような再帰処理を正しく書くのは、わりと自明ではない。

実際、標準のデータ構造でも、データ構造からアロケータへのポインタを持っていることが多い。リソース開放用のメソッドは deinit と呼ばれる関数で行われるが、このメソッドはパラメータを取らず、自分自身が参照するアロケータにリソースの開放を依頼するかたちになっている。私もこの構造に従ったし、それはそれで便利なんだが、なんだかありとあらゆるデータ構造がアロケータへの参照を持っているというのはいいことなんだろうかという疑問がないでもない。もう少しうまい仕組みはないものか。

defer / errdefer については自分がGo言語にひっぱられすぎている面もあるが、ちょっと戸惑ったところもあった。zigのほうがわかりやすいというか直感的ではあり、defer / errdefer はそのスコープを抜ける時にしか発動しない。その一方、

while (i < input.len) : (i+=1) {
  var v = Value{.Table = Table.init(self.allocator)};
  errdefer v.deinit();
  ....
  if (xxxx) {
    break;
  }
  results.append(v);
  if (yyyy) {
    return i+1;
  }
}
return ParseError.FailedToParse;

のように書いた時、このコードがエラーを返してもvが開放されることはないので困ったということもあった。関数全体としてはエラーだが、errdeferの設定したスコープではエラー終了していない(正常にbreakしている)ためにerrdeferは実行されない。breakのかわりにその場でreturnでエラーを返すとerrdeferが実行される。ちょっとわかりづらいと思った(zigに慣れていないだけかもしれない)。

ほかにも、標準の文字列リテラルは[]u8でしかないというのはけっこう戸惑いがあった。ちょうどいい文字列型のようなものは存在しないし、文字列リテラルからかんたんに利用する方法もない。std.ArrayList(u8)がわりと使えるが、ArrayListは対象の文字列をつねに所有しているので作るなら毎回コピーするしかない。使いやすいユーティリティ関数も少ないし、もうすこしできの良い文字列型がほしい。

また、先述したように総称型が「型を受け取り型を返すコンパイル時関数」なのはかっこいいんだけど、これは裏を返すと、ある型に対して「これはArrayListの型である」かどうか、というチェックは書けない。ArrayList自体はコンパイル時関数であって、いま手元にある型Tが、どのコンパイル時関数から生成されたものなのかなんてことはわからない。tomlパーサのようなものを書くとき、呼び出し側が指定したデータ型の構造に応じてパース結果を埋めていくみたいなことをしたいわけだけど、そういう理由により、ライブラリの定義する型をうまくあてはめるのはかんたんではないという印象を持った。

もうひとつ、エラー型が単純なenumなのは正直納得がいかない。tagged unionもありにしてほしい。パースエラーが発生した時、エラーの発生箇所を埋め込みたいんだけど、できない。パーサーオブジェクトに「最後のエラーの箇所」みたいなプロパティを与えるみたいな方法がありうるだろうけれど、どうしてもアドホックになってしまう。まあtagged unionにした場合、エラーオブジェクトをアロケートしている途中で発生するエラーみたいなややこしい問題がありうるだろうから、この設計方針はわからないでもないのだが、しかし不便じゃないか?

けっきょく、面倒なので ParseError.FailedToParse だけを返すコードになっていて、どこでエラーになったかは検出する方法がないようにした。そのほうが実装が楽だから……なんだけど、テストが失敗したときなどはわりとつらい目にあってしまった。

tomlについて

tomlは(主にCargo.tomlで)ちょっとだけ書いたことがあるくらいで仕様のことはあんまり詳しくなかったので、パーサを書くのはtomlの詳細についてもいい勉強にもなった。人間にとってわかりやすい仕様だしあんまり長くなく、複雑度が低くて良いというイメージであったが、人間にとってわかりやすい仕様は、パーサを書きやすい仕様ではないという学びもあった。

とくに面倒なのは重複するキーの禁止と、[]で設定するテーブルとインラインテーブル・インラインアレイで設定されるものの再定義が禁止されるという仕様。単純にキーに対してオブジェクトをトラバースしていくだけなら実装が単純なんだけど、このパターンは上書きになってだめ、このパターンはむしろ許される、みたいなルールがいくつもあって、けっこうわからない。仕様に書いてある例をテストケースに移して確認することにしたけど、当初の実装方針だとぜんぜんだめなパターンもけっこうあって難しかった。