Fiber と Proc ―― 手続きを抽象化する二つの機能

Fiber と Proc ―― 手続きを抽象化する二つの機能

はじめに

最近、複数のプログラミング言語を学ぶことが重要だと良く耳にするようになりました。

たとえば書籍「プログラマが知るべき 97 のこと」の「43. プログラミング言語は複数習得すべき」において、Russel Winder 氏は次のように書き、複数のプログラミング言語 (それも異なるパラダイムに属する言語) を学ぶことを勧めています。

プログラミング言語のパラダイムは大きく、手続き型、オブジェクト指向型、 関数型、論理型、データフロー型などに分類することができます。 2番目に学ぶ言語のパラダイムが最初の言語と同じであれば習得は楽ですが、 パラダイムが違っていると、習得は困難になります。

しかし第二の言語には、是非とも、最初の言語とはパラダイムの違う言語を 選ぶべきです。それはなぜかというと、パラダイムの違う言語を学ぶと、 アルゴリズム、イディオム、パターンの実装について嫌でも考えるように なるからです。同様のアルゴリズムを実装するにしても、色々なやりかたが あり得ることに気づきます。この体験が、プログラマの技術を大きく向上させます。

そして 2007 年に行われた「日本 Ruby 会議 2007」のキーノートスピーチ「The Island of Ruby (島国としてのRuby) 」にて、Dave Thomas 氏は次のように述べました。

Rubyはいくつものパラダイムを許容する言語だ。 オブジェクト指向的にだけではなくて、手続き的にも書けるし、 プロトタイプベースのようにも書ける。 試してみると面白いよ。 関数型のようにだって書ける。副作用が無いわけじゃないけれど。 こういうことができるといろいろと良いことがある。

Ruby では異なるパラダイムのプログラミング言語を学ぶことで得られた新しい知見を生かすことができます。今回の記事では Ruby の Fiber と Proc という「手続きを抽象化する二つの機能」を「手続き型言語」と「関数型言語」という視点で解説し、その考え方の違いを見ていきたいと思います。

手続き型言語と関数型言語

先ほども出てきた通り、Ruby は「オブジェクト指向言語」だと言われていますが、「手続き型言語」や「関数型言語」のようにプログラムを書くこともできます。Fiber と Proc の説明に入る前に、手続き型言語と関数型言語はどのようなものなのかを見てみることにしましょう。

「手続き型言語」とは、プログラムを書いた順番通りに実行していくプログラム言語のことです。たとえば次のような「結婚披露宴の次第」は手続き型言語と考えることが出来ます。

  1. 招待客の入場
  2. 新郎新婦の入場
  3. 主賓の祝辞
  4. 乾杯
  5. 食事・歓談
  6. ケーキ入刀
  7. ・・・

結婚披露宴の当日は、上記の次第に書かれた通りに実行されます (ハプニングがあるかもしれませんが‥‥) 。

「関数型言語」とは、プログラムを関数の集合と考えるプログラム言語のことです。プログラムは関数に引数を与え、その戻り値をまた次の関数の引数として与える‥‥という形で実行されます。

たとえば自動販売機 (vendingMachine) を関数で表すとしたら、「お金をいくら入れたか (Money)」「どのボタンを押したか (Switch)」を引数として取り、「おつり (Money)」「商品 (Product)」を返す関数として定義することができます。

例として関数型言語のひとつである Haskell というプログラミング言語では、次のようにして関数の引数と戻り値を定義できます。

vendingMachine :: (Money, Switch) -> (Money, Product)

上記の説明を聞いても、何だかよく分かりませんよね。では実際に「手続き型言語」と「関数型言語」のそれぞれで、どのような発想でプログラムが書かれていくのか、Ruby のコードで実際に実装しながら見ていきましょう。

フィボナッチ数列

突然ですが、ここでクイズです。

フィボナッチ数列 (0, 1, 1, 2, 3, 5, 8, ...) とは、次のような数列です。

  • fib(0) は「0」
  • fib(1) は「1」
  • 2 以上の整数 n に対して、fib(n) は「fib(n - 1) + fib(n - 2)」

つまり、fib(2) 以降は「ひとつ前の数とふたつ前の数を足したもの」になります。

  • fib(2) は「0 + 1」なので「1」
  • fib(3) は「1 + 1」なので「2」
  • fib(4) は「1 + 2」なので「3」
  • fib(5) は「2 + 3」なので「5」
  • fib(6) は「3 + 5」なので「8」
  • ‥‥

では問題です。

Q. 任意の n 番目のフィボナッチ数列の値を求めるメソッド fib(n) を書いてください。

