正規表現を言語機能に昇格させる — grammar という、Raku にしかないもの

Perl 5 の正規表現は業界標準になり、そして記法が飽和した。拡張のたびに (? で始まる記号を足すしかなくなっていたからだ。Raku は互換を切り、記法を整理し、そして regex / token / rule という 3 つの宣言子を用意した。この 3 つの区別こそが、正規表現をパーサへ昇格させた鍵である。

rakugrammarregexparserlanguage-designprogramming-languages

ここからは、言語としての Raku の話だ。

第 4 話で 9 項目を並べたが、1 つだけ選ぶならこれである。 Raku で最も「他の言語に無いもの」— grammar

出発点 — 正規表現の記法が飽和していた

Perl 5 の正規表現は、事実上の業界標準になった。 PCRE として他言語へ輸出され、今でも多くの言語の正規表現は Perl 由来の記法である。

そして、記法が飽和していた。

拡張のたびに足せる記号が残っていなかったため、 (? で始まる記号を新設するしかなくなっていた。

(?:...)     # 非キャプチャグループ
(?=...)     # 先読み
(?!...)     # 否定先読み
(?<=...)    # 後読み
(?<name>…)  # 名前付きキャプチャ
(?#...)     # コメント
(?{...})    # コード実行

すべて (? から始まる。 新しい機能を足すには、 (? の後ろにまだ使っていない記号を探すしかない。

これは記法の設計として行き詰まりである。 Apocalypse 5(第 7 話で触れた、最も影響の大きかった設計文書)はここに手を入れた。

記法を、頻度に合わせて割り当て直す

Raku は互換を切り、記法を整理した。主な変更はこうだ。

変更 Perl 5 Raku
空白 意味がある 既定で無視/x 相当が既定)
リテラル文字列 そのまま '...' で引用
グループ化(非キャプチャ) (?:...) [...]
文字クラス [abc] <[abc]>
先読み (?=...) <?before ...>
名前付き規則の呼び出し (?&name) <name>

[ ]< > の役割が入れ替わったのが最大の非互換だ。

なぜか。使用頻度である。

非キャプチャグループの方が、文字クラスより頻繁に書かれる。 なのに Perl 5 では、頻度の高い方が長く((?:...))、低い方が短かった([...])。

Raku はそれを逆にした。記法の長さを、使用頻度に合わせ直した。

ここに、この連載の 12 番目の型がある。

記法の長さは、頻度への割り当てである。 機能を足し続けると、その割り当ては必ず狂う。

そして割り当てを直すには、互換性を切るしかない。 第 2 話で書いた「成功した言語は成功の形に固定される」の、記法版である。

if "2026-09-18" ~~ / ^ (\d ** 4) '-' (\d ** 2) '-' (\d ** 2) $ / {
    say "year=$0 month=$1 day=$2";
}

空白が自由に置けるので、/x 修飾子なしで読みやすい。 '-' と引用されているので、リテラルであることが目で分かる。

昇格の鍵 — regex / token / rule

ここからが本題だ。

Raku は正規表現に名前をつけて再利用できる。そして宣言子が 3 つある。

宣言子 バックトラック 空白の自動処理
regex する しない
token しない(ratchet) しない
rule しない する<.ws> を自動挿入)

この 3 つの区別が、正規表現をパーサへ昇格させた鍵である。

なぜか。パーサを書くとき、2 つの決定が要る。

  1. 後戻りするか — 字句解析では普通しない。1 度決めたトークンは確定
  2. 空白を飛ばすか — 字句レベルでは飛ばさない。構文レベルでは飛ばす

Perl 5 の正規表現には、この 2 つを宣言する場所が無かった。 常にバックトラックし、空白は常に意味を持つ。 だからパーサを書こうとすると、その都度手で書くことになる。

Raku は宣言子で選ばせた。

  • token が既定の選択肢。バックトラックしないので速く、挙動が予測しやすい
  • rule は「トークンの間に空白が入ってよい」文法、つまり普通のプログラミング言語向け

字句解析と構文解析の両方を、同じ記法で書けるようになった。

grammar — パーサをクラスとして書く

名前付き規則をまとめたものが grammar である。

grammar Calc {
    rule  TOP    { <expr> }
    rule  expr   { <term> +% <addop> }
    rule  term   { <factor> +% <mulop> }
    rule  factor { <number> | '(' <expr> ')' }
    token number { \d+ }
    token addop  { '+' | '-' }
    token mulop  { '*' | '/' }
}

say Calc.parse('1 + 2 * 3');

見どころを挙げる。

  • TOP が開始規則.parse / .parsefile で実行する
  • 結果は Match オブジェクトの木
  • +% は「区切り文字つき 1 個以上」<term> +% <addop> は 「term を addop で区切った並び」を表す。左再帰を書かずに二項演算子の列が書ける
  • 末端は token、構造は rule空白の扱いが宣言で分かれている

7 行で電卓の文法になる。しかもこれは読める。 yacc の文法定義や、パーサコンビネータの入れ子より、構造がそのまま見える。

文法がクラスである — 継承できる

grammar はクラスの一種なので、継承できる。

grammar CalcWithPower is Calc {
    rule factor { <base> ['**' <exp>]? }
    ...
}

既存の文法を継承して、一部の規則だけ上書きする。

これは他の多くのパーサジェネレータに無い性質だ。 yacc や ANTLR で「この文法をベースに、この規則だけ変えたい」は素直に書けない。 文法ファイルをコピーして書き換えることになる。

実用上これが効くのは、方言を扱うときである。 SQL の方言、設定ファイルの拡張、マークアップの独自記法。 基本文法を 1 つ書いて、方言ごとに差分だけ継承する。

Actions — 構造と意味を分ける

grammar は「構造を認識する」だけで、意味は Actions クラスが与える。

class CalcActions {
    method TOP($/)    { make $<expr>.made }
    method expr($/)   { make [+] $<term>.map(*.made) }
    method term($/)   { make [*] $<factor>.map(*.made) }
    method factor($/) { make $<number> ?? $<number>.made !! $<expr>.made }
    method number($/) { make +$/ }
}

say Calc.parse('1 + 2 * 3', actions => CalcActions.new).made;   # 7
  • $/ が現在の Match
  • make で「この規則の結果」を設定する
  • .made で子の結果を取り出す
  • [+][*] は第 4 話のリデュースメタ演算子<term> の結果の列を畳み込んでいる (この Actions は +* だけを扱う簡略版。-/ は省いた)

文法と意味づけが分離されている。

だから、同じ文法に別の Actions を与えれば、別のものが作れる。 評価器、整形器、型検査器、シンタックスハイライタ — 文法は 1 つでいい。

これは実装として重要な性質だ。 文法定義が複数箇所にコピーされると、必ずずれる。 1 つの文法から複数の処理を作れるなら、ずれようがない。

Raku 自身が grammar で書かれている

決定的な点として、Raku の文法そのものが grammar で記述されている。

歴史的には STD.pm6 がそれだった。 Larry Wall が保守していた、Perl 6 の文法を Perl 6 の grammar で書いた文書である。 実行可能な仕様に近い位置づけで、専用のツールで実際に動かすことができた。

帰結として、ユーザがコンパイル時に文法を拡張できる。

sub infix:<∈>($x, @set) { $x (elem) @set }
say 2 ∈ (1, 2, 3);     # True

infix:<...> / prefix:<...> / postfix:<...> / circumfix:<...> という名前で 演算子を定義でき、結合性と優先順位も指定できる。 定義した瞬間、それは本当に構文になる。

第 4 話で「足す対象を、機能から規則へ引き上げた」と書いた。 grammar はその最も深いところにある。言語が、言語を拡張する手段そのものを提供している。

⚠️ ただしこの性質には代償がある。 第 12 話で扱う RakuAST(コンパイラ基盤の刷新)では、 文法の構造が変わるため、旧 grammar に依存した拡張は手直しが要る。 「文法を開く」と決めた言語は、文法を変えるときに二重のコストを払う。

他言語のパーサ道具と比べる

道具 位置づけ
Raku grammar 言語機能。継承可能、Actions で意味分離、実行時に使える
ANTLR / yacc / bison 外部ツール。コード生成の段が要る
Parsec(Haskell)/ nom(Rust) ライブラリ(パーサコンビネータ)。ホスト言語の関数として書く
Python の re / Ruby の Regexp 正規表現どまり。文法は書けない

Raku の位置は「パーサコンビネータを言語構文にしたもの」が近い。

Parsec と違うのは、専用の記法を持ち、その記法が正規表現の延長にあることだ。 正規表現を知っている人が、そのまま文法を書き始められる。

第 5 話で、Audrey Tang が Pugs を Haskell で書いた理由の 1 つに 「パーサコンビネータが使える」を挙げた。 Raku はそれを、ライブラリではなく言語機能として持っている。

文法を拡張できる言語は、他にもある

ここで正直に線を引いておきたい。「文法を拡張できる」のは Raku だけではない。 ただしこの能力は 3 つの層に分かれていて、層ごとに持っている言語が違う。

層 1 — 演算子を定義できる(優先順位・結合性つき)

珍しくない。むしろ Raku より 30 年早い例がある。

言語 方法
Prolog op/3(優先順位 1〜1200、xfx/xfy/yfx…)。1970 年代から
Agda ミックスフィックス if_then_else__ の位置で任意の語順
Swift infix operator + precedencegroup。優先順位の群を新設できる
Haskell infixl 6 <+>
Raku infix:<∈> + is tighter / is looser / is equiv
Scala / OCaml / F# 中置演算子はあるが、優先順位は先頭文字で決まる(固定表)

層 2 — 新しい構文形式を足せる(マクロ)

ここも広い。

Lisp 族(Common Lisp / Scheme / Racket / Clojure)、Rocq(旧 Coq)の NotationRustmacro_rules! と手続きマクロ、Elixir / Julia / Nim の AST マクロ、 Haskell の Template Haskell と準クォート。

ただし制約の形が違う。Rust のマクロはトークン木が単位で、括弧の対応が取れている必要がある。 新しい構文形式は足せるが、トークナイザは変えられない。

層 3 — パーサそのものを差し替えられる

ここが狭い。Raku の slang はこの層にいる。

言語 できること
Racket #lang でリーダごと差し替え。表面構文が S 式と無関係な言語を定義できる
Raku slang。パース中に文法を切り替えられる(レキシカルスコープ)
Seed7 構文と意味の拡張を前提に設計された言語
Forth immediate word。そもそも固定した構文がほぼ無い
OCaml(Camlp4/5) 構文拡張プリプロセッサ。現在はレガシー
Perl 5 ソースフィルタ(テキスト置換)。実質的に力業

この層では Racket の方が Raku より強いと言っていい。 #lang でリーダを丸ごと差し替えられるので、Racket の上に「Racket に見えない言語」を載せられる。

規則性 — 拡張しやすさは、元の構文の小ささと引き換え

並べると見えてくるものがある。

言語 元の構文の量 拡張のしやすさ
Lisp / Racket 極小(ほぼ括弧だけ) 非常に易しい
Forth ほぼ無い 非常に易しい
Rust 中(トークン木の形に制約)
Raku 極大 難しいが、やっている

Lisp は「構文をほとんど持たない」ことで構文拡張を易しくした。 マクロがリストを受け取ってリストを返すだけで済むのは、プログラムの表現がリストしかないからだ。

Raku は逆である。シジル、twigil、メタ演算子、多重ディスパッチ、遅延リスト — 膨大な構文を持ったまま、それを拡張可能にした。

ここに、この連載の 13 番目の型がある。

構文拡張のしやすさは、元の構文の小ささと引き換えである。

そして代償は実際に請求されている。すぐ上に書いた RakuAST の話がそれだ。 Lisp ならこの問題は起きない。壊れる文法が無いからである。

では Raku の位置はどこか

層 3 に居ること自体は Racket・Seed7・Forth と共有している。Raku 固有なのは 2 点だと思う。

  1. 文法の記法が正規表現の延長にある。 Racket のマクロを書くには syntax-parse という別の道具を学ぶ。 Raku は token / rule で、正規表現の知識がそのまま繋がる
  2. 文法がクラスで、継承できる。 既存の文法を継承して一部の規則だけ上書きする操作は、他にほとんど見ない

つまり 「できること」ではなく「入口の低さと再利用の形」が Raku 固有である。 この回のタイトルに「Raku にしかないもの」と書いたのは、その意味においてだ。

文字列の単位との組み合わせ

第 6 話で書いたとおり、Raku の文字列はグラフェム単位である。 つまり . が「見た目の 1 文字」にマッチする。

結合文字や絵文字を含むテキストを正規表現で扱うとき、 他の言語で必要になる面倒が、そもそも発生しない。

正しさを実装コストで買う、という第 4 話の姿勢が、ここでも効いている。 専用 VM(第 6 話)→ グラフェム文字列 → 正規表現の正しさが一本の線で繋がっている。

自作言語をやっている立場から

私は自分の言語のパーサを書いている。 この回を書くにあたって Raku の grammar を読み直して、持ち帰るものが 2 つあった。

1. tokenrule の区別は、実装の都合ではなく設計である。

「バックトラックするか」「空白を飛ばすか」は、パーサを書く人が毎回決めていることだ。 それを宣言として書かせるのは、暗黙の決定を明示に変える仕事である。 自分のパーサでは、それは関数の書き方の慣習として散らばっていて、名前が付いていない。

2. 文法を 1 つにして Actions を差し替える構造。

これは第 7 話の roast の話と同じ形をしている。 同じものを 2 箇所に書くと、2 通りの値になる。 文法定義が評価器と整形器に別々にコピーされていれば、必ずずれる。

Raku がそれを言語機能として提供しているのは、 ずれる余地を構造から消しているということだ。


次回(最終話): 2026 年の Raku。 RakuAST が既定になり、6.e が来る。 そして「小さいが継続している」という現在地を、どう正確に書くかの話をする。

← Back to Perl と Raku の系譜