asopi techOSS Developer
asopi tech
asopi techOSS Developer
Rustは万能じゃない — 言語パーサーはNimかRocで書け

【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/docsfeature/document-reorganization ブランチと、実装コードは alopex-db/alopex experiment/sql-parser-trial です。

前提: なぜ Rust ではダメだったのか

まず「なぜ移行が必要になったのか」の背景です。AlopexDB は Rust で書かれた分散データベースですが、その中の SQL パーサー層が 開発を続けるほど厳しくなる 状態に陥っていました。

Rust の型システムが SQL 文法に対して不利な理由

AlopexDB の現行 alopex-sql は、以下のような enum 群を持っています。

  • ExprKind 10 variants
  • StatementKind 8 variants
  • Token 24 variants
  • Keyword 69 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 に近い感覚です。

src/ast.nim
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 の意味構造がそのまま型として立ち上がります。

Ast.roc
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 で同等のことを書くとこうなります。

src/ast.rs
#[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 オブジェクトを渡し歩く 手続き型スタイルです。

src/parser.nim
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 で結果と次位置を返します。

Parser.roc
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 一発でパスしています。

実装コード量の差

ファイルNimRocRust
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 が生えてくる、というのが最大の利点です。エントリポイントはこうなります。

src/alopex_sql_parser.nim
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 側はこれだけで完結します。

src/ffi.rs
#[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 パーサーというワークロードに関しては当てはまりません。汎用ベンチマークを集約すると次の通りです。

ベンチマークNimRustパーサーとの関連
Binary Trees (18)685 ms1,259 msNim 46% 高速◎ AST 構築と同型
Base641,348 ms842 msRust 38% 高速△ レキサーで少し
Regex Redux1,635 ms438 msRust 3.7x 高速△ ほぼ不使用
N-Body319 ms163 msRust 2x 高速× 無関係
Spectral Norm3,589 ms492 msRust 7.3x 高速× 無関係

Rust が優位な数値計算・正規表現・マルチスレッド並列は、SQL パーサーではほとんど使わない領域 です。逆に SQL パーサーの主要な処理は次の 3 つで、これらは Binary Trees ベンチマークとほぼ同じ特性です。

  1. AST ノードの大量生成(ヒープアロケーション主体)
  2. 文字列比較(キーワード判定)
  3. 再帰的なツリー構築(パターンマッチ + ノード接続)

Nim の ORC は「大量の小オブジェクトを生成・解放する」パターンで Rust の Box + Drop より効率的に動作します。実際 Binary Trees では Nim が Rust より 46% 高速 です。したがって AST を大量に作るワークロードでは、Nim は Rust に劣らないどころか、むしろ速い可能性が高い ということです。

Roc は Rust との定量比較データがまだ公開されていませんが、Perceus 参照カウントの設計思想から見て、同様の傾向は期待できます。

5. ビルド時間 — 開発イテレーションの根本的な違い

SQL パーサー開発において実は これが最大の差 かもしれません。

計測項目NimRocRust
現在の計測値
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 パーサー移行先の本命 として選定しました。決定の根拠を整理するとこうなります。

判断軸NimRoc
即座に使えるか◎ 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ホストと組む未来」の記事も併せてご覧ください。

それではまた次回!

参考リンク