Crystal で C 言語のバインディングを作っていると、必ずと言っていいほど問題になる箇所がある。
それは、each で yield するオブジェクトを毎回新しく生成するべきか、それとも再利用するべきか、という問題だ。
まだ整理と理解が十分ではないが、現時点での考えをメモとして書き残しておく。
まず、そもそもなぜ Ruby ではなく Crystal で C 言語のバインディングを作るのか。その目的を考えてみたい。
最大の理由は、Crystal に速度を期待しているからである。
Crystal で高速なプログラムを書くうえで、特に気をつけたいのがメモリアロケーションである。Crystal は GC 言語であり、メモリの確保と解放にはコストがかかる。また、そのタイミングをコードから直接制御しにくいという特徴もある。
普段はあまり気にする必要がないが、C バインディングではこれが問題になる。
例えば、コレクションクラスでイテレータを使う場合だ。
bam.each do |record|
p record
end
このようなコードを高速に実行したい場合、record は反復のたびに新しいオブジェクトとして確保するべきだろうか。
仮に毎回新しいオブジェクトを生成するとして、そのオブジェクトが C 側のメモリを参照している場合、参照先のデータまでコピーするべきだろうか。
問題になるのが、Enumerable との整合性である。
include Enumerable
Enumerable を安全に利用できるようにするには、each が yield する値は、後から保持しても問題のないものである必要がある。
例えば、select や to_a は要素を配列に収集する。同じオブジェクトを反復ごとに再利用していると、配列には同一オブジェクトへの参照が蓄積されてしまう。
そのため、速度をあまり気にしない場面では、素直に独立したオブジェクトを生成する設計が合理的だと思う。
しかし、高速性を求める場合、話は単純ではない。ベンチマークをとると、メモリ確保、コピー、およびその後の解放によって無視できないほど速度が低下する。
Ruby なら安全性優先だと思う。しかし、C言語に大きく劣らない速度を目指す Crystal では不要なメモリ確保と、それに伴う GC の負荷は、できるだけ避けたい。
この問題に対して、Crystal の標準ライブラリで近い例は Enumerable#each_slice だと思う。
a.each_slice(3, reuse: true) { |s| p s }
each_slice はデフォルトでは毎回新しい配列を生成するが、reuse: true を指定すると同じ配列を再利用する。
つまり、性能を優先したい場合には明示的に再利用を指定することができる。
ただし、同じメソッドの中で引数によって処理を分岐させると、実装や型推論、最適化などの面で扱いにくくなるケースも考えられる。
そこで、メソッド自体を分離する方法もある。以下にその命名候補をあげるが、メソッドの命名の慣習はコミュニティの中でまだ確立されていないと思う。
eacheach_reuseeach_vieweach_copy
個人的にはパフォーマンスを優先して each で再利用を行い、Enumerable に相当するものは自前実装するという選択肢も残したい。
ただし、Crystalのオブジェクトの再利用と、C 側のメモリの借用は厳密には異なる。reuse と view のどちらが適切かは、実際の所有関係によって変わってくる。
さらに、Record に finalize を実装するべきか、C 側のメモリを誰が所有し、いつ解放するべきか、といった問題もある。
このあたりは、また別の機会に考えたい。