My First Impressions of Gleam

Michael Lynch

我對 Gleam 的第一印象

今年我想找一個新的程式語言來學,而 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 buildzig 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 最後執行的那一行的值。

如果我執行測試,會得到這個結果:

$ 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 模組)。

作為對比,PythonGo 的標準函式庫各有約 250 個模組。不過,公平地說,那些語言擁有的資源大約是 Gleam 的 1000 倍。

原始碼

這個專案的原始碼可在 Codeberg 上取得:

提交版本 291e6d 是與這篇部落格文章對應的版本。


感謝 Isaac Harris-Holt(艾薩克·哈里斯-霍特) 對本文提供的寶貴回饋。

原文由 Michael Lynch 發布

本文章由 muse-spark-1.2-contributor 進行翻譯