はじめに
First of all, thank you for your post and generosity, Wuest!
限定共有していましたが原著者から許可を頂けたので、全体公開させて頂きます。
この投稿はTina Wuest氏による「Demystifying the IO Monad for Rubyists」というブログ記事の翻訳文です。この記事ではRubyで関数型プログラミングの難所と言われる「モナド」の概念を学ぶことができますが、英語でかつ長文のため翻訳があると便利なので本稿を書きました。
この記事を通じてモナドの概念を初めて学んだので、訳に間違いや改善点があればご指摘頂ければ幸いです。
以下、翻訳文となります。
Rubyistに贈るIOモナド謎解き講座
関数型言語を扱うプログラマ達にはそのキャリアにおいて、ひどいサイクルが訪れます。モナドは初めは混乱しがちなので、彼らはモナドとは実はとても簡単だと解説する沢山の記事を読むでしょう--しかし、それらの記事は自己言及的な言葉が並んでおり、教えるという目的を全く達成することなく彼らをさらに混乱させてしまうのです。
やがて(ポインタやリファレンス渡し、その他の多くの概念がそうであるように)モナドがいつかピンとくる時が来たら、プログラマは幸福と生産性を手にします。
数年ほどこの理解に基づいて自身が仕事をしたり、他のプログラマが理解するのを手助けした後に、最終的にはそのプログラマはモナドが肉とチーズと野菜をパンに挟み込んだ実に美味しい代物だと語る文章を書くようになるのです。
これでこのサイクルは完了し、また繰り返します。
この記事は関数型プログラミングの悪の権化との契約を終え、私自身のサイクルを完了させる事でしょう。そしてこの記事が読者の皆さんにとっても色んな意味で役に立つことを願っています。
私は数年間Haskellを書いていました-この言語について手に入る資料には問題があったので理解に時間がかかりました:詳細をぼやかした入門資料や、高度なトピック(XMonadのようなよく利用されるコードベースはいうまでもなく)を学ぶための上級者向け資料なら沢山ありましたが、入門資料を終えたプログラマ達がその先へ堀り進めていくための道筋を見つけるのは困難でした。
私のHaskellとの冒険は2007年に突然終わりました。それは表面上は動作するプログラムが書けても、何故そうする必要があるのか良く分かっていない事が沢山あったためです。この事が私を悩ませたのです。
そこで私はもっと自分にとって楽なプログラミング言語でソフトウェアを書き続けました- Ruby、Perl、C言語 - そして数年後にはErlangを見つけました。
メーリングリストに短期間参加したことがきっかけで、Haskellについて自分が何を分かっていないのかを掘り下げようと決意したのは2013年になってからでした。
これから読者の皆さんに幾つかご理解頂きたい事があります。まず第一にこれはRubyに関する記事なので、紹介するあらゆる概念はRubyのコードで解説します。
Haskellでよく用いられる概念に依る例もあるでしょうが、それらの全てについてRubyの用法で同様に説明できる事をお約束します。
この記事は長文で沢山の情報を含んでいますが、とても広いブラシで塗っていきます。その狙いは網羅的であることでなく必要十分に留めておくこと、そしてより深く学びたい読者にとって手始めであることです。
Haskellでのプログラミングでコアとなる概念が、よりメンテナンスしやすいRubyプログラムを書く事に応用することを理解できると同時に、まだRubyの学習過程にある読者はうまくいけばRubyとHaskellの両方の言語への理解が深まることでしょう。
分かりやすくするために例に示すコードでは、より慣用的な&:symbol
構文を例え明らかにそちらを選択すべきケースであっても、あえて長文のブロックを渡す構文を用いています。
##Let's Talk About Arrays
では早速飛び込んで行きましょう!配列についての解説は必要ないですね:
データの集合を扱うことは他の事と同様にプログラミングで大事な事です。Rubyでは異なる種類のオブジェクトの配列が使えますが、ここでは簡単のために同じ種類のオブジェクトの配列に限定して考えます。配列でデータを操作する方法で最も一般的なものはmap
です:
[1, 2, 3, 4].map { |int| int.to_s } # => ['1', '2', '3', '4']
このコードは簡潔で、かつデータのリストを扱う際に必要な事の多くを網羅しています。私たちは集合に対して関数をマップし、要素のそれぞれに対し処理を実行できます。
この種のパターンはよく共通の_形_を持ちますが、私たちがRubyの世界で欠いているのはパターンがどのようなものかを具体的に述べる方法です。
この目標に向けて、Haskellでこの問題を取り扱う方法を紹介します:型シグネチャを用います。上述のmapついて言えば、こんな感じになります:
[Fixnum] -> [String]
これはとても明白で、「Fixnum
の配列をString
の配列に変換せよ」という事を意味します。
この特定のコールについてmap
として議論できるのは良いことです -- しかしこのコールをmap
としてしか議論できません。
そこでこの特定の型を型変数に置き換えてみましょう:
[a] -> [b]
これはmap
が受け取る関数のベターな記述方法です:私たちは何らかの型の配列を渡して、何らかの型の配列に変換するのです(同じ型かもしれないし、違っても良い)。
次にこの関数を括弧で隔離し、私達の型シグネチャが引数にとる配列と出力する配列を加え、「aからbへの関数とaの配列を引数に取って、bの配列を出力する」と読み取れるようにしておきましょう。
(a -> b) -> [a] -> [b]
それでは関数の「形」について議論できるようになったので、map
関数をもう一度見てみましょう。データの集合に関数をマップできる事はとても便利なので、この原則を共有できる型について議論する事ができれば良いですね。
そうすればいかなる種類のデータの入れ物についても議論することができます。それが配列、ハッシュ、木構造、または単一の値を入れる箱であってもです。もし内包しているデータに関数をマップできるのなら、それはこのグループの一員です。この型のグループには名前があります - この型はFunctorと呼ばれます。
驚くべきものではないことを確かめるため、新しくFunctorになるものについて幾つかの基本ルールを決めましょう。
- Functorに対して同一性を持つ関数(つまり、渡された値をそのまま返す関数)をマップした場合は、Functorそれ自身のデータは変更されるべきでない。Rubyでは全てのオブジェクトが
itself
という同一性を持つ関数を持っている。 - Functorに対して
f(g(x))
をマップした結果は、g(x)をマップした結果にf(x)をマップした結果と同じになること。
これら2つのルールは1つのとても大事な原則に要約されます:Functorは自身が内包しているデータを変更してはならないという事です。
何といっても、例えば配列を利用する際に内部のデータが変更されていないと信用できなければ、何らか他の方法でデータの集合を扱わなければならないのですから。
Let's Talk About Maybe
配列から離れて、私たちがRubyでよく出くわすのは、ある処理の結果が値またはnil
となるケースです。これに対しては処理の前にnil
チェックを行うものから、nil
が潜んでいるかもしれないとして全ての計算の先頭で明示的にキャストするものまで様々な対応策があります。
ほかの点ではRubyは素晴らしいと確信できる事とは対象的に、この事はコードベースにちょっとした不安定さをもたらします。次の例を見てみましょう:
# 比較できる値の配列を降順にソートし、最大値を取得する
def highest_number(array)
sorted = array.sort { |x, y| y <=> x }
sorted.first
end
これを型シグネチャを使って説明する方法を見つけるのは困難です。私たちは比較できる要素からなる配列を期待していますが、このメソッドは空の配列が与えられればnil
を返すのです。そのため例え私たちがFixnumのみを考えることにしても、Fixnumはnilでなく、nilはFixnumではないのです。この関数を説明できるようにするために、新しい入れ物を考えましょう:値を含んでいるかもしれないし、まったくの空っぽなのかもしれない入れ物。これをMaybe
と呼びましょう。この新しいデータ型を使って、highest_number
メソッドを表せます:
[Comparable] -> Maybe Comparable
ほら書けた!しかしnilチェックをし続けなくてもいいようにするには、どのようにMaybe
型を実装すればよいのでしょう?先に進む前に、頑張って上記のカプセル化を行う新しいMaybe Functorを作成してみて下さい。そのクラスは包含する値に対してマップするメソッドを持っています(私はこのfmap
を「Functor map」と呼んでいます)。inspect
メソッドを実装することもあなたが利用するREPLのタイプによっては便利かもしれません。比較的シンプルな解決策はこんな感じでしょうか:
# nilかもしれない値をカプセル化し、条件的に必要な計算を行う
class Maybe
def initialize(val)
@_val = val
end
# 中身の値がnilでなければ取り出して処理を行い、
# まっさらなコンテクストに結果を詰めて返す
def fmap
return self if @_val.nil?
self.class.new(yield @_val)
end
# irb/pryでMaybeを使う際に便利な関数
def inspect
return 'Nothing' if @_val.nil?
'Just ' + @_val.inspect
end
end
これで私達はこのMyabe
コンストラクタをコードからnilチェックを取り除くために利用できます!Railsでの開発経験のあるプログラマならおそらくこのMaybe
とRailsがtry
メソッドでオブジェクトの操作を試みるという考え方に近い物を感じるでしょう。
# Rails
array_of_numbers
.detect { |n| n.even? }
.try { |n| n + 1 }
.try { |n| n.to_s }
# Maybe
Maybe.new(array_of_numbers.detect { |n| n.even? })
.fmap { |n| n + 1 }
.fmap { |n| n.to_s }
Let's Talk About Usefulness
ここまでオブジェクトがnil
かどうかをいちいち気にする作業を取り除くための下地をこつこつと作ってきました。では先に進んでhighest.rbを私達の新しいMaybe Functorでラップされた結果を返すように書き直しましょう:
# nilを扱う処理を隠蔽するためにMaybeのコンテクストで、
# 比較できる値の配列を降順にソートし、最大値を取得する
def highest_number(array)
sorted = array.sort { |x, y| y <=> x }
Maybe.new(sorted.first)
end
素晴らしい。早速試してみましょう:
irb :001 > highest_number([1, 2, 3]).fmap { |n| n**2 }.fmap { |n| n.to_s }
=> Just "9"
irb :002 > highest_number([]).fmap { |n| n**2 }.fmap { |n| n.to_s }
=> Nothing
これは正に私達が望んでいる結果です。nil
となる値があるかチェックする必要は無く、予期しない箇所のnilによるNoMethodError
が発生しないか心配しなくても良いのです。コードに対してほんのちょっと自信が持てるようになって、楽しい気持ちになりますね。
しかしながら、いまだ水面下に潜んでいる問題が首をもたげようとしています。おそらく既にそれがあなたの神経を逆撫でしていることに気付いているでしょう。Maybeでラップされた値を返すメソッドがあるとどうなるのでしょうか?
# 数字が完全な方形ならその二乗根を返し、そうでなければ何も返さない
def clean_square_root(number)
square = Math.sqrt(number)
clean = square.to_i if square == square.to_i
Maybe.new(clean)
end
この場合は私たちは妥協を許さず完璧な正方形だけが認められる世界に居るので、このような方法を選択するでしょう。「整数の平方根を返すか、全く何も返すな」、私たちはそう叫んで- そしてコードは従います。でも私たちが作ったhighest_numberでリストの中の最大値を求めたものにこのコードをマップするとどうなるでしょう?
irb :001 > highest_number([1, 4, 25, 9, 2]).fmap { |n| clean_square_root(n) }
=> Just Just 5
irb :002 > highest_number([1, 4, 26, 9, 2]).fmap { |n| clean_square_root(n) }
=> Just Nothing
irb :003 > highest_number([]).fmap { |n| clean_square_root(n) }
=> Nothing
これは良くありません。いま私たちは「Just Just 5」や「Just Nothing」、または「Nothing」という「Just Nothing」ではない単に何もないというものと戦わなければなりません。これは頭を混乱させるでしょう。そして終いには私たちはこんなコードを書いてしまうでしょう。
five_layer_burrito.fmap do |l1|
l1.fmap do |l2|
l2.fmap do |l3|
l3.fmap do |l4|
l4.fmap { |l5| l5 + "I have five mouths and I must scream" }
end
end
end
end
ええ、これは最悪です。プログラマとしての全神経を逆撫でします。私たちはFunctorにFunctorの層を重ねなくても良いようにする必要があります。こんな形をしたものでしょう:
Functor a -> (a -> Functor b) -> Functor b
もしこれを見てmap
の型シグネチャを思い出したのなら、いい傾向です。[ ]で全てをくるんだArrayクラスについてだけ考え続けるのではなく、あらゆるFunctor(map
をfmap
とみなす事でArrayをFunctorと捉えられることもお忘れなく)についての議論であることを明確にして事柄を一般化しています。
ここでの大きな違いはコンテクストで中身の値を取り出して再度ラップするのではなく値を一旦取り出して、引数として与えた関数にラップした値を返させることです。この新しいメソッドをbind
と呼びましょう。正しいと思える解決策を見つけるには手間がかかりそうですが、その価値はあります。
# nilかもしれない値をカプセル化し、nilでなければ行いたい計算を実行する
class Maybe
def initialize(val)
@_val = val
end
# 中身の値がnilでなければ取り出して処理を行い、
# まっさらなコンテクストに結果を詰めて返す。
def fmap
return self if @_val.nil?
self.class.new(yield @_val)
end
# 中身の値がnilでなければ取り出して処理を行い結果を返す。
# bindに渡されるブロックは新しいコンテクストに結果を詰めて返す。
def bind
return self if @_val.nil?
yield @_val
end
# irb/pryでMaybeを使う際に便利な関数
return 'Nothing' if @_val.nil?
'Just ' + @_val.inspect
end
end
これはかなり単純化した方法ですが、Rubyの用法ともほぼ矛盾がありません。渡されるブロックは最後には適切なオブジェクトの型で結果を返すと信じ、適切に動く仕組みを構築する限り、ここには何の問題もありません。しかしながらFunctorの定義と同様に、いくつかの基本ルールを決めておきましょう:全ての人がうまく出来るように。
- このコンテクストにおいて同一関数と等価な、新しい構造体を返す関数を持つべきである。
- 新しい
bind
メソッドは計算が発生した順序は気にするが、計算がネストされた方法については気にしない。
ここで特に不条理に思えることはありません。私達はすでにコンテクストを作り出す関数を持っていることに気づくでしょう:new
です!そしてbind
のコンテクストではnew
は同一性の関数として作用することが分かります。
irb :001 > clean_square_root(25).bind { |n| Maybe.new(n) } # 「右側」同一性と呼ばれます
=> Just 5
irb :002 > Maybe.new(25).bind { |n| clean_square_root(n) } # 「左側」同一性と呼ばれます
=> Just 5
また計算の順序に一貫性がある限りネストしても影響はないことも示せました(この点はFunctorにおいても同様に重要です)
irb :001 > Maybe.new(3).bind { |n| Maybe.new(n + 3) }.bind { |n| Maybe.new(n * 2) }
=> Just 12
irb :002 > Maybe.new(3).bind { |n| Maybe.new(n + 3).bind { |n| Maybe.new(n * 2) } }
=> Just 12
やりました!私達は値をカプセル化して望むように扱う能力をレベルアップできました。しかし私達はFunctorの国を出たので、この新たに作った構造体に名前を付ける必要があります。
もしここまであなたが付いて来られたなら、おめでとうございます:モナドのエッセンスを理解できたのです。この新しい構造体はより簡単に構成できるシステムを設計する力となるでしょう。
Let's Talk About Laziness
私達はモナド構造を構築するのにすばらしい進歩を遂げてきましたが、Rubyが配列について提供するとても便利な機能がありません:遅延評価ができることです。遅延評価によって私達はメモリ容量を超えるデータや無限に連続するデータ、または計算コストが高すぎて即時評価には不向きなデータを扱うようなシステムを、柔軟に構築できます。
即時評価と遅延評価の違いは良い例が無いと理解しにくいので、2つの違いをお見せしましょう:
#!/usr/bin/env ruby
[1, 2, 3, 4, 5]
.map { |n| n**2 }
.map { |n| p n }
.map { |n| "Stringified: #{n}" }
.map { |n| p n }
.take(2)
.to_a
上記のコードで特に目立つのはp
メソッドを集合に複数回マップしていることくらいでしょう。これは処理途中の値を見るための仕掛けです。p
メソッドはRubyの標準ライブラリの一部でおおよそ以下のようなものと考えられます:
def p(obj)
puts obj.inspect
obj
end
実際のp
メソッドにはもう少しコードがありますが、p
の効果的な使い方を理解するにはこれで十分です。同様の効果がmap
の代わりにtap
を使っても得られますが、map
を使うことで即時評価と遅延評価の例で(それ以外の点では)同一のコードを用いることができます。
このプログラムの実行結果に期待することはきっと明白でしょう:最初に2乗した値が表示され、次に「Stringified」という文字が前置きされた2乗の値が出力されることです。
[wuest@kaitain]$ ./eager.rb
1
4
9
16
25
"Stringified: 1"
"Stringified: 4"
"Stringified: 9"
"Stringified: 16"
"Stringified: 25"
特に驚くべき事はありません。しかしRubyの遅延評価を使うように調整してみると:
#!/usr/bin/env ruby
[1, 2, 3, 4, 5]
.lazy # Rubyに遅延評価する処理であることを教える^
.map { |n| n**2 }
.map { |n| p n }
.map { |n| "Stringified: #{n}" }
.map { |n| p n }
.take(2)
.to_a
上記のコードと先ほどの即時評価のコードの違いはlazy
の呼び出しだけです。ここで、このファイルの実行結果がどうなるか予想してみましょう。私が聞いた中でもっとも一般的な予想は、同じ結果が得られるというものです。マップした処理への評価が遅延して行われるとしても、それはだらだらと遅延し続けるデータ構造にストップをかけるto_a
メソッドまでのことだから。
しかしながらプログラムの出力を見てみると、違った展開が見られます:
[wuest@kaitain]$ ./lazy.rb
1
"Stringified: 1"
4
"Stringified: 4"
驚きです!不連続な結果が得られ、それは本当に素敵な特性を持っています:与えたデータは一度だけ横断され、必要になった時点で順番に各要素ごとに処理が実行されています。この特性こそが遅延評価の核心で、不要な計算処理を避けてメモリに搭載できるよりももっと大量のデータを処理できるのです。
この特性はとても便利で注目に値するでしょう。ここでMaybeモナドの実装をもう一度見て、bind
を実行した時点では何も実行しないように修正することがどれだけ大変か考えてみて下さい。これは難しいステップなので、解決策を思いつくのに時間がかかったとしてもがっかりしないで下さい。ここからは、必要になるまで処理を実行しないことと、評価を強制的に行わせる方法について注目していきます。
遅延評価を行うenumerableオブジェクトは、ペンディングされている処理を実行するforce
メソッドを持っているので、その名前をもらいましょう。ここに1つの解となりうるものを示します:
# nilかもしれない値をカプセル化し、nilでなければ行いたい計算を遅延評価して実行する
class Maybe
def initialize(val)
@_val = val
@_computations = []
end
# いつか将来のfmapへのコールをステージングする
def fmap(&block)
next_computation = ->(obj) { obj._fmap(&block) }
self.class
.new(@_val)
.pending(@_computations + [next_computation])
end
# いつか将来のbindへのコールをステージングする
def bind(&block)
next_computation = ->(obj) { obj._bind(&block) }
self.class
.new(@_val)
.pending(@_computations + [next_computation])
end
# 評価を遅延している処理を完了する
def force
@_computations.reduce(self) { |a, e| e.call(a) }
end
# irb/pryでMaybeを使う際に便利な関数
def inspect
return 'Nothing' if @_val.nil?
'Just ' + @_val.inspect
end
# 同型のオブジェクトからのみ実行されるべきメソッド
protected
# 実行をペンディングしている処理を格納する
def pending(blocks)
@_computations = blocks
self
end
# 中身の値がnilでなければ取り出して処理を行い、
# まっさらなコンテクストに結果を詰めて返す。
def _fmap
return self if @_val.nil?
self.class.new(yield @_val)
end
# 中身の値がnilでなければ取り出して処理を行い結果を返す。
# bindに渡されるブロックは新しいコンテクストに結果を詰めて返す。
def _bind
return self if @_val.nil?
yield @_val
end
end
このコードには改善の余地が多く残されていますが、ほぼ問題を解決できたようです。必要となるまで処理を行わす、Maybeモナドが持つほかの特性についてはそのままです!
でも私達自身が作りだしてしまった小さな問題があります:force
メソッドはネストの構造に依存して実行結果が変わります:
irb :001 > Maybe.new(3).bind { |n| Maybe.new(n + 3) }.bind { |n| Maybe.new(n * 2) }.force
=> Just 12
irb :002 > Maybe.new(3).bind { |n| Maybe.new(n + 3).bind { |n| Maybe.new(n * 2) } }.force
=> Just 6
irb :003 > Maybe.new(3).bind { |n| Maybe.new(n + 3).bind { |n| Maybe.new(n * 2) } }.force.force
=> Just 12
私達のために決めたモナドのルールを自分達で壊してしまっており、それは望んだものではありません。簡単な解決策がいくつかあるので、「正しい」ものを見つけようとしすぎないで下さい。「動く」ものを見つけましょう:
# nilかもしれない値をカプセル化し、nilでなければ行いたい計算を、
# 私達がモナドについて定めたルールを満たしつつ、遅延評価により実行する
class Maybe
def initialize(val)
@_val = val
@_computations = []
@_forced_value = nil
end
# いつか将来のfmapへのコールをステージングする
def fmap(&block)
next_computation = ->(obj) { obj._fmap(&block) }
self.class
.new(@_val)
.pending(@_computations + [next_computation])
end
# いつか将来のbindへのコールをステージングする
def bind(&block)
next_computation = ->(obj) { obj._bind(&block) }
self.class
.new(@_val)
.pending(@_computations + [next_computation])
end
# 評価を遅延している処理をネストされたモナドも同様に完了する
def force
# 最初にforceをコールしたときのみ@_forced_valueに値を代入するが、
# 再実行を避けるためにメモ化しておく
@_forced_value ||= @_computations.reduce(self) { |a, e| e.call(a).force }
end
# irb/pryでMaybeを使う際に便利な関数
def inspect
return 'Nothing' if @_val.nil?
'Just ' + @_val.inspect
end
# 同型のオブジェクトからのみ実行されるべきメソッド
protected
# 実行をペンディングしている処理を格納する
def pending(blocks)
@_computations = blocks
self
end
# 中身の値がnilでなければ取り出して処理を行い、
# まっさらなコンテクストに結果を詰めて返す。
def _fmap
return self if @_val.nil?
self.class.new(yield @_val)
end
# 中身の値がnilでなければ取り出して処理を行い結果を返す。
# bindに渡されるブロックは新しいコンテクストに結果を詰めて返す。
def _bind
return self if @_val.nil?
yield @_val
end
end
ここでもう一度、モナド構造を構成することが、命令コードのやっかいな事柄を緩和するための物であることを示します。与えられたデータにおいてforce`が呼ばれたかどうかをチェックし、再度実行することを防ぐために実行結果を保存する(これをメモ化と呼びます)構造を作りました。うまくいくか確認してから次に進みましょう:
2.3.2 :001 > Maybe.new(3).bind { |n| Maybe.new(n + 3) }.bind { |n| Maybe.new(n * 2) }.force
=> Just 12
2.3.2 :002 > Maybe.new(3).bind { |n| Maybe.new(n + 3).bind { |n| Maybe.new(n * 2) } }.force
=> Just 12
##Let's Talk About Purity
すばらしい、これで私達は一回りして当初の疑問へと戻ってきました:「いったいモナドはI/Oとどのような事をしなければならないのか!?」
純粋なコードを書くことを考えてみましょう。純関数は副作用がありません:副作用とはメモリ上のオブジェクトを改変したり(それはプログラムの他の場所の問題の特定を困難にし得るのです)I/O処理を行ったりすることです。副作用が入り込む余地を許した場合、どんな事が起こるのか見てみましょう:
def last_even(array)
num = array.pop
until num.nil? || num.even?
num = array.pop
end
num
end
このスニペットは簡単のために短くそしてわざと分かりやすくしていることを考慮してもらえば、この種のバグは製品のコードベースにもあり得るものです。想像してください:このコードを作った元のプログラマが担当しているメソッドの範疇では、この配列にpop
メソッドを使っても安全だと信じているのです。そして他のプログラマは配布されたインターフェースの仕様書に基づいて、メソッドの外側のデータは改変しないものと期待してこのメソッドを使うでしょう。結果として悲喜劇が起こるのです:
irb :001 > nums = [1, 3, 64, 9, 25]
=> [1, 3, 64, 9, 25]
irb :002 > even_num = last_even(nums)
=> 64
irb :003 > highest_num = highest_number(nums)
=> 3
このようなバグは陰湿です。これらのバグはプログラムが何ヶ月かもっと先になってから静かに問題を起こします。多くの場合では大きな事故を起こさないのでそのバグは生き続け、ひっそりとシステムを通るデータを壊すのです。配列に対する副作用を取り除くようにlast_even
メソッドのバグを修正しましょう:
def last_even(array)
array
.select { |num| num.even? }
.last
end
コードを実行してみれば、結果はずっと賢明なものに思えます。
irb :001 > nums = [1, 3, 64, 9, 25]
=> [1, 3, 64, 9, 25]
irb :002 > even_num = last_even(nums)
=> 64
irb :003 > highest_num = highest_number(nums)
=> 64
一見して分かる事柄がいくつあります。まず第一に、コードから副作用を取り除くことで、プログラムのロジックで問題を特定しやすくなりました。またいつの間にか知らないところでデータの変更があったかもしれないと心配する必要がなくなり、データが正しいものとして計算処理に集中することができます。
次に思いつくのは純関数はいつでも同じ入力に対して同じ出力をすることです。何回last_even([1, 3, 64, 9, 25])
を実行しても、常に64
という出力が得られます。この処理をeven_num = 64
と置き換えても周辺のプログラムに全く影響を与えないことは明らかでしょう。このような与えられた関数とその結果の値を置き換えることができることを参照透過性と呼びます。
Let's Talk About I/O
ここまで作成してきたオブジェクトは便利ですが、小さく纏められすぎています。作成したArrayやMaybeクラスはとても便利な一方、それ単体ではあまり多くの事はできません。プログラムで何らかの便利な処理を行わせたいならI/O処理が必要となります。I/O処理はややこしく、処理結果をその結果の値と置き換えられなくします。その事をいくつかの例で示しましょう:
puts 'Ruby is pretty rad! Say hi!' # => nil
# Let's assume the user types "hello\nworld\n"
x = gets # => "hello\n"
y = gets # => "world\n"
1行目のputs
コールを出力のnil
コールに置き換えられないのは明らかです。何と私達のプログラムは便利なプログラムの世界から、ユーザと行った処理の結果を共有しない世界へと舞い戻ってしまいました。ユーザに間違った回答をしないという点では好ましいのですが、けっして便利なものではありません。
次にgets
があります。最初のgets
コールの出力は「hello\n」という文字列で、もし全てのgets
コールをその出力と置き換えられるとしたら、次の変数y
も同様に置き換えることができ、さらに後続の全てのコールにも適用できます。この仮定は私達プログラマにとっては最善と思えるようにリファクタリングや置き換えが安全に行えるアルゴリズムの部品や欠片である点で、コードの設計がしやすいものですが、ユーザにとってはソフトウェアがちょっとした頭痛の種となるでしょう。
つまり私達は純粋にI/Oを呼び出して、満足するように作業を完了させてそのまま立ち去ることなど明らかに出来ないのです。アプリケーションの影響をうまく管理する方法を考えないといけません。Rubyのコードベースで最も一般的な方法は、純粋であるものとそうでないものをはっきりと分割して、プログラマにそれとすぐ分からないような形では呼び出せないようにすることです。たいていはメソッド名に「!」マークを含めることで何らかの副作用があることを明示して、純粋でないコードを分けることで実現しています。
(この事の典型的な例としては標準ライブラリのmap
とmap!
メソッドの違いを考えてください)
しかしながらこのステップは後で取り掛かる事にしましょう。I/O処理をカプセル化するクラス全体を作成し、そこから綺麗なシステムが作成できるか調べてみましょう。これまでやってきた作業によって使えるようになった道具が色々あるので、副作用から隔離したスタブを作ることができます!しばらくは出力結果だけに注目しましょう:
# 副作用の発生を許容しつつ置換することが出来るように、グローバルな状態をカプセル化する
# 現時点ではクラス名は全くのデタラメである
class PureOut
# 恐らくここに何かが必要だが、まだ明らかになっていない
def initialize
end
# putsメソッドをラップして、何らか望ましい状態にする
def puts(str)
puts str
self
end
# irb/pryでオブジェクトを見やすくする関数
def inspect
'<Output>'
end
end
いまいちパッとしませんね。puts
コールをチェインできるオブジェクトができましたが、ただそれだけです:
PureOut.new
.puts('Ruby\'s pretty rad!')
.puts('But this could probably be better...')
ちょっと未完成な所がありますね:puts
コールはまだ結果との置き換えをできなくしています。これはどうしようも無い事に思えますが、心配ありません:あなたの直感は正しいのです。I/O処理と理想的な純関数の楽園の間のややこしい関係を滑らかにする方法を頑張って思いつかないといけません。
明白なことを述べる事から始めてみましょう:ある時点で、現実世界と関わった場合には出力結果でコードを置き換えることが出来なくなる事があるでしょう。この考えをもう少し広げて、puts
やgets
自身が常に置換を出来なくする事を認めましょう。呼び出された瞬間から、コードがそれまでと同じである事の理由はなくなってしまうのです。
だから私達に必要なのは、コードの純粋性を損なうことなく現実世界との相互作用を表せるように、私達の純粋なコードと砂混じりの現実世界の間のインターフェースがどのような形となるかを見つけ出すことです。
すぐに見つかる道具は遅延評価です。Maybe
モナドにより、値の有無に関わらず遅延評価するコンテクストを作ることができます。遅延評価の構造を利用すれば、即時実行することなく実行したい処理をステージングしておくことができます。この事は新たなI/O処理ラッパーに求めていることと大いに合致すると思われます。PureOut
クラスを遅延評価できるように書き換えましょう:
# 副作用の発生を許容しつつ置換することが出来るように、グローバルな状態をカプセル化する
# 今回は嘘が少なくなってます!
class PureOut
def initialize
@_stage = []
end
# いつか将来の文字列出力へのコールをステージングする
def puts(str)
next_action = ->() { _bind { $stdout.puts str } }
self.class
.new
.pending(@_stage + [next_action])
end
# さあ出番です! 全部吐き出しましょう!
def force
@_forced_output ||= @_stage.reduce(self) { |_, e| e.call.force }
end
# irb/pryでオブジェクトを見やすくする関数
def inspect
'<Output>'
end
# 同型のオブジェクトからのみ実行されるべきメソッド
protected
# 実行をペンディングしている処理を格納する
def pending(blocks)
@_stage = blocks
self
end
# ステージングされたアクションを実行する; アクションは常にnilを返す$stdout.putsなので
#selfを返してどのコンテクストを返すべきかを管理するのはローカルのputsメソッドに任せる
def _bind
yield
self
end
end
これを実行してみれば、期待通り動いているのが分かります!
irb :001 > x = PureOut.new
=> <Output>
irb :002 > y = x.puts('Hello')
=> <Output>
irb :003 > z = y.puts('World!')
=> <Output>
irb :004 > z.force
Hello
World!
=> <Output>
1つ見落としがちで重要な事は各PureOut
オブジェクトは新たなアクションが評価される毎にそれぞれ_bind
のブロックを持つという事です。このようにして実行順序へ強い依存性を持たせることでコードをその実行結果と置き換える機能を保てるのです。
出力を遅延して表示することが出来ました。次に、ユーザからの入力を受け付けて処理できるようにする必要があります。PureIn
クラスを実装することにしましょう。もし不安を感じてもPureOut
と似通った所が多いので大丈夫です。
# 副作用の発生を許容しつつ置換することが出来るように、グローバルな状態をカプセル化する
# 今回は嘘はぴったり0%です!
class PureIn
def initialize
@_stage = []
@_forced_result = nil
end
# ユーザへの入力リクエストと、それに基づくアクションをステージングする
def gets(&block)
next_action = ->() { block.call($stdin.gets) }
self.class
.new
.pending(@_stage + [next_action])
end
# ユーザからの入力を受付けアクションを実行する
def force
@_forced_result ||= @_stage.reduce(self) { |m, e| m._bind(&e).force }
end
# irb/pryでオブジェクトを見やすくする関数
def inspect
'<Input>'
end
# 同型のオブジェクトからのみ実行されるべきメソッド
protected
# 実行をペンディングしている処理を格納する
def pending(blocks)
@_stage = blocks
self
end
# ステージングしたアクションを実行する
def _bind
yield
end
end
私達が思い描いたとおりに動いてくれることが確認できます。
irb :001 > x = PureOut.new
=> <Output>
irb :002 > y = PureIn.new
=> <Input>
irb :003 > y.gets { |i| x.puts("Hello, #{i}") }.force
World
Hello, World
=> <Input>
irb :001 > x = highest_number([3, 10, 1, 9, 25, 17])
=> Just 25
irb :002 > y = x.bind { |num| clean_square_root(num) }
=> Just 25
irb :003 > z = y.bind { |num| PureOut.new.puts("The answer is #{num}") }
=> Just 25
irb :004 > z.force
The answer is 5
=> <Output>
Let's Talk About Unification
ここまで入力と出力の遅延評価について作業してきましたが、少し気になる点があります。まず、入力と出力の構造を分けて作成しましたが、共通するコードが沢山あります。これはリファクタリングして一部を削除し、コードを統合できることのサインです。さらに、遅延評価を実装することである程度モナド構造をうまく操れたといえますが、私達がモナドに本当に備わって欲しいと望むbind
やfmap
は未実装です。
この実装が最後の作業です:作成した入力と出力の構造を統合してみて下さい。puts
とgets
がクラスメソッドとなるよう修正し、モナド則の観点で実装して下さい。Maybe
モナドから着想が得られそうですね。
# 評価時に置換できなくなるステージングしたアクションのカプセル化機能を提供する
class IOMonad
def initialize(val = nil)
@_val = val
@_stage = []
@_forced_result = nil
end
# いつか将来の文字列出力をステージングする
def self.puts(str)
new.bind { |_| new($stdout.puts(str)) }
end
# いつか将来のユーザからの入力リクエストをステージングする
def self.gets
new.bind { |_| new($stdin.gets) }
end
# いつか将来の実行するアクションをステージングする
def bind(&block)
next_action = ->(obj) { obj._bind(&block) }
self.class
.new(@_val)
.pending(@_stage + [next_action])
end
# いつか将来のカプセル化されたデータの変換をステージングする
def fmap(&block)
next_action = ->(obj) { obj._fmap(&block) }
self.class
.new(@_val)
.pending(@_stage + [next_action])
end
# ステージングしたアクションを実行する
def force
@_forced_value ||= @_stage.reduce(self) { |a, e| e.call(a).force }
end
# irb/pryでオブジェクトを見やすくする関数
def inspect
'<IO>'
end
# 同型のオブジェクトからのみ実行されるべきメソッド
protected
# 実行をペンディングしている処理を格納する
def pending(blocks)
@_stage = blocks
self
end
# 中身の値を取り出し、処理を実行し、結果を新しいIOモナドに詰めて返す
def _fmap
self.class.new(yield @_val)
end
# 中身の値を取り出し、処理を実行し、結果を新しいIOモナドに詰めて返す
def _bind
yield @_val
end
end
上記のコードの大部分はこれまでと同様です。IOMonad.puts
とIOMonad.gets
を私達が与えたアクションを必要になるまで評価せずに保存するbind
へのコールを利用して実装できました。私達は今や、以前のニ分割されたものよりずっと洗練された完全にモナドなI/Oシステムを作成したのです:
irb :001 > x = IOMonad.gets
=> <IO>
irb :002 > y = x.fmap { |str| str.upcase }
=> <IO>
irb :003 > z = y.bind { |str| IOMonad.puts(str) }
=> <IO>
irb :004 > z.force
Hello, monads!
HELLO, MONADS!
=> <IO>
Let's Talk About the Big Picture
まあ、沢山のトピックがありましたね。もしここまで付いてこられたなら、遅延評価するI/Oシステムを一から作成したことを祝福します!まだまだ改良の余地はありますし、ここで立ち止まる理由は全くありません:もし掘り下げてみたいと思ったなら、是非するべきです!
元々のトピックであるHaskellのIOモナドに話を戻しましょう:私達が実装したものは決して遠いものではありません。どの時点で遅延評価IOが評価されて結果との置き換えが出来なくなるのかを決めたように、Haskellも同じ事をします - Haskellの視点では、プログラムのメイン関数を実行した時点でIOの評価が行われます。私達がもし、現実世界への主な出口として決めたコード片の中で一度だけ許可する以外には、force
メソッドへのコールを許可しないようにすれば、上述の事柄との違いはありません。
Rubyで自作したモナドIO構造が特に有用となる見込みは少ないものの、それはただ単にHaskellへ誘うためのものではありません。私がHaskellについて見つけた最も有益な使い方の一つは、他の言語で仕事をする時に異なる考え方に目を開くことです。
今あなたの矢筒にはもう1つ別の矢が入っていて、このレンズを通してあなたが抱える課題を見てみる価値はあるでしょう:時に明確にモナド的な方法で考えることはワークフローを大いに単純化し、組み立て可能なシステムの設計への新たなアプローチを示します。
この記事で述べた各トピックはそれ1つずつで一冊の本となるようなものですが、大まかな概要を知ることで、モナドとは一体何か、それらがなぜHaskellでI/Oを扱いやすくするために使われ、さらにあなたが自身のプロジェクトでどの様に利用できるのかという事についてより良いアイデアを得る助けとなる事を願っています。
もし型システムとそれらがコンピュータをプログラムする方法へ及ぼす影響にとても興味が出たら、「Types and Programming Languages」(邦訳版:型システム入門 −プログラミング言語と型の理論− )を強く推薦しすぎるという事はありません。ここで学んだことの全てが載っており、そしてはるかに深く掘り下げられています。
この記事から何を学んだかに関わらず、これらの点だけは覚えていって下さい:
- Haskellの文法で頻出する専門用語は考えを伝えるのに便利なことがあるので、知っておく価値があります:
- もし何かがFunctorであるなら、私達が決めたルールセットに従うということです。それ以上に特別なことはありません。
- もし何かがモナドであるなら、それもまた私達が定義したルールセットに従うということなのです!
Maybe
はnilチェックの実装を隠蔽しているに過ぎないことを覚えておいて下さい。魔法ではないのです。
- 純粋なコードは与えられたコードが何をするための物かを明らかにするような作業を簡単にしてくれますが、最終的には現実世界とうまく関わらないといけません。
- プログラマーとして、私達は現実世界とコードの境界線を定義したのです。