Ruby の配列をまとめて RAII 処理する


2016年 09月 09日

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 節では何も解放されない。

RAII を諦めて 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 の保存

こういう処理を書こうとするとうっかり popshift を使ってしまいがちだが、これは呼び出し元の配列を破壊してしまう。結果を回収する 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)

こうしてみると fristdropArray ではなく Enumrable のメソッドである。ところが残念なことに empty?Array のメソッドであり、 Enumerable ではない。ここをどうにかできると引数 arrArray であることに制限されず、 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 ではあまり見かけない手法なので、本当にこれがいいかどうかはまた別の話ではあるが。