つまり、次のようなファイル fib.rb を書いて、

   1|def fib(n)
   2|  # ここを実装する
   3|end

次のようなテストケース test_fib.rb を満たすような fib メソッド実装してください。

   1|require 'fib'
   2|require 'test/unit'
   3|
   4|class TestFib < Test::Unit::TestCase
   5|  def test_fib
   6|    assert_equal(0, fib(0))
   7|    assert_equal(1, fib(1))
   8|    assert_equal(1, fib(2))
   9|    assert_equal(55, fib(10))
  10|  end
  11|end

テストが成功すると、次のような実行結果になるはずです。

$ ruby test_fib.rb
Loaded suite test_fib
Started
.
Finished in 0.000377 seconds.

1 tests, 4 assertions, 0 failures, 0 errors
$

では、この問題を「手続き型言語の発想」と「関数型言語の発想」で実装してみましょう。

手続き型言語の発想

ひとつ前の数とふたつ前の数を足した結果を求めると次の値が分かるから、ふたつ前までの数を覚えながら、次々に足していけばいいんじゃないかな?

ええと、じゃあまず 0 番目と 1 番目で「ひとつ前の数 (b)」と「ふたつ前の数 (a)」を変数に代入して初期化しよう。

   1|a, b = 0, 1

次に、ひとつ前の数 (b) とふたつ前の数 (a) を足した結果を求めると、もうひとつ先の値が求められるね。なので「a + b」を新しい「b」に代入すればいいかな。そうそう、さらに先を求めるときに「ひとつ前の数」も覚えておく必要があるから、古い「b」は「a」に代入して保存しておこう。

   1|a, b = 0, 1
   2|
   3|a, b = b, a + b

あとはこれを n 回繰り返せばいいよね。カウンタ (i) を用意して、ループごとにカウントアップして n より大きくなるまで繰り返せばいいかな。

   1|a, b = 0, 1
   2|i = 0
   3|while i < n
   4|  a, b = b, a + b
   5|  i += 1
   6|end

最後に結果 a を返せば完成だね。

   1|def fib(n)
   2|  a, b = 0, 1
   3|  i = 0
   4|  while i < n
   5|    a, b = b, a + b
   6|    i += 1
   7|  end
   8|  a
   9|end
関数型言語の発想
  • fib(0) は「0」
  • fib(1) は「1」
  • 2 以上の整数 n に対して、fib(n) は「fib(n - 1) + fib(n - 2)」

って定義が書かれているんだから、そのまま定義を書き出してみればいいんじゃないかな。

n が 0 のとき、1 のとき、2 以上のときで場合分けしよう。場合分けは case を使えばいいよね。

   1|case
   2|when n == 0 then 0
   3|when n == 1 then 1
   4|when n >= 2 then fib(n - 2) + fib(n - 1)
   5|end

あとはこれをメソッドとして定義してあげればとりあえず動く*1ね。

   1|def fib(n)
   2|  case
   3|  when n == 0 then 0
   4|  when n == 1 then 1
   5|  when n >= 2 then fib(n - 2) + fib(n - 1)
   6|  end
   7|end

さて、手続き型言語と関数型言語とで、発想の違いを感じていただけたでしょうか?

手続き型言語の発想だと、プログラマはコンピュータが逐一どのように実行していくかを考えながらソースコードを書いていくことになります。今回の場合だと 「ループ」とか「カウンタ」とかが必要だ、というのは人間が判断してプログラムを書いていることになります。

それに比べて関数型言語の発想だと「定義を書いたからあとはコンピュータ頑張ってね」という発想であるのが分かると思います。計算の際にループしようとかカウンタがどうとかは全く考えません。

FizzBuzz

もうひとつ問題です。

Q. 1 から 100 までの数をプリントするプログラムを書け。ただし 3 の倍数のときは数の代わりに「Fizz」と、5 の倍数のときは「Buzz」とプリントし、3 と 5 両方の倍数の場合には「FizzBuzz」とプリントすること。

この問題は「Fizz-Buzz問題」という有名な問題で、一時期話題になったので知っている方も多いと思います。

では、この問題も「手続き型言語の発想」と「関数型言語の発想」それぞれで実装してみましょう。

手続き型言語の発想

1 から 100 までの数をプリントするんだから、ループしてプリントすればよさそうだよね。

   1|i = 1
   2|while i <= 100
   3|  puts i
   4|  i += 1
   5|end

