Newshot
Account

你应该试试 Gleam

Translation(model translation, not the source's own text)

引言

如果你读过我最近那篇关于编程语言的吐槽,你就会知道我一直对 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 版本。 ↩︎

The translation is machine-generated and individual terms may be off. Nothing is rewritten or cut; the original is authoritative.

Published by Lobsters
This site only summarises and aggregates; copyright stays with the original author. Headlines and summaries follow the source — where they differ, the original wins.
Read the original ↗