정규표현식을 언어 기능으로 승격시킨다 — grammar라는, Raku에만 있는 것
Perl 5의 정규표현식은 업계 표준이 되었고, 그리고 표기법이 포화했다. 확장할 때마다 (?로 시작하는 기호를 더할 수밖에 없게 되었기 때문이다. Raku는 호환을 끊고, 표기법을 정리하고, 그리고 regex / token / rule이라는 세 개의 선언자를 마련했다. 이 셋의 구별이야말로, 정규표현식을 파서로 승격시킨 열쇠다.
여기서부터는, 언어로서의 Raku 이야기다.
4화에서 9항목을 늘어놓았지만, 하나만 고른다면 이것이다. Raku에서 가장 「다른 언어에 없는 것」— grammar.
출발점 — 정규표현식의 표기법이 포화해 있었다
Perl 5의 정규표현식은, 사실상의 업계 표준이 되었다. PCRE로 다른 언어에 수출되었고, 지금도 많은 언어의 정규표현식은 Perl 유래의 표기법이다.
그리고, 표기법이 포화해 있었다.
확장할 때마다 더할 수 있는 기호가 남아 있지 않았기 때문에,
(?로 시작하는 기호를 신설할 수밖에 없게 되어 있었다.
(?:...) # 비캡처 그룹
(?=...) # 전방 탐색
(?!...) # 부정 전방 탐색
(?<=...) # 후방 탐색
(?<name>…) # 이름 붙은 캡처
(?#...) # 주석
(?{...}) # 코드 실행
전부 (?로 시작한다. 새로운 기능을 더하려면,
(? 뒤에 아직 쓰지 않은 기호를 찾을 수밖에 없다.
이것은 표기법의 설계로서 막다른 길이다. Apocalypse 5(7화에서 언급한, 가장 영향이 컸던 설계 문서)는 여기에 손을 댔다.
표기법을, 빈도에 맞춰 다시 배분한다
Raku는 호환을 끊고, 표기법을 정리했다. 주요 변경은 이렇다.
| 변경 | Perl 5 | Raku |
|---|---|---|
| 공백 | 의미가 있다 | 기본적으로 무시(/x 상당이 기본) |
| 리터럴 문자열 | 그대로 | '...'로 인용 |
| 그룹화(비캡처) | (?:...) |
[...] |
| 문자 클래스 | [abc] |
<[abc]> |
| 전방 탐색 | (?=...) |
<?before ...> |
| 이름 붙은 규칙의 호출 | (?&name) |
<name> |
[ ]와 < >의 역할이 뒤바뀐 것이 최대의 비호환이다.
왜인가. 사용 빈도다.
비캡처 그룹 쪽이, 문자 클래스보다 자주 쓰인다.
그런데 Perl 5에서는, 빈도가 높은 쪽이 길고((?:...)), 낮은 쪽이 짧았다([...]).
Raku는 그것을 뒤집었다. 표기법의 길이를, 사용 빈도에 맞춰 다시 배분했다.
여기에, 이 연재의 열두 번째 형이 있다.
표기법의 길이는, 빈도에 대한 배분이다. 기능을 계속 더하면, 그 배분은 반드시 어긋난다.
그리고 배분을 고치려면, 호환성을 끊을 수밖에 없다. 2화에서 쓴 「성공한 언어는 성공의 형태에 고정된다」의, 표기법판이다.
if "2026-09-18" ~~ / ^ (\d ** 4) '-' (\d ** 2) '-' (\d ** 2) $ / {
say "year=$0 month=$1 day=$2";
}
공백을 자유롭게 놓을 수 있으므로, /x 수식자 없이 읽기 쉽다.
'-'로 인용되어 있으므로, 리터럴인 것이 눈으로 보인다.
승격의 열쇠 — regex / token / rule
여기서부터가 본론이다.
Raku는 정규표현식에 이름을 붙여 재사용할 수 있다. 그리고 선언자가 셋 있다.
| 선언자 | 백트래킹 | 공백의 자동 처리 |
|---|---|---|
regex |
한다 | 하지 않는다 |
token |
하지 않는다(ratchet) | 하지 않는다 |
rule |
하지 않는다 | 한다(<.ws>를 자동 삽입) |
이 셋의 구별이, 정규표현식을 파서로 승격시킨 열쇠다.
왜인가. 파서를 쓸 때, 두 가지 결정이 필요하다.
- 되돌아갈 것인가 — 어휘 분석에서는 보통 하지 않는다. 한 번 정한 토큰은 확정
- 공백을 건너뛸 것인가 — 어휘 수준에서는 건너뛰지 않는다. 구문 수준에서는 건너뛴다
Perl 5의 정규표현식에는, 이 둘을 선언할 곳이 없었다. 항상 백트래킹하고, 공백은 항상 의미를 가진다. 그래서 파서를 쓰려고 하면, 그때마다 손으로 쓰게 된다.
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의 방언, 설정 파일의 확장, 마크업의 독자 표기. 기본 문법을 하나 쓰고, 방언마다 차분만 상속한다.
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를 주면, 다른 것을 만들 수 있다. 평가기, 정형기, 타입 검사기, 문법 강조기 — 문법은 하나면 된다.
이것은 구현으로서 중요한 성질이다. 문법 정의가 여러 곳에 복사되면, 반드시 어긋난다. 하나의 문법에서 여러 처리를 만들 수 있다면, 어긋날 여지가 없다.
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로 쓴 이유의 하나로 「파서 콤비네이터를 쓸 수 있다」를 들었다. Raku는 그것을, 라이브러리가 아니라 언어 기능으로 가지고 있다.
문법을 확장할 수 있는 언어는, 다른 데도 있다
여기서 정직하게 선을 그어 두고 싶다. 「문법을 확장할 수 있다」는 것은 Raku만이 아니다. 다만 이 능력은 세 개의 층으로 나뉘어 있고, 층마다 가지고 있는 언어가 다르다.
층 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, 메타 연산자, 다중 디스패치, 지연 리스트 — 방대한 구문을 가진 채로, 그것을 확장 가능하게 했다.
여기에, 이 연재의 열세 번째 형이 있다.
구문 확장의 용이함은, 원래 구문의 작음과 맞바꾼 것이다.
그리고 대가는 실제로 청구되고 있다. 바로 위에 쓴 RakuAST 이야기가 그것이다. Lisp이라면 이 문제는 일어나지 않는다. 깨질 문법이 없기 때문이다.
그러면 Raku의 위치는 어디인가
층 3에 있다는 것 자체는 Racket · Seed7 · Forth와 공유한다. Raku 고유한 것은 두 가지라고 생각한다.
- 문법의 표기법이 정규표현식의 연장에 있다.
Racket의 매크로를 쓰려면
syntax-parse라는 별도의 도구를 배운다. Raku는token/rule로, 정규표현식의 지식이 그대로 이어진다 - 문법이 클래스이고, 상속할 수 있다. 기존 문법을 상속해 일부 규칙만 덮어쓰는 조작은, 다른 데서 거의 보지 못한다
즉 「할 수 있는 것」이 아니라 「입구의 낮음과 재사용의 형태」가 Raku 고유하다. 이번 회의 제목에 「Raku에만 있는 것」이라고 쓴 것은, 그런 의미에서다.
문자열의 단위와의 조합
6화에서 쓴 대로, Raku의 문자열은 그래핌 단위다.
즉 .이 「보이는 한 글자」에 매치한다.
결합 문자나 이모지를 포함한 텍스트를 정규표현식으로 다룰 때, 다른 언어에서 필요해지는 번거로움이, 애초에 발생하지 않는다.
올바름을 구현 비용으로 산다는 4화의 자세가, 여기서도 효과를 발휘한다. 전용 VM(6화) → 그래핌 문자열 → 정규표현식의 올바름이 한 줄기 선으로 이어져 있다.
자작 언어를 하고 있는 입장에서
나는 내 언어의 파서를 쓰고 있다. 이번 회를 쓰면서 Raku의 grammar를 다시 읽고, 가져갈 것이 둘 있었다.
1. token과 rule의 구별은, 구현의 사정이 아니라 설계다.
「백트래킹할 것인가」「공백을 건너뛸 것인가」는, 파서를 쓰는 사람이 매번 정하고 있는 것이다. 그것을 선언으로 쓰게 하는 것은, 암묵의 결정을 명시로 바꾸는 일이다. 내 파서에서는, 그것은 함수를 쓰는 관습으로 흩어져 있고, 이름이 붙어 있지 않다.
2. 문법을 하나로 하고 Actions를 갈아 끼우는 구조.
이것은 7화의 roast 이야기와 같은 형태를 하고 있다. 같은 것을 두 곳에 쓰면, 두 가지 값이 된다. 문법 정의가 평가기와 정형기에 따로따로 복사되어 있으면, 반드시 어긋난다.
Raku가 그것을 언어 기능으로 제공하고 있는 것은, 어긋날 여지를 구조에서 지웠다는 것이다.
다음 회(최종화): 2026년의 Raku. RakuAST가 기본값이 되고, 6.e가 온다. 그리고 「작지만 계속되고 있다」는 현재 위치를, 어떻게 정확히 쓸 것인가의 이야기를 한다.