3 の倍数のときは「Fizz」、5 の倍数のときは「Buzz」、3 と 5 両方の倍数の場合には「FizzBuzz」とプリントするのだから、「puts i」のところを if 文で条件分岐すればいいかな? 15 の倍数 (つまり 3 と 5 の倍数)、5 の倍数、3 の倍数それぞれで分岐すればいいよね。

   1|i = 1
   2|while i <= 100
   3|  if i % 15 == 0 
   4|    puts 'FizzBuzz'
   5|  elsif i % 5 == 0
   6|    puts 'Buzz'
   7|  elsif i % 3 == 0
   8|    puts 'Fizz'
   9|  else
  10|    puts i
  11|  end
  12|  i += 1
  13|end
関数型言語の発想

1 から 100 まで出力せよ、と言っているので、Range を Array に変換して出力すればいいよね。

   1|puts (1..100).to_a

3 の倍数のときは Fizz、5 の倍数のときは Buzz、3 と 5 の倍数のときは FizzBuzz を出力するのだから、変換ルールの関数を書いて map すればいいよね。変換ルールの関数は、15 の倍数 (つまり 3 と 5 の倍数)、5 の倍数、3 の倍数それぞれで場合分けすればいいけど、あとで考えよう。

   1|puts (1..100).to_a.map {|x|
   2|  # 3と5の倍数のときは FizzBuzz、
   3|  # 5の倍数のときは Buzz、
   4|  # xが3の倍数のときは Fizz に変換する関数
   5|}

関数の中身は「複数の場合の場合分け」だから、case を使えばいいよね。

   1|puts (1..100).to_a.map {|x|
   2|  case
   3|  when x % 15 == 0 then 'FizzBuzz'
   4|  when x % 5 == 0 then 'Buzz'
   5|  when x % 3 == 0 then 'Fizz'
   6|  else x
   7|  end
   8|}

いかがでしたか。

先ほど同様、手続き型言語の発想は「プログラムは順番に実行されていくもの」なので、「ループしながら条件を変えつつ出力していく」というものですね。それに比較して関数型言語だと「ある構造を持ったデータ (ここでは 1 から 100 までの整数) に対して、変換ルール (関数) をそれぞれに適用する (map メソッド) 」という発想になり、「ループ」という発想が全くないことが分かります。

Fiber と Proc

だいぶ前置きが長くなってしまいましたが、ようやくここからが本題の Fiber と Proc の解説となります。

Fiber は Ruby 1.9 で新しく導入されたクラスで、「これから起こること」を抽象化したものです。と言っても何のことか分からないと思いますので、実際にコードを書いてみることにしましょう。

   1|Fiber.new do
   2|  # 処理
   3|end

と書くと、「処理」の部分を「これから起こること」としてオブジェクト (Fiber クラスのインスタンス) を生成します。そしてその「処理」を実行したい場合、Fiber#resume メソッドを使います。また実際にコードを書いてみましょう。

   1|fi = Fiber.new do
   2|  # 処理
   3|end #=> 「処理」が Fiber クラスのインスタンスとして抽象化され、変数 fi に代入される
   4|
   5|fi.resume #=> 処理が実行される

Proc も「これから起こること」をオブジェクト (Proc クラスのインスタンス) として抽象化したものです。たとえば、

   1|Proc.new do
   2|  # 処理
   3|end

と書くことで、先ほどの Fiber と同様、「処理」の部分を「これから起こること」としてオブジェクト化することができます。Proc の場合は、「処理」を実行したい場合、Proc#call メソッドを使います。

   1|pr = Proc.new do
   2|  # 処理
   3|end #=> 「処理」が Proc クラスのインスタンスとして抽象化され、変数 pr に代入される
   4|
   5|pr.call #=> 処理が実行される

こうして見ると、どちらも「手続き (これから起こること) 」を抽象化するという点で同じようなもののように見えるかもしれません。ところが、Fiber と Proc では決定的に違うことがあります。Fiber で抽象化した「処理」は「処理」の中で Fiber.yield が呼ばれたタイミングで処理が終了し、再度呼び出した場合は Fiber.yield の呼び出しの続きから「処理」が再開されるのに対して、Proc で抽象化した「処理」は何度呼び出してもその「処理」の最初から実行されます。

これも言葉で聞いても何のことか分かりづらいと思いますので、実際のコードを見て Fiber と Proc がどのような挙動をするのかを見てみましょう。まずは Fiber の例です。

   1|fi = Fiber.new do |first|
   2|  second = Fiber.yield("#{first}!")
   3|  "#{first}, #{second}!"
   4|end
   5|
   6|puts fi.resume('Hello') #=> "Hello!" と出力
   7|puts fi.resume('World') #=> "Hello, World!" と出力

