正規表現を言語機能に昇格させる — grammar という、Raku にしかないもの
Perl 5 の正規表現は業界標準になり、そして記法が飽和した。拡張のたびに (? で始まる記号を足すしかなくなっていたからだ。Raku は互換を切り、記法を整理し、そして regex / token / rule という 3 つの宣言子を用意した。この 3 つの区別こそが、正規表現をパーサへ昇格させた鍵である。
ここからは、言語としての 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 度決めたトークンは確定
- 空白を飛ばすか — 字句レベルでは飛ばさない。構文レベルでは飛ばす
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
$/が現在の Matchmakeで「この規則の結果」を設定する.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)の Notation、
Rust の macro_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 点だと思う。
- 文法の記法が正規表現の延長にある。
Racket のマクロを書くには
syntax-parseという別の道具を学ぶ。 Raku はtoken/ruleで、正規表現の知識がそのまま繋がる - 文法がクラスで、継承できる。 既存の文法を継承して一部の規則だけ上書きする操作は、他にほとんど見ない
つまり 「できること」ではなく「入口の低さと再利用の形」が Raku 固有である。 この回のタイトルに「Raku にしかないもの」と書いたのは、その意味においてだ。
文字列の単位との組み合わせ
第 6 話で書いたとおり、Raku の文字列はグラフェム単位である。
つまり . が「見た目の 1 文字」にマッチする。
結合文字や絵文字を含むテキストを正規表現で扱うとき、 他の言語で必要になる面倒が、そもそも発生しない。
正しさを実装コストで買う、という第 4 話の姿勢が、ここでも効いている。 専用 VM(第 6 話)→ グラフェム文字列 → 正規表現の正しさが一本の線で繋がっている。
自作言語をやっている立場から
私は自分の言語のパーサを書いている。 この回を書くにあたって Raku の grammar を読み直して、持ち帰るものが 2 つあった。
1. token と rule の区別は、実装の都合ではなく設計である。
「バックトラックするか」「空白を飛ばすか」は、パーサを書く人が毎回決めていることだ。 それを宣言として書かせるのは、暗黙の決定を明示に変える仕事である。 自分のパーサでは、それは関数の書き方の慣習として散らばっていて、名前が付いていない。
2. 文法を 1 つにして Actions を差し替える構造。
これは第 7 話の roast の話と同じ形をしている。 同じものを 2 箇所に書くと、2 通りの値になる。 文法定義が評価器と整形器に別々にコピーされていれば、必ずずれる。
Raku がそれを言語機能として提供しているのは、 ずれる余地を構造から消しているということだ。
次回(最終話): 2026 年の Raku。 RakuAST が既定になり、6.e が来る。 そして「小さいが継続している」という現在地を、どう正確に書くかの話をする。