笨辦法學函數式編程:Elm 初體驗

翻譯自: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 是什麼?

Elm 是一種編程語言,它會被編譯爲 HTML5: HTML, CSS 和 JavaScript。根據你顯示輸出結果的不一樣,它多是一個內置了對象的 <canvas>,或者一個更傳統的網頁。讓我重複一遍,Elm 是一種語言,它會被編譯爲 三種語言 來構建 web 應用。並且,它是一個擁有強類型和 不可變(immutable)數據結構的函數式語言。web

好了,你能夠猜到我並非這個領域的專家,爲了防止你走丟,我專門在這篇文章的最後列出了下面的術語解釋:附錄:術語表.express

I. 限制是有益的

我決定嘗試使用 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 -- 沒有辦法改變任何已經定義過的東西。那麼,如何在程序中改變一個狀態呢?

II. 狀態是 Immutable 曾經的樣子

有一些毀三觀的事情將要發生了。在面向對象編程中,程序的狀態是分散在一些實例中的。這裏的 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,因此改變物理值並無移動它。

Signals

如今我但願你拿出一些時間來讀一下 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)

這裏發生了一些事情:

  1. 我使用 Signal.foldp 來更新 signal,初始值是 initShip

  2. Folding 仍然返回一個 signal,由於它要繼續更新 "folded 狀態"。

  3. 我使用 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 同樣

  • 程序的每一個狀態都是明確的起源於全部以前發生的事情

III. 學到了什麼

這些嘗試讓我學到了不少。我但願你也同樣能有所收穫。我我的的主觀感受是:

  • 類型(Types)的確很是漂亮,並且有用

  • 不可修改的數據結構(Immutability)和對全局狀態的限制並無聽起來那麼難以接受

  • 函數式編程在 Elm 中很是簡潔,可讀性很強

  • 函數式編程使輸入和輸出清晰明確

  • 由於全部的這些關於狀態的想法是那麼的不同凡響,它有些難以掌握,可是它確實頗有意義

  • 由於每一個狀態都是一個輸入的直接結果,因此不須要擔憂那些混合了各類狀態的 bug

  • 響應式地監聽各類更改, 而不是主動地觸發修改,這種感受很幸福

最後一句:若是你喜歡這篇文章,請把它分享給你的好基友。分享就是真愛!

附錄: 術語表

不可變數據(Immutable data) 意思是一旦你給一個東西賦了值,它再也沒法改變。拿 JavaScript 的 Array 來舉個反例。若是它是不可變的,myArray.push(item) 就沒法修改 myArray 已有的值,但它會返回一個新的追加了一個值的數組。

強類型 這種編程語言試圖防止不可預知的行爲致使的錯誤發生,例如:把一個字符串賦值爲一個整數。當出現類型不匹配時 Scala、Haskell 和 Elm 這些語言使用 靜態類型檢查 來阻止編譯經過。

純函數(Pure functions) 給相同的輸入永遠給出相同的輸出,並且沒有任何反作用的函數。本質上,這些函數絕對不能依賴輸入參數以外的任何東西,而且它不能修改任何東西。

函數式編程 特指以純函數爲主要表現形式的一種編程範式。

響應編程(Reactive programming) 歸納地說就是組件能夠被監聽,而且根據事件作出所須要的反應。在 Elm 中,這些可被監聽的東西是 signals。使用 signal 的組件知道如何利用它,可是 signal 徹底不知道組件或組件們的存在。

相關文章
相關標籤/搜索