変数 fi には、Fiber.new により do から end までの処理を抽象化した Fiber オブジェクトが代入されます。そして最初に文字列 'Hello' を引数にその Fiber オブジェクトのインスタンスメソッド resume を呼び出すと、ブロックの引数 first に 'Hello' が代入され、ブロックの実行が開始します。

そして Fiber.yield が実行されたタイミングで resume メソッドの呼び出し元に処理が戻り、戻り値は Fiber.yield の引数 "#{first}!" すなわち "Hello!" になります。

次に文字列 'World' を引数にまた resume メソッドを呼び出すと、次は先ほどの Fiber.yield メソッドの戻り値が 'World' の状態で処理が再開されます(つまり変数 second に 'World' が代入される)。次はもう Fiber.yield メソッドが呼ばれることはありませんので、ブロックの最後の評価値である "#{first}, #{second}!" すなわち "Hello, World!" を戻り値として呼び出し元に戻ります。

次に Proc の例です。

   1|pr = Proc.new do |text|
   2|  "Hello#{text}"
   3|end
   4|
   5|puts pr.call('!') #=> "Hello!" と出力
   6|puts pr.call(', World!') #=> "Hello, World!" と出力

変数 pr には、Proc.new により do から end までの処理を抽象化した Proc オブジェクトが代入されます。そして最初に文字列 '!' を引数にその Proc オブジェクトのインスタンスメソッド call を呼び出すと、ブロック引数 text に '!' が代入され、ブロックの実行が開始します。そしてブロックの最後の評価値である "Hello#{text}" すなわち "Hello!" を戻り値として呼び出し元に戻ります。

次に文字列 ', World!' を引数にまた call メソッドを呼び出すと、ブロック引数 text に ', World!' が代入され、またブロックの最初から実行されます。そしてブロックの最後の評価値である "Hello#{text}" すなわち "Hello, World!" を戻り値として呼び出し元に戻ります。

乱暴にまとめてしまうと、Fiber は「手続きと、それをどこまで実行したか」をオブジェクトにしたもので、呼び出すたびに「どこまで実行したか」の状態が少しずつ進んでいくため、プログラムを順番に実行していく手続き型言語の発想をオブジェクト指向言語に持ち込んだものと考えることができます。対して Proc は「一連の手続き」をオブジェクトにしただけのものなので、呼び出すときの引数は変更できますが、手続き自体は毎回最初から実行されます。言わば、何度でも評価できる「関数」のようなもの(後述しますが、厳密には関数とは違うものです)をオブジェクトにしたものなので、関数型言語の発想をオブジェクト指向言語に持ち込んだものと考えることができます。

Fiber によるフィボナッチ数列

では、先ほどのフィボナッチ数列を Fiber を使って実装してみましょう。といっても全く同じ問題では Fiber のありがたみが分かりづらいと思いますので、問題を次のように変えてみました。

Q. フィボナッチ数列の値を fib(1) から fib(10) まで、1 秒ごとに順に出力するプログラムを書いてください。

回答は次のようなプログラムになります。

   1|fib = Fiber.new do
   2|  a, b = 0, 1
   3|  loop do
   4|    a, b = b, a + b
   5|    Fiber.yield(a)
   6|  end
   7|end
   8|
   9|10.times do
  10|  puts fib.resume
  11|  sleep(1)
  12|end

プログラムを見ていただけると分かると思いますが、Fiber.yield メソッドを無限ループの中で呼ぶことで、無限ループ処理を抽象化し、処理を少しずつ実行することができています。言い換えると、Fiber を使うと、手続き型言語によるフィボナッチ数列の計算方法はそのままで、フィボナッチ数列を算出する処理をオブジェクトとして抽象化することができます。

Fiber による FizzBuzz

FizzBuzz 問題のほうも 1 秒ごとに順に出力するバージョンを Fiber を使って実装してみましょう。

   1|fizzbuzz = Fiber.new do
   2|  i = 1
   3|  loop do
   4|    if i % 15 == 0 
   5|      Fiber.yield('FizzBuzz')
   6|    elsif i % 5 == 0
   7|      Fiber.yield('Buzz')
   8|    elsif i % 3 == 0
   9|      Fiber.yield('Fizz')
  10|    else
  11|      Fiber.yield(i)
  12|    end
  13|    i += 1
  14|  end
  15|end
  16|
  17|100.times do
  18|  puts fizzbuzz.resume
  19|  sleep(1)
  20|end

それでは最後に、フィボナッチ数列と FizzBuzz の合わせ技の問題です。

