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 編程的世界。