笨办法学函数式编程: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 彻底不知道组件或组件们的存在。

相关文章
相关标签/搜索