← ブログ一覧へ戻る ← Back to blog index

/blog/rust-parser-state-machine

手書きパーサと状態機械で学ぶ Rust の型設計 Learning Rust type design through a handwritten parser and state machine

Token、AST、状態遷移、proc macro を通じた、Rust における入力ルールの型設計メモ。 Notes on type design for input rules in Rust through tokens, ASTs, state transitions, and proc macros.

  • Rust
  • Parser
  • StateMachine
  • Macro
  • TypeDesign

整理対象

手書きパーサ、段階的なデータ変換、状態機械、proc macro は一見別分野に見えるが、実はどれも「入力の曖昧さを減らし、型に落とす」処理を書く目的で繋がっている。この記事では、Rust が入力ルールをどう型に閉じ込めるかを整理。

TokenExpr の分離

Rustではトークン列、AST、評価器、エラー型を全て型として分離できる。

pub enum Token {
    Number(i64),
    Plus,
    Minus,
    Star,
    Slash,
    LParen,
    RParen,
}

pub enum Expr {
    Number(i64),
    Unary {
        op: UnaryOp,
        expr: Box<Expr>,
    },
    Binary {
        left: Box<Expr>,
        op: BinaryOp,
        right: Box<Expr>,
    },
}

この切り方の利点は、字句解析と構文解析の責務が混ざらないこと。lex は「どの記号が来たか」だけを扱い、Parser は「どの結合順で木を作るか」だけに集中する。責務ごとに enum を分けたほうが後で読みやすい。

優先順位は関数階層で表現

この parser は再帰下降構文解析。parse_primaryparse_unaryparse_factorparse_term と関数を積み上げる。重要なのは、演算子優先順位を if 文ではなく関数境界で表す点である。

読み順が、そのまま処理の優先順位の説明になる。

  • parse_primary: 数字と括弧
  • parse_unary: 単項 -
  • parse_factor: */
  • parse_term: +-

この構成では、パーサは「トリッキーな再帰」ではなく、「強く結びつく構文ほど下の関数に押し込む」構造として読める。実装テクニックではなく設計方針として重要。

Parse と Convert の分離

業務データ寄りの例でも、処理を ingestnormalizeparsevalidateconvert に分解。

特に重要なのは、parseconvert の分離。

  • parse: 行を列へ分解する
  • validate: 禁止条件を弾く
  • convert: 文字列を型付き値へ落とす

例えば ParsedFields はまだ文字列を保持する。

struct ParsedFields {
    name: String,
    date_text: String,
    amount_text: String,
}

この段階で確定しているのは、「三つの列に分けられた」という事実だけである。その後 LoadedRecord へ変換されて、初めて「使えるデータ」になる。parse は「意味づけ」ではなく「構造の回収」。

状態集合は enum で明示

struct で状態を持つ例と enum で状態を持つ例を比較。後者では、あり得る状態がコード上に明示される。

enum State {
    Accumulating { acc: i32 },
    Empty,
}

enum Event {
    Add { x: i32, y: i32 },
    Reset,
}

これを step(state, event) で遷移させると、表現したい状態集合が明確になる。struct State { acc: i32 } でも動くが、Empty のような特別状態を入れたいとき、無効値や追加フラグを持ち込みやすい。

macro は構文の入口

macro 周りの小さな実験も、パーサや状態機械と同じ線で読める。

use proc_macro::TokenStream;

#[proc_macro]
pub fn double(input: TokenStream) -> TokenStream {
    let expr = syn::parse_macro_input!(input as syn::Expr);
    quote::quote! {
        (#expr) * 2
    }
    .into()
}

macro もまた、「入力を受け取り、構造として読み直し、別の形に出力する」仕組み。対象が runtime の文字列ではなく compile time の token tree になっているだけで、処理の性質は parser に近い。

特に parse_macro_input! を使うと、不正入力を panic ではなくコンパイルエラーとして返せる。

設計方針としての整理

ここまでをまとめると、各実験の役割は次の通り。

実験主な役割型設計の学び
手書きパーサ字句解析と AST 構築入力規則を enum と再帰構造に分離する
段階的なデータ変換業務データの段階的変換parse と convert を分けてエラーの意味を守る
状態機械状態遷移の表現あり得る状態を enum に持たせる
式や block の観察Rust 構文の観察Rust 構文そのものを入力として意識する
proc macrotoken からコード生成compile time の parser として macro を見る

対象は違っても基本姿勢は同じ。曖昧な文字列やイベント列を曖昧なままロジック側に流さず、段階ごとに別の型へ変えていくこと。

設計の成功と失敗

以前は、型を増やすと実装が重くなると考えていた。しかし、「どの段階で何が確定しているか」の曖昧なままにすることこそが実際に重くなる原因。型の数が増やすことは、コード量こそ増えるが、その曖昧さを解消する利点が得られる。

たとえば次の設計は、短くても後から読むと危険。

  • String のまま日付や金額を持ち続ける
  • パース失敗と業務ルール違反を同じ Err(String) に押し込む
  • 状態機械の特別状態を bool や sentinel 値で表す

反対に、段階ごとに名前のある型を置くと、コード量は少し増えても論点は減る。「型を増やす勇気」は、実装トリックより価値がある。

次にやるなら

この系列の次の実験候補。

  1. 式パーサに識別子と let 文を追加する
  2. ParseError に line/column を持たせて診断を改善する
  3. 状態機械の例を generic event source に広げて、遷移表を外出しする
  4. proc macro で attribute macro も試し、宣言的 macro との差分を比べる

要点は、Rust の型設計は「複雑さを消す」より「複雑さの位置をずらす」作業に近いということ。入力の曖昧さを型の境界で止めることが、読みやすさと保守性につながる。

Image preview