Gleam v1.11.0 で、JavaScript にトランスパイルされたコードが 30% 速くなる!【翻訳】

に公開

この記事は、Gleam公式サイト記事を著者の承諾を得た上で和訳[1]したものです。

本記事の内容は、Gleam v1.11.0での変更点・追加機能について書かれたものです。Gleamの最新版は以下になります:
https://github.com/gleam-lang/gleam/releases

※ちなみに個人的には、タイトルにある「トランスパイル」という言葉はあまり使いたくなかったのですが(英語版記事でもtranspileは出てこない)、ここを「コンパイル」にすると、Gleamを知らない人がパッと見でGleamのことをJavaScript処理系だと思ってしまう可能性があるかなと思ったので、記事全体のタイトルは、あえてこのようにしました。

Gleamから生成されたJavaScriptの処理速度が30%速くなる

​ By Louis Pilfold (2025/06/02)

GleamはErlang仮想マシンとJavaScriptランタイム向けの型安全でスケーラブルな言語である。本日、Gleam v1.11.0が公開された。

30%速くなる?本当に?

本記事のタイトルでは大胆な主張をしている。「GleamがJavaScriptにコンパイルされたものが30%速くなる!」 GleamからJavaScriptにコンパイルするときには追加のランタイムを必要とせず、生成されたコードは人間が書いたものにかなり近くなるため、これは非常に喜ばしい成果と言える。

まず、ベンチマークを示す。Lustreは、Gleam向けのフロントエンドWebフレームワークで、SPAスタイルとLiveViewスタイルの両方の機能をサポートしている。仮想DOMを実装しており、ReactやElmなど他の言語でよく知られたフレームワークに匹敵するパフォーマンスを発揮する。Gleamで最も広く利用されているパッケージの一つであり、間違いなく最も普及しているGleam製Webフレームワークである。

Lustreの仮想DOM差分計算はGleamで実装されているため、多くのGleamユーザーがパフォーマンス向上の恩恵を受け、性能向上を示す良いベンチマークになると思われる。以下のグラフは、様々なサイズのHTMLテーブルに対する差分処理の一秒当たりの操作回数である(数値が大きいほど高性能)。

Gleam-Lustre-Benchmark

Lustre本体に手を加えることなく、30%の速度向上を達成していることが分かる。他のJavaScriptにコンパイルされるGleamのプロジェクトでも同じようなパフォーマンス向上が実現すると考えられ、より複雑なパターンマッチングを含むプロジェクトでは更なる効果が期待される。

どういう仕組みなのか?

Gleamには、フロー制御の構文としてcase式がある。これは、上から下へと与えられたパターンを見ていき、値がどのパターンにマッチするかをチェックするものである。

pub fn greet(person: Person) -> String {
  case person {
    Teacher(students: [], ..) -> "Hello! No students today?"
    Student(name: "Daria", ..) -> "Hi Daria"
    Student(subject: "Physics", ..) -> "Don't be late for Physics"
    Teacher(name:, ..) | Student(name:, ..) -> "Hello, " <> name <> "!"
  }
}

上記のコードは、以前までは次のようにコンパイルされていた。

export function greet(person) {
  if (isTeacher(person) && isEmpty(person.students)) {
    return "Hello! No students today?";
  } else if (isStudent(person) && person.name === "Daria") {
    return "Hi Daria";
  } else if (isStudent(person) && person.subject === "Physics") {
    return "Don't be late for Physics";
  } else if (isTeacher(person)) {
    return "Hello, " + person.name + "!";
  } else {
    return "Hello, " + person.name + "!";
  }
}

注意:このコードは可読性のため若干変更を加えているが、処理内容的には実際のものと同じである。

人名 (person.name) や教科 (person.subject) によっては、isTeacherisStudentが2回呼ばれてしまう。

新しいアプローチでは、パターンマッチングは決定木に変換され、必要最低限の回数だけチェックが行われる。JavaScriptがターゲットの場合、その後決定木はネストしたif else文にコンパイルされる。

export function greet(person) {
  if (isTeacher(person)) {
    if (isEmpty(person.students)) {
      return "Hello! No students today?";
    } else {
      return "Hello, " + person.name + "!";
    }
  } else {
    if (person.name === "Daria") {
      return "Hi Daria";
    } else {
      if (person.subject === "Physics") {
        return "Don't be late for Physics";
      } else {
        return "Hello, " + person.name + "!";
      }
    }
  }
}

