翻译自: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。根据你显示输出结果的不同,它可能是一个内置了对象的 ,或者一个更传统的网页。让我重复一遍,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)
这里发生了一些事情:
我使用
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
一样程序的每个状态都是明确的起源于所有之前发生的事情
III. 学到了什么
这些尝试让我学到了很多。我希望你也一样能有所收获。我个人的主观感觉是:
类型(Types)的确非常漂亮,而且有用
不可修改的数据结构(Immutability)和对全局状态的限制并没有听起来那么难以接受
函数式编程在 Elm 中非常简洁,可读性很强
函数式编程使输入和输出清晰明确
因为所有的这些关于状态的想法是那么的与众不同,它有些难以掌握,但是它确实很有意义
因为每个状态都是一个输入的直接结果,所以不需要担心那些混合了各种状态的 bug
响应式地监听各种更改, 而不是主动地触发修改,这种感觉很幸福
最后一句:如果你喜欢这篇文章,请把它分享给你的好基友。分享就是真爱!
附录: 术语表
不可变数据(Immutable data) 意思是一旦你给一个东西赋了值,它再也无法改变。拿 JavaScript 的 Array
来举个反例。如果它是不可变的,myArray.push(item)
就无法修改 myArray
已有的值,但它会返回一个新的追加了一个值的数组。
强类型 这种编程语言试图防止不可预知的行为导致的错误发生,例如:把一个字符串赋值为一个整数。当出现类型不匹配时 Scala、Haskell 和 Elm 这些语言使用 静态类型检查 来阻止编译通过。
纯函数(Pure functions) 给相同的输入永远给出相同的输出,而且没有任何副作用的函数。本质上,这些函数绝对不能依赖输入参数之外的任何东西,并且它不能修改任何东西。
函数式编程 特指以纯函数为主要表现形式的一种编程范式。
响应编程(Reactive programming) 概括地说就是组件可以被监听,并且根据事件做出所需要的反应。在 Elm 中,这些可被监听的东西是 signals。使用 signal 的组件知道如何利用它,但是 signal 完全不知道组件或组件们的存在。