我對 Gleam 的第一印象
原文由 Michael Lynch 于 發布,訂閱此部落格
今年我想學一個新的程式語言,而尋找新的程式語言的過程中,Gleam 看起來最好玩。它是一個類似 Elixir、但支援靜態型別的語言。
我讀了語言導覽,大致都看得懂,不過我得實際做點東西,才能好好評斷一個程式語言。
這裡分享我使用 Gleam 頭幾個小時的一些筆記,或許對其他正在學 Gleam 的人,或是開發這個語言的團隊會有所幫助。
我的專案:解析舊的 AIM 聊天紀錄
我從大約 1999 年到 2007 年都在使用 AOL Instant Messenger。大部分時間,我用的 AIM 客戶端都會自動記錄對話,但格式各不相同。大部分的紀錄格式是 XML 或 HTML,讓這些舊紀錄變得很難重讀。
最簡單的 AIM 紀錄是純文字格式,看起來像這樣:
Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
[18:44] Jane: hi
[18:55] Me: hey whats up
Session Close (Jane): Mon Sep 12 18:56:02 2005每隔大約十年,我就會嘗試寫一個通用的 AIM 紀錄解析器,想把所有舊紀錄轉成一致、易讀的格式。可惜每次做到一半就覺得無聊而放棄。上一次嘗試是七年前,當時是用 Python 2.7 寫的。
解析紀錄這個題目跟 Gleam 很搭,因為專案中有些部分很簡單(例如解析純文字紀錄),我可以一邊熟悉 Gleam 這個語言,一邊先從簡單的部分下手,再慢慢挑戰更複雜的紀錄格式,最後加上網頁前端。
我也聽說函數式語言特別適合做解析這類任務,但我一直不太理解原因,正好趁這個機會學習一下。
我的程式語言背景
我寫程式已經有 20 年了,但我並不是什麼語言設計的行家。接下來會提到一些我覺得 Gleam 不直覺或不好用的地方,但這些並不是對語言本身的批判,只是坦率的第一印象。
我從來沒有在專為函數式程式設計而設計的語言上工作過,最接近的大概就是 JavaScript。我最熟悉的語言是 Go 和 Python。
該怎麼解析命令列參數?
我最先想搞清楚的是要怎麼解析命令列參數,這樣我才能像這樣呼叫我的程式:
./log-parser ~/logs/aim/plaintext但 Gleam 的標準函式庫裡沒有讀取命令列參數的模組。我找到了glint,但為了只讀一個參數,用它感覺太複雜了。後來我發現有個更簡單的第三方函式庫叫argv。
我可以這樣解析命令列參數:
pub fn main() {
case argv.load().arguments {
[path] -> io.println("command-line arg is " <> path)
_ -> io.println("Usage: gleam run <directory_path>")
}
}$ gleam run ~/whatever
Compiled in 0.01s
Running log_parser.main
command-line arg is /home/mike/whatever不錯,還算簡單!
gleam build 到底做了什麼?
我已經能用 gleam run 執行程式了,但我好奇能不能像 go build 或 zig build 那樣編譯出一個可執行檔。
$ gleam build
Compiled in 0.01s嗯,編譯了什麼?我完全看不到任何執行檔。
關於 gleam build 的文件只寫著「Build the project」,卻沒有說明它到底建置了什麼、或是把產物放在哪裡。
雖然有個 build 目錄,但裡面看不到明顯的可執行檔。
$ rm -rf build && gleam build
Downloading packages
Downloaded 5 packages in 0.00s
Compiling argv
Compiling gleam_stdlib
Compiling filepath
Compiling gleeunit
Compiling simplifile
Compiling log_parser
Compiled in 0.52s
$ ls -1 build/
dev
gleam-dev-erlang.lock
gleam-dev-javascript.lock
gleam-lsp-erlang.lock
gleam-lsp-javascript.lock
gleam-prod-erlang.lock
gleam-prod-javascript.lock
packages到處翻了一下,我覺得可執行檔應該在 build/dev/erlang/log_parser/ebin/ 底下:
$ ls -1 build/dev/erlang/log_parser/ebin/
log_parser.app
log_parser.beam
log_parser@@main.beam
log_parser_test.beam
plaintext_logs.beam
plaintext_logs_test.beam那些看起來是 BEAM 位元組碼,所以沒辦法直接執行。我猜應該可以手動啟動 BEAM 虛擬機器來執行那些檔案,但聽起來就不太吸引人。
所以我還是繼續用 gleam run 來執行程式,不過我希望 gleam build 能更清楚地說明它產出了什麼、開發者又能拿它做什麼。
先來實作最簡單的解析器
一開始,我決定先寫一個能做純文字紀錄基本解析的函式。
所以我先寫了一個測試,描述我想要的結果。
pub fn parse_simple_plaintext_log_test() {
"
Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005
[18:44] Jane: hi
[18:55] Me: hey whats up
Session Close (Jane): Mon Sep 12 18:56:02 2005
"
|> string.trim
|> plaintext_logs.parse
|> should.equal(["hi", "hey whats up"])
}最終我想解析對話中的所有詮釋資料,包括暱稱、時間戳記和工作階段資訊。但作為第一步,我的函式只需要把 AIM 聊天紀錄當作字串讀進來,然後把其中的聊天訊息以字串串列的形式輸出。
也就是說,我實際的函式會長這樣:
pub fn parse(contents: String) -> List(String) {
// Note: todo is a Gleam language keyword to indicate unfinished code.
todo
}為了讓它能先編譯過,我先加了一個假的實作:
pub fn parse(contents: String) -> List(String) {
["fake", "data"]
}然後我可以這樣測試:
$ gleam test
Compiling log_parser
warning: Unused variable
┌─ /home/mike/code/gleam-log-parser2/src/plaintext_logs.gleam:1:14
│
1 │ pub fn parse(contents: String) -> List(String) {
│ ^^^^^^^^^^^^^^^^ This variable is never used
Hint: You can ignore it with an underscore: `_contents`.
Compiled in 0.22s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["fake", "data"]
output:
Finished in 0.008 seconds
1 tests, 1 failures不錯,這正是我預期的結果。測試失敗是因為它回傳的是寫死的假資料,跟我的測試預期不符。
讓大腦適應函數式語言
好,現在要來真的實作解析邏輯了。我需要實作這個函式:
pub fn parse(contents: String) -> List(String) {
todo
}到這個階段,我有點卡住了。我突然意識到,Gleam 排除了我在其他語言中習慣使用的那麼多工具:
- 沒有
if敘述 - 沒有迴圈
- 沒有
return關鍵字 - 沒有串列的索引存取器
- 例如,無法存取
List的第 n 個元素
- 例如,無法存取
那我到底該怎麼做?把字串切成詞彙,然後再做點什麼嗎?
最後我想到,對於一個簡單的實作,我只要把字串按行切開就好,所以我想這樣寫:
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
}如果再跑一次測試,會得到這個結果:
$ gleam test
Compiling log_parser
Compiled in 0.21s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["Session Start (DumbAIMScreenName:Jane): Mon Sep 12 18:44:17 2005", "[18:44] Jane: hi", "[18:55] Me: hey whats up", "Session Close (Jane): Mon Sep 12 18:56:02 2005"]
output:
Finished in 0.009 seconds
1 tests, 1 failures好,現在又更接近一點了。
在沒有迴圈的語言裡要怎麼遍歷串列?
我已經把紀錄轉成了一行一行的串列,但在這裡又卡住了。
我太習慣用 for 迴圈了,腦中一直想著:「要怎麼用 for 迴圈來遍歷這些元素?」
後來我才意識到,我需要呼叫list.map。我得定義一個會對串列中每個元素作用的函式。
import gleam/list
import gleam/string
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> line
}
}
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
|> list.map(parse_line)
}這是我第一次在任何語言中使用模式匹配,感覺很巧妙,不過因為還是很陌生,我很難判斷什麼時候該用它。
稍微放大來看模式匹配的部分,就是這裡:
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> line
}它會對 line 這個變數求值,並將它與大括號內後續的某個模式進行匹配。如果該行以 "Session Start" 開頭(<> 表示前面的字串是前綴),那麼 Gleam 就會執行 -> 後面的程式碼,在這裡就是回傳空字串。"Session Close" 的情況也一樣。
如果該行不符合 "Session Start" 或 "Session Close" 的模式,Gleam 就會執行 case 裡的最後一行,也就是匹配任何字串的那一行。在那種情況下,它會直接回傳同樣的字串。也就是說 "hi" 就會被求值為 "hi" 本身。
就是在這裡,我才深深感受到沒有 return 關鍵字有多奇怪。在我所知的其他所有語言中,都必須用 return 關鍵字來明確地從函式回傳值,但在 Gleam 裡,回傳值就是函式中最後一行被執行的那個值。
如果我執行測試,會得到這個結果:
$ gleam test
Compiling log_parser
Compiled in 0.22s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["", "[18:44] Jane: hi", "[18:55] Me: hey whats up", ""]
output:
Finished in 0.009 seconds
1 tests, 1 failures同樣地,這也是我預期的結果,而且離目標又更近了一步。
我已經把 "Session Start" 和 "Session End" 的那幾行轉成了空字串,而串列中間的兩個元素就是包含 AIM 訊息的那些行。
剩下的工作是:
- 去除紀錄中時間和寄件者的部分。
- 過濾掉空字串。
從一行文字中擷取 AIM 訊息
到這個階段,我手上有像這樣的一個字串:
[18:55] Me: hey whats up而我需要只擷取寄件者名稱之後的部分,變成這樣:
hey whats up我的直覺是用字串分割函式,以 : 字元來分割。我看到有string.split,它會回傳 List(String)。
還有一個string.split_once 函式,應該也適用,因為我可以只用 : (注意冒號後面有個空格)來分割一次。
問題在於 split_once 回傳的是 Result(#(String, String), Nil),這個型別對我來說感覺有點可怕。它是一個包在 Result 裡的二元組,代表這個函式在失敗時可能會回傳錯誤。讓人困惑的是 split_once 可能會失敗,而 split 卻不會,所以為了簡單起見,我還是選擇用 split。
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
echo string.split(line, on: ": ")
todo
}
}
}如果我執行測試,會得到這個結果:
$ gleam test
warning: Todo found
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
│
10 │ todo
│ ^^^^ This code is incomplete
This code will crash if it is run. Be sure to finish it before
running your program.
Hint: I think its type is `String`.
Compiled in 0.01s
Running log_parser_test.main
src/plaintext_logs.gleam:9
["[18:44] Jane", "hi"]很好,這正是我想要的效果。我已經成功地把 "hi" 的部分分離出來,現在只要把它回傳就行了。
要怎麼取得串列的最後一個元素?
到這個階段,我感覺離成功不遠了。我已經把那一行轉成了字串串列,也知道我要的字串就是串列的最後一個元素,但要怎麼把它取出來呢?
在大多數其他語言裡,我只要寫 line_parts[1] 就好,但在 Gleam 的串列沒有用索引存取的機制。
查看 gleam/list 模組,我看到有個list.last 函式,所以試試看:
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
string.split(line, on: ": ")
|> list.last
|> echo
|> todo
}
}
}如果執行這個,會得到:
$ gleam test
Compiling log_parser
warning: Todo found
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:12:11
│
12 │ |> todo
│ ^^^^ This code is incomplete
This code will crash if it is run. Be sure to finish it before
running your program.
Hint: I think its type is `fn(Result(String, Nil)) -> String`.
Compiled in 0.24s
Running log_parser_test.main
src/plaintext_logs.gleam:11
Ok("hi")又更接近一點了!我已經擷取到串列的最後一個元素,找到了 "hi",但現在它被包在Result 型別裡面。
我可以用result.unwrap 來解開它
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
string.split(line, on: ": ")
|> list.last
|> result.unwrap("")
}
}
}重新執行 gleam test 後,得到:
$ gleam test
Compiling log_parser
Compiled in 0.22s
Running log_parser_test.main
F
Failures:
1) plaintext_logs_test.parse_simple_plaintext_log_test: module 'plaintext_logs_test'
Values were not equal
expected: ["hi", "hey whats up"]
got: ["", "hi", "hey whats up", ""]
output:
Finished in 0.008 seconds
1 tests, 1 failures太好了!這正是我想要的。我已經把訊息的那些行精簡到只剩下訊息內容本身。
過濾掉空字串
唯一剩下的就是把串列中的空字串過濾掉,用list.filter 就能簡單做到:
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
|> list.map(parse_line)
|> list.filter(fn(s) { !string.is_empty(s) })
}然後我重新執行測試:
$ gleam test
Compiling log_parser
Compiled in 0.22s
Running log_parser_test.main
.
Finished in 0.007 seconds
1 tests, 0 failures瞧!測試現在通過了!
整理字串分割的寫法
我的測試現在已經通過了,所以理論上,我已經達成了最初的目標。
我可以就此宣告勝利、收工回家。或者,我也可以來重構一下!
我選擇重構。
對於我那個字串分割的邏輯,我感到有點不好意思,因為它感覺不太像是道地的 Gleam 寫法。有沒有辦法可以不用處理 result 的解包就做到呢?
再看一次程式碼,我意識到可以用這個新奇的模式匹配來解決。我知道字串分割後會變成一個有兩個元素的串列,所以我可以為這個雙元素串列建立一個模式:
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
case string.split(line, on: ": ") {
[_, message] -> message
_ -> ""
}
}
}
}這樣感覺比呼叫 result.last 優雅一點。
還能再整理得更乾淨一點嗎?我之前避開了 string.split_once,因為它的型別太讓人困惑,但如果我預期只會分割一次,它可能是更好的選擇,那會長什麼樣子呢?
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
echo string.split_once(line, on: ": ")
todo
}
}
}為了檢視資料,我再跑一次測試:
$ gleam test
[...]
src/plaintext_logs.gleam:9
Ok(#("[18:44] Jane", "hi"))好吧,這看起來沒有我想的那麼可怕。雖然我的第一直覺是解開錯誤並存取元組中的最後一個元素(其實對元組來說很容易,只是對串列不行),但我現在知道應該有個更符合模式匹配的做法。而的確有:
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
case string.split_once(line, on: ": ") {
Ok(#(_, message)) -> message
_ -> ""
}
}
}
}Ok(#(_, message)) 這個模式會匹配 split_once 成功時回傳的結果,也就是一個包在 Ok 結果裡、由兩個 String 組成的元組。另一個 case 分支則是萬用的捕捉,會回傳空字串。
擺脫空字串這種取巧的做法
Gleam 最吸引我的特色之一就是它的靜態型別,所以用空字串來代表某一行沒有訊息,感覺有點取巧。我能不能改用型別系統,而不是用空字串當作哨兵值呢?
在 Gleam 中,用來表示某件事可能會失敗、但失敗不一定是錯誤的慣用寫法是 Result(<type>, Nil),所以讓我試著改寫成這樣:
import gleam/list
import gleam/result
import gleam/string
fn parse_line(line: String) -> Result(String, Nil) {
case line {
"Session Start" <> _ -> Error(Nil)
"Session Close" <> _ -> Error(Nil)
line -> {
case string.split_once(line, on: ": ") {
Ok(#(_, message)) -> Ok(message)
_ -> Error(Nil)
}
}
}
}
pub fn parse(contents: String) -> List(String) {
string.split(contents, on: "\n")
|> list.map(parse_line)
|> result.values
}太好了!我喜歡這樣更明確地表達:沒有訊息的行是回傳 Error(Nil) 而不是空字串。而且,result.values 用來過濾掉空行,比之前的 list.filter(fn(s) { !string.is_empty(s) }) 更簡潔。
整體心得
花了幾個小時接觸 Gleam 之後,我還滿喜歡它的。它恰到好處地把我推出舒適圈,讓我感覺自己正在學習新的程式思考方式,但又不至於讓人不知所措到什麼都學不了。
我目前覺得 Gleam 最大的缺點是,它是一個還很年輕、團隊也相對較小的語言。它才剛滿六歲,而且看起來創辦人在一年前之前都是一個人單打獨鬥。現在雖然有少數幾位核心維護者,但我不確定其中是否有人是全職投入 Gleam,因此生態系還有些侷限。接下來我打算解析其他 HTML 和 XML 格式的紀錄,雖然 Gleam 也有對應的 HTML 和 XML 解析器,但似乎都不太普及,所以我不確定實際用起來效果如何。
喜歡:管線
我超級、超級喜歡 Gleam 的管線語法。你可以在測試中看到我用 |> 符號:
"..."
|> string.trim
|> plaintext_logs.parse
|> should.equal(["hi", "hey whats up"])如果不用管線,同樣的測試會長這樣:
pub fn parse_simple_plaintext_log_test() {
let input = "..."
let trimmed = string.trim(input)
let parsed = plaintext_logs.parse(trimmed)
should.equal(parsed, ["hi", "hey whats up"])
}相較之下,它看起來就像一坨濕垃圾。
自從看過管線之後,就覺得它理所當然到不行,反而讓人覺得我用的其他程式語言竟然沒有這東西,實在很突兀。
我在 bash 中一直很喜歡用管線,但我從來沒想過,其他程式語言竟然都沒有採用這種寫法,是多麼奇怪的一件事。
喜歡:以範例為主的文件
Gleam 的文件有點簡潔,但我喜歡它有大量的範例。
我最擅長透過閱讀範例來學習,所以我很欣賞 Gleam 標準函式庫中有這麼多文件,都是用範例來展示每個 API 函式的簡單用法。
喜歡:內建的未使用符號警告
我喜歡 Gleam 編譯器原生就會對未使用的函式、變數和引入發出警告。而且我喜歡這些只是警告,而不是錯誤。
在 Go 裡面,除錯時我常常因為暫時註解掉某些程式碼,然後編譯器就頑固地拒絕做任何事,直到我把那個該死的引入修好,而等我除錯完,又得再把它改回來,實在很惱人。
喜歡:todo 關鍵字
我最喜歡的一個蠢程式笑話,發生在約 15 年前我的第一份程式設計工作時。在一封有好幾位 C++ 開發者的群組郵件中,我朋友分享了一個關於 C++ 開發的絕妙小技巧。
他說,如果我們哪天受夠了那些艱澀難懂的 C++ 編譯錯誤,只要在原始碼中加入一行特別的程式碼,那麼即使是無效的 C++ 程式碼也能成功編譯:
#pragma always_compile暴雷一下:它並不是真的 C++ 前置處理器指令。
但在開發到一半、根本不在乎編譯器想幫我防範的那些錯誤時,我偶爾還真希望語言裡有這種東西。
Gleam 的 todo 幾乎就像是 #pragma always_compile。即使你的程式碼是無效的,Gleam 編譯器也只會說:「好吧,算了。我還是照樣幫你跑。」
你可以在我實作 parse_line 到一半時看到這個效果:
fn parse_line(line: String) -> String {
case line {
"Session Start" <> _ -> ""
"Session Close" <> _ -> ""
line -> {
echo string.split(line, on: ": ")
todo
}
}
}如果我把 todo 拿掉,Gleam 就完全拒絕執行程式碼:
$ gleam test
Compiling log_parser
error: Type mismatch
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:8:5
│
8 │ ╭ line -> {
9 │ │ echo string.split(line, on: ": ")
10 │ │ }
│ ╰─────^
This case clause was found to return a different type than the previous
one, but all case clauses must return the same type.
Expected type:
String
Found type:
List(String)沒錯,我回傳了錯誤的型別,那編譯器為什麼還要配合我呢?
但加上 todo 就能讓我照樣執行這個函式,這有助於我理解程式碼正在做什麼,即使我還沒完成實作:
$ gleam test
warning: Todo found
┌─ /home/mike/code/gleam-log-parser/src/plaintext_logs.gleam:10:7
│
10 │ todo
│ ^^^^ This code is incomplete
This code will crash if it is run. Be sure to finish it before
running your program.
Hint: I think its type is `String`.
Compiling log_parser
Compiled in 0.21s
Running log_parser_test.main
src/plaintext_logs.gleam:9
["[18:44] Jane", "hi"]
F
[...]
Finished in 0.007 seconds
1 tests, 1 failures喜歡:模式匹配
我覺得模式匹配既優雅又簡潔,雖然它是 Gleam 中我覺得最難適應的部分。它跟我在其他熟悉的語言中所習慣的程序式程式設計風格,感覺截然不同。
缺點是我很難判斷什麼時候該用模式匹配,而且我也覺得模式匹配比較難讀。不過我想這只是因為還不熟練,我想多練習之後,就能用模式匹配的方式來思考了。
不喜歡:錯誤處理
我覺得 Gleam 的錯誤處理有點彆扭,尤其因為錯誤會破壞原本漂亮、整齊的管線之美。
例如,如果我有像這樣一個字串處理管線:
string.split(line, on: "-")
|> list.last
|> result.unwrap("") // Ugly!
|> string.uppercase那一行 result.unwrap 在我看來又醜又突兀。我希望語法能像這樣:
string.split(line, on: ": ")
|> try list.last
|> string.uppercase
|> Ok其中 try 會讓函式直接回傳錯誤,有點像在 Zig 裡面的寫法。
不喜歡:核心語言過於精簡
我不確定這是長期的設計選擇,還是只是因為它是獨立開發的語言所以目前還很精簡,但 Gleam 最先讓我印象深刻的一點,就是它的內建功能非常少。
舉例來說,List 型別本身沒有內建遍歷元素的功能,型別本身也沒有提供遍歷用的函式,所以你必須使用標準函式庫中的 gleam/list 模組。
同樣地,如果一個函式可能會失敗,它會回傳Result 型別,但也沒有內建的函式來處理 Result,所以你必須使用gleam/result 模組來檢查函式是否成功。
對我來說,這些功能感覺是如此核心,應該是語言本身的一部分,而不是放在標準函式庫裡。
不喜歡:標準函式庫過於有限
除了語言本身感覺很精簡之外,標準函式庫也感覺相當有限。
目前 Gleam 標準函式庫中只有 19 個模組。特別明顯缺席的是處理檔案系統的模組(目前事實上的標準似乎是第三方的simplifile 模組)。
相較之下,Python 和Go 的標準函式庫各有約 250 個模組。不過,公平來說,那些語言擁有的資源大概是 Gleam 的 1000 倍。
原始碼
這個專案的原始碼可在 Codeberg 上找到:
Commit 291e6d 是與這篇部落格文章對應的版本。
感謝 Isaac Harris-Holt 對這篇文章提供的寶貴回饋。
隨機一篇部落格
留言
登入後參與討論