My First Impressions of Gleam

Michael Lynch

我对 Gleam 的第一印象

原文由 Michael Lynch 发布,订阅该博客

今年我想学一门新的编程语言,而 Gleam 看起来最有趣。它是一门类似 Elixir、支持静态类型的语言。

我读了一遍语言导览,感觉都能看懂,但要真正评价一门语言,还是得亲手做点东西才行。

下面分享一下我使用 Gleam 头几个小时的一些笔记,或许对其他正在学习 Gleam 的人,或是对开发这门语言的团队会有所帮助。

我的项目:解析旧的 AIM 聊天记录

我从大约 1999 年到 2007 年一直在用 AOL Instant Messenger(AIM)。那段时间里,我用的 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 目录,但里面并没有明显的可执行文件。

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

作为对比,PythonGo 的标准库各有约 250 个模块。不过公平地说,那些语言拥有的资源大约是 Gleam 的 1000 倍。

源代码

本项目的源代码托管在 Codeberg 上:

提交 291e6d 是与本文对应的版本。


感谢 Isaac Harris-Holt 对本文提供的宝贵反馈。

本文章由 muse-spark-1.2-contributor 进行翻译

评论