この変更によってコードサイズは少し増加する(今回のテストでは最大15%)。しかし、コードの増加分は圧縮処理を行うことで打ち消され、以前と同じくらいのバンドルサイズになる。

なお、この最適化はJavaScriptがターゲットの場合にのみ行われ、Erlangがターゲットの場合には行われない。これは、Erlang仮想マシン自身が既にこの最適化を実装しているためである。

また、この改善の一環として、特にbit-arrayパターン周りのパターンマッチング解析が強化されている。コンパイラは、先行する節の条件にマッチする値しか含まないために到達不能となるbit-arrayパターンを同定できるようになった(次の例を参照)。

case payload {
  <<first_byte, _:bits>> -> first_byte
  <<1, _:bits>> -> 1
  _ -> 0
}

パターンマッチングの効率的なコンパイルは思いのほか難しい問題で、この分野に関する以下の学術的研究が無ければ達成できなかった。

これは Gleam v1 以前からの集大成であり、長い間待ち望まれていた機能である。最後の仕上げを行ったGiacomo Cavalieri氏に深く感謝する。

変更はこれだけではない。このリリースでは他にも多くのものが追加されている。順番に見ていこう。

assertによるテスト

Gleamは、歴史的に組み込みのテスト機構を備えていないため、テスト用のアサーション関数がテストライブラリで定義されている。

pub fn hello_test() {
  telephone.ring()
  |> should.equal("Hello, Joe!")
}

これらの機能は有益ではあるが、Gleamチームが求めている水準には届かない。結局のところ、これらのアサーション関数は引数を受け取って値を返すだけの単なる関数に過ぎず、引数がどのように生成されたかや、関数呼び出しのコンテキストについて知ることはできない。Gleamのテストフレームワークが提供できるデバッグ情報は、組み込みのアサーション機能やマクロシステムなどを持つ他の言語と比べて、かなり限定的なものである。

Gleamは生産性と開発者体験を重視した言語であるが、これまでのテスト体験はGleamチームが要求する基準を満たしていなかった。このリリースでassertが追加されたことにより、今までの状況を是正するべく大きな一歩を踏み出すことができた。

pub fn hello_test() {
  assert telecom.ring() == "Hello, Joe!"
}

panic構文との違いは、ランタイムエラーにFalseとして評価された式に関する情報が注釈として加えられる点で、これにより、テストフレームワークがアサーション失敗時に詳細なデバッグ情報を提供できるようになる。

以下は、gleeunitフレームワークの出力である。フレームワークによっては、これよりもさらに優れたフォーマットで出力することも可能だ。

...............
assert test/my_app_test.gleam:215
 test: my_app_test.hello_test
 code: assert telecom.ring() == "Hello, Joe!"
 left: "Hello, Mike!"
right: literal
 info: Assertion failed.
..........
25 tests, 1 failures

テストフレームワークがアサーションコードをソースファイルに書かれた通りに表示できることに注目したい。また、==演算子の両辺の値も出力できる。

関数呼び出しも解析でき、各引数が何であるかも表示できる。

........
assert test/my_app_test.gleam:353
 test: my_app_test.system_test
 code: assert telecom.is_up(key, strict, 2025)
    0: "My WIFI"
    1: True
    2: literal
 info: My internet must always be up!
.................
25 tests, 1 failures

カスタムアサーションメッセージを出力することもできる(info以降)。この仕組みは、todopanic構文と同じく、asキーワードにより実現されている。

pub fn system_test() {
  let key = "My WIFI"
  let strict = True

  assert telecom.is_up(key, strict, 2025)
    as "My internet must always be up!"
}

gleeunitのアサーション関数を新しいassert構文に置き換えることは、人間が行うには時間がかかり退屈な作業である。これを自動化するためのコマンドラインツールが、Gearsによって開発されている。このツールを実行すれば、コードがすぐに新しい構文のものへと更新される。

Surya Rose氏に感謝!この機能は、全てのGleamプログラマーにとってテストを書く体験を大きく変えることだろう。

gleam dev

Gleamアプリケーションは、基本的に2つのmain関数を持つことができる。srcに置かれたアプリケーション実行用のものと、testに置かれたテスト実行用のものである。この仕様は理解しやすいが、開発時にのみ実行する必要がある別のコードがある場合に問題となることがある。例えば、バックエンドWebアプリケーション開発時におけるローカル開発環境でのデータベースの構成/設定、あるいはフロントエンドアセットのコンパイル処理などである。

