Gleamを試してみよう
要約
この記事は、関数型言語Gleamを紹介し、その「フレンドリー」「型安全」「スケーラブル」という特徴を解説しています。Rustに似た構文、パイプ演算子、`use`パターン、そして再帰とテイルコール最適化によるループの代替について説明し、BEAM(Erlang VM)やJavaScriptへのコンパイル能力、OTPフレームワークとの連携についても触れています。一方で、エコシステムの小ささや言語のシンプルさといった欠点も指摘しています。
全文翻訳
Gleamを試してみよう
はじめに
最近のプログラミング言語に関する私の愚痴を読んだ方なら、私がGleamにかなり興味を持っていることをご存知でしょう。Gleamは、シンプルな構文と広範なBEAMエコシステム(ErlangとElixir)との相互運用性を持つ、BEAM(Erlang VM)をターゲットとする関数型言語です¹。興味深いことに、GleamはJavaScriptもターゲットにできます。つまり、GleamはJavaScriptにコンパイルしてNode.jsで実行できるのです。
私は趣味のプロジェクトでは主にRustを使用しており、現時点では私にとってコーディングを楽しくさせてくれる唯一の言語ですが、Gleamもその欲求を満たしてくれました。もしあなたがRustの愛好家なら、いくつかの重要な違いを除けば、Gleamは馴染み深く感じるでしょう。私が書くコードの80%はRustにコピー&ペースト(構文の検索・置換を少し加える)すればコンパイルできると感じています。
この記事では、言語の網羅的な概要は提供しません。さらに詳しく知りたい場合は、素晴らしいインタラクティブツアーをお勧めします。30分足らずでGleamの感触を掴むことができるでしょう。
Gleam:良い点
ホームページでは、Gleamは「スケーラブルな型安全なシステムを構築するためのフレンドリーな言語!」(強調は原文ママ)と説明されており、Gleamの本質をよく伝えています。
Gleamはフレンドリー
構文は非常にシンプルで、複雑な部分はほとんどErlangランタイムに隠されており、自分で望まない限り、それを操作する必要はありません!もちろん、Gleamは関数型言語なので、もしあなたが一生C型の命令型世界で生きてきたなら、最初は少し daunting に感じるかもしれません。しかし、他の関数型言語(OCaml、Haskell、Erlang)と比較すると、Gleamがはるかにシンプルであることがすぐにわかるでしょう。少し使ってみると、Rustの関数型部分すべてに、いくつかの欠けている部分があり、命令型部分は一切ないように感じます。
例として、コード片から始めましょう。
Gleam
// インポートは省略
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)、無効なエントリはドロップされる
|> 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)
// ここで .sum() を使用することもできた
.reduce(|acc, x| acc + x)
.unwrap_or(0);
println!("{}", total); // 12
}
これを少し詳しく見ていくと、Gleamのいくつかの特徴が見えてきます。
- Gleamの変数は不変です(
mutキーワードやそれに類するものはありません)。ここでの連鎖する関数はすべて新しい要素を返します²。新しいletでバインディングをシャドウすることは依然として可能であり、それが値を「更新」する方法です。 |>(型付き|>)は、データ上の関数を連鎖するために使用されるパイプ演算子です。主な制約は、パイプされる変数がパイプ内の関数の最初の引数である必要があることです。- Rustと同様に、Gleamのエラーは
Resultモナドの一部としての値です³。Rustのコードスニペットと比較すると、いくつかの命名の選択を除けば、非常に似ていることがわかります。この例での主な違いは、パイピングとチェイニングの構文です。Gleamの型にはメソッドがありませんので、代わりにパイピングが使用されます。
パイピング演算子 |> は、この投稿で紹介するGleamのマスコット機能3つのうちの最初のものです。他の2つはuseパターンとテイルコール最適化です。
use パターン
JavaScriptをたくさん書いたことがあるなら、多くのコールバックをネストすることによって生じるインデントの増加に慣れているかもしれません。
someFunc(arg, function(param) {
someOtherFunc(param, function(otherParam) {
// ...
})
})
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%のケース⁴では、ループは固定された反復回数に対する単純な操作です。リストやセットに対するforループを考えてみてください。Rustで常に2つの方法で実行できることとまったく同じです。1つは命令型で正確なforループを使用し、もう1つはイテレータに対する操作の組み合わせでより関数型にします。さて、前のセクションのコード例を見ると、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ループに近いパフォーマンスが得られます⁵。
Gleamは型安全
このスローガンのこの部分は、主に「なぜGleamで、Elixirではないのか?」という明白な質問に答えるためにあります(ElixirもErlangベースの言語です)。Elixirは動的型付け言語ですが(現在は段階的型付けも利用可能!)、Gleamは静的型付け言語として、強力な型システムを備えて構築されました:強力な型はその存在理由です。
Gleamのtypeは、Rustのenumとstructの両方の概念を包含します。
Gleam
// 通常のenum
type Directions {
Left
Right
}
// 'structのような'型
type Person {
Person(name: String, age: Int)
}
// 両方の組み合わせ
type MyError {
Missing
InvalidQuery(String)
}
Rust
enum Directions {
Left,
Right
}
struct Person {
name: String,
age: i64
}
enum MyError {
Missing,
InvalidQuery(String)
}
Gleamの型は代数的データ型であるため、望ましくない状態が不可能であることを保証するのに非常に役立ちます。Gleamの素晴らしい点は、少し後で関連しますが、BEAM仮想マシンとJavaScriptの両方にコンパイルされることです。そのため、これらの強力な型定義を両方のターゲットの異なるアプリケーション間で共有できます。これは、WebアプリケーションがBEAMアプリと通信する必要がある場合に特に便利です。
Gleamはスケーラブルなシステムのために作られている
それはもちろん、Erlang VMであるBEAMを指します。ネイティブシステム(Rust、Go、C++)をターゲットとする言語とは異なり、VMはシステムとプログラムの間に抽象化レイヤーを提供します。BEAMの場合、そのレイヤーは、高速性を維持しながら、同時実行プロセスと障害を処理するのに非常に優れています。
Gleamはそれをうまく活用しており、同時実行プログラムは次のようになります。
// インポートは省略
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)
// 非リンクでスポーン:クラッシュしたワーカーは親を巻き込まないようにする。
process.spawn_unlinked(fn() {
// 101と103は成功し、102はクラッシュする
process_order(order_description, amount)
})
})
io.println("All orders dispatched!")
// 子プロセスが実行される時間を与えるまで親プロセスを生存させる。
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")
}
}
// 標準出力
// ----
// All orders dispatched!
// Order #101 processed successfully
// Order #103 processed successfully
このコードからわかるべきことは、プロセスを処理するのはBEAMであるということです。
process.spawn_unlinked の呼び出しは、作成者とのリンクがない新しいプロセスをトリガーします。そのため、注文 102 がクラッシュしても、親も兄弟も影響を受けません。通常の process.spawn では、子プロセスは呼び出し元とリンクされ、その panic はプログラム全体をダウンさせていたでしょう。よく見ると、親が子プロセスが終了する前に終了しないように process.sleep(10) も追加しました。これはハックであり、遅延は推測に過ぎず、遅い子プロセスはそれでも中断される可能性があります。
幸いなことに、BEAMの大きな利点は、プロセス間通信が非常に簡単であることです。これは、次のコード片がよく示しています。
// インポートは省略
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() {
// 処理時間をシミュレート
process.sleep(1000)
// 101と103は成功し、102は失敗する
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)
}
}
}
// 標準出力
// ----
// All orders dispatched!
// Processing of order #103 successful!
// Processing of order #102 failed!
// Processing of order #101 successful!
//
// 最後の3行の順序はスケジューリングによって異なります。
OTP:継続的な贈り物
上記の段落で、BEAM仮想マシンがメッセージパッシングで通信する同時実行プログラムにどれほど優れているかを簡単に示しました。Goに慣れているなら、これはgoroutinesで同じ問題を処理する方法に似ています。しかし、Erlangに付属するライブラリのセットであるOTP(Open Telecom Platform)は、それよりも強力です。これは、組み込みのスーパーバイザー・アクターパラダイムを提供し、アプリケーション全体をダウンさせることなくクラッシュする可能性のあるスケーラブルで同時実行性の高いサービスを作成できるようにします。
要するに、OTPはアクターを公開します。アクターは、状態を保持し、メッセージを介して互いに通信する軽量プロセスです(上記の例のように)。スーパーバイザーとペアになると、アプリケーション全体が個々のアクターのクラッシュに対してより回復力のあるものになります。スーパーバイザーはプロセスを監視し、必要に応じて再起動します。また、アクターのグループを動的に開始/停止することもできます。
これを読んで「うーん、Kubernetesの使い方に似ているな」と思ったなら、あなたは一人ではありません。OTPモデルはKubernetesのコントローラー・ポッドアーキテクチャと非常に似ており、同じ問題の多くを解決します⁶。しかし、私の経験では、Kubernetesを使用するワークフローの多くは実際にはクラスターを必要としませんが、追加のボーナスである分散エクスペリエンスのために依然として使用しています(そしてそれは高価です!)。それらのケースでは、OTPは非常に興味深い代替手段を提供します。
具体的な例を挙げましょう。詳細には立ち入らずに、私たちのチームでは、手書きのGoジョブスケジューラーが他のプロセスを処理する一般的なセットアップがあります。
Scheduler(REST API)WorkerWorkerUser interface (web)UserDatabase(job queue) HTTP requestsRegisters jobsPolls jobsPolls jobs
スケジューラー、各ワーカー、およびユーザーインターフェースはすべてクラスター内のポッドであり、Kubernetesによって管理されています。正直なところ、これはうまく機能しますが、多くの複雑さを導入します。まず、これらのサービスのほとんどは、互いにそれほど分離する必要はありません。ここでの結果は、さまざまなサービス間の通信を可能にするために、さまざまな場所にREST APIを重複させる必要があるということです。
Gleamアプリケーション内でこれを再編成したい場合はどうでしょうか?Gleamが輝く可能性のある2つの部分があります。
- OTPは、本質的にすべてのスケジューリングサービスを単一アプリケーション内のアクターとして統合します。
- ユーザーインターフェース(現在はNode.jsアプリ)はGleamで記述し、JavaScriptにコンパイルできます。
以下は、改訂されたアーキテクチャです。
Gleam common codeGleam app on BEAMUser interface (web)UserDatabase(job queue)HTTP server(REST API)Worker supervisorWorkerWorker HTTP requestsPersistent jobs
BEAMアプリケーション内では、すべてのアクターはメッセージを介して通信します。
- HTTPサーバーはリクエストを受け取り、DBに保存します。
- ワーカーはジョブを取得し、ステータスをDBに書き戻します。
したがって、複数のアプリケーションを個別のポッドにデプロイする代わりに、OTPにスケジューリング部分内のすべてのプロセス管理を1つのVMで処理させることができます。
フロントエンドとバックエンドでコードを再利用することの意味
前述のように、Gleamの大きな利点の1つは、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` は shared/job にあり、`client` は選択したHTTPクライアントです。
// (BEAMではgleam_httpc、ブラウザではgleam/fetch)。
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のせいではまったくないのですが、若い言語であることの結果にすぎません⁷。外部関数(BEAM上のErlang、Elixir、またはJavaScriptランタイム上のJavaScript)をGleamから直接簡単に使用できるといった緩和策もあります。しかし、コミュニティ自体はGleamファーストのパッケージを好み、今日現在、パッケージの多様性にはいくつかのギャップがあります。
- 言語がシンプルすぎる。まあ、正直に言うと、私はそれ自体には同意しません。私はこのままで好きです。しかし、マクロやその他の複雑な機能がないことが、一部の人々を遠ざける可能性があることは完全に理解できます(例えば、カリー化が切望されていると読みました)。
- BEAM仮想マシンは優れたターゲットですが、同時に特殊でもあります。私はそれを実際に使用している大企業/システムをほとんど名前を挙げることができません。しかし、簡単な検索によると、WhatsApp、Discord、Pinterest、さらにはCisco、Nintendo、そしてNHSさえもそれに依存しています。ErlangとBEAMは、その信頼性により、静かに巨大なプラットフォームを支えていますが、「Rust製」というマーケティングほど派手ではありません。RustがMozillaを持ち、GoがGoogleを持ち、JavaがOracleを持っていたのに対し、現在Gleam/Erlangを推進している大企業はありません。
もちろん、これは非常に偏った記述です。私は言語自体に多くの欠点を見落としていると思いますが、それは熱狂期間が終われば明らかになるでしょう。しかし、今のところ、私はGleamを楽しんでおり、あなたにも試してみることをお勧めします!きっと私と同じように、大いに楽しめるはずです!
- もし私のようにこれが何を意味するかわからないなら、Erlangも関数型言語ですが、はるかに古い言語(約40年前)で、主に通信システムに使用されてきました。BEAMは、分散型、同時実行型、耐障害性のあるシステム(つまり、通信システム)で輝きます。↩︎
- 不変言語において重要なのは、
let new_list = list.take([1, 2, 3], 2)のようなことを行うとき、データのコピーを作成しているわけではない(それは非常に無駄になるでしょう)ということです。代わりに、両方の変数で共通のデータはメモリ内の同じ場所から来ています。↩︎ - Gleamは
gleam/option経由でOptionモナドも持っていますが、Rustとは異なり、ある程度推奨されておらず、可能な場合は単純なResult(T, Nil)が好まれます。そのため、GleamのreduceはResult(T, Nil)を返すのに対し、RustはOption<T>を返します。↩︎ - これは完全に架空の統計ですが、現実からそれほど離れていないと感じています。↩︎
- RustはTCOを保証しません。LLVMは時々サイクリックコール最適化を適用しますが、それに頼ることはできず、明示的なテイルコールはまだ不安定です。Rustもループを提供するため、命令型スタイルが一般的に安全な選択です。関数型言語のコンパイラは、TCOがパフォーマンスにとって非常に重要であるため、通常はこちらにより積極的です。↩︎
- 主な違いは分離性です。OTPノードは単一のOSプロセスであり、その上のすべての役者は同じメモリを共有しますが、Kubernetesは各ポッドに独自のコンテナを提供し、クラスターのマシン全体に分散させます。Erlangは複数のノードをクラスター化する方法を知っていますが、OTPにはクラスタースケジューラー、配置、コンテナ分離がありません。ノードは自分でデプロイして配置する必要があります。↩︎
- Gleamの正確な年齢は議論の余地があります。Wikipediaによると、Gleamは当初2016年に作成されましたが、「真剣な」取り組みと見なされるのは2019年(番号付きバージョン管理が導入されたとき)からであり、バージョン1.0.0に到達したのは2024年でした。↩︎