
【2026年7月版】
Rustは万能じゃない — 言語パーサーはNimかRocで書け
公開日: 2026/07/03
読了時間: 約 22分
はじめに
こんにちは、あそぴテックです。
これまで「「Nim」入門 — Cにトランスパイルするマルチパラダイム言語の現在地」と「「Roc」入門 — Elm譲りの純粋関数型言語がRustホストと組む未来」の 2 本の言語紹介記事を投稿してきましたが、いよいよ本題です。今回は、AlopexDB チームが実際に行った 「Rust の SQL パーサーを Nim と Roc で書き直したらどうなるか?」 の比較検証を、実装コードとベンチマークとともに解説します。
3 言語で同じ SQL パーサーを書いた結果、「なぜ Rust では大規模 SQL 文法が事実上実装できないのか」「代替言語として何が本命なのか」がかなり具体的に見えました。参考にした一次資料は alopex-db/docs の feature/document-reorganization ブランチと、実装コードは alopex-db/alopex experiment/sql-parser-trial です。
前提: なぜ Rust ではダメだったのか
まず「なぜ移行が必要になったのか」の背景です。AlopexDB は Rust で書かれた分散データベースですが、その中の SQL パーサー層が 開発を続けるほど厳しくなる 状態に陥っていました。
Rust の型システムが SQL 文法に対して不利な理由
AlopexDB の現行 alopex-sql は、以下のような enum 群を持っています。
ExprKind10 variantsStatementKind8 variantsToken24 variantsKeyword69 variants
合計 約 180 variants で、すでにビルドが困難な状態です。SQL パーサーは本質的に「再帰的な列挙型を大量に持つ」プログラムなので、Rust では次の壁にぶつかります。
- 再帰的 enum に
Box<T>が必須(現状 12 箇所、今後さらに増える) #[allow(clippy::large_enum_variant)]の常態化- 再帰深度制限(RecursionCounter、デフォルト 50) でスタックオーバーフローを防ぐコードが必要
- ライフタイム注釈の伝播 —
Parser<'a>が全メソッドに波及 - match 網羅性チェック — variant 追加のたびに全 match 修正
これは実際に業界標準の sqlparser-rs でも顕在化していて、parser/mod.rs だけで 約 18,000 行に膨張 しており、コンパイル時間・保守性ともに限界に近づいています。
SQL 文法規模が線形に増加すると、Rust の型の複雑度は指数的に増加する — これが構造的な問題です。
「Rust 以外」で書ける可能性はあるのか
そこで AlopexDB チームは、Rust をパーサー層から外し、Nim または Roc をパーサー、Rust をプランナー/実行エンジンに専念させる アーキテクチャを検討し始めました。両言語で 同じ SQL パーサーを試験実装 して、実装コード量・ビルド時間・型システム上の書き味を比較する、というのが今回の実験の狙いです。
試験実装の概要
Rust 版 alopex-sql のテストケースを Nim と Roc に移植し、同等の SQL パーサーを構築しました。カバー範囲は SELECT / INSERT / UPDATE / DELETE / CREATE TABLE / DROP TABLE、および式(演算子優先順位、BETWEEN、LIKE、IN、IS NULL、関数呼び出し)です。SQLite 文法全体の約 15〜20% をカバーします。
1. AST 定義の書き味 — 判別共用体 vs Tag Union
3 言語で SQL AST を定義してみると、書き味の違いが最初に現れます。
Nim: object variant
Nim は case kind 節を持つ ref object で「タグ付き共用体」を表現します。Box の明示は不要、C 言語の union に近い感覚です。
type
SqlNodeKind* = enum
nkSelect, nkInsert, nkUpdate, nkDelete
nkIdentifier, nkStringLit, nkIntLit, nkBoolLit, nkNull
nkStar, nkColumnRef
nkBinaryOp, nkUnaryOp, nkFunctionCall, nkAlias
nkFromClause, nkWhereClause, nkJoin
# ...
BinaryOpKind* = enum
opEq, opNeq, opLt, opLe, opGt, opGe
opAdd, opSub, opMul, opDiv, opMod
opAnd, opOr
opLike, opNotLike, opIn, opNotIn, opBetween, opNotBetween, opIs
SqlNode* = ref object
case kind*: SqlNodeKind
of nkIdentifier, nkStringLit:
strVal*: string
of nkIntLit:
intVal*: int64
of nkBinaryOp:
binOp*: BinaryOpKind
binLeft*, binRight*: SqlNode
of nkJoin:
joinKind*: JoinKind
joinLeft*, joinRight*, joinCond*: SqlNode
else:
children*: seq[SqlNode]構築ヘルパも簡潔に書けます。
proc newBinaryOp*(op: BinaryOpKind, left, right: SqlNode): SqlNode =
SqlNode(kind: nkBinaryOp, binOp: op, binLeft: left, binRight: right)Roc: Tag Union
Roc は Tag Union(直和型)が言語の第一級要素 です。variant ごとに構造体風のペイロードを持てるため、AST の意味構造がそのまま型として立ち上がります。
SqlNode : [
Ident Str,
StrLit Str,
IntLit I64,
BoolLit Bool,
NullLit,
StarLit,
BinOp { op : BinaryOp, left : SqlNode, right : SqlNode },
UnOp { op : UnaryOp, operand : SqlNode },
FnCall { name : Str, args : List SqlNode },
ColRef { table : Str, column : Str },
SelectStmt {
columns : List SqlNode,
from : List SqlNode,
where : [Some SqlNode, None],
orderBy : List SqlNode,
# ...
},
InsertStmt { table : Str, columns : List Str, values : List SqlNode },
UpdateStmt { table : Str, sets : List { col : Str, val : SqlNode }, where : [Some SqlNode, None] },
DeleteStmt { table : Str, where : [Some SqlNode, None] },
CreateTableStmt { table : Str, columns : List SqlNode, ifNotExists : Bool },
DropTableStmt { table : Str, ifExists : Bool },
]Option 型がそのまま [Some SqlNode, None] として書ける、Box 相当の間接参照が存在しない など、Rust の enum + Box + Option を書き慣れた目には劇的な軽さです。
Rust: enum + Box の宿命
参考までに、Rust で同等のことを書くとこうなります。
#[allow(clippy::large_enum_variant)]
pub enum SqlNode {
Ident(String),
StrLit(String),
IntLit(i64),
BoolLit(bool),
NullLit,
StarLit,
BinOp {
op: BinaryOp,
left: Box<SqlNode>,
right: Box<SqlNode>,
},
SelectStmt {
columns: Vec<SqlNode>,
from: Vec<SqlNode>,
where_: Option<Box<SqlNode>>,
// ...
},
// ...
}Box<SqlNode>が至るところに現れるOption<Box<...>>の二段構造 — 意味論的にはただの[Some SqlNode, None]なのに、間接参照とヌル判定が別レイヤになる#[allow(clippy::large_enum_variant)]を書かないと lint が通らないSqlNodeに振る match は variant 追加のたびに全部書き直し
行数で言うと、AST 定義だけで Nim 140 行 / Roc 93 行 / Rust 478 行(5 ファイル) という差になります。
2. パーサー戦略 — 手続き型 Pratt vs 純関数コンビネータ
パーサーの実装スタイルも 2 言語で対照的です。
Nim: 手続き型 Pratt parser
Nim では、Rust の Pratt parser と同じ「優先順位ごとに関数を分ける」スタイルがそのまま書けます。可変な Parser オブジェクトを渡し歩く 手続き型スタイルです。
proc parseMulDiv(p: var Parser): SqlNode =
result = p.parsePrimary()
while p.current.kind in {tkStar, tkSlash, tkPercent}:
let op = case p.current.kind
of tkStar: opMul
of tkSlash: opDiv
of tkPercent: opMod
else: opMul # unreachable
discard p.advance()
result = newBinaryOp(op, result, p.parsePrimary())
proc parseAddSub(p: var Parser): SqlNode =
result = p.parseMulDiv()
while p.current.kind in {tkPlus, tkMinus}:
let op = if p.current.kind == tkPlus: opAdd else: opSub
discard p.advance()
result = newBinaryOp(op, result, p.parseMulDiv())
proc parseAndExpr(p: var Parser): SqlNode =
result = p.parseComparison()
while p.check(tkAnd):
discard p.advance()
result = newBinaryOp(opAnd, result, p.parseComparison())
proc parseExpr(p: var Parser): SqlNode =
result = p.parseAndExpr()
while p.check(tkOr):
discard p.advance()
result = newBinaryOp(opOr, result, p.parseAndExpr())var Parser(Nim の可変参照)でトークン列の位置を進めながら AST を構築する、素直な下降型パーサーです。C や Rust から入った人なら違和感がありません。
npeg を使えば以下のように PEG を直書きすることもできます。
import npeg
let parser = peg("input"):
input <- select_stmt * !1
select_stmt <- i"SELECT" * ws * columns * ws * i"FROM" * ws * >table_name
columns <- column * *(',' * ws * column)
column <- >+Alpha
table_name <- +Alpha
ws <- +' '
let r = parser.match("SELECT name, age FROM users")
echo r.captures # @["name", "age", "users"]マクロで PEG を Nim 関数にコンパイル時展開する ため、実行時のパース文法解釈コストがゼロです。文法規模が大きくなっても、コード量は文法の長さに比例するだけで済みます。
Roc: 純関数の再帰下降
Roc では すべてが不変値 です。「トークン列と現在位置」を関数引数で受け渡し、Result ParseResult Str で結果と次位置を返します。
ParseResult : { node : SqlNode, pos : U64 }
parseExpr : List Token, U64 -> Result ParseResult Str
parseExpr = |tokens, pos|
parseOr(tokens, pos)
parseOr : List Token, U64 -> Result ParseResult Str
parseOr = |tokens, pos|
when parseAnd(tokens, pos) is
Ok({ node: left, pos: pos2 }) ->
parseOrTail(tokens, pos2, left)
Err(msg) -> Err(msg)
parseOrTail : List Token, U64, SqlNode -> Result ParseResult Str
parseOrTail = |tokens, pos, left|
when getToken(tokens, pos) is
Ok(tok) ->
if tok.kind == TkOr then
when parseAnd(tokens, pos + 1) is
Ok({ node: right, pos: pos2 }) ->
parseOrTail(tokens, pos2, BinOp({ op: Or, left, right }))
Err(msg) -> Err(msg)
else
Ok({ node: left, pos })
Err(_) -> Ok({ node: left, pos })while ではなく 末尾再帰 でループするので冗長に見えますが、副作用ゼロ、テストは入力と期待値だけで完結 という強みがあります。実際 Alopex の試験実装では、56 テストすべてが expect 一発でパスしています。
実装コード量の差
| ファイル | Nim | Roc | Rust |
|---|---|---|---|
| AST 定義 | 140 行 | 93 行 | 478 行(5 ファイル) |
| レキサー | 234 行 | 242 行 | 620 行(3 ファイル) |
| パーサー | 616 行 | 686 行 | 1,866 行(6 ファイル) |
| FFI / エントリ | 114 行 | 28 行 | — |
| 合計 | 1,104 行 | 1,049 行 | 2,964 行 |
Nim / Roc いずれも Rust の約 35〜37% のコード量 で同等のパーサーが書けたことになります。
3. Rust ホストとの統合 — Nim の C 共有ライブラリ vs Roc の Platform/Host
「パーサーを別言語にする」以上、プランナー・実行エンジンの Rust 本体との連携 が最重要です。両言語のアプローチはかなり違います。
Nim: --app:lib で C 共有ライブラリを直接出す
Nim は C にトランスパイルされるので、Rust から見ると 普通の .so + .h が生えてくる、というのが最大の利点です。エントリポイントはこうなります。
import std/[json]
import ast, parser
type
ParseResultKind {.exportc.} = enum
prkOk = 0
prkError = 1
CParseResult {.exportc.} = object
kind: ParseResultKind
json_ptr: cstring
json_len: cint
error_ptr: cstring
error_len: cint
proc NimMain() {.importc.}
proc alopex_parser_init*() {.exportc, dynlib, cdecl.} =
## Initialize Nim runtime. Must be called once.
NimMain()
proc alopex_parse_sql*(input: cstring, length: cint): CParseResult
{.exportc, dynlib, cdecl.} =
let sql = if length > 0: ($input)[0 ..< length] else: $input
try:
let astNode = parseSql(sql)
let jsonStr = $toJson(astNode)
let copied = cast[cstring](alloc(jsonStr.len + 1))
copyMem(copied, cstring(jsonStr), jsonStr.len + 1)
result = CParseResult(
kind: prkOk,
json_ptr: copied,
json_len: cint(jsonStr.len),
error_ptr: nil,
error_len: 0,
)
except ParseError:
let errMsg = getCurrentExceptionMsg()
let copied = cast[cstring](alloc(errMsg.len + 1))
copyMem(copied, cstring(errMsg), errMsg.len + 1)
result = CParseResult(
kind: prkError, json_ptr: nil, json_len: 0,
error_ptr: copied, error_len: cint(errMsg.len),
)ポイントは次の 3 点です。
- AST を JSON にシリアライズして返す — 複雑な構造体を C ABI に載せない
- メモリは
allocで確保 → Rust 側は使い終わったらalopex_free_stringを呼ぶ NimMainを最初に一度呼ぶ — ORC ランタイムの初期化に必要
Rust 側はこれだけで完結します。
#[repr(C)]
#[derive(Debug)]
pub enum ParseResultKind { Ok = 0, Error = 1 }
#[repr(C)]
pub struct CParseResult {
pub kind: ParseResultKind,
pub json_ptr: *const c_char,
pub json_len: c_int,
pub error_ptr: *const c_char,
pub error_len: c_int,
}
extern "C" {
fn alopex_parser_init();
fn alopex_parse_sql(input: *const c_char, length: c_int) -> CParseResult;
fn alopex_free_string(p: *const c_char);
}
pub fn parse_sql(sql: &str) -> Result<serde_json::Value, String> {
unsafe { alopex_parser_init(); } // 初回のみ
let cs = CString::new(sql).unwrap();
let r = unsafe { alopex_parse_sql(cs.as_ptr(), sql.len() as c_int) };
match r.kind {
ParseResultKind::Ok => { /* json_ptr → serde_json::Value */ }
ParseResultKind::Error => { /* error_ptr → String */ }
}
}Nim パーサーの出力 → JSON → Rust 側で serde_json という橋渡しで、複雑な AST を FFI 越しにやり取りする際の型不一致問題を完全に回避しています。
Roc: Platform / Host モデル
一方 Roc は、Roc アプリが Rust Host に対して C ABI 関数を export する という設計です。
Roc App (パーサー、純粋関数)
↓ C ABI として export (roc_app_main 等)
Rust Platform Host (alopex-sql)
↓ extern "C" でリンク
alopex-core / alopex-embeddedこれは basic-webserver が採用している 「Rust の hyper + tokio が Roc 関数を呼び出す」 アーキテクチャと同じで、Roc の思想がよく現れた設計です。
ただし Alopex のドキュメントでも指摘されているように、現時点では以下の点が Nim より重い課題 です。
- ライブラリ出力ワークフローが未確立 — Roc の主用途は今のところ「実行バイナリ」
- ヘッダ自動生成なし
- 静的リンクは未検証
- surgical linker の issue で
--linker=legacyが必須
4. ランタイム性能 — SQL パーサーは本当に Rust より遅くなるのか
「別言語にしたら遅くなる」という直感的懸念は、実は SQL パーサーというワークロードに関しては当てはまりません。汎用ベンチマークを集約すると次の通りです。
| ベンチマーク | Nim | Rust | 差 | パーサーとの関連 |
|---|---|---|---|---|
| Binary Trees (18) | 685 ms | 1,259 ms | Nim 46% 高速 | ◎ AST 構築と同型 |
| Base64 | 1,348 ms | 842 ms | Rust 38% 高速 | △ レキサーで少し |
| Regex Redux | 1,635 ms | 438 ms | Rust 3.7x 高速 | △ ほぼ不使用 |
| N-Body | 319 ms | 163 ms | Rust 2x 高速 | × 無関係 |
| Spectral Norm | 3,589 ms | 492 ms | Rust 7.3x 高速 | × 無関係 |
Rust が優位な数値計算・正規表現・マルチスレッド並列は、SQL パーサーではほとんど使わない領域 です。逆に SQL パーサーの主要な処理は次の 3 つで、これらは Binary Trees ベンチマークとほぼ同じ特性です。
- AST ノードの大量生成(ヒープアロケーション主体)
- 文字列比較(キーワード判定)
- 再帰的なツリー構築(パターンマッチ + ノード接続)
Nim の ORC は「大量の小オブジェクトを生成・解放する」パターンで Rust の Box + Drop より効率的に動作します。実際 Binary Trees では Nim が Rust より 46% 高速 です。したがって AST を大量に作るワークロードでは、Nim は Rust に劣らないどころか、むしろ速い可能性が高い ということです。
Roc は Rust との定量比較データがまだ公開されていませんが、Perceus 参照カウントの設計思想から見て、同様の傾向は期待できます。
5. ビルド時間 — 開発イテレーションの根本的な違い
SQL パーサー開発において実は これが最大の差 かもしれません。
| 計測項目 | Nim | Roc | Rust |
|---|---|---|---|
| 現在の計測値 | |||
| release ビルド | 2.6 秒 | 4.7 秒 | 165 秒 |
| dev ビルド | — | 0.8 秒 | — |
| テスト(ビルド込み) | 4.9 秒 | — | 124 秒 |
| テスト(実行のみ) | — | 0.3 秒 | 0.01 秒 |
| SQLite 相当推定(×6) | |||
| release ビルド | 〜16 秒 | 〜28 秒 | 〜250 秒(4 分超) |
| dev ビルド | — | 〜5 秒 | — |
| テスト(ビルド込み) | 〜30 秒 | — | 〜200 秒(3 分超) |
Rust は現時点で release 2 分 45 秒 / テスト 2 分 4 秒 で、SQLite 相当まで拡張すると release 4 分超 / テスト 3 分超 が見込まれます。1 回の修正→テストサイクルに 3〜4 分かかる環境では、SQL パーサーの反復開発は現実的ではない というのが率直な結論です。
Nim は SQLite 相当でも release 16 秒 / テスト 30 秒、Roc は dev 5 秒 + テスト 2 秒 で回ります。Rust 比 10 倍以上のイテレーション速度 が得られます。
6. 結論 — 移行先は Nim に決まった
以上を踏まえ、AlopexDB プロジェクトでは Nim を SQL パーサー移行先の本命 として選定しました。決定の根拠を整理するとこうなります。
| 判断軸 | Nim | Roc |
|---|---|---|
| 即座に使えるか | ◎ 2.x 安定版 | △ alpha4-rolling |
| C ライブラリ出力 | ◎ --app:lib + ヘッダ自動生成 | △ ワークフロー未確立 |
| GC 制御 | ◎ --mm:orc / --mm:none | ○ Perceus 自動 |
| SQL パーサー実績 | ◎ std/parsesql 標準内蔵 | × なし |
| バージョン安定性 | ◎ API 変更 1 箇所 | △ alpha で 24 箇所修正 |
| ビルド速度(対 Rust) | 63x → 16x 高速 | 35x → 9x 高速 |
| 型安全性 | ○ | ◎ HM 型推論 + ADT |
| テスタビリティ | ○ | ◎ 純粋関数 |
| 将来性 | 安定成長 | 先進的だが未成熟 |
「今すぐ本番に持ち込める」×「Rust 側の C ABI 統合が確立している」×「AST 構築ワークロードで速い」の 3 拍子が揃っているのが Nim — というのが決め手です。
一方 Roc は言語設計として非常に魅力的で、将来 1.0 が出れば再評価の価値は高いという位置づけです。実際に GitHub の alopex-db/docs を見ると、Nim パーサー移行計画は仕様(.spec-workflow/specs/nim-sql-parser-migration/)として正式に昇格しており、以下のマイルストーンで進行中です。
- M0: 準備 (1d)
- M1: Nim パーサー拡充 — Rust AST 完全互換 (3-5d)
- M2: JSON シリアライズ層 (1-2d)
- M3: Rust FFI Bridge (2-3d)
- M4: 呼び出し元の feature flag 切替 (1d)
- M5: 検証・最適化 (2-3d)
- M6: Rust パーサー段階的廃止 (1d)
合計 11〜16 日(2〜3 週間) で移行完了予定、というかなり具体的な計画です。
7. 最終アーキテクチャ
こうしてでき上がる Alopex SQL の新構成は、以下のようになります。
┌─────────────────────────────────────────┐
│ SQL Parser (Nim) │
│ ・npeg PEG 文法 or 手書き Pratt │
│ ・object variant で AST │
│ ・JSON シリアライズして返す │
│ 出力: libalopex_sql_parser.so + .h │
└──────────────┬──────────────────────────┘
│ extern "C" FFI
▼
┌─────────────────────────────────────────┐
│ alopex-sql (Rust) │
│ ・AST(JSON) を受け取り LogicalPlan に │
│ ・Planner / Optimizer / Executor │
│ ・カタログ・トランザクション制御 │
└──────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ alopex-core (Rust) │
│ ・ストレージ、複製、分散 │
└─────────────────────────────────────────┘「文法の複雑度に対して指数的にスケールしなくなる Nim 層」×「ストレージと分散システムに Rust の強みを活かす層」 という、それぞれの言語が本当に得意な領域だけを使う構成です。この決定により、AlopexDB は SQLite 相当(JOIN 5 種類、サブクエリ、CTE 等)の SQL サポートを、v0.6 で現実的なスケジュールに乗せられる見込みになりました。
まとめ
3 言語で同じ SQL パーサーを書いた結果、次のことが明確になりました。
- AST 表現の書きやすさ: Roc(ADT)> Nim(object variant)>> Rust(enum + Box)
- パーサーの書き味: Nim(Pratt)と Roc(純関数)は好み次第、Rust は変更コストが高い
- Rust 連携のしやすさ: Nim の C 共有ライブラリ + ヘッダ自動生成が現状最強
- ランタイム性能: 「AST 大量生成」ワークロードでは Nim が Rust より速い可能性すらある
- ビルド時間: Rust の 10〜60 倍のイテレーション速度
- 本番採用時期: Nim は今すぐ、Roc は 1.0 待ち
言語選択は「宗教戦争」になりがちですが、ワークロード特性と型システムの相性 を丁寧に見ると、けっこう定量的に議論できます。AlopexDB の判断は「Rust をパーサーから外し、Nim に任せる」というものですが、これは Rust を貶めるものでは全くなく、Rust の強みを本来の得意領域に集中させる ためのアーキテクチャ決定と言えます。
Nim / Roc について興味を持たれた方は、「「Nim」入門 — Cにトランスパイルするマルチパラダイム言語の現在地」と「「Roc」入門 — Elm譲りの純粋関数型言語がRustホストと組む未来」の記事も併せてご覧ください。
それではまた次回!
参考リンク
- alopex-db/docs — Alopex DB 調査ドキュメント
- alopex-db/alopex
experiment/sql-parser-trial - alopex-db/alopex
feature/nim-sql-parser— 本移行ブランチ - sqlparser-rs (デファクト Rust 実装)
- npeg — PEG for Nim
- roc-parser — Parser Combinator for Roc
- std/parsesql — Nim 標準 SQL パーサー
- Zig, Parser Combinators, and Why They’re Awesome (Hexops) — 参考: Zig での比較