Rubyで実装する案件で、Pythonのgeneratorのような形で遅延読み込みをしようと思い、どんなふうに実現したかという話です。
Pythonのgenerator
Pythonのgeneratorは遅延読み込みなどを行う際に便利です。
例えば大きいjsonlファイルを扱う場合だと、以下のような感じに、データを取り出す処理と加工する処理を分けて書くことができつつ、データを1行ずつ取り出して変換することができます。
(jsonlについては https://qiita.com/yasubei/items/95e6a742c24189ded3d6 あたりを参照してください)
import json
def jsonl_generator(jsonl_path):
with open(jsonl_path) as f:
for l in f:
yield json.loads(l)
def main()
gen = jsonl_generator('大きい.jsonl')
for dict in gen:
print(dict['name'])
main()
RubyのFiber
Rubyで上記のようなことをするにはFiberを使うことで実現できます。
上記と同様の処理を書いてみると、以下のような感じです。
※ 追記 にコメントいただいた内容を反映した改良版があります。
require 'json'
class JsonlFiber
def initialize(jsonl_path)
f = File.open(jsonl_path)
@fiber = Fiber.new do
until f.eof? do
Fiber.yield(JSON.parse(f.readline)
end
end
end
def each
while @fiber.alive? do
yield(@fiber.resume)
end
end
end
def main
JsonlFiber.new('大きい.jsonl').each do |h|
print(h['name'])
end
end
main
感想
RubyのFiberで書くと、Pythonのgeneratorよりもやや長くなってしまいますが、要件や好み次第で、どっちで書くのもありかなと思いました。
あと、 Fiber.yield
はPythonの yield
と同様な感じで、 each
で使った yield
はブロックの実行と、ちょっとややこしいなと思いました。
追記
コメントをいただいたので、ちょっと改良版を書いてみました。
これで案件の方で 動くかはまだ試していませんが 取り入れようとしたのですが、案件の方ではループで回ってくるタイミングと、 yield
するタイミングが異なるため Fiber
を使うことにしました。
ただしタイミングが同じなら、 class
にして Fiber
を使って each
を実装するよりも、コメントいただいた to_enum
を使う方法の方がコンパクトに実装できますし、 Enumerator
の全メソッドが使えて汎用性が高いですね。
def jsonl_fiber(jsonl_path)
unless block_given?
return to_enum(__method__, jsonl_path)
end
f = File.open(jsonl_path)
until f.eof? do
yield(JSON.parse(f.readline))
end
end
def main
enum = jsonl_fiber('大きい.jsonl')
enum.each do |h|
print(h['name'])
end
end
上にも書いた通り、この案件を対応した際に扱ったファイルがGBオーダーのものでして、1行ずつではなくもっとまとめて読んで、含まれているデータによってごにょごにょしてと、もうちょっと複雑なことになっていたため Fiber
を使った次第です。
案件ではデータの塊単位でまとめて読み込んで、Fiber.yield
は、読み込んだ内容に応じて加工したデータを返すところで使っていました。
例を簡単にしすぎてしまって、すみません、うまく伝わらなかったかもしれません。