RAII の話題は C++ の例が多いが、それもそのはず、ここ最近は GC が面倒を見てくれることがほとんどなので、 RAII の需要そのものが減っている。とはいえネットワークコネクションやらファイルハンドルやらと、あまりリソース解放のタイミングを遅らせたくないものに関しては、 RAII パターンで寿命を明示しておきたいのは GC があったとしても同じこと。
Ruby もそういったリソースに関してはブロックを渡すことで解放もしてくれる RAII パターンを実装したインターフェースが提供されているので、積極的に使っていきたいところだ。そういったリソースがひとつだけなら、素直に書けばいいだけのことなのであまり考える必要はない。
open(...) do |fp|
something(fp)
end # ここでファイルハンドルが解放される
ところが、配列になっているものに対し、すべての要素を開き、同時に使いたいとなると意外と面倒なことに気付く。配列 arr = [a1, a2, a3, ...]
があったとき、意味的にはこういうことがしたい。
open(a1) do |b1|
open(a2) do |b2|
open(a3) do |b3|
...
something([b1, b2, b3, ...])
end
end
end
が、配列の要素数がわからない以上、この方針でネストを続けていくわけにはいかない。
果て…。どうすればいいだろう?
map
する配列全部に処理したいんだから map
すればいいんじゃないかと思うわけだが:
arr.map do |a|
open(a) do |b|
b # ...んん?
end
end
要素ごとにスコープが閉じてしまい、結果の配列が意味を成さない。
閉じてしまうのが問題なら、閉じないように RAII を使うことをあきらめればできそうではある。
r = arr.map { |a| open(a) }
something(r)
けど、これだと解放処理がなくなってしまうので自分で書かなければならない。
r.each { |b| close(b) }
シンプルにこれでいいか。と思いたいところだが、 RAII の利点である例外安全の達成に難儀する。
begin
r = arr.map { |a| open(a) }
something(r)
ensure
r.each { |b| close(b) } unless r.nil? # 怪しい
end
全ての要素の open
に成功する限りはとりあえずこれでも問題はなさそうだが、配列要素の途中で open
が例外を投げると台無しである。配列内のいくつかは open
されているにも関わらず、変数 r
は初期化されないのでこの ensure
節では何も解放されない。
each
するそもそも map
内に副作用式が含まれていることそのものが筋が悪い。こういうときはもっと丁寧にことを進めるべきである。
begin
r = []
arr.each { |a| r << open(a) }
something(r)
ensure
r.each { |b| close(b) } # ん...?
end
よし、これで open
途中に例外が発生してもきちんと途中まで開いたリソースは解放されるはずだ。
が、ちとまだ問題があって、実はこれ確保順と解放順が一緒なのだ。ファイルハンドル程度ならほとんど問題にはならないだろうが、これがロックだったりするとデッドロックに陥るリスクをはらんでいる。厳密にはこうあらねばならない。
begin
...
ensure
r.reverse_each { |b| close(b) }
end
よしよし、できたできた…。
いや、ちょっと待って。こういう細かいことをいちいち考えたくないから RAII ってのが考案されたんだよね? 先人の知恵は有効活用するべきなのでは?
RAII はある意味リソース解放タイミングをスタック上に隠していく手法なので、再帰呼び出しで積んでいくのが素直なやり方だろう。
def stack_loop(arr, brr = [])
return something(brr) if arr.empty?
open(arr.shift) do |b|
stack_loop(arr, brr << b)
end
end
stack_loop(arr)
良さそうに見える? 当初の目的は十分に達成していそうだ。しかしいくつか気になる点はある。
arr
が破壊されてしまうopen
と最終処理 something
がハードコーディングされているちょっと改善を試みたい。
arr
の保存こういう処理を書こうとするとうっかり pop
や shift
を使ってしまいがちだが、これは呼び出し元の配列を破壊してしまう。結果を回収する brr
は破壊してもこのメソッドの責任だといえるが、外から受け取ったものを破壊してしまうと往々にしてバグの原因となり得る。非破壊メソッドに直してみよう。
def stack_loop(arr, brr = [])
return something(brr) if arr.empty?
open(arr.first) do |b|
stack_loop(arr.drop(1), brr << b)
end
end
stack_loop(arr)
こうしてみると frist
も drop
も Array
ではなく Enumrable
のメソッドである。ところが残念なことに empty?
は Array
のメソッドであり、 Enumerable
ではない。ここをどうにかできると引数 arr
が Array
であることに制限されず、 Enumerable
を実装するプロキシオブジェクトなどでも動作してくれるようになる。終了条件を if arr.first.nil?
にすればとりあえず Array
依存はなくなるが、要素に nil
そのものが含まれると終了条件を誤認する可能性がある。 empty?
相当のメソッドがなさそうなので厳密にやろうとするとこうなるだろうか。
def stack_loop(arr, brr = [])
a = begin
arr.each.next
rescue StopIteration
return something(brr)
end
open(a) do |b|
stack_loop(arr.drop(1), brr << b)
end
end
stack_loop(arr)
どうもごちゃごちゃしてしまった…。これ、頑張ってみた割には要素がひとつでもあれば drop
の時点で Array
になってしまうので、実はあんまり意味がなかったりはする。でもまあ、「ちゃんとしたコードを書く」というのはつまりそういうことだ。
というか drop
が常に Array
を返すのは少々筋が悪い気もするのだがどうなのか。言語的に仕方なさそうか。
さて、今どうやって配列に対して RAII をかけていくかという話をしている通り、具体的な RAII の初期化部分や処理本体は open
だの something
だのとあまり意味のないメソッドでごまかしている。要は今は関心がないということだが、こういった「関心の分離」はプログラミングにおいて重要な観点だ。
実際にその関心の分離をコード上でもしたい。Ruby ではブロックを取れる構文があるので、まず最終処理 something
をブロックとして外に出そう。
def stack_loop(arr, brr = [], &final)
a = begin
arr.each.next
rescue StopIteration
return final.call(brr)
end
open(a) do |b|
stack_loop(arr.drop(1), brr << b, &final)
end
end
stack_loop(arr) do |brr|
something(brr)
end
問題は open
のほうだ。こちらもブロックとして切り離したいのだが、 Ruby にはふたつのブロックを受け取る構文はない。どうにかしようと思うと Proc
オブジェクトをもらうくらいしかやりようがなさそうだ。
def stack_loop(arr, opener, brr = [], &final)
a = begin
arr.each.next
rescue StopIteration
return final.call(brr)
end
opener.call(a) do |b|
stack_loop(arr.drop(1), opener, brr << b, &final)
end
end
stack_loop(arr, proc { |x, &blk| open(x, &blk) }) do |brr|
something(brr)
end
うーん、呼び出し側がちっと野暮ったい感じの構文なのが残念ではあるが、特殊な事情がなければもう少しすっきりした構文もある。配列の要素自体が open
メソッドを持つなら次のように書けるし、
stack_loop(arr, :open.to_proc) do |brr|
something(brr)
end
外のメソッドなのであればそれを持つオブジェクトからメソッドオブジェクトを頂けばよい。例えば File.open
したいならこうだ。
stack_loop(arr, File.method(:open)) do |brr|
something(brr)
end
reduce
を使うさて素直に再帰呼び出しで頑張ってはみたものの、どうにか高階関数で書けないものか気になるところではある。先に見たように map
ではスコープが閉じてしまうので都合が悪い。だが原始の foldr
、つまり Ruby 語では reduce
(= inject
)ならなんとかしてくれるはずだ…。
arr.reduce([]) do |acc, a|
open(a) do |b|
acc << b
end
end
ん、んんん…? 結局スコープ閉じるじゃねえか!
ちょっと待って。落ち着こう。 Haskell の foldr
の型はこうだった。
(a -> b -> b) -> b -> [a] -> b
Haskell の foldr
で素朴なループを行う場合は、アキュムレータ b
に継続 c -> d
を渡すのが常套手段である。
(a -> (c -> d) -> c -> d) -> (c -> d) -> [a] -> c -> d
Ruby の reduce
とは引数順が違うので合わせるとこうなる。
[a] -> (c -> d) -> ((c -> d) -> a -> (c -> d)) -> (c -> d)
常時カリー化されている Haskell では不要な括弧は外してしまうが、 Ruby ではそうもいかないので括弧は残しておいた。型が見えてくればコードも書けそうだ。アキュムレータは継続 c -> d
であり、ブロックは「継続」と「配列の要素」を受け取って「継続」を返す関数とみなせる。継続が扱う型を個別に見ると、 c
はこの場合は結果を回収する配列だろう。 d
は全体の終了コード的なものとみなせるが、ループそのものがこれを触ることはないのでなんでもよい。
arr.reduce(proc { |brr| something(brr) }) do |cont, a|
proc do |brr|
open(a) do |b|
cont.call([b] + brr)
end
end
end
できた! …と言いたいところだがちょっと待って。型が示す通りこの式全体は c -> d
、即ち c
を受け取って d
を返す関数である。ということは呼び出さなければならない。 c
は結果を回収する配列であり、結果配列の初期値は空であるから、最終的にはこうだ。
arr.reduce(proc { |brr| something(brr) }) do |cont, a|
proc do |brr|
open(a) do |b|
cont.call([b] + brr)
end
end
end.call([])
今度こそできた!
期待通りに動作しているか確認してみよう。
def raii_open(i)
puts "OPEN: #{i}\n"
yield i.to_s
ensure
puts "CLOSE: #{i}\n"
end
def finish(brr)
puts "FINISH: #{brr}\n"
"FINAL VALUE"
end
arr = 1..3
final_value =
arr.reduce(method(:finish)) do |cont, a|
proc do |brr|
raii_open(a) do |b|
cont.call([b] + brr)
end
end
end.call([])
p final_value
実行結果:
OPEN: 3
OPEN: 2
OPEN: 1
FINISH: ["1", "2", "3"]
CLOSE: 1
CLOSE: 2
CLOSE: 3
"FINAL VALUE"
期待通りに動いているようだ。途中で例外が発生しても大丈夫である。例えばこうすると:
def raii_open(i)
puts "OPEN: #{i}\n"
raise if i == 2
yield i.to_s
ensure
puts "CLOSE: #{i}\n"
end
こうなる。
OPEN: 3
OPEN: 2
CLOSE: 2
CLOSE: 3
raii_array.rb:3:in `raii_open': unhandled exception
もちろん最終処理で例外が発生しても終了処理はきちんと呼ばれる。
def finish(brr)
raise
end
OPEN: 3
OPEN: 2
OPEN: 1
CLOSE: 1
CLOSE: 2
CLOSE: 3
raii_array.rb:9:in `finish': unhandled exception
めでたしめでたし。
…まあ継続渡しは Ruby ではあまり見かけない手法なので、本当にこれがいいかどうかはまた別の話ではあるが。