Q. 先ほどのフィボナッチ数列と FizzBuzz を、交互に 5 つずつ 100 個まで出力するプログラムを書いてください。

このような「処理を途中で止めて別の処理をする」といった「状態」を管理する用途に Fiber は向いています。回答例は次のようになります。

   1|fizzbuzz = Fiber.new do
   2|  i = 1
   3|  loop do
   4|    if i % 15 == 0 
   5|      Fiber.yield('FizzBuzz')
   6|    elsif i % 5 == 0
   7|      Fiber.yield('Buzz')
   8|    elsif i % 3 == 0
   9|      Fiber.yield('Fizz')
  10|    else
  11|      Fiber.yield(i)
  12|    end
  13|    i += 1
  14|  end
  15|end
  16|
  17|fib = Fiber.new do
  18|  a, b = 0, 1
  19|  loop do
  20|    a, b = b, a + b
  21|    Fiber.yield(a)
  22|  end
  23|end
  24|
  25|20.times do
  26|  5.times do
  27|    puts fib.resume
  28|  end
  29|  5.times do
  30|    puts fizzbuzz.resume
  31|  end
  32|end

Proc によるフィボナッチ数列と FizzBuzz

先ほど Proc は「関数のようなもの」と説明しましたが、実は Proc は純粋な関数ではなくクロージャです。なので、クロージャのレキシカル変数を使うことで「状態」を扱うこともできます。これにより、Proc でも次のように手続き型言語の発想でフィボナッチ数列や FizzBuzz を実装することができます。それでは、先ほどのフィボナッチ数列と FizzBuzz を交互に 5 つずつ 100 個まで出力するプログラムを Proc で実装してみましょう。

   1|def fizzbuzz_maker
   2|  i = 0
   3|  Proc.new do
   4|   i += 1
   5|   case
   6|   when i % 15 == 0 then "FizzBuzz"
   7|   when i % 5 == 0 then "Buzz"
   8|   when i % 3 == 0 then "Fizz"
   9|   else i
  10|   end
  11|  end
  12|end
  13|
  14|def fib_maker
  15|  a, b = 0, 1
  16|  Proc.new do
  17|    a, b = b, a + b
  18|    a
  19|  end
  20|end
  21|
  22|fizzbuzz = fizzbuzz_maker
  23|fib = fib_maker
  24|
  25|20.times do
  26|  5.times do
  27|    puts fib.call
  28|  end
  29|  5.times do
  30|    puts fizzbuzz.call
  31|  end
  32|end

fizzbuzz_maker メソッドは、FizzBuzz の状態をレキシカル変数 i に保持した Proc オブジェクトを生成します。同様に、fib_maker メソッドは、フィボナッチ数列の算出過程をレキシカル変数 a, b に保持した Proc オブジェクトを生成します。そうして生成された Proc オブジェクトは、一連の手続き自体は毎回同じですが、call メソッドを呼ばれる度にレキシカル変数が変化するため、先ほどの Fiber のように計算を少しずつ進めることができています。しかし Proc のレキシカル変数の考え方は、Fiber の「処理がどこまで進んだか」という考え方と比較すると、直感的に理解するのは難しいのではないでしょうか。

手続き型言語の発想、関数型言語の発想、それぞれの発想にはそれぞれの良さがあり、ぴたっとはまる用途はケースバイケースです。ひとつの発想をむりやり当てはめるよりも、発想の引き出しをたくさん持っている方が優れていることは言うまでもありません。冒頭に出てきた複数のプログラミング言語 (それも異なるパラダイムに属する言語) を学ぶことのメリットは、まさにここにあると言えるでしょう。

終わりに

今回は「手続きの抽象化」という視点から Fiber と Proc の解説をしてみました。Proc は日常的に使っている方でも、Ruby 1.9 からの新機能である Fiber はなじみのない方が多いのではないでしょうか。

より実践的な Fiber の使い方は、Fiber が使われているソースコード (たとえばチャットサーバなど) を探して読んでみるのが勉強になります。良さそうなコードがありましたら、ぜひ教えてください。

著者について

郡司啓(@gunjisatoshi)

Asakusa.rb のすみっこで Twitter 実況中継する係りの人。Asakusa.rb は毎週火曜日 19:30 頃から開催されていますので、ご興味のある方でお近くをお通りの際は是非お立ち寄りを。


*1 もちろんこの実装は無邪気に過ぎるのですが (末尾再帰とかメモ化とかの最適化や、引数チェックなどの例外処理とかを考慮していない) 、今回の話の本質ではないのでそのあたりの説明は省略します。興味のある方は調べてみると面白いでしょう。