FRPとVirtual DOM、状態について。
今回は、Elmを通して、FRPに対する個人的な理解、そしてVirtual DOMがFRPに合っているということを述べていきます。
FRP
関数プログラミングはどのように「状態」を扱うのか?
「Functional Reactive Programming」とあるように、FRPは関数プログラミングのパラダイムの技法だと思います。なので、関数プログラミングについてまず、「状態」について考えてみたいと思います。
状態とは何か?
まず、状態とは何でしょうか? CTMCPによると、
状態(state)とは、必要とされる計算の途中結果を含む、値の時系列である
とあります。関数プログラミングのような宣言的なプログラミングは、そしてこの書籍では暗黙的状態を扱い、オブジェクト指向のような命令的なプログラミングは明示的状態を扱うと述べています。
値と、オブジェクトと、「状態」
関数プログラミングは、「値」を扱います。ここでの「値」というのは、それ自身は状態を持ちません。つまり、値は、状態を変更することができないものです。そして、オブジェクトは内部状態を持ち可変です。つまり、「不変な値」をそのまま「値」として、「オブジェクト」を「可変な状態を持つ値」としています。
関数プログラミングは、値しか扱うことができません。では、どうやって、状態を扱うのでしょうか?ここでは、まず命令的なプログラミングを、関数プログラミングに置き変えながら考察していきたいと思います。
さて、まず、命令的なプログラミングで、状態を考えてみましょう。たとえば、以下のようなコードを考えましょう。
function Person(name, age){
this.person = name;
this.age = age;
}
Person.prototype.older = function(){
this.age++;
}
var nobkz = Person("nobkz", 20);
nobkz.older(); // age : 21
nobkz.older(); // age : 22
さて、このコードは、ageが状態変数であり、olderを呼ぶ回数だけ、ageがインクリメントされます。つまり、ageが更新され、再代入されるということになります。つまり、Person オブジェクトが明示的に状態を持ち、olderによって、状態を変更しているということです。
さて、関数は値を更新することができません。なので、どのように状態を扱うのでしょうか? 状態が更新するとき、更新後の値を別の値にすれば良いのです。つまり、遷移前の値と、遷移後の値を別々にするということです。コードにするとこういうことになります。
function newPerson(name, age){
return {name:name,age:age};
}
function older(person){
return {name:person.name, age:person.age};
}
var nobkz = newPerson(name,age);
var nobkz1 = older(nobkz);
var nobkz2 = older(nobkz1);
このコードは、暗黙的に状態を扱っています。というのも、ここで、nobkzとnobkz1、nobkz2の値がありますが、コード上では単に違う値としか扱えません。プログラマが、nobkz,nobkz1,nobkz2が状態として扱うと解釈しているにしかすぎません。
ここで、重要なのは、状態遷移の関数の書き方です。状態遷移の関数は、古い状態を受けとり、新しい値を作ってそれを、新しい状態として返すということをやっています。つまり、宣言的な書き方と、命令的な状態の記述は、状態の変更をそのまま記述するか、古い状態から、新しい状態を作るの違いがあります。
FRP(Functional Reactive Programming)とは?
時間変化の型
さて、Functional Reactive Programmingは、何か?と言われれば、時間変化の型があって、その型の値を結合しながら、プログラミングしていくということだと解釈しています。
たとえば、Elm などをは、Signalという型が時間変化の型で、その値を結合しながらプログラミングします。Signalは一つの型引数を持ち、様々なデータ型を内包することができます。
Signalと関数を結合する
Signal例として、elmでは、マウスをクリックしたときは、Mouse.clicksというSignalの型の値を使います。
Mouse.clicks : Signal ()
さて、Elmでは、map関数などによって、Signalの値を関数を組み合わせて行きます。
たとえば、次の関数を結合しましょう。
one : () -> 1
one () = 1
関数oneは、()を取り、1を返すだけです。さて、これと、Mouse.clicksを結合させたいのですが、残念ながら型が合いません。
import Mouse
oneClicks = one Mouse.clicks -- 型が合いません!
one : () -> Int
one () = 1
上記のコードはこのような型がマッチしないという、エラーを出すでしょう。
Type mismatch between the following types on line 3, column 17 to 29:
Signal.Signal ()
()
では、どうすれば良いかというと、Signal.map関数を使い、one関数をSignalの型に持ち上げれば良いのです。
Signal.map : (a -> result) -> Signal a -> Signal result
signalOne : Signal () -> Signal Int
signalOne = map one
そして、それを、Mouse.clicksに適用すれば良いのです。
import Mouse
import Signal (Signal, map)
oneClicks = signalOne Mouse.clicks
signalOne : Signal () -> Signal Int
signalOne = map one
one : () -> Int
one () = 1
Signalは動的に生成する値
Signalという型は、動的に生成する値です。先程のMouse.clicksは、()という値を、クリックしたときに動的に生成しているわけです。そして、動的に生成されて、map関数などで、結合された関数が呼びだされます。
それは次のコードで確かめることができます。
import Mouse
import Signal (..)
import Text (asText)
import Debug (log)
main = asText <~ map (\x -> log "clicked" x) Mouse.clicks
とすると、こんな感じで、ログが表示されるはずです。
さて、このコードは、Mouse.clicksと、ラムダ(\x -> log "clicked" x)が結合しており、クリックすると、Mouse.clicksのSignalの中身の値が生成され、そして、結合している、ラムダ式が呼び出されます。
注意したいのが、あくまでも、Signalの中身の値が動的に生成されるというわけです。つまり、Signalの値自体は、最初から生成され、クリックしてもなにも起こりません。
import Mouse
import Signal (..)
import Text (asText)
import Debug (log)
main = asText <~ log "clicked" Mouse.clicks -- クリックしても、何も起こらない!
動的に末尾に追加されるストリーム
さて、Signal Value自体を見てみますと、状態を持つ、可変な値のように見えます。たしかに、たとえば、elmのMouse.isDownは、そのように見えるかもしれません。
main = asText <~ Mouse.isDown
-- マウスを押したとき-> True
--- マウスを話したとき -> False
Mouse.isDownは、Signalというオブジェクトとして、内部状態を持っており、Trueと、Falseを更新しているように見えます。
先に、関数型は、可変な値を扱うことができないと言いました。あれ?何か矛盾してる気がしますね。
それはSignalを動的に末尾に追加されるストリームとして考えれば良いと思います。良い代えれば、動的に伸びる無限リストとみるということです。
たとえば、普通のリストの計算を考えてみましょう。
List.map not [True,False,True] -- [False, True, False]
さて、この計算は、Bool型のリストをnotでmapします。これと似たような、Signalのコードを見てみましょう。
Signal.map not Mouse.isDown
さて、これは、次のように考えると、スッキリするでしょう。
Signal.map not [True, False, True, False, True ......
--クリックする度に伸びていく
-- 入力値 -> [True, False, True, False ....
-- 出力値 -> [False, True, False, True....
さて、更にListのfoldの計算を考えてみましょう。たとえば、リストの長さは次にのようになりますが、
List.foldl (\x y -> y + 1) 0 [(),(),(),(),(),()] -- -> 5
これと、似たようなことをSignalにすると、Signalには、foldpという関数があり、
Signal.foldp (\x y -> y + 1) 0 Mouse.clicks
これは次のように、なり、
Signal.foldp (\x y -> y + 1) 0 [(), (), (), (), (), .....
--クリックする度に伸びていく
とすればクリックした回数をカウントすることもできます。
つまり、Signalは、無限に追加されるリストのようなものであって、追加されたときに、結合した関数を呼び出すものだと言えます。
FRPとVirtual DOM
さて、関数プログラミングでは、先程も良いました通り、状態管理は、遷移前の値と、遷移後の値を別々なものとして扱い、古い状態から、新しい状態を作ることによって、状態を表現すると言いました。
とすれば、何かイベントがある毎に、新しい値が生成されます。それを、そのまま、新しい値からDOMを生成するとなれば、イベント毎に毎回新しいDOMが生成され、非常に非効率です。
なので、古い状態の仮想DOMと新しい状態の仮想DOMの差分を取り更新するというような、最適化はFRPのプログラムの効率化をサポートするでしょう。
つまり、ElmのSignalのような仕組みによって、イベント毎に新しいGUIの状態が生成され、Virtual DOMによって、古い状態と新しい状態を比較して、状態を反映していくと言う形で、関数プログラミングをしながらGUIを構築していくということが、FRPによるプログラミングになるでしょう。