引言
如果你读过我最近那篇关于编程语言的吐槽,你就会知道我一直对 Gleam 相当感兴趣。Gleam 是一门函数式语言,目标平台是 BEAM,也就是 Erlang VM,1,语法简单,并且能与更广泛的 BEAM 生态(Erlang 和 Elixir)互操作。有意思的是,Gleam 也可以以 JavaScript 为目标,也就是说,Gleam 可以编译成 JavaScript 并通过 Node.js 运行。
我大多用 Rust 做业余项目,目前它是唯一真正让我觉得写代码很享受的语言,而 Gleam 也成功挠到了这个痒处。如果你是 Rust 行家,那么除了一些关键差异之外,Gleam 会让你觉得很熟悉。我感觉我写的代码里有 80% 可以复制粘贴到 Rust(只需稍微搜索替换一下语法),然后就能编译。
这篇文章不会全面概述这门语言,如果你想了解更多,我推荐这个出色的交互式导览。不到半小时,你就能对 Gleam 有不错的感受。
Gleam:优点
在其主页上,Gleam 被描述为“一门用于构建可扩展的类型安全系统的友好语言!”(强调是他们的),这很好地传达了 Gleam 的核心所在。
Gleam 很友好
语法极其简单,大多数复杂的东西都被 Erlang 运行时隐藏起来了,除非你想,否则你并不真的需要与它交互!当然,Gleam 是一门函数式语言,所以如果你一辈子都生活在 C 形状的命令式世界里,一开始可能会有点令人望而生畏。但如果你把它与其他函数式语言(OCaml、Haskell、Erlang)相比,你很快会注意到 Gleam 简单得多。用了一段时间后,感觉它就像 Rust 的所有函数式部分,少了一些,而且没有任何命令式部分。
让我们从一段示例代码开始:
图表:Gleam
// Omitted imports
pub fn main() {
let total =
"1,X,5,6" // String
|> string.split(on: ",") // List(String)
|> list.map(int.parse) // List(Result(Int, Nil))
|> result.values // List(Int), the invalid entries are dropped
|> list.reduce(fn(acc, x) { acc + x }) // Result(Int, Nil)
|> result.unwrap(0) // Int
echo total // 12
}
图表:Rust
fn main() {
let total = "1,X,5,6" // &str
.split(",")
.map(|s| s.parse::<i64>())
.filter_map(Result::ok)
// Could have used .sum() here
.reduce(|acc, x| acc + x)
.unwrap_or(0);
println!("{}", total); // 12
}
稍微剖析一下,你就能看到 Gleam 的一些特性:
- Gleam 中的变量是不可变的(所以没有 mut 关键字之类的东西),这里链式调用的函数都会返回一个新元素。2 你仍然可以用新的 let 遮蔽一个绑定,这就是你“更新”一个值的方式。
- |>(带类型的 |>)是用于在数据上串联函数的管道操作符。主要约束是,被管道传入的变量应当是管道中函数的第一个参数。
- 就像 Rust 一样,Gleam 中的错误只是作为 Result monad 一部分的值。3
与 Rust 代码片段相比,你可以看到,除了一些命名选择之外,它们看起来非常相似。这个例子的主要区别其实在于管道语法与链式调用语法。Gleam 类型没有方法,所以改用管道。
管道操作符 |>
在 Gleam 中,许多函数都期望接收回调,例如作为一种控制资源访问并在进程结束时进行清理的方式(关闭数据库连接、释放文件句柄等)。为了避免这种层层回调的金字塔,Gleam 提供了一些语法糖:use param <- some_func(arg) 等价于 some_func(arg, fn(param) { ... })。回调必须始终是函数的最后一个参数。这与 |> 相反,后者填充的是第一个参数。
图表:使用 use
use param <- some_func(arg)
use other_param <- some_other_func(param)
echo other_param
图表:不使用 use
some_func(arg, fn(param) {
some_other_func(param, fn(other_param) {
echo other_param
})
})
尾调用优化
如果你不熟悉它,这一条可能真的会让你栽跟头。你需要知道的第一件事是:Gleam 里没有循环!再说一次,如果你以前写过 OCaml 或 Haskell,你不会对此感到意外。在 98% 的情况下,4 循环都是对有界迭代的简单操作。想想对列表或集合的 for 循环。这正是 Rust 中你总有两种做法的那类事情:一种是指令式的、直接用 for 循环,另一种更函数式,组合对迭代器的操作。嗯,如果你看上一节的代码示例,你会看到 Gleam 使用第二种方式,并且有大量函数可用于遍历列表、集合、元组、字符串,任何东西。
但如果你需要更复杂的东西呢?那 2% 呢?这时你就该改用递归了!一个说明这一点的简单算法是计算最大公约数(GCD)的欧几里得算法。下面你可以找到一个 Rust 迭代示例,和一个 Gleam 递归示例。
图表:Rust
fn gcd(mut a: u64, mut b: u64) -> u64 {
while b != 0 {
let remainder = a % b;
a = b;
b = remainder;
}
a
}
图表:Gleam
fn gcd(a: Int, b: Int) -> Int {
case b {
0 -> a
_ -> gcd(b, a % b)
}
}
当然,你也可以用 Rust 写递归版本,但这通常不是什么好主意。命令式语言中的递归可能很快失控:每次调用都会新增一个栈帧,而深度递归的函数可能撑爆栈(臭名昭著的栈溢出)。
在 BEAM 上没有固定大小的栈,因此失控的递归反而会消耗堆内存,直到进程被杀掉,但重点仍然成立:深度递归等于麻烦。为了避免这个问题,函数式语言的编译器实现了一种称为尾调用优化(TCO)的技术,它适用于递归调用是函数最后执行的事情时。然后复用现有的栈帧,而不是叠加一个新的栈帧,从而获得接近命令式 while 循环的性能。5
Gleam 是类型安全的
这句口号中的这一部分主要是为了回答那个显而易见的问题:“为什么是 Gleam 而不是 Elixir?”(另一种基于 Erlang 的语言)。虽然 Elixir 是一种动态类型语言(现在带有渐进类型!),但 Gleam 是作为具有强类型系统的静态类型语言而构建的:强类型是它存在的理由。
Gleam 中的类型同时涵盖 Rust 中 enum 和 struct 的概念:
图表:Gleam
// A usual enum
type Directions {
Left
Right
}
// A 'struct-like' type
type Person {
Person(name: String, age: Int)
}
// A combination of both
type MyError {
Missing
InvalidQuery(String)
}
图表:Rust
enum Directions {
Left,
Right
}
struct Person {
name: String,
age: i64
}
enum MyError {
Missing,
InvalidQuery(String)
}
你应该试试 Gleam
因为 Gleam 的类型是代数数据类型,所以它们非常有助于确保不希望出现的状态不可能发生。Gleam 的一个很棒的特点(稍后会涉及)是,它可以编译到 BEAM 虚拟机和 JavaScript 两个目标。因此,你可以在两个目标的不同应用之间共享这些强类型定义。如果你需要让一个 Web 应用与一个 BEAM 应用通信,这尤其方便。
Gleam 是为可扩展的系统而设计的
当然,这指的是 BEAM,即 Erlang 虚拟机。与面向原生系统的语言(Rust、Go、C++)不同,虚拟机在系统和程序之间提供了一个额外的抽象层。就 BEAM 而言,这一层在处理并发进程和故障方面异常出色,同时保持快速。
Gleam 很好地利用了这一点,一个并发程序看起来会是这样:
// Omitted imports
pub fn main() {
let order_number_and_amounts = [
#(101, 100),
#(102, 200),
#(103, 300)
]
order_number_and_amounts
|> list.each(fn(x) {
let #(order_no, amount) = x
let order_description = "Order #" <> int.to_string(order_no)
// Spawn unlinked: a crashing worker must not take the parent down.
process.spawn_unlinked(fn() {
// 101 and 103 will succeed, 102 will crash
process_order(order_description, amount)
})
})
io.println("All orders dispatched!")
// Keep the parent alive until the children have had time to run.
process.sleep(10)
}
fn process_order(order: String, amount: Int) {
process.sleep(1)
case amount {
200 -> panic as "Payment failed!"
_ -> io.println(order <> " processed successfully")
}
}
// Stdout
// ----
// All orders dispatched!
// Order #101 processed successfully
// Order #103 processed successfully
你应该从这段代码中了解到,是 BEAM 在处理进程。
调用 process.spawn_unlinked 会触发一个新进程,该进程与其创建者没有链接,因此当订单 102 崩溃时,父进程和兄弟进程都不会受到影响。如果使用普通的 process.spawn,子进程会与调用者链接,那个 panic 会导致整个程序崩溃。如果你仔细看,我还添加了 process.sleep(10),这样父进程就不会在子进程完成之前关闭:这是一个 hack,因为延迟只是一个猜测,较慢的子进程仍然会被切断。
幸运的是,BEAM 的一大优势是进程间通信非常容易,下面的代码片段很好地说明了这一点:
// Omitted imports
pub type PaymentError {
PaymentFailed
}
pub fn main() {
let parent = process.new_subject()
let orders = [
#(101, False),
#(102, True),
#(103, False)
]
orders
|> list.each(fn(x) {
let #(order_no, should_fail) = x
process.spawn(fn() {
// Simulate processing time
process.sleep(1000)
// 101 and 103 succeed, 102 fails
let res = case should_fail {
True -> Error(PaymentFailed)
False -> Ok(Nil)
}
process.send(parent, #(order_no, res))
})
})
io.println("All orders dispatched!")
receive_results(list.length(orders), parent)
}
fn receive_results(
remaining: Int,
parent: process.Subject(#(Int, Result(Nil, PaymentError))),
) -> Nil {
case remaining {
0 -> Nil
_ -> {
let #(order_no, res) = process.receive_forever(parent)
let status = case res {
Ok(_) -> "successful"
Error(_) -> "failed"
}
io.println(
"Processing of order #" <> int.to_string(order_no) <> " " <> status <> "!",
)
receive_results(remaining - 1, parent)
}
}
}
// Stdout
// ----
// All orders dispatched!
// Processing of order #103 successful!
// Processing of order #102 failed!
// Processing of order #101 successful!
//
// The order of the last three lines depends on scheduling.
OTP:持续给予的礼物
在上面那段中,我简要展示了 BEAM 虚拟机如何非常适合通过消息传递进行通信的并发程序。如果你熟悉 Go,这与它用 goroutine 处理同一问题的方式类似。但 OTP(开放电信平台),即 Erlang 附带的一套库,比这更强大。它为你提供了内置的监督者-演员范式,使你能够创建可扩展的并发服务,这些服务即使崩溃也不会拖垮整个应用。
简而言之,OTP 暴露了演员:持有状态并通过消息相互通信的轻量级进程(就像上面的例子那样)。当与监督者配对时,整个应用对任何单个演员的崩溃会更具韧性:监督者将监控进程并在需要时重启它们。它还可以帮助动态地启动/停止一组演员。
如果你读到这里心想“呃,这听起来很像我用 Kubernetes 的方式”,你并不孤单。OTP 模型与 Kubernetes 的控制器-Pod 架构极为相似,并解决了许多相同的问题。6 然而,根据我的经验,许多使用 Kubernetes 的工作流其实并不真正需要集群,但仍然使用了一个(而且这很昂贵!),因为分布式体验带来了额外的好处。对于那些情况,OTP 提供了一个非常有趣的替代方案。
让我给你一个具体的例子。不涉及太多细节,我的团队有一个常见的设置,涉及一个手写的 Go 作业调度器来处理一些其他进程。
调度器、每个 worker 以及用户界面都是集群内的 Pod,由 Kubernetes 管理。老实说,这运行良好,但确实引入了大量复杂性。首先,这些服务中的大多数并不需要彼此如此隔离。这里的后果是,我们需要在各个地方重复 REST API,以实现不同服务之间的通信。
如果我们想在 Gleam 应用中重新组织这一点呢?有两个部分 Gleam 会大放异彩:
- OTP 本质上会将所有调度服务统一为单个应用内的演员,
- 用户界面(目前是一个 Node.js 应用)可以用 Gleam 编写并编译为 JavaScript。
这是修订后的架构:
在 BEAM 应用内,所有演员将通过消息通信:
- HTTP 服务器接收请求并将它们保存到数据库,
- worker 会领取作业并将状态写回数据库。
因此,无需在单独的 Pod 中处理多个应用的部署,你只需让 OTP 在一个 VM 内处理所有进程管理(对于调度部分)。
在前端和后端之间复用代码意味着什么?
如前所述,Gleam 的一大优势是它可以编译为 JavaScript。因此,你可以在用户界面和作业调度器之间捆绑共享类型定义,而不是让重复的 API 保持同步。
图表:shared/job.gleam
pub type JobStatus {
Pending
Running
Completed
Failed
}
pub type Job {
Job(
id: String,
name: String,
status: JobStatus,
)
}
pub type CreateJobRequest {
CreateJobRequest(name: String)
}
pub fn new_job(id: String, request: CreateJobRequest) -> Job {
Job(
id: id,
name: request.name,
status: Pending,
)
}
图表:frontend.gleam
import gleam/json
import shared/job.{type CreateJobRequest, CreateJobRequest}
// `create_job_encoder` lives in shared/job; `client` is your HTTP client
// of choice (gleam_httpc on the BEAM, gleam/fetch in the browser).
pub fn submit_job(name: String) {
let request = CreateJobRequest(name: name)
let body = json.to_string(
job.create_job_encoder(request),
)
// POST /api/jobs
client.post(
"/api/jobs",
body,
)
}
图表:server.gleam
import shared/job.{type CreateJobRequest, type Job}
// `Database`、`DatabaseError` 和 `generate_id` 代表你的
// 数据库层和 ID 生成器。
pub fn create_job_handler(
request: CreateJobRequest,
db: Database,
) -> Result(Job, DatabaseError) {
let id = generate_id()
let created_job = job.new_job(id, request)
// 在 PostgreSQL 中持久化为 Pending 状态。
database.insert_job(db, created_job)
|> result.map(fn(_) { created_job })
}
Gleam:不足之处
每种语言都有其缺陷,是时候对 Gleam 给出一些诚实的反馈了。我并不假装自己已经对这门语言有很多经验,所以如果你觉得这些抱怨不公平,欢迎纠正我。到目前为止,我注意到的是这些问题:
- 生态系统很小。这本身不是 Gleam 的错,只是作为一门年轻语言的必然结果,7 而且还有一些缓解因素,比如可以很容易地直接从 Gleam 中使用外部函数(BEAM 上的 Erlang、Elixir,以及自带运行时的 JavaScript)。然而,社区本身更青睐 Gleam 优先的包,而截至目前,包的种类上还存在一些空白。
- 语言太简单了。嗯,老实说,我本人并不同意这一点,我就喜欢它这样。但我完全理解,缺少宏或其他复杂特性会让一些人望而却步(例如,我读到有人说非常怀念柯里化)。
- BEAM 虚拟机是一个极好的目标平台,但它也很奇特。我实在说不出有什么大公司/系统在用它。然而,快速搜索一下就会发现,WhatsApp、Discord 和 Pinterest,以及 Cisco、Nintendo,甚至 NHS 都依赖它。Erlang 和 BEAM 凭借其可靠性,正悄然支撑着庞大的平台,但这并不像“Made in Rust”那样的营销那么光鲜。Rust 有 Mozilla,Go 有 Google,Java 有 Oracle,而眼下并没有大型科技公司在推动 Gleam/Erlang。
当然,这是一篇非常有偏见的叙述。我想我漏掉了这门语言本身的很多缺陷,这些缺陷会在蜜月期结束后显现出来。但到目前为止,我用 Gleam 玩得很开心,我只能鼓励你也试一试!我相信你也会像我一样,收获很多乐趣!
- 如果像我一样,这对你来说没什么意义,那么 Erlang 也是一门函数式语言, albeit 要古老得多(大约 40 年历史),其主要用途一直是电信系统。BEAM 在分布式、并发、容错系统方面表现出色(所以,显然包括电信)。↩︎
- 关于不可变语言的一件重要事情是,当你执行类似 let new_list = list.take([1, 2, 3], 2) 这样的操作时,你并不是在复制数据(那会非常浪费),而是两个变量中的公共数据来自内存中的同一位置。↩︎
- Gleam 也通过 gleam/option 提供了 Option monad,但与 Rust 不同,它在某种程度上不被鼓励,在可能的情况下更倾向于简单的 Result(T, Nil)。这就是为什么 Gleam 中的 reduce 返回 Result(T, Nil),而 Rust 返回 Option<T>。↩︎
- 这是一个完全虚构的统计数据,但我觉得它离现实不远。↩︎
- Rust 不保证 TCO。LLVM 有时会应用兄弟调用优化,但这不是你可以依赖的,而且显式尾调用仍然不稳定。由于 Rust 也提供了循环,命令式风格通常是更安全的选择。函数式语言的编译器在这方面通常要激进得多,因为 TCO 对它们的性能至关重要。↩︎
- 主要区别实际上在于隔离性:一个 OTP 节点是一个单独的操作系统进程,其上的所有 actor 共享同一内存,而 Kubernetes 为每个 pod 提供自己的容器,并将它们分散在集群的机器上。Erlang 确实知道如何将多个节点集群在一起,但 OTP 没有集群调度器、没有放置逻辑、也没有容器隔离:你仍然需要自己部署和放置节点。↩︎
你应该试试 Gleam
- Gleam 的确切年龄有争议。根据 Wikipedia,Gleam 最初创建于 2016 年,但直到 2019 年(它开始采用编号版本时)才被视为一项“严肃”的事业,并且直到 2024 年才达到 1.0.0 版本。 ↩︎