Haskell vs Ruby
(2008.9.25 新規作成)
刺激的なタイトルにしてみましたが、どちらがいいとか悪いとか、という話ではありません。あしからず。
Haskell の特徴を調べるために、手続き型言語のRubyと比較してみようと思います。
ループ
変数の再代入やオブジェクトの破壊的な変更ができない Haskell ではループが書けません。
はるか昔、goto文が悪とされ、構造化プログラミングとしてgotoを使わないようになりました。goto文があればどのようなフローでも書けましたが、それに代わってwhileやforでループを書くようになりました。不自由ですが、ソースコードが明瞭になり、他人が書いたものでも読みやすくなりました。
変数への再代入が禁止されることで、ある変数が参照する値が勝手に変わることもなく、よりプログラムが読みやすくなります。ちょっと私はまだその境地には達していませんが。
ループのようなことは再帰で書きます。Haskell (に限らずたいていの最近の言語) では、再帰は可能であれば、gotoと同じ動作に変換されるので、効率も悪くありません。
- fn x = if x == 1 then 1
- else x * fn (x - 1)
- main = print $ fn 10
Rubyでループを使って書くとこうでしょうか。
- def fn x
- r = 1
- for i in 2..x do
- r *= i
- end
- r
- end
- puts fn(10)
こんなに短いとどちらが読みやすいも何もないですが、規模が大きくなると、変数の値が一つに決まるというのは安心感がある、かもしれません。
高階関数
関数型プログラミングでは高階関数を使いまくります。
リストの各要素に対して関数を適用してその結果のリストを作る、リストの要素に関数を適用して真になったものだけでリストを作る、リストを畳み込む、などなど、様々なパターンがあらかじめ組み込み関数などで用意されていて、その引数として自分で書いた関数を与えます。
例えば filter 関数は、最初の引数として関数を取り、その関数が真を返すリストを返します。
- fn x
- | x `mod` 3 == 0 = True
- | otherwise = False
- main = print $ take 5 $ filter fn [1..]
実行結果:
[3,6,9,12,15]
Rubyではブロックを使います。Rubyのブロックは、メソッドの引数として関数(クロージャ)を渡すためのシンタックスシュガーです。
- p (1..15).select {|x|
- x % 3 == 0
- }
Rubyでもブロックを使いまくりますし、この辺は差が出ません。ただ、打ち切り条件の書き方に違いがあります。
内側と外側の逆転
Haskell は遅延評価 (lazy evaluation) で式を評価(実行)します。遅延評価は、データのうち、実際に必要になった部分しか使われません。
ループというかコードの内側で無限リストを使い、ループの外側に打ち切り条件を書く、ということができます。
例えばリストの最初の5つを得るときは、次のように書きます。
- main = print $ take 5 [1..]
take によって必要とされる最初の5要素だけが評価されます。lazyでない、引数を先に評価する言語ではこれは完了しません。
Rubyだとこうです。
- r = []
- i = 0
- while true
- r << (i += 1)
- break if i >= 5
- end
- p r
あまりにも短いので作為的ですが、とにかく、ループの内側に脱出条件を書く、というのがポイント。
私が Haskell を練習していたとき、このような逆転で、腸が捩れるような感覚になりました。
例題:素因数分解
少しだけ大きな題材として、大きな数の素因数分解をしてみます。
問題文は Project Euler から持ってきました (Problem 3)。
The prime factors of 13195 are 5, 7, 13 and 29. What is the largest prime factor of the number 600851475143 ?
正の整数nを素因数分解するごく簡単な方法は、2から√n までの素数で割り切れるかどうか試すものです。
素数は、エラトステネスの篩(ふるい)で生成できます。
Rubyで書いた1回目。これはダメすぎな例です。
- number = 600851475143
- # エラトステネスの篩 (ふるい) で素数リストを作成
- def prime_while max
- rest = Array.new(max - 1) {|i| i + 2}
- max_s = Math.sqrt(max).floor
- while (prime = rest.shift)
- return if !(yield prime)
- rest.delete_if {|i| (i % prime) == 0}
- break if prime > max_s
- end
- rest.each {|x|
- return if !(yield x)
- }
- end
- puts Time.now
- x = number
- prime_while (Math.sqrt(number).floor) {|pn|
- break if x == 1
- while x % pn == 0
- print "#{pn} "
- x /= pn
- end
- true
- }
- print "\n"
- puts Time.now
私の機械で、ちょうど1分ぐらい掛かりました。
次は Haskellで書いたもの。
- number = 600851475143
- -- number = 34
- {- (1) 素数を求める
- 無限リストを用意し、2 を取り除いて、
- 2で割り切れないのだけ残したリストを作って、
- 再帰的に呼び出す。
- -}
- primes' (p:xs) = p : (primes' $ filter (\x -> x `mod` p /= 0) xs)
- primes :: [Integer]
- primes = primes' [2..]
- {- (2) 素因数分解する
- 単に、割って、再帰呼び出しする
- -}
- facto :: Integer -> [Integer] -> [Integer]
- facto n (pn:pns)
- | n == 1 = []
- | floor (sqrt (fromInteger number)) < pn = [n]
- | n `mod` pn == 0 = pn : (facto (n `div` pn) (pn:pns))
- | otherwise = facto n pns
- main = do
- print $ facto number $ primes
非常に短くなりました。
Haskell が遅延評価する、というところが重要で、素数を求める部分は無限リストを返しますが、効率が悪くなることがありません。primes' にも無限リストを与えていますが、これも大丈夫です。
実行すると一瞬で答えが出ます。
ただし、これをもってHaskellのほうが速い、というのは早計です。Ruby版は、篩(ふるい)のために 775,000 の要素を持つ配列を作っています。しかも素数を求める過程で詰めたりしています。
次のようにプロファイルを取ってみると、Array#initialize がほとんどの時間を占めています。アホです。
$ ruby -rprofile 003.rb
では、どうするのがいいでしょうか。
素数かどうか不明な配列をあらかじめ用意するのではなく、素数かどうか調べたい数値について、素数の倍数でないか試し、当たらなかったら新しい素数です。効率化のために、素数の次の倍数にマークを付けるのがよさそうです(テストしたときにマークする先を替えていく)。
クロージャを使って面白い書き方をされている方がいましたので、貼り付けます(少し修正しました)。
- class MyPrime
- include Enumerable
- def initialize
- @hash = {}
- @n = 1 # 直前の素数
- end
- # 次の素数を返す
- def succ
- n = @n
- while true
- n += 1
- if (s = @hash[n])
- s.call()
- @hash.delete(n)
- else
- # n は素数
- i,j = n,n
- f = Proc.new {
- k = i + j
- if (g = @hash[k])
- g.call()
- end
- @hash[k] = f
- j += n
- }
- f.call()
- @n = n
- return n
- end
- end
- end
- alias next succ
- def each
- g = MyPrime.new
- while true
- yield g.succ
- end
- end
- end
- x = 600851475143
- p Time.now
- gen = MyPrime.new
- while pn = gen.next
- break if x == 1
- while x % pn == 0
- print "#{pn} "
- x /= pn
- end
- end
- print "\n"
- p Time.now
今度は一瞬です。
比べてみると、
- Haskell のほうがずいぶん短いコードになった。
- Rubyではパフォーマンスを考えて作り込まなければならない。もちろん作り込めばRubyのほうが速いコードが書けるが、このぐらいの問題だとHaskellでは何も考えなくてもコンパイラがそこそこのコードを出力する。
問題の種類にもよるのでしょうけど、Haskell のほうが効率よく書ける可能性がありそうです。