翻譯自:https://gist.github.com/ohanhi/0d3d83cf3f0d7bbea9db
原做者: Ossi Hanhinen, @ohanhi
翻譯:Integ, @integ
愛心支持 Futurice .
受權協議 CC BY 4.0.react
不久之前一個好朋友給我安利了 響應式編程(Reactive Programming)。不寫 函數式響應編程 簡直就是犯罪 -- 很明顯函數式方法大幅彌補了響應編程的不足。它如何作到的,我並不知道,因此我決定學一下這些東西。git
經過了解本身,我很快發現只有用它解決一些實際的問題,我才能領會它的觀念模式。寫了這麼多年 Javascript,我原本早就能夠開始使用 RxJS 的。但再一次,由於我瞭解本身,而且我發現它會給我太多空間來違背常理。我須要一個強制我用函數式思惟來解決任何問題的工具,正在這時 Elm 出現了。github
Elm 是一種編程語言,它會被編譯爲 HTML5: HTML, CSS 和 JavaScript。根據你顯示輸出結果的不一樣,它多是一個內置了對象的 <canvas>
,或者一個更傳統的網頁。讓我重複一遍,Elm 是一種語言,它會被編譯爲 三種語言 來構建 web 應用。並且,它是一個擁有強類型和 不可變(immutable)數據結構的函數式語言。web
好了,你能夠猜到我並非這個領域的專家,爲了防止你走丟,我專門在這篇文章的最後列出了下面的術語解釋:附錄:術語表.express
我決定嘗試使用 Elm 製做一個相似《太空侵略者》的遊戲。讓咱們站在玩家的視角思考一下它是怎麼工做的。編程
在屏幕下部有一艘表明着玩家的飛船canvas
玩家能夠經過相應的方向鍵控制飛船左右移動segmentfault
玩家能夠按向上鍵發射子彈射擊數組
好了,咱們切換到飛船的視角,再來看下數據結構
飛船有一個一維的位置座標
飛船能夠得到一個速度(向左或向右)
飛船根據它的速度改變位置
飛船可能被擊中
這些基本上給了我一個飛船的數據結構的定義,或者說一個 Elm 術語中的 記錄。儘管並不是必須,我仍是喜歡把它定義爲一個 aliases 類型,這樣就可使用 Ship
來表示它的類型了。
type alias Ship = { position : Float -- just 1 degree of freedom (left-right) , velocity : Float -- either 0, 1 or -1 , shooting : Bool }
太棒了,如今讓咱們建立一個飛船吧。
initShip : Ship -- this is the type annotation initShip = { position = 0 -- the type is Float , velocity = 0 -- Float , shooting = False -- Bool }
因此,咱們已經到了一個有趣的地步。再看一遍上面的定義,它是一個簡單的陳述仍是一個函數定義?無所謂!initShip
既能夠被認爲只是字面量的定義紀錄,也能夠看做一個永遠返回這些紀錄的函數。由於函數是純函數,而且它的數據結構是不可改變的,因此也沒有辦法區分他們,Wow,cool。
旁註:若是你像我同樣,你會思考若是試着從新定義
initShip
會發生什麼。好的,會發生一個編譯時錯誤:「命名衝突:只能有一個對foo
的定義」。
好,咱們來開始移動飛船!我記得高中時學過 s = v*dt
,或者說距離等於速度乘以時間差。因此這就是我如何改變個人飛船。在 Elm 中會像下面這樣實現。
applyPhysics : Float -> Ship -> Ship applyPhysics dt ship = { ship | position = ship.position + ship.velocity * dt }
類型標記描述了:給出一個 Float
和一個 Ship
,我會返回一個 Ship
,甚至:給出一個 Float
,我會返回 Ship -> Ship
。例如,(applyPhysics 16.7)
實際上會返回一個能夠傳入一個 Ship
參數的函數,而且獲得應用了物理方程的飛船做爲返回值。這個特性叫作 柯里化 並且全部 Elm 函數自動這樣運做。
旁註: 然而,這一切有什麼意義呢?好吧,假設我要建立一個由兩列數據組成的表格。我知道如何構建它相似「給出一個列表和一個簡單的值,從列表中找出匹配的項」或者直接寫做
findMatches : List -> Item -> List
。可是我須要把一些先前已經知道的列表映射到新的列表中。這就是柯里化偉大的地方:我能夠僅僅寫出crossReference = map (findMatches listA) listB
就能夠實現了。(findMatches listA)
是一個Item -> List
類型的函數,徹底就是咱們想要的。
如今,回到實際的話題,applyPhysics
建立了一個新的紀錄,使用提供的 Ship
做爲基礎,設置 position
爲一些其餘的值。這就是 { ship | position = .. }
句法的含義。更多的,請參考 Updating Records。
更新飛船的其餘兩個屬性也是相似:
updateVelocity : Float -> Ship -> Ship updateVelocity newVelocity ship = { ship | velocity = newVelocity } updateShooting : Bool -> Ship -> Ship updateShooting isShooting ship = { ship | shooting = isShooting }
把這些拼在一塊兒,咱們就獲得了一搜完整的飛船,像下面這樣:
-- represents pressing the arrow buttons -- x and y go from -1 to 1, and stay at 0 if nothing is pressed type alias Keys = { x : Int, y : Int } update : Float -> Keys -> Ship -> Ship update dt keys ship = let newVel = toFloat keys.x -- `let` defines local variables for `in` isShooting = keys.y > 0 in updateVelocity newVel (updateShooting isShooting (applyPhysics dt ship))
如今,假設我只是調用 update
30 次每分鐘,傳給他距離上次更新的時間差、被按下的鍵和先前的 ship
,我已經有了一個完美的小遊戲模型了。除了我看不到任何東西,由於沒有進行渲染... 可是理論上它是可行的。
讓咱們來總結一下目前爲止發生了什麼。
aliases 類型定義了數據模型
全部數據是不可變的
類型標記分清了函數的目標
全部函數都是純函數的
事實上,這個預覽里根本沒有辦法意外地改變狀態。也沒有任何循環。
我已經講了不少關於這個遊戲的底層的東西。定義了一個 model 和全部用於更新它的函數。惟一的麻煩是全部函數依賴於飛船的上一次更新。記住,在 Elm 裏,任何狀況下,你都不能在共享的做用域中保存狀態,包括當前的 module -- 沒有辦法改變任何已經定義過的東西。那麼,如何在程序中改變一個狀態呢?
有一些毀三觀的事情將要發生了。在面向對象編程中,程序的狀態是分散在一些實例中的。這裏的 Ship
是算是一個類,並且 myShip
應該是這個類的實例。在程序運行的任何一個時間 myShip
都知道本身的位置和其餘屬性。但在函數式編程中並非這樣,在程序運行時 initShip
與剛開始時徹底同樣。爲了獲得當前的狀態,我須要知道過去發生了什麼。我須要使用那些事情做爲參數傳遞給已經定義好的函數,只有這樣我才能獲得 Ship
當前應該處在的狀態。這與曾經的玩法徹底不一樣,因此我要詳細講解這個過程。
在剛開始時 initShip
有一個默認的值: 0, 0, False
。還有一些函數能夠轉換一個 Ship
成爲另外一個 Ship
。詳細地說,有個 update
函數,它獲得用戶輸入和一個 ship 返回一個更新過的 ship。我要再寫一遍這個函數,因此你不用向上翻頁找它了。
update : Float -> Keys -> Ship -> Ship update dt keys ship = let newVel = toFloat keys.x isShooting = keys.y > 0 in updateVelocity newVel (updateShooting isShooting (applyPhysics dt ship))
若是 initShip
是這個 model 初始的狀態,至少,我能夠向前走一步了。Elm 程序定義了一個 main
函數,整個程序經過它開始運行。因此,首先讓咱們試着顯示 initShip
。我引入了 Graphics.Element
庫來調用 show
函數。
import Graphics.Element exposing (..) -- (other code) main : Element main = show initShip
這給了咱們
{ position = 0, shooting = False, velocity = 0 }
如今,若是我想再前進一步,我能夠在顯示飛船以前調用一次 update
函數。我試了一下,看到了 keys
,因此左右鍵被按下時已經有效果了(x
是 -1,y
是 1)。
dt = 100 keys = { x = -1, y = 1 } main = show (update dt keys initShip)
咱們有了
{ position = 0, shooting = True, velocity = -1 }
很好!搞定了!按下向上鍵時個人飛船開始射擊了,而且它有一個負的速度說明向左鍵也被按下了。請注意這時 position
尚未改變。這是由於我定義的更新的順序是:先應用物理屬性,而後才更新其餘屬性。 initShip
的速度是 0,因此改變物理值並無移動它。
如今我但願你拿出一些時間來讀一下 Elm-lang 的 Signals,若是你感興趣,甚至能夠看一兩個關於 Elm Signals 的視頻。從如今開始我假設你已經知道什麼是 Signals 了。
再來總結一下:一個 signal 就像一個 stream,在任何一個時間點,都有一個簡單的值。因此一個鼠標點擊的 signal 的計數永遠是一個整數 - 換句話說,它是一個 Signal Int
類型。若是我願意,我也能夠搞一個飛船的 signal: Signal Ship
,它能夠一直保存着當前的 Ship
。可是我須要重構以前全部的函數並記錄下那些複雜的值,事實上是那些值的 signals... 因此我遵從了來自 Elm-lang.org 的建議:
使用 signals 最常犯的錯誤是過多的使用它們。它會引誘你用 signals 作任何事情,但在你的代碼中儘可能不使用它們纔是墜吼滴!
因此,個人飛船能夠再前進一步,可是它沒有那麼使人激動了。我想要當我按下向左鍵時它向左移動,反之亦然。更重要的是,我要按向上鍵時發射子彈!
事實上我已經用一種偉大的方法構建了個人 models 和邏輯,由於那裏正好有個已經搞好的 signal 叫作 fps n
, 它更新 n
次每秒。它告訴咱們距離上次更新的時間差。這就是我須要的 dt
。並且,還有一個內置的 signal 被稱做 Keyboard.arrows
,它保存了當前的方向鍵信息跟我定義的 Keys
徹底同樣。不管什麼時候只要發生變化,這些都會被更新。
好了,爲了獲得一個有趣的輸入 signal,我會不得不聯合這兩個內置的 signals,以便 「當每次改變 fps
時,檢查 Keyboard.arrows
的狀態,並報告它們兩個的值」。
"它們倆" 聽起來像一個組合,(Float, Keys)
"在每一次更新" 聽起來像 Signal.sampleOn
在代碼中,這應該是下面這樣:
import Time exposing (..) import Keyboard -- (other code) inputSignal : Signal (Float, Keys) inputSignal = let delta = fps 30 -- map the two signals into a tuple signal tuples = Signal.map2 (,) delta Keyboard.arrows -- and update `inputSignal` whenever `delta` changes in Signal.sampleOn delta tuples
碉堡了,如今我須要作的是隻是接通個人 main
以使得用戶輸入能真正的被 update
函數得到到。爲了實現它,我須要 Signal.foldp
,或者想個辦法"抱緊過去"。這個跟搞個簡單的 fold 差很少:
summed = List.foldl (+) 0 [1,2,3,4,5]
這裏咱們從 0 開始,而後把它加上 1,再加上 2,以此類推,直到全部的數字被加在一塊兒,最後咱們獲得返回值爲 15。
簡單的說,這個頗有意義。foldp
一直記錄着 "開始時間" 的值,而且整合全部 signal 的過去狀態,直到當前這一刻 -- 整個應用完整的過去一步一步迭代到當前的狀態。
個人天.. 讓我喘口氣。好了,至少如今好點了。
不管怎樣,讓咱們看看它在代碼中是什麼樣的。如今,既然我有了 main
函數來更新它的結果,它應該也會在它的類型上反映出來,因此我會用一個 Signal Element
代替以前的 Element
。
main : Signal Element main = Signal.map show (Signal.foldp update initShip inputSignal)
這裏發生了一些事情:
我使用 Signal.foldp
來更新 signal,初始值是 initShip
。
Folding
仍然返回一個 signal,由於它要繼續更新 "folded 狀態"。
我使用 Signal.map
把當前的 "folded 狀態" 映射到 show
中。
只作這些會致使類型錯誤,尾部會有下面的報錯:
Type mismatch between the following types on line 49, column 38 to 44: Temp9243.Ship -> Temp9243.Ship Temp9243.Keys It is related to the following expression: update
呃... 好吧,至少我知道了問題出在哪裏。個人函數的類型簽名看上去像這樣:update : Float -> Keys -> Ship -> Ship
。然而,實際上我傳給它的參數是 (Float, Keys)
和 Ship
。嗯,我只須要稍微修改下函數的簽名...
update : (Float, Keys) -> Ship -> Ship update (dt, keys) ship = -- the same as before
... 嗒嗒,搞定了!
個人遊戲如今有了一個完整的函數模型,須要的更新和其餘任何東西,一共才 50 行代碼!完整的代碼在這看: game.elm。若想要看它的效果,你能夠複製粘貼到 Try Elm 這個交互編輯器中(點擊編譯按鈕按,在右邊的屏幕上按下方向鍵)。
再來總結一下剛纔發生了什麼:
一個信號是一個時間的函數
每一個時間點都對應着一個 signal 純粹的值
Signal.foldp
最後迭代出結果的原理與 List.foldl
同樣
程序的每一個狀態都是明確的起源於全部以前發生的事情
這些嘗試讓我學到了不少。我但願你也同樣能有所收穫。我我的的主觀感受是:
類型(Types)的確很是漂亮,並且有用
不可修改的數據結構(Immutability)和對全局狀態的限制並無聽起來那麼難以接受
函數式編程在 Elm 中很是簡潔,可讀性很強
函數式編程使輸入和輸出清晰明確
由於全部的這些關於狀態的想法是那麼的不同凡響,它有些難以掌握,可是它確實頗有意義
由於每一個狀態都是一個輸入的直接結果,因此不須要擔憂那些混合了各類狀態的 bug
響應式地監聽各類更改, 而不是主動地觸發修改,這種感受很幸福
最後一句:若是你喜歡這篇文章,請把它分享給你的好基友。分享就是真愛!
不可變數據(Immutable data) 意思是一旦你給一個東西賦了值,它再也沒法改變。拿 JavaScript 的 Array
來舉個反例。若是它是不可變的,myArray.push(item)
就沒法修改 myArray
已有的值,但它會返回一個新的追加了一個值的數組。
強類型 這種編程語言試圖防止不可預知的行爲致使的錯誤發生,例如:把一個字符串賦值爲一個整數。當出現類型不匹配時 Scala、Haskell 和 Elm 這些語言使用 靜態類型檢查 來阻止編譯經過。
純函數(Pure functions) 給相同的輸入永遠給出相同的輸出,並且沒有任何反作用的函數。本質上,這些函數絕對不能依賴輸入參數以外的任何東西,而且它不能修改任何東西。
函數式編程 特指以純函數爲主要表現形式的一種編程範式。
響應編程(Reactive programming) 歸納地說就是組件能夠被監聽,而且根據事件作出所須要的反應。在 Elm 中,這些可被監聽的東西是 signals。使用 signal 的組件知道如何利用它,可是 signal 徹底不知道組件或組件們的存在。