5

翻译自:https://gist.github.com/ohanhi/0d3d83cf3f0d7bbea9db
原作者: Ossi Hanhinen, @ohanhi
翻译:Integ, @integ
爱心支持 Futurice .
授权协议 CC BY 4.0.

前言

不久以前一个好朋友给我安利了 响应式编程(Reactive Programming)。不写 函数式响应编程 简直就是犯罪 -- 很明显函数式方法大幅弥补了响应编程的不足。它如何做到的,我并不知道,所以我决定学一下这些东西。

通过了解自己,我很快发现只有用它解决一些实际的问题,我才能领会它的观念模式。写了这么多年 Javascript,我本来早就可以开始使用 RxJS 的。但再一次,因为我了解自己,并且我发现它会给我太多空间来违背常理。我需要一个强制我用函数式思维来解决任何问题的工具,正在这时 Elm 出现了。

Elm 是什么?

Elm 是一种编程语言,它会被编译为 HTML5: HTML, CSS 和 JavaScript。根据你显示输出结果的不同,它可能是一个内置了对象的 <canvas>,或者一个更传统的网页。让我重复一遍,Elm 是一种语言,它会被编译为 三种语言 来构建 web 应用。而且,它是一个拥有强类型和 不可变(immutable)数据结构的函数式语言。

好了,你可以猜到我并不是这个领域的专家,为了防止你走丢,我专门在这篇文章的最后列出了下面的术语解释:附录:术语表.

I. 限制是有益的

我决定尝试使用 Elm 制作一个类似《太空侵略者》的游戏。让我们站在玩家的视角思考一下它是怎么工作的。

  • 在屏幕下部有一艘代表着玩家的飞船

  • 玩家可以通过相应的方向键控制飞船左右移动

  • 玩家可以按向上键发射子弹射击

好了,我们切换到飞船的视角,再来看下

  • 飞船有一个一维的位置坐标

  • 飞船可以获得一个速度(向左或向右)

  • 飞船根据它的速度改变位置

  • 飞船可能被击中

这些基本上给了我一个飞船的数据结构的定义,或者说一个 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 完全不知道组件或组件们的存在。


Integ
5.6k 声望295 粉丝

全栈溢出工程师,兼职拆段错误水表,代写内核紧张拉丁语情书。


« 上一篇
Elm 架构教程