モナドという言葉を使うことなく、純粋関数型言語の入出力を解説する。Rubyで。
これはRuby Advent Calendarの21日目の記事です*1。20日目は@sasata299さんのHerokuって便利だし利用までたった3ステップですお!!でした。
はじめに
本当は「Pure Rubyで動くかっこいい形態素解析エンジンができた!!!」みたいな記事を書く予定でしたが、目標のクオリティに達しなかったのでリリースは延期されました。
クリスマスごろにリリースされるといいですね。
さて、この記事では、Haskellのような純粋関数型言語がどのように副作用を伴う処理を実現しているかについて、Rubyによる実装を使って解説します。モナドという言葉が全く出てこないのでアレルギーのかたも安心ですね。
純粋関数とは
同じ引数を渡す限り、どのような順番で何度呼んでも同じ結果が返るような関数のことです。
何が問題なのか
puts 'hello!' res1=gets puts 'hello!' res2=gets
このコードにおける、putsやgetsは純粋関数ではありません。
なせなら、
- putsは同じ引数で呼ばれているが、結果が同じとは限らない(パイプが壊れていたらエラーになるかも)
- getsは引数なしで2回呼ばれているが、ユーザ入力によって返り値は変わる
からです。
では、純粋な関数を使用して入出力を実現するにはどうしたらいいのでしょうか。
純粋にする(むりやり)
def dirty_greet puts '苗字は?' last_name=gets puts '名前は?' first_name=gets puts "こんにちは、#{last_name.strip} #{first_name.strip}さん!" end
> dirty_greet 苗字は? まつもと 名前は? ゆきひろ こんにちは、まつもと ゆきひろさん! => nil
dirty_greet関数は、内部で純粋でない関数を呼んでいるので純粋ではありません。
これと同等の処理を純粋関数で実現するにはどうしたらいいでしょう。
純粋関数とは、同じ引数で何度呼んでも同じ結果が返る関数のことでした。副作用のあるすべての関数に対して、同じ引数で呼ぶのは一度きりという制限を付けたらどうなるでしょう。たとえば、呼ぶたびに連番を渡すとか:
def manual_pure_gets seq gets end a=manual_pure_gets 1 b=manual_pure_gets 2 # aとbが異なっても良い
関数manual_pure_getsは、純粋です!というか、制限を守る限り純粋じゃないことがバレない!
副作用のある関数を同じ引数で呼ぶのは一度きりという制約を守るには、その関数を使っている側にも細工をする必要があります。
def double_gets [ manual_pure_gets(1), manual_pure_gets(2) ] end aa=double_gets bb=double_gets # aa,bbは異なるかも知れないので、double_getsは純粋ではない
def manual_pure_double_gets offset [ manual_pure_gets(offset+1), manual_pure_gets(offset+2) ] end aa=manual_pure_double_gets 1 bb=manual_pure_double_gets 4 # aa,bbが違っても引数が違うので純粋
バンザーイ!!!
どんなに大きいプログラムでも、このやりかたでどうにかなりそうですね
洗練させる
しかし、いくらなんでもこれは面倒です。連番をうまく扱うために、関数の戻り値として「次の番号」を追加で返すようにしてみます。
# これはデバッグログ用の関数なので純粋じゃなくてもいいんです。 def enter method_name,*args puts " ENTER: #{method_name}(#{args.map(&:inspect).join(', ')})" end def s_pure_gets seq enter 's_pure_gets',seq line=gets # 「次の番号」と「本来の値」のペアを返す return [seq+1,line] end def s_pure_double_gets seq seq2,ret1=s_pure_gets seq seq3,ret2=s_pure_gets seq2 return [seq3,[ret1,ret2]] end seq1,aa=s_pure_double_gets 1 seq2,bb=s_pure_double_gets s1 # 純粋!
番号の管理をしなくていいので、すこし楽になりました。
名前を表示する例はこうなります:
def s_pure_puts str,seq enter 's_pure_puts',str,seq puts str return [seq+1,nil] end def s_pure_greet seq seq2 , _ = s_pure_puts '苗字は?',seq seq3 , last_name = s_pure_gets seq2 seq4 , _ = s_pure_puts '名前は?',seq3 seq5 , first_name = s_pure_gets seq4 seq6 , _ = s_pure_puts "こんにちは、#{last_name.strip} #{first_name.strip}さん!",seq5 return [seq6,nil] end
> s_pure_greet 1 ENTER: s_pure_puts("苗字は?", 1) 苗字は? ENTER: s_pure_gets(2) まつもと ENTER: s_pure_puts("名前は?", 3) 名前は? ENTER: s_pure_gets(4) ゆきひろ ENTER: s_pure_puts("こんにちは、まつもと ゆきひろさん!", 5) こんにちは、まつもと ゆきひろさん! => [6, nil]
よかったですね。
中身を隠す
a=s_pure_gets 1 b=s_pure_gets 1 # おっと!
「副作用のあるすべての関数は同じ引数で二回呼ばない」というルールを守るのは、なかなか大変です。いちいち連番を渡す必要があるとめんどくさすぎるので、このあたりの機構を隠蔽したいものです。
# ここで、クラスPureを導入します。 class Pure # Pureクラスは、「実際の処理」を受け取るコンストラクタと def initialize &body @body=body end # 連番を受け取って実際の処理を起動、結果を返すeffect!メソッドを持っています def effect! seq seq,ret=@body.call seq return [seq,ret] end end
「連番を受け取って次の番号と値のペアを返す」という構造を、これで抽象化しようというわけ。
def pure_puts str Pure.new {|seq| puts str; [seq+1,nil] } end def pure_gets Pure.new {|seq| line=gets; [seq+1,line] } end
pure_gets,pure_putsはseqを引数として取る必要がありません。これらの関数はPureオブジェクトを返すだけ、実際の入出力は行わないので純粋なんです。
pure_ret=pure_gets # この時点では実際の処理は行われない pure_ret.effect! 1 # effect!を呼んだ時点で処理が実行される
ユーザが自由な引数でeffect!を呼ぶと純粋性が破綻してしまうので、かならずPure.run!を経由するルールを作ります。
class Pure def self.run! pure pure.effect! 1 end end # ユーザは連番を意識することなく実行出来る Pure.run! pure_gets
では、これらの関数でgreet関数を作ってみましょう。
def pure_greet pure_puts '苗字は?' pure_gets # => Pure {...} last_name=???? end
おっと、このままでは入力を読むことができません。実際に入力を読むにはeffect!メソッドに連番を渡してやる必要があるのですが、pure_greet内でそれをすると抽象化が崩れてしまう。
そこで、Pureクラスを拡張して、「次の処理」を合成できるようにします。
class Pure # 続きの処理を合成する # contはなにか値を受け取ってPureを返すProc def >> cont return Pure.new {|seq| seq2,ret=self.effect! seq cont_pure=cont.call(ret) cont_pure.effect! seq2 } end end
これを使えば、Pureを組み立てて大きな処理を作ることができる。
readwrite = pure_gets >> # 一行読んで、 lambda{|line| pure_puts line} # その値を表示する Pure.run! readwrite
するとこうなります
def pure_greet pure_puts('苗字は?') >> lambda{|_| pure_gets } >> lambda{|last_name| pure_puts('名前は?') >> lambda{|_| pure_gets } >> lambda{|first_name| pure_puts("こんにちは、#{last_name.strip} #{first_name.strip}さん!") } } end
# pure_puts,pure_getsにログ出力を追加しておきます def pure_puts str enter 'pure_puts',str Pure.new {|seq| enter 'pure_puts[Pure]',seq; puts str; [seq+1,nil] } end def pure_gets enter 'pure_gets' Pure.new {|seq| enter 'pure_gets[Pure]',seq;line=gets; [seq+1,line] } end Pure.run! pure_greet
> Pure.run! pure_greet ENTER: pure_puts("苗字は?") ENTER: pure_puts[Pure](1) 苗字は? ENTER: pure_gets() ENTER: pure_gets[Pure](2) まつもと ENTER: pure_puts("名前は?") ENTER: pure_puts[Pure](3) 名前は? ENTER: pure_gets() ENTER: pure_gets[Pure](4) ゆきひろ ENTER: pure_puts("こんにちは、まつもと ゆきひろさん!") ENTER: pure_puts[Pure](5) こんにちは、まつもと ゆきひろさん! => [6, nil]
イエイ!
最後の1ピース
ユーザに何かを尋ねるpure_prompt関数を定義し、処理の重複を減らしてみましょう。
def pure_prompt message pure_puts(message) >> lambda{|_| pure_gets }>> lambda{|line| line.strip } # おっと end def pure_greet2 pure_prompt('苗字は?') >> lambda{|last_name| pure_prompt('名前は?') >> lambda{|first_name| pure_puts "こんにちは、#{last_name} #{first_name}さん!" } } end
しかし、このコードは動きません。Pureと合成するlambdaはPureを返り値として返せねばならないのに、pure_promptの最後の処理では文字列を返しています。
ふつうの値をPureに変換する処理があったらいいですね。
class Pure def self.ret value return Pure.new {|seq| [seq,value]} end end def pure_prompt message enter 'pure_prompt',message pure_puts(message) >> lambda{|_| pure_gets }>> lambda{|line| Pure.ret line.strip } # StringをPureに変換する end def pure_greet2 pure_prompt('苗字は?') >> lambda{|last_name| pure_prompt('名前は?') >> lambda{|first_name| pure_puts "こんにちは、#{last_name} #{first_name}さん!" } } end Pure.run! pure_greet2
> Pure.run! pure_greet2 ENTER: pure_prompt("苗字は?") ENTER: pure_puts("苗字は?") ENTER: pure_puts[Pure](1) 苗字は? ENTER: pure_gets() ENTER: pure_gets[Pure](2) まつもと ENTER: pure_prompt("名前は?") ENTER: pure_puts("名前は?") ENTER: pure_puts[Pure](3) 名前は? ENTER: pure_gets() ENTER: pure_gets[Pure](4) ゆきひろ ENTER: pure_puts("こんにちは、まつもと ゆきひろさん!") ENTER: pure_puts[Pure](5) こんにちは、まつもと ゆきひろさん! => [6, nil]
まとめ
- なんか純粋関数でIOできてしまいました!!!
- モナドという名前がでてこなかった
- 本当に合ってるのかわからなくなってきた…… 間違っていたら誰かが指摘してくれることを信じています……
- 連番はやばいだろうという気が
- でも「世界の状態」に変えても全然解説変わらない気がするんですよね
*1:もう日付変わったけど