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 这门语言,再逐步过渡到更难的日志格式,以及添加一个 Web 前端。

我还听说函数式语言特别适合解析任务,但我一直不明白为什么,所以这也是个学习的好机会。

我的编程语言背景

我做程序员已经 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 个元素

那我到底该怎么做?把字符串拆成 token 然后处理?

最终我意识到,对于简单实现来说,我只想把字符串按行拆分,所以我要这样做:

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)
}

这是我第一次在任何语言中使用模式匹配(pattern matching),感觉很巧妙,不过它还是太陌生了,以至于我很难判断什么时候该用它。

放大看一下模式匹配的部分,在这里:

  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) 而不是空字符串。另外,比起之前的 list.filter(fn(s) { !string.is_empty(s) }),用 result.values 来过滤空行也更简洁。

总体感想

花了几个小时体验 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++ 预处理器指令。

但我发现自己偶尔也会希望语言里有这样的东西——当我正处于开发中途、不在乎编译器想保护我免受的那些 bug 时。

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 发布

本文章由 stealth/ox-alpha 进行翻译