2.2 正規表示式與 Lex/Flex 工具#
正規表示式的定義#
在字母集 \(\Sigma\) 上,正規表示式(regular expression, regex)遞迴定義如下:
- \(\epsilon\) 是正規表示式,表語言 \(\{\epsilon\}\)
- \(a \in \Sigma\) 是正規表示式,表語言 \(\{a\}\)
- 若 \(r, s\) 為正規表示式,則:
- \(r \mid s\) (選擇,union): \(L(r) \cup L(s)\)
- \(rs\) (連接,concatenation): \(L(r)L(s)\)
- \(r^*\) (Kleene star):零次或多次重複
優先序:* > 連接 > |。現代工具再加 +(一次以上)、?(零或一次)、字元類 [a-z]、計數 {m,n} 等語法糖。
Thompson 構造:從 regex 到 NFA#
1968 年,Ken Thompson 在貝爾實驗室發表論文《Regular Expression Search Algorithm》,提出將正規表示式轉為 NFA 的構造法,並把它寫進 QED 編輯器——這是 grep(1974)與後來所有 regex 引擎的直系祖先。構造規則:
- \(a\) :兩狀態一邊
- \(rs\) :串接(前者接受狀態 ε 連到後者起始)
- \(r \mid s\) :新增分支與匯合狀態
- \(r^*\) :新增環回 ε 邊
由 \(n\) 個符號的 regex 構造出的 NFA 至多 \(2n\) 個狀態、 \(3n\) 條轉移,構造是線性的。
Lex 與 Flex 的誕生#
1975 年,Mike Lesk 與 Eric Schmidt 在貝爾實驗室寫出 Lex——lexical analyzer generator,搭配 Johnson 的 Yacc 構成 UNIX 編譯工具鏈的黃金組合。Lex 讀入規則檔(.l),產生 C 語言掃描器 lex.yy.c。開源世界的 Flex(fast lex, 1987, Vern Paxson 等)是其現代替代品,採用 DFA 表驅動,速度大幅提升。
規則檔三段式:
%{
#include "token.h"
int line = 1;
%}
%option noyywrap
DIGIT [0-9]
ID [a-zA-Z_][a-zA-Z0-9_]*
%%
[ \t]+ ;
\n { line++; }
"if" { return IF; }
"while" { return WHILE; }
{ID} { yylval.id = strdup(yytext); return ID; }
{DIGIT}+ { yylval.num = atoi(yytext); return NUM; }
"+" { return PLUS; }
"<=" { return LE; }
. { printf("非法字元 %s\n", yytext); }
%%
int main() { while (yylex() != 0) ; return 0; }
運作流程:
flowchart LR
R[正規表示式規則] --> N[NFA 合併<br>加上優先序]
N --> D[DFA<br>子集構造+最小化]
D --> T[轉移表]
T --> S[驅動程式 yylex]
S --> O[token 流]規則的優先序與二義性消解#
Lex 的兩條鐵律:
- 最長符合優先(maximal munch):
<=會勝過<。 - 先列出的規則優先:把
"if"放在{ID}之前,否則if會被當成識別字。
這兩條規則讓使用者不需要處理多數歧義,代價是規則順序具有語意意義——排列錯誤是詞法層最常見的 bug。
正規表示式在編譯器之外的影響#
- grep / awk / sed(1970 年代 UNIX 工具)把 regex 帶入日常腳本。
- Perl(Larry Wall, 1987)大幅擴充 regex 語法,引入回溯引擎與反向參照,使其不再是正規語言(可表達 \(a^n b^n\) ),但也帶來 ReDoS(catastrophic backtracking)風險。
- Rust 的 regex crate、Go 的 RE2(Cox, 2010)回歸 DFA/NFA 模擬,保證線性時間、無指數爆炸——理論上的正規語言模型在工程上重新獲勝。
小結#
- 正規表示式是描述語言的最小代數,Thompson 構造讓它可自動編譯為 NFA/DFA。
- Lex/Flex 將「regex → NFA → DFA → 表驅動 C 碼」整條流水線自動化,是編譯器工具化的濫觴。
- 理解 maximal munch 與優先序,是寫出正確掃描器規則的關鍵。