🏟️

[Zig] Arena Allocatorを使った "ふつうの" Zigプログラミング

2023/06/04に公開

タイトルは、『ふつうのHaskellプログラミング』にちなんでみた。一見難しく感じるZigが、Pythonなどのように"ふつうに"使えると感じるきっかけにこの記事がなると嬉しい。

(記事自体の難易度もふつうにしたかったのだけれど、自分の技量不足で専門用語が多くなってしまったので、今後加筆して簡単にいきたい。)

自分がZigを書き始めようと思った理由はいくつかあるのだけれど、一番大きな理由は「Arena Allocator(アリーナ・アロケーター)」を理解したことが大きかった。

Zigなどのメモリを自分で管理しなければならない言語というのは、開発コストが大きい、と自然に感じてしまう。自分もZigに以前から魅力を感じていたけれど、それがネックで長い間敬遠していた。

ちなみにArena Allocatorを自分が知ったきっかけは実はZigではなく、EmberGenという有名なリアルタイムシミュレータがOdinという言語で書かれていて、実はZigとそっくりの戦略を取っていたことに気づいたことだった。きっとそこには秘密があるはずだと思って詳しく調べて、OdinでもArena Allocatorなどがうまく活用されていることを知った。

それまでは、ZigやOdinがなぜカスタムアロケータという戦略をとっているのかがよくわからなかったし、単なるC言語の焼き直し、Better Cというだけのような気がしていたのだけれど、Zigの言語哲学を深く知るにつれ、それに留まらないことを知り、それ以来、Zigを見る目がすっかり変わってしまった。

カスタムアロケータの持つ可能性というものを、Arena Allocatorをきっかけに知ってもらえると嬉しい。

Arena Allocatorとは

さて、前置きはこれくらいにして、Arena Allocatorとは何なのかというと、freeを書くことなく一発でまとめてメモリを開放することのできるアロケータのこと。

https://ufcpp.net/blog/2018/12/futurememorymanagement/

あえてZigではなくてC#のコードを上記記事から引用すると、以下の通り。

Arena arena = new Arena();
using (arena.Activate())
{
    // この内側で new したものは「arena」内に確保される
}
// arena 内のオブジェクトは arena の Dispose 時にまとめて解放
arena.Dispose();

手動でメモリ管理する、と聞くと、もしスマートポインタを使わないのであれば、一つ一つ malloc() (メモリアロケート)して、一つ一つ free() して、という昔ながらのC言語のスタイルを思い描いてしまうけれど、実際、Zigで書かれたコードの多くはそうしてはいない。Arena Allocatorをうまく活用している。

ちなみに上記C#のコードにあたるのは、Zigでは以下。(コメントもあえて似せてみる。)

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit(); // arena 内のオブジェクトは、スコープを抜けたときにまとめて解放

var allocator = arena.allocator();

// これ以下でallocatorに確保したものは「arena」内に確保される

実際Zigで書かれたコードをたくさんコードリーディングしていると、ほとんど全てのケースはArena Allocatorで事足りる。[1]

しかもArena Allocatorがよくできているのは、std.heap.page_allocator に限らず好きなアロケータを内部で利用することができるので、例えばメモリがすごく限られた環境なら、std.heap.FixedBufferAllocator のように、スタック上の固定長バッファーを使ったアロケータを利用したりもできたり、デバッグ時だけチェック機構を持つアロケータを利用したり、かなり柔軟にカスタムすることができる。

また、defer/errdeferのおかげで、解放を書き忘れることはほとんどなく、万一開放忘れがあってもチェック機構を持つアロケータに差し替えれば良いので、安心感がある。

こう聞くと、一気に自分にも書けそうだなと思えてくるんじゃないだろうか。

Arena Allocatorはクラス (struct) に持たせるケースが多く、C++でいうところのデストラクタ (zigでは慣例でdeinit関数。基本的にdefer xxx.deinit()を呼び出し後すぐに書いて使う。) で開放させることがほとんど。つまり考え方としては、ある機能を持ったクラスやモジュールがまとまったメモリ領域をarenaとして持っていて、そのモジュールが不要になったらメモリごとまとめて開放する。

ちなみにアロケータを毎回定義したり呼び出したりするのが億劫ではないか、と思われるかもしれないけれど、そこはZigの言語哲学 (zig zen) にある、「見えない動作はしない」「書くより読むを重視する」によって、かえって分かりやすいと自分は感じる。

Zigの言語仕様は非常にシンプルで、一見して意味がわからないような難解なコードはほとんどなく、そしてこのアロケータが目にみえることによって、何がスタックにあって何がヒープにあって、というのもとても分かりやすい。

そしてこれに慣れると、逆に今まで他の言語で、自分が"わかっていたようでわかっていなかった"挙動が多いことにも気付かされる。オーバーな表現かもしれないけれど、自分はひょっとしたら、Zigで初めて「書かれたコードの全部がわかる」というのを体験しているのかもしれない、とすら思う[2]

自分はこの言語哲学や表現性が、Zigが低レイヤーやWASMなどのコアな開発領域に留まらず活躍する可能性を持っていると期待していて、Zigの作者であるAndrew Kelleyもおそらくそう思って開発しているはずだと、以下の動画などを観ると確信する。

https://www.youtube.com/watch?v=Gv2I7qTux7g

だからこそZigのトップページには、"Zig is a general-purpose programming language and toolchain for maintaining robust, optimal and reusable software." と、汎用言語であることをあえて強調してあるのだと思う。

ちなみにカスタムアロケータにはArena Allocatorに留まらない可能性がいろいろあるはずで、二重開放のチェックなどもそうだけれど、例えば必要ならNimのARC/ORCのようなものをライブラリとして実装してガベージコレクタ(GC)を使ったりとか、今はまだ未知の新しいメモリ管理を、言語が対応するのを待たずにユーザが自発的に取り入れたり、しかもそれをコードの一部分にだけ使ったりもできる。

つまりカスタムアロケータというのは、単に言語からメモリ管理を切り離すだけに過ぎなくて、必ずしも手動でメモリ管理しなければいけないのではなくて、好きなメモリ管理手法を利用する余地をユーザに残してくれているのである。

脚注
  1. それ以外の場合は、従来のalloc/freeを利用したり、ツリー構造などメモリ所有者が断定できずスマートポインタが必要なケースと思われるが、実は自分はまだZigの後者の実例を見たことがないので、実例を知ったらまた記事を書きたい。Zigで参照カウントやARCのスマートポインタをどうやって利用するかといえば、カスタムアロケータを書くことになるが、現状プロダクトレベルのスマートポインタ実装は、ライブラリとしてはない様子で、現状で必要であればCやRustを呼び出すことになるかもしれない。 ↩︎

  2. コードの抽象度が上がったり、アルゴリズム自体が難しかったりすればこの限りではないけれど、少なくとも言語仕様や文法として、読んでも理解できないコードや挙動がある、というのはZigが今後進化したとしても、言語哲学としてありえないはず。 ↩︎

Discussion