Elm 编程语言入门
已发表: 2022-03-11当一个非常有趣和创新项目的首席开发人员建议从 AngularJS 切换到 Elm 时,我的第一个想法是:为什么?
我们已经有一个写得很好的 AngularJS 应用程序,它处于固态、经过良好测试并在生产中得到证明。 Angular 4 作为 AngularJS 的一个值得升级的版本,可能是重写的自然选择——React 或 Vue 也是如此。 Elm 似乎是一种人们几乎没有听说过的奇怪的特定领域语言。
好吧,那是在我对榆树一无所知之前。 现在,有了一些使用它的经验,尤其是在从 AngularJS 过渡到它之后,我想我可以回答“为什么”。
在本文中,我们将介绍 Elm 的优缺点,以及它的一些奇特概念如何完美地满足前端 Web 开发人员的需求。 如需更多类似教程的 Elm 语言文章,您可以查看这篇博文。
Elm:纯函数式编程语言
如果您习惯使用 Java 或 JavaScript 进行编程,并且觉得这是编写代码的自然方式,那么学习 Elm 就像掉进兔子洞一样。
你会注意到的第一件事是奇怪的语法:没有大括号,有很多箭头和三角形。
你可以学会不用花括号,但你如何定义和嵌套代码块? 或者, for
循环或任何其他循环在哪里? 虽然可以使用let
块定义显式范围,但没有经典意义上的块,也根本没有循环。
Elm 是一种纯函数式、强类型、反应式和事件驱动的 Web 客户端语言。
您可能会开始怀疑是否可以通过这种方式进行编程。
实际上,这些品质加起来为您提供了一种令人惊叹的编程和开发优秀软件的范例。
纯功能性
您可能认为通过使用较新版本的 Java 或 ECMAScript 6,您可以进行函数式编程。 但是,这只是表面。
在那些编程语言中,您仍然可以访问大量语言结构,并且很容易求助于其中的非功能部分。 您真正注意到差异的地方是您除了函数式编程之外什么都做不了。 正是在那个时候,您最终开始感受到函数式编程是多么自然。
在 Elm 中,几乎所有东西都是函数。 记录名称是一个函数,联合类型值是一个函数——每个函数都由部分应用于其参数的函数组成。 甚至像加号 (+) 和减号 (-) 这样的运算符也是函数。
为了将编程语言声明为纯粹的功能性,而不是此类结构的存在,其他一切的缺失至关重要。 只有这样,您才能开始以纯粹的功能方式进行思考。
Elm 以成熟的函数式编程概念为蓝本,它类似于 Haskell 和 OCaml 等其他函数式语言。
强类型
如果您使用 Java 或 TypeScript 进行编程,那么您就会知道这意味着什么。 每个变量必须只有一种类型。
当然,存在一些差异。 与 TypeScript 一样,类型声明是可选的。 如果不存在,它将被推断。 但是没有“任何”类型。
Java 支持泛型类型,但方式更好。 Java 中的泛型是后来添加的,因此除非另有说明,否则类型不是泛型的。 而且,要使用它们,我们需要丑陋的<>
语法。
在 Elm 中,类型是通用的,除非另有说明。 让我们看一个例子。 假设我们需要一个方法,它接受一个特定类型的列表并返回一个数字。 在Java中它将是:
public static <T> int numFromList(List<T> list){ return list.size(); }
并且,在 Elm 语言中:
numFromList list = List.length list
虽然是可选的,但我强烈建议您始终添加类型声明。 Elm 编译器绝不允许对错误类型进行操作。 对于人类来说,犯这样的错误要容易得多,尤其是在学习语言时。 所以上面带有类型注释的程序将是:
numFromList: List a -> Int numFromList list = List.length list
起初在单独的行上声明类型似乎很不寻常,但一段时间后它开始看起来很自然。
网络客户端语言
这意味着 Elm 编译为 JavaScript,因此浏览器可以在网页上执行它。
鉴于此,它不是像 Java 或带有 Node.js 的 JavaScript 这样的通用语言,而是一种用于编写 Web 应用程序客户端部分的领域特定语言。 更重要的是,Elm 包括编写业务逻辑(JavaScript 所做的事情)和表示部分(HTML 所做的事情)——所有这些都使用一种函数式语言。
所有这些都以一种非常具体的类似框架的方式完成,称为 Elm 架构。
反应性
Elm 架构是一个响应式 Web 框架。 模型中的任何更改都会立即呈现在页面上,无需显式 DOM 操作。
这样,它类似于 Angular 或 React。 但是,Elm 也以自己的方式做到这一点。 理解其基础的关键在于view
和update
函数的签名:
view : Model -> Html Msg update : Msg -> Model -> Model
Elm 视图不仅仅是模型的 HTML 视图。 它是可以生成Msg
类型消息的 HTML,其中Msg
是您定义的精确联合类型。
任何标准页面事件都可以产生消息。 并且,当生成消息时,Elm 会在内部使用该消息调用更新函数,然后根据消息和当前模型更新模型,并且更新的模型再次在内部呈现到视图。
事件驱动
很像 JavaScript,Elm 是事件驱动的。 但与 Node.js 不同,Node.js 为异步操作提供单独的回调,Elm 事件被分组在离散的消息集中,定义为一种消息类型。 而且,与任何联合类型一样,单独的类型值携带的信息可以是任何东西。
可以产生消息的事件源有三种: Html
视图中的用户操作、命令的执行以及我们订阅的外部事件。 这就是为什么Html
、 Cmd
和Sub
这三种类型都包含msg
作为参数的原因。 而且,通用msg
类型在所有三个定义中必须相同——提供给更新函数的相同类型(在前面的示例中,它是Msg
类型,大写 M),其中所有消息处理都是集中的。
一个真实例子的源代码
您可以在此 GitHub 存储库中找到完整的 Elm Web 应用程序示例。 虽然很简单,但它展示了日常客户端编程中使用的大部分功能:从 REST 端点检索数据,解码和编码 JSON 数据,使用视图、消息和其他结构,与 JavaScript 通信,以及编译和打包所需的一切带有 Webpack 的 Elm 代码。
该应用程序显示从服务器检索到的用户列表。
为了更简单的设置/演示过程,Webpack 的开发服务器用于打包所有内容,包括 Elm,并为用户列表提供服务。
有些功能在 Elm 中,有些在 JavaScript 中。 这样做是出于一个重要原因:显示互操作性。 您可能想尝试 Elm 开始,或者逐渐将现有的 JavaScript 代码切换到它,或者在 Elm 语言中添加新功能。 通过互操作性,您的应用程序可以继续使用 Elm 和 JavaScript 代码。 这可能比在 Elm 中从头开始启动整个应用程序更好。
示例代码中的 Elm 部分首先使用来自 JavaScript 的配置数据进行初始化,然后检索用户列表并以 Elm 语言显示。 假设我们已经在 JavaScript 中实现了一些用户操作,因此在 Elm 中调用用户操作只是将调用分派回给它。
该代码还使用了下一节中解释的一些概念和技术。
Elm 概念的应用
让我们来看看 Elm 编程语言在现实世界中的一些奇特概念。
联合类型
这是 Elm 语言的纯金。 还记得当结构不同的数据需要与相同的算法一起使用时的所有这些情况吗? 对这些情况进行建模总是很困难。
这是一个示例:假设您正在为列表创建分页。 在每一页的末尾,应该有指向上一页、下一页和所有页面的链接,按其编号。 你如何构造它来保存用户点击了哪个链接的信息?
我们可以对上一个、下一个和页码点击使用多个回调,或者我们可以使用一个或两个布尔字段来指示点击了什么,或者为特定的整数值赋予特殊含义,如负数、零等。但没有一个这些解决方案可以准确地模拟这种用户事件。
在 Elm 中,这非常简单。 我们将定义一个联合类型:
type NextPage = Prev | Next | ExactPage Int
我们将其用作其中一条消息的参数:
type Msg = ... | ChangePage NextPage
最后,我们更新函数case
检查nextPage
的类型:
update msg model = case msg of ChangePage nextPage -> case nextPage of Prev -> ... Next -> ... ExactPage newPage -> ...
它使事情变得非常优雅。
使用<|
创建多个地图函数
许多模块包括一个map
函数,有几个变体可以应用于不同数量的参数。 例如, List
有map
、 map2
、 ... ,直到map5
。 但是,如果我们有一个带有六个参数的函数呢? 没有map6
。 但是,有一种技术可以克服这一点。 它使用<|
作为参数的函数和偏函数,其中一些参数作为中间结果应用。
为简单起见,假设List
只有map
和map2
,我们想要应用一个函数,该函数在三个列表上接受三个参数。
下面是实现的样子:
map3 foo list1 list2 list3 = let partialResult = List.map2 foo list1 list2 in List.map2 (<|) partialResult list3
假设我们想使用foo
,它只是乘以它的数字参数,定义如下:
foo abc = a * b * c
所以map3 foo [1,2,3,4,5] [1,2,3,4,5] [1,2,3,4,5]
的结果是[1,8,27,64,125] : List number
。
让我们解构这里发生的事情。
首先,在partialResult = List.map2 foo list1 list2
中, foo
部分应用于list1
和list2
中的每一对。 结果是[foo 1 1, foo 2 2, foo 3 3, foo 4 4, foo 5 5]
,一个接受一个参数(因为前两个参数已经应用)并返回一个数字的函数列表。
接下来在List.map2 (<|) partialResult list3
中,实际上是List.map2 (<|) [foo 1 1, foo 2 2, foo 3 3, foo 4 4, foo 5 5] list3
。 对于这两个列表中的每一对,我们调用(<|)
函数。 例如,对于第一对,它是(<|) (foo 1 1) 1
,它与foo 1 1 <| 1
相同。 foo 1 1 <| 1
,这与产生1
的foo 1 1 1
相同。 对于第二个,它将是(<|) (foo 2 2) 2
,即foo 2 2 2
,其计算结果为8
,依此类推。
此方法对于用于解码具有许多字段的 JSON 对象的mapN
函数特别有用,因为map8
最多为Json.Decode
提供它们。
从 Maybes 列表中删除所有 Nothing 值
假设我们有一个Maybe
值列表,并且我们只想从具有一个值的元素中提取值。 例如,列表是:

list : List (Maybe Int) list = [ Just 1, Nothing, Just 3, Nothing, Nothing, Just 6, Just 7 ]
而且,我们想要得到[1,3,6,7] : List Int
。 解决方案是这一行表达式:
List.filterMap identity list
让我们看看为什么会这样。
List.filterMap
期望第一个参数是一个函数(a -> Maybe b)
,它应用于提供的列表的元素(第二个参数),结果列表被过滤以省略所有Nothing
值,然后是真正的值是从Maybe
s 中提取的。
在我们的例子中,我们提供了identity
,所以结果列表又是[ Just 1, Nothing, Just 3, Nothing, Nothing, Just 6, Just 7 ]
。 过滤之后,我们得到[ Just 1, Just 3, Just 6, Just 7 ]
,在提取值之后,它是[1,3,6,7]
,正如我们想要的。
自定义 JSON 解码
随着我们对 JSON 解码(或反序列化)的需求开始超过Json.Decode
模块中公开的内容,我们可能难以创建新的外来解码器。 这是因为这些解码器是从解码过程的中间调用的,例如,在Http
方法中,它们的输入和输出并不总是很清楚,尤其是在提供的 JSON 中有很多字段的情况下。
这里有两个例子来说明如何处理这种情况。
在第一个中,我们在传入的 JSON 中有两个字段a
和b
,表示矩形区域的边。 但是,在 Elm 对象中,我们只想存储它的面积。
import Json.Decode exposing (..) areaDecoder = map2 (*) (field "a" int) (field "b" int) result = decodeString areaDecoder """{ "a":7,"b":4 }""" -- Ok 28 : Result.Result String Int
这些字段使用field int
解码器单独解码,然后将两个值都提供给map2
中提供的函数。 由于乘法( *
)也是一个函数,它有两个参数,我们可以这样使用它。 结果areaDecoder
是一个解码器,它在应用时返回函数的结果,在本例中a*b
。
在第二个例子中,我们得到了一个混乱的状态字段,它可以是 null,也可以是任何字符串,包括空,但我们知道只有当它是“OK”时操作才成功。 在这种情况下,我们希望将其存储为True
,对于所有其他情况,我们希望将其存储为False
。 我们的解码器如下所示:
okDecoder = nullable string |> andThen (\ms -> case ms of Nothing -> succeed False Just s -> if s == "OK" then succeed True else succeed False )
让我们将其应用于一些 JSON:
decodeString (field "status" okDecoder) """{ "a":7, "status":"OK" }""" -- Ok True decodeString (field "status" okDecoder) """{ "a":7, "status":"NOK" }""" -- Ok False decodeString (field "status" okDecoder) """{ "a":7, "status":null }""" -- Ok False
这里的关键在于提供给andThen
的函数,该函数获取先前可为空的字符串解码器(它是一个Maybe String
)的结果,将其转换为我们需要的任何内容,并在succeed
的帮助下将结果作为解码器返回。
关键要点
从这些示例中可以看出,函数式编程对于 Java 和 JavaScript 开发人员来说可能不是很直观。 需要一些时间来适应它,需要大量的试验和错误。 为了帮助理解它,您可以使用elm-repl
来练习和检查表达式的返回类型。
本文前面链接的示例项目包含更多自定义解码器和编码器的示例,它们也可能有助于您理解它们。
但是,为什么选择 Elm?
与其他客户端框架如此不同,Elm 语言当然不是“又一个 JavaScript 库”。 因此,与它们相比,它具有许多可以被认为是积极或消极的特征。
让我们先从积极的一面开始。
没有 HTML 和 JavaScript 的客户端编程
最后,您拥有一门可以完成所有工作的语言。 没有更多的分离,以及它们混合的尴尬组合。 没有在 JavaScript 中生成 HTML,也没有带有一些精简逻辑规则的自定义模板语言。
使用 Elm,您只有一种语法和一种语言,尽显其魅力。
均匀度
由于几乎所有的概念都是基于函数和一些结构的,因此语法非常简洁。 您不必担心是否在实例或类级别上定义了某些方法,或者它只是一个函数。 它们都是在模块级别定义的函数。 而且,迭代列表没有一百种不同的方法。
在大多数语言中,总是存在关于代码是否以该语言的方式编写的争论。 很多成语需要掌握。
在 Elm 中,如果它编译,它可能是“Elm”方式。 如果不是,那当然不是。
表现力
虽然简洁,但 Elm 语法非常富有表现力。
这主要是通过使用联合类型、正式类型声明和函数式风格来实现的。 所有这些都激发了使用更小的功能。 最后,你会得到几乎是自我记录的代码。
无空
当您长时间使用 Java 或 JavaScript 时, null
对您来说变得非常自然——这是编程中不可避免的一部分。 而且,虽然我们经常看到NullPointerException
和各种TypeError
,但我们仍然不认为真正的问题是null
的存在。 这太自然了。
使用 Elm 一段时间后,它很快就会变得清晰。 没有null
不仅可以让我们避免一遍又一遍地看到运行时 null 引用错误,它还可以通过明确定义和处理我们可能没有实际值的所有情况来帮助我们编写更好的代码,从而通过不推迟null
来减少技术债务处理直到有东西破裂。
对它有效的信心
创建语法正确的 JavaScript 程序可以很快完成。 但是,它真的会起作用吗? 好吧,让我们在重新加载页面并彻底测试后看看。
对于 Elm,情况正好相反。 使用静态类型检查和强制null
检查,它需要一些时间来编译,尤其是当初学者编写程序时。 但是,一旦编译完成,它很有可能会正常工作。
快速地
在选择客户端框架时,这可能是一个重要因素。 一个广泛的网络应用程序的响应能力通常对用户体验至关重要,因此对整个产品的成功也是至关重要的。 而且,正如测试所示,Elm 非常快。
Elm 与传统框架的优点
大多数传统的 Web 框架都为 Web 应用程序的创建提供了强大的工具。 但这种力量是有代价的:架构过于复杂,有很多不同的概念和规则,关于如何以及何时使用它们。 掌握这一切需要很多时间。 有控制器、组件和指令。 然后是编译和配置阶段,以及运行阶段。 然后,提供的指令中使用了服务、工厂和所有自定义模板语言——在所有这些情况下,我们需要直接调用$scope.$apply()
来刷新页面,等等。
Elm 编译到 JavaScript 当然也非常复杂,但是开发人员不必知道它的所有细节。 只需编写一些 Elm 并让编译器完成它的工作。
而且,为什么不选择 Elm?
赞美榆树就够了。 现在,让我们看看它不那么出色的一面。
文档
这确实是一个重大问题。 Elm 语言缺乏详细的手册。
官方教程只是浏览了语言并留下了很多未回答的问题。
官方的 API 参考更糟糕。 许多功能缺乏任何解释或示例。 而且,还有那些带有句子的人:“如果这令人困惑,请阅读 Elm 架构教程。 真的有帮助!” 不是您希望在官方 API 文档中看到的最棒的一行。
希望这会很快改变。
我不相信 Elm 可以被如此糟糕的文档广泛采用,尤其是对于来自 Java 或 JavaScript 的人,这些概念和功能根本不直观。 要掌握它们,需要包含大量示例的更好的文档。
格式和空白
摆脱花括号或括号并使用空格进行缩进看起来不错。 例如,Python 代码看起来非常整洁。 但对于elm-format
的创建者来说,这还不够。
由于所有的双行空格,以及表达式和赋值分成多行,Elm 代码看起来比水平的更垂直。 好的旧 C 语言中的单行代码可以很容易地扩展到 Elm 语言中的多个屏幕。
如果您通过编写的代码行获得报酬,这听起来不错。 但是,如果您想将某些内容与早 150 行开始的表达式对齐,那么祝您找到正确的缩进。
记录处理
很难与他们合作。 修改记录字段的语法很丑陋。 没有简单的方法可以修改嵌套字段或按名称任意引用字段。 而且,如果您以通用方式使用访问器函数,则正确键入会带来很多麻烦。
在 JavaScript 中,记录或对象是可以通过多种方式构造、访问和修改的中心结构。 甚至 JSON 也只是记录的序列化版本。 开发人员习惯于在 Web 编程中处理记录,因此如果将它们用作主要数据结构,则在 Elm 中处理记录的困难会变得很明显。
更多打字
Elm 需要编写比 JavaScript 更多的代码。
字符串和数字操作没有隐式类型转换,因此需要大量的 int-float 转换,尤其是toString
调用,这需要括号或函数应用程序符号来匹配正确数量的参数。 此外, Html.text
函数需要一个字符串作为参数。 对于所有这些Maybe
、 Results
、类型等,都需要很多 case 表达式。
造成这种情况的主要原因是严格的类型系统,这可能是一个公平的代价。
JSON 解码器和编码器
更多类型真正突出的一个领域是 JSON 处理。 JavaScript 中的JSON.parse()
调用可以跨越 Elm 语言中的数百行。
当然,JSON 和 Elm 结构之间需要某种映射。 但是为同一段 JSON 编写解码器和编码器的需求是一个严重的问题。 如果您的 REST API 传输具有数百个字段的对象,这将是很多工作。
包起来
我们已经看到 Elm,是时候回答众所周知的问题了,这些问题可能与编程语言本身一样古老:它比竞争对手更好吗? 我们应该在我们的项目中使用它吗?
第一个问题的答案可能是主观的,因为不是每个工具都是锤子,也不是所有东西都是钉子。 在许多情况下,Elm 可以大放异彩,成为比其他 Web 客户端框架更好的选择,而在其他情况下却不尽如人意。 但它提供了一些真正独特的价值,可以使 Web 前端开发比其他替代品更安全、更容易。
对于第二个问题,为了避免也用古老的“视情况而定”来回答,简单的答案是:是的。 即使上面提到了所有缺点,但 Elm 给你的关于你的程序正确的信心就足以让你有理由使用它。
用 Elm 编码也很有趣。 对于习惯于“传统”网络编程范式的人来说,这是一个全新的视角。
在实际使用中,您不必立即将整个应用程序切换到 Elm 或完全在其中启动一个新应用程序。 您可以利用它与 JavaScript 的互操作性来尝试一下,从界面的一部分或用 Elm 语言编写的某些功能开始。 您会很快发现它是否适合您的需求,然后扩大其使用范围或离开它。 谁知道呢,您可能还会爱上函数式 Web 编程的世界。