本番環境にデプロイすべきではない開発用コードが、src内に混ざってしまうことは、事故の原因となる。

これまでもtest/ディレクトリ(本番環境には含まれない)に新しいモジュールとメイン関数を配置し、gleam run --module $モジュール名で実行するという回避手段はあったものの、testディレクトリにテスト以外のコードを置くのは非直感的であり、srcに開発時のみに利用するコードを配置したために、開発用コードや不要な依存ライブラリが誤って公開されてしまうことも珍しくなかった。

このリリースでは、新しく開発用コードのためのソースディレクトリdev/が追加される。dev/に置かれたコードはsrc/のコードをインポートでき、開発用依存ライブラリを使うことができる。$パッケージ名_devモジュールのmain関数は、新しく追加されたgleam devコンソールコマンドで実行できる。より直感的なシステムがこの変更により実現し、潜在的なミスを防ぐことができるだろう。

Surya Rose氏に感謝!

不変性 (immutability) への理解の補助

Gleamはイミュータブルな言語である。これは、値はその場でアップデートされるのではなく、古い値に要求された変更が適用された新しい値がその都度構築されることを意味する。ミュータブルな言語に慣れ親しんでいる人にとって、これは非効率的に見えるかもしれないが、イミュータブルな言語では巧妙な最適化によって優れたパフォーマンスとメモリ効率を実現している。

イミュータブルなスタイルに慣れていない人が、誤って更新後の新しいデータを破棄してしまうことがある。

pub fn call_api(token: String) -> Response(String) {
  let req = sdk.new_api_request()
  request.set_header(req, "authentication", "Bearer " <> token)
  http_client.send(req)
}

set_header関数は新しいリクエストの値を返すことが期待されるが、これは変数に割り当てられないため、元のリクエスト値が代わりに送信されてしまう。このバグは、各関数の出力を次のものに渡していくことで解消できる。

pub fn call_api(token: String) -> Response(String) {
  sdk.new_api_request()
  |> request.set_header("authentication", "Bearer " <> token)
  |> http_client.send
}

こうしたバグを防ぐため、副作用のない関数の戻り値が使われない場合に、コンパイラは警告を発するようになった。

fn go() -> Int {
  add(1, 2)
  add(3, 4)
}

上のコードは以下のような警告を生成する。

warning: Unused value(値が不使用)
    ┌─ /src/main.gleam:4:34add(1, 2)
    │   ^^^^^^^^^ This value is never used(この値は一度も使われていません)

This expression computes a value without any side effects, but then the
value isn't used at all. You might want to assign it to a variable, or
delete the expression entirely if it's not needed.
(この式は副作用なしに値を導出しますが、その値は実際には使われません。
変数に代入するか、必要ないのであれば式全体を削除することを検討してください。)

Surya Rose氏に感謝!

JavaScriptターゲットにおけるbit arrayの改善

Gleamには、BEAM系言語でおなじみの、バイナリデータを構築・パースするための強力なリテラル構文がある。Gleamはこの機能をErlangとJavaScriptの両方のターゲットでサポートしているが、いくつかの機能はJavaScriptターゲットでは使用することができなかった。このリリースでは、UTF-16とUTF-32でエンコードされたbit arrayが、JavaScriptでもサポートされるようになった。

Surya Rose氏に感謝!

POSIXでの動作改善

Gleamのビルドツールには、コンパイルと共にプロジェクトをサーバーなどにデプロイするための準備を行うコマンドとして、gleam export erlang-shipmentが用意されている。このコマンドには、Erlang仮想マシンを立ち上げてプログラムを実行するスクリプトが含まれているが、その実装に欠陥があり、POSIXの終了シグナルを受け取れないという問題があった。

Christopher De Vries氏がこの問題の修正を行った。感謝!

ドキュメント生成の改善

パッケージをHex(BEAMエコシステムのパッケージレポジトリ)に公開する際、GleamのビルドツールはコードのHTMLドキュメントを生成することができる。

ドキュメント生成時に、型変数がソースコード内で使われているものと同じ名前で表示されるようになったことにより、これらの型パラメータが何を表しているのかが分かりやすくなった。例えば、これまではドキュメント内で次のように表示されていた関数が↓

