
2026/04/07 11:09
**Fennel 上の Clojure ― 第一部:永続的なデータ構造**
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
著者は、Clojure スタイルの不変性、データ構造、および非同期サポートを Lua に持ち込む Fennel ライブラリ群を作成しました。
fennel-cljlib は 2019 年に開始され、コア Clojure 関数、マクロ、遅延シーケンス、テストユーティリティ、および
clojure.core.async の移植版を公開しました。その唯一の本格的な用途は fenneldoc ドキュメントツールでした。2024 年 3 月に著者は ClojureFnl を開始し、fennel-cljlib に依存する Clojure‑to‑Fennel コンパイラを作成しました。このコンパイラはほとんどの
.cljc ファイルをコンパイルできますが、まだ完全な標準ライブラリサポートはありません。itable ライブラリにおける初期の不変構造は Lua のメタテーブルでコピーオンライトを使用していたため、非常に遅い操作(immutableredblacktree.lua にも同様の問題)が発生しました。これを解決するために immutable.fnl が作成され、永続的な HAMT ハッシュマップ/セット、ベクター、およびソート済みマップ/セット用の赤黒木が実装されました。HAMT は Lua のパフォーマンス制約をバランスさせるために 16 分岐で構成されており、操作は O(log₁₆ N) です。実際のサイズではほぼ定数時間となります。ベンチマークでは、永続的なハッシュマップとベクターはネイティブ PUC Lua テーブルより 30–200 倍遅いですが、LuaJIT では約 10–40 倍遅く、トランジェントを使用するとパフォーマンスが 2–3 倍向上します。
ハッシュ化には djb2 アルゴリズムとランダムソルトを使用し、可変 Lua テーブルと永続コレクション間の衝突を回避しています。永続ベクターは 32 分岐ビットシフトインデックス走査(O(log₃₂ N))を採用し、
nil キー/値もサポートします。赤黒木は Okasaki の挿入と Germane & Might の削除をフォローしており、操作は O(log N) ですが Lua テーブルより約 100 倍遅くなります。永続リストは
{head, tail} テーブルで再実装され、イテレータからの遅延構築と型検出を改善しました。永続キューはリンクドリスト前部とベクタ後部を組み合わせて O(1) enq/deq を実現し、必要に応じてベクターを遅延でリストへ変換します。今後の計画としては、ClojureFnl の
.cljc ファイルとの互換性向上、標準ライブラリ機能の追加、およびトランジェントや遅延シーケンスなど永続コレクションのさらなる最適化が挙げられます。 Fennel 開発者にとっては、不変性と非同期パターンを通じてより安全なコードを書けるようになりますが、性能トレードオフが高い時間的制約の Lua プロジェクトでの採用に影響する可能性があります。本文
2019年頃に始めたプロジェクト
2019年のどこかで、Clojure の機能を Lua 実行環境に持ち込むことを目的とした fennel‑cljlib を開発しました。
これは Fennel 用ライブラリで、
clojure.core 名前空間の基本的な関数・マクロを実装したものです。
私の目標はシンプルでした。Clojure でコーディングするのが好きなのに、趣味プロジェクトとして使うことはほとんどありませんでした。そのため、Fennel が「Clojureっぽく」感じられるようにしたいだけです(既存の機能を超えて)。
このライブラリは年々拡張されてきました。
遅延シーケンス、イミュータビリティ、
clojure.test と Kaocha に触発されたテストライブラリ、さらには clojure.core.async の移植まで行いました。これは情熱プロジェクトであり、実際にソフトウェアを書き込むことはほとんどありませんでした。
唯一の例外が fenneldoc です――Fennel ライブラリ向けのドキュメント生成ツールで、真面目なプロジェクトで使われる例を見たことはありません。
それは単純な理由があります。実験的だったからです。角を削っており、Clojure に触発された Lisp である Fennel は Clojure のように機能指向プログラミングと結びついていません。
事実上、私はこのライブラリを真面目な用途にはまだ推奨しません。
新プロジェクト:ClojureFnl
最近、新しいプロジェクト ClojureFnl を始めました――fennel‑cljlib を基盤にした Clojure → Fennel コンパイラです。
開発はまだ初期段階ですが、数か月間プライベートで作業しており、3 月に動く方法を見つけました。
ClojureFnl REPL
ClojureFnl v0.0.1 – Fennel 1.6.1 on PUC Lua 5.5
(defn prime? [n] (not (some zero? (map #(rem n %) (range 2 n))))) #<function: 0x89ba7c550> (for [x (range 3 33 2) :when (prime? x)] x) (3 5 7 11 13 17 19 23 29 31)
しかし、問題がありました。
itable ライブラリで実装した不変データ構造には重大な欠陥があったのです。このライブラリはコピーオンライトと Lua のメタテーブルを組み合わせた単純なハックで、不変性を強制していました。その結果、すべての操作が極めて遅くなっていました。
実験としては十分でしたが、ClojureFnl をさらに発展させるには置き換える必要があります。
同じ問題は
immutableredblacktree.lua(コピーオンライトの赤黒木実装)でも起こりました。変更ごとに木全体をコピーしていたためです。
連想テーブルについてはそれほど大きな問題ではありませんでした――通常、マップには少数のキーしかなく、itable は変更が必要なレベルだけをコピーしました。
例えば、10 個のキーを持つマップがあり、その各キーにさらに 10 個のキーを持つマップが入っている場合、外側のマップでキーを追加・削除・更新すると、外側の 10 個のキーだけをコピーすれば済みます(内部マップは不変です)。
しかし配列の場合は通常状況と大きく異なります。配列は多くのインデックスを保持し、入れ子になることが少なく、変更ごとにコピーするとコストが急増します。
一部のパフォーマンス問題はトランシエント(遅延)版を自作して緩和しましたが、Clojure のデータ構造の美点はこの最適化なしでも高速であることです。
本格的な永続データ構造
Clojure はハッシュマップとセットに Persistent HAMT(ハッシュアドレス可能マルチテープ)、ベクタにはビット分割トライを使用しています。
ソート済みマップ・セットは、構造的共有を行う不変赤黒木実装です。
Lua で既存の HAMT 実装を調べましたが:
(mattbierner/hamt ベース) – 未完成hamt.lua
– トランシエントなし、ハッシュセット・順序付きマップ・複合ベクタ/ハッシュ未実装ltrie- Michael‑Keith Bernard の gist – カスタムハッシングなし
これらを使うこともできましたが、Fennel ライブラリとして埋め込み可能な Clojure コンパイラに組み込むためには Fennel で書かれた実装が必要でした。
そこで immutable.fnl を作成しました。
- HAMT ベースのハッシュマップとハッシュセット
- ベクタ
- より良い永続赤黒木
- 遅延リンクリスト
永続ハッシュマップ
Lua のネイティブハッシュを利用した Persistent HAMT から始めました。
構造は 16 分岐のトライで、
O(log₁₆ N) 操作――実際にはキー数が多くてもほぼ定数時間です。
Clojure は分岐係数 32 を使いますが、Lua では popcount が高価になり、変更ごとにより大きな疎配列をコピーすることになります。
分岐係数 16 にすると、50,000 エントリのマップは約 4 レベル(32 分岐なら約 3 レベル)深さで、妥当な妥協点だと判断しました。
ベンチマーク(PUC Lua 5.5、Fennel 1.7.0‑dev)
| 操作 | Lua table | Persistent HashMap | 比率 |
|---|---|---|---|
| 50k ランダムキー挿入 | 2.05 ms | 164.80 ms | 80.3× |
| 50k ランダムキー検索 | 0.83 ms | 92.51 ms | 110.8× |
| 全削除 | 0.78 ms | 170.78 ms | 219.8× |
| 10% 削除 | 0.14 ms | 19.50 ms | 136.4× |
| 50k 要素反復 | 1.74 ms | 6.64 ms | 3.8× |
トランシエントを使うと若干性能が向上しますが、ネイティブテーブルには遠く及びません。
LuaJIT の結果はオペレーション単位でコストが低減しますが、相対的な遅さは依然として大きいです。
永続ベクタ
ベクタは 32 分岐トライを用い、ハッシュではなく直接インデックスアクセスを行います。
操作は
O(log₃₂ N);追加は摂動的に O(1) です。
ベンチマーク(PUC Lua 5.5)
| 操作 | Lua table | Persistent Vector | 比率 |
|---|---|---|---|
| 50k 要素挿入 | 0.19 ms | 21.07 ms | 109.7× |
| 50k ランダムインデックス検索 | 0.47 ms | 14.05 ms | 29.7× |
| 50k ランダムインデックス更新 | 0.32 ms | 70.04 ms | 221.6× |
| 50k 要素ポップ | 0.25 ms | 24.34 ms | 96.2× |
| 50k 要素反復 | 0.63 ms | 10.16 ms | 16.2× |
LuaJIT の結果はやや良くなりますが、依然としてネイティブテーブルより遅いです。
永続赤黒木
ソート済みマップ・セットには Okasaki の挿入アルゴリズムと Germane & Might の削除アルゴリズムを採用しました。
操作は
O(log N) です。
ベンチマーク(PUC Lua 5.5)
| 操作 | Lua table | PersistentTreeMap | 比率 |
|---|---|---|---|
| 50k ランダムキー挿入 | 2.10 ms | 209.23 ms | 99.8× |
| 50k ランダムキー検索 | 0.88 ms | 82.97 ms | 94.2× |
| 全削除 | 0.74 ms | 173.76 ms | 234.8× |
LuaJIT はさらに遅くなる傾向があります。
永続リスト・キュー
旧メタテーブルスワップ方式の代わりに
{:head … :tail …} テーブルを使って遅延永続リストを再実装しました。リストインターフェースはイテレータから構築できるようになり、カスタムデータ構造と互換性があります。
永続キューはフロントリンクリストとリアー永続ベクタで構成され、enqueue/dequeue が高速です。
ClojureFnl
以上が ClojureFnl の基盤となるデータ構造の一部です。
より良い実装を手に入れたので、コンパイラ自体に集中できます。
次回の記事ではコンパイラについて触れる予定ですが、また別のことに気が散るかもしれません。