たごもりすメモ

コードとかその他の話とか。

Golangの defer をRubyでも使いたい

前にRubyでtry-with-resourcesが使いたいという話を書いてそのときにリリースしたgemもあるが、人類の安全に・便利にリソースを解放したいという欲求には際限がない。
try-with-resources は便利なんだけど欠点がないわけではなくて、リソースの確保と解放を一ヶ所でまとめてやらないとネストが深くなる。複数箇所に分けて書くならネストも2段になってしまう。
これはこれで整理されたコードを書く圧力になるので悪くない面もあるんだけど、とはいえもうちょっと自由にやりたい、いい方法は無いもんか、という話。

defer

ある。Golangの defer が便利そう。defer foobar って書いとくと、そのスコープを外れるときに自動的に foobar の内容が実行される。あるスコープのどこに何度書いてもいい。これは便利。

# GolangのdeferのままRubyにもってきたの図(イメージ)

def my_method
  # ...
  file = File.new("filename.txt", "w")
  defer file.close
  # ...
end

ただし上記のコードはRubyでは実現できない……syntaxに本当に手を入れない限りは。defer の後が即座に評価されてしまう。

なお try-with-resource ほしいよーって話をRubyのbugsに上げたときにライブラリとして実装できるじゃんって話があったけど、これじゃダメなんですよ。なおその例はこちら(引用)。

  Deferred.new do |d|
    r0 = Resource.new
    d.defer {r0&.close}

    r1 = Resource.new
    d.defer r1

    r2 = d.defer Resource.new
    abort unless Resource === r2

    r3 = d.defer(Resource.new) {|r|r.delete}
  end

defer 使いたいときにいちいちブロックで囲わないといけないのは、元の golang の defer と較べると圧倒的に使いにくいし、見た目も理想的ではない。deferしとけば必要なときに実行されるのがいいのに、この例だとわざわざブロックの終端という deferred な処理が発動する場所を明示してやらないといけないってことじゃないですか。コレジャナイんですよ。defの内側で必ずもう1段 Deferred.new do ... end でネストするとかイヤでしょ。

deferral

というわけでgemを作った。名前は空いてたところで deferral にした。

defer に与えるのがブロックであるところが元のgolangのと少し違うが、あとは同じような挙動になるはず。スコープを外れるとdeferredな処理が自動的に実行される。複数登録する場合は登録したのの逆順で実行される。例外が発生した場合でも実行される。

require "deferral/toplevel"
using Deferral::TopLevel

def my_method_name
  # ...
  file = File.open("my_file", "r")
  defer { file.close }

  file.write "data..."
  # ...
end

ほーら便利っしょ。あと記述がシンプル。余計なネストも作らないし。

実装

TracePoint 使って実装しているので本番環境で使い倒すのはオススメできません。またRubyの最適化処理をいろいろ台無しにする可能性があります。

あとdeferredな処理内で例外が起きたときには全部握り潰すようになってます。これはイマイチ。本当は例外が起きるならそれをそのまま上げる + 複数例外が起きた場合は suppressed な形にしてひとつの例外に他のをぶら下げる、みたいにするべきなんだけど、今はやってない。
これは何故かというと、メソッド(やブロック)内から例外が上がって強制的に脱出するとき、それがTracePointで捕捉できないことによる。メソッドから出ようとしていることはわかるが、それが通常のreturnによるものなのか例外によるものなのかの区別ができない + 上がってきた例外オブジェクトもつかまえられない。
これ、どうにかなんないかなあと思っているので、そのうち bugs に報告しようと思っている。忘れなければ。

で、TracePoint使えば実装できなくはないけど、ちゃんとやるにはやっぱり言語側でサポートしてほしいなあ、と思っている。ライブラリでいいじゃんというコミッタの意見で#13923が止まっちゃったけど、いや、ここに示した通り、ライブラリじゃダメなんですよ。

ライブラリじゃ駄目なわけ (追記)

某所で指摘されたけど、ブロックが参照する変数の中身が書き変わったときもこのライブラリでは手当できません。たとえば以下のコードだと "fileB.txt" を開いた File オブジェクトが2回閉じられちゃう(いっぽう fileA.txt を開いたほうはリークする……GCの回収を待つ)。

require "deferral/toplevel"
using Deferral::TopLevel

def my_method_name
  f = File.open("fileA.txt", "w")
  defer { f.close }
  # ...

  f = File.open("fileB.txt", "w")
  defer { f.close }
  # ...

これは block で書く以上どうにもならないすね。

ライブラリじゃ駄目なわけがひとつ消滅 (追記2)

ローカル変数の中身を binding 経由で強制的に保存・再生するという悪魔的発想により上記コード例もちゃんと動く deferral v0.1.2 がリリースされました。

というわけで

興味がある人はぜひどうぞ。