pub fn from_list(entries: List(#(a, b))) -> Dict(a, b)

以下のように表示されるようになる。

pub fn from_list(entries: List(#(key, value))) -> Dict(key, value)

ドキュメントにおけるもう一つの改善点は、他のモジュールからインポートされた型の表示方法である。こういった型はモジュール修飾子付きで表示され、マウスオーバーすると完全なモジュール名が表示されるようになる。

import gleam/dynamic/decode

pub fn something_decoder() -> decode.Decoder(Something) {
  ...
}

例えば、↑のコードは、ドキュメント内で↓のように表示される。

pub fn something_decoder() -> decode.Decoder(Something)

decode.Decoderテキストをホバーすると、以下の表示が現れる。

gleam/dynamic/decode.{type Decoder}

decode.Decoderをクリックすることで、その型のドキュメントが表示できる。

Surya Rose氏に感謝!

耐障害性 (fault tolerance) の更なる強化

Gleamコンパイラは、耐障害性解析を備えている。このことは、何らかのエラーが含まれ有効なコードではないためコンパイルできない場合にも、コンパイラは無効な部分を無視しコードの解析を継続するよう最善を尽くすことを意味する。そのためGleamの言語サーバーは、コードベースが無効な状態にあったとしても、その内容をきちんと把握し、IDE向けの機能を提供することができる。

今回のリリースでは、リスト、タプル、否定演算子、panicechotodo、関数パラメーター/ラベルの解析が耐障害性を持つようになった。我々は、言語サーバーが提供する情報が可能な限り最新のものを反映し正確になるよう、残された細かいケースについてもカバーするところである。

Giacomo Cavalieri氏とSurya Rose氏に感謝!

ラベルが不完全なcase式のエラー

Gleamにはパターンマッチングの網羅性チェックがある。これは、パターンマッチングの制御フローでは、マッチング対象の型が取りうる全ての可能な値が処理されなければならないことを意味する。もし、欠落しているケースがあれば、そのプログラムは不完全であるとみなされ、コンパイラは欠落しているパターンを明示する有用なエラーを返す。

このエラーメッセージ中にレコード内のフィールドのラベル情報も含まれるよう、今回のアップデートで改善された。以下のメッセージ下部がその表示例である。

error: Inexhaustive patterns(非網羅的なパターン)
  ┌─ /src/main.gleam:6:36 │ ╭   case person {
8 │ │     Teacher(name:) -> io.println("Good morning!")
7 │ │     Student(name: "Samara", age: 27) -> io.println("Hello Samara!")
9 │ │   }
  │ ╰───^

This case expression does not have a pattern for all possible values. If it
is run on one of the values without a pattern then it will crash.
(このcase式には、すべての可能なパターンが含まれていません。
もしパターンが定義されていない値で実行した場合、プログラムはクラッシュします。)

The missing patterns are:(足りないパターンは…)

    Student(name:, age:)

コードアクションも強化され、欠如しているパターンをcase式に追加できるようになった。不足しているケースをコードに追加される際には、レコードのラベルも自動的に含まれるようになる。

Surya Rose氏に感謝!

パターンのラベルを補完するコードアクション

Gleamの言語サーバーは、関数やレコードのラベルを自動補完するコードアクションを備えている。この機能が便利なのは、コード記述時すぐに引数のラベル名を思い出せない場合などに特に大幅な時間短縮ができるという点である。

このコードアクションは、パターン中のレコードにも対応するよう改善された。以下のコード例では、Personレコードのパターンが2つのフィールドを欠いているが、コードアクションを実行することによりこれらのフィールドが補完される。

pub type Person {
  Person(name: String, age: Int, job: String)
}

pub fn age(person: Person) {
- let Person(age:) = person
+ let Person(age:, name:, job:) = person
  age
}

Surya Rose氏に感謝!

Bit arrayの情報落ちへの警告

コンパイラは、bit arrayリテラルのint部分が切り詰められる場合に警告を発するようになった。これにより、開発者が予期せぬ動作の可能性を把握しやすくなった。

warning: Truncated bit array segment(切り詰められたbit arrayセグメント)
    ┌─ /src/main.gleam:4:54<<258>>
    │     ^^^ You can safely replace this with 2(ここは安全に2で置き換えることができます)

This segment is 1 byte long, but 258 doesn't fit in that many bytes. It
would be truncated by taking its first byte, resulting in the value 2.
(このセグメントの長さは、1バイトですが、258はそれに収まりません。
最初の1バイトのみに切り詰められ、結果として値は2になります。)

Giacomo Cavalieri氏に感謝!

言語サーバーの定数に関するサポートの改善

以前までのGleamの言語サーバーでは、定数におけるホバー表示、オートコンプリート、定義へのジャンプがサポートされていなかった。Surya Rose氏がこれらの機能を実装する修正を行った。Surya氏に感謝!

関数生成コードアクションの改善

Gleamの言語サーバーには、コード中で使用されているが定義されていない関数のアウトラインを生成するコードアクションが備わっている。

それが、使用されているラベルや変数名に基づいて、より適切な引数名を選択するようアップデートされた。例えば、まだ定義されていないremoveという関数に対してコードアクションを実行した場合:

pub fn main() -> List(Int) {
  let list = [1, 2, 3]
  let number = 1
  remove(each: number, in: list)
//^^^^^^ This function doesn't exist yet!(この関数はまだ存在していません)
}

言語サーバーは、以下のような未定義関数のアウトラインを生成する:

pub fn main() -> List(Int) {
  let list = [1, 2, 3]
  let number = 1
  remove(each: number, in: list)
}

fn remove(each number: Int, in list: List(Int)) -> List(Int) {
  todo
}

Giacomo Cavalieri氏に感謝!

バリアント生成コードアクション

generate functionコードアクションと同じように、カスタム型のバリアントを自動生成するコードアクションが言語サーバーに新しく追加された。

この例でUserPressedButtonバリアントは存在しないが、コンパイラはその使用状況から、もし存在するとすればMsg型のバリアントとして定義されるべきものであると認識する。

pub type Msg {
  ServerSentResponse(Json)
}

pub fn view() -> Element(Msg) {
  div([], [
    button([on_click(UserPressedButton)], [text("Press me!")])
    //               ^^^^^^^^^^^^^^^^^ This doesn't exist yet!(これはまだ存在しません)
  ])
}

コードアクションをUserPressedButtonに対して発動させると、Msg型にバリアントが追加される。

pub type Msg {
  ServerSentResponse(Json)
+ UserPressedButton(String)
}

このコードアクションは、on_click関数(最初の例)が値をどのように処理しているかに基づいて、新しいバリアントが文字列を保持していることを正しく認識する。もし、このバリアントがラベルと共に用いられていたなら、定義にはそのラベルも含まれることになる。

Giacomo Cavalieri氏に感謝!これもまた「トップダウン」スタイルのプログラマーにとって有用なコードアクションとなるだろう。

未使用インポート削除コードアクションの改善

以前から、Gleam言語サーバーは未使用インポートを削除するコードアクションを備えていたが、修飾されていない型や値を含むインポートについては、望んだ通りに機能せず、コードアクションを実行しても、インポートが残り続けてしまう問題があった。

import a_module.{type Unused, unused, used}

pub fn main() {
  used
}

コードアクションを実行すると、すべての使われていない型と値が削除される:

import a_module.{used}

pub fn main() {
  used
}

Giacomo Cavalieri氏に感謝!

Windows ARMバイナリについて

開発チームは、Gleamのリリースごとに、Windows、MacOS、Linux向けのコンパイル済み実行ファイルを提供している。Gleamユーザーやパッケージ管理システムは、時間をかけてGleamプロジェクトを一からコンパイルする代わりに、これらの実行ファイルをダウンロードし使用することができる。

Jonatan Männchen氏が新たにARM64 Windowsビルドを追加した。ARMベースの開発マシンが以前に増して普及しつつある中、このバイナリの追加は有用であると思われる。Jonatan氏に感謝!

その他の変更点についてはこちら

支援のお願い

Gleamは企業が所有しているのではなく、スポンサーの支援によって成り立っている。ほとんどのスポンサーが月$5~$20USドルの寄付を行っており、Gleamは私の唯一の収入源となっている。

コアチームメンバーに適正な報酬を支払うという目標に向けて大きな前進はあるものの、まだ道半ばである。GitHub Sponsorsを通じて、プロジェクトやコアチームメンバーであるGiacomo Cavalieri氏とSurya Rose氏への支援を検討していただければと思う。


脚注
  1. 和訳に当たり、PLaMo翻訳を援用していますが、最終的には全て私 (Hizuru) が推敲しています。 ↩︎

Discussion