正则表达式引擎的两种实现方法:导数与 Thompson 虚拟机

正则表达式引擎的实现方式多样,不同方法在性能、内存消耗和实现复杂度上各有权衡。本文将介绍两种数学上等价但实际表现迥异的正则匹配方法:Brzozowski 导数方法和 Thompson 虚拟机方法。
这两种方法都基于相同的抽象语法树表示,为直接的性能对比提供了统一的基础。其核心思想在于:这些看似不同的方法实际上是用不同的计算策略来解决同一个问题——一个依靠代数变换,另一个则通过程序执行。
约定与定义
为了建立统一的基础,两种正则表达式引擎都采用相同的抽象语法树(AST)表示,用树形结构来描述正则表达式的基本构造:
enum Ast {
Chr(Char)
Seq(Ast, Ast)
Rep(Ast, Int?)
Opt(Ast)
} derive(Show, ToJson, Hash, Eq)
此外,我们还提供了智能构造函数来简化正则表达式的构建:
fn Ast::chr(chr : Char) -> Ast {
Chr(chr)
}
fn Ast::seq(self : Ast, other : Ast) -> Ast {
Seq(self, other)
}
fn Ast::rep(self : Ast, n? : Int) -> Ast {
Rep(self, n)
}
fn Ast::opt(self : Ast) -> Ast {
@fs.
Opt(self)
}
AST 定义了四种基本的正则表达式操作:
Chr(Char)- 匹配单个字符字面量Seq(Ast, Ast)- 序列匹配,即一个模式紧跟另一个模式Rep(Ast, Int?)- 重复匹配,None表示无限次重复,Some(n)表示恰好重复 n 次Opt(Ast)- 可选匹配,相当于标准正则语法中的pattern?
举个例子,正则表达式 (ab*)? 表示一个可选的序列('a' 后跟零个或多个 'b'),可以这样构建:
Ast::chr('a').seq(Ast::chr('b').rep()).opt()
Brzozowski 导数方法
导数方法基于形式语言理论,通过代数变换来处理正则表达式。对于输入的每个字符,该方法计算正则表达式的"导数",实质上是在问:"消费掉这个字符后,还剩下什么需要匹配?"这样就得到了一个新的正则表达式,代表剩余的匹配模式。
为了明确表示导数和可空性,我们对基本的 Ast 类型进行了扩展:
enum Exp {
Nil
Eps
Chr(Char)
Alt(Exp, Exp)
Seq(Exp, Exp)
Rep(Exp)
} derive(Show, Hash, Eq, Compare, ToJson)
Exp 中各构造器的含义如下:
Nil- 表示不可能匹配的模式,即空集Eps- 匹配空字符串Chr(Char)- 匹配单个字符Alt(Exp, Exp)- 表示选择(或),在多个模式间进行选择Seq(Exp, Exp)- 表示连接,将两个模式依次连接Rep(Exp)- 表示重复,对模式进行零次或多次重复
通过 Exp::of_ast 函数,我们可以将 Ast 转换为表达能力更强的 Exp 格式:
fn Exp::of_ast(ast : Ast) -> Exp {
match ast {
Chr(c) => Chr(c)
Seq(a, b) => Seq(Exp::of_ast(a), Exp::of_ast(b))
Rep(a, None) => Rep(Exp::of_ast(a))
Rep(a, Some(n)) => {
let sec = Exp::of_ast(a)
let mut exp = sec
for _ in 1..<n {
exp = Seq(exp, sec)
}
exp
}
Opt(a) => Alt(Exp::of_ast(a), Eps)
}
}
同样,我们也为 Exp 提供了智能构造函数来简化模式构建:
fn Exp::seq(a : Exp, b : Exp) -> Exp {
match (a, b) {
(Nil, _) | (_, Nil) => Nil
(Eps, b) => b
(a, Eps) => a
(a, b) => Seq(a, b)
}
}
不过,Alt 的智能构造函数特别重要——它保证构造出的 Exp 符合 Brzozowski 原论文中的"相似性"标准化要求。两个正则表达式如果能通过以下规则相互转换,就被认为是相似的:
因此,我们对 Alt 构造进行标准化,确保始终使用一致的结合律和选择顺序:
fn Exp::alt(a : Exp, b : Exp) -> Exp {
match (a, b) {
(Nil, b) => b
(a, Nil) => a
(Alt(a, b), c) => a.alt(b.alt(c))
(a, b) => {
if a == b {
a
} else if a > b {
Alt(b, a)
} else {
Alt(a, b)
}
}
}
}
nullable 函数用于判断一个模式是否能够在不消费任何输入的情况下成功匹配(即匹配空字符串):
fn Exp::nullable(self : Exp) -> Bool {
match self {
Nil => false
Eps => true
Chr(_) => false
Alt(l, r) => l.nullable() || r.nullable()
Seq(l, r) => l.nullable() && r.nullable()
Rep(_) => true
}
}
deriv 函数计算模式对于特定字符的导数,按照 Brzozowski 导数理论中定义的规则对模式进行变换。我们对规则进行了重新排列,使其与 deriv 函数的实现顺序保持一致:
fn Exp::deriv(self : Exp, c : Char) -> Exp {
match self {
Nil => self
Eps => Nil
Chr(d) if d == c => Eps
Chr(_) => Nil
Alt(l, r) => l.deriv(c).alt(r.deriv(c))
Seq(l, r) => {
let dl = l.deriv(c)
if l.nullable() {
dl.seq(r).alt(r.deriv(c))
} else {
dl.seq(r)
}
}
Rep(e) => e.deriv(c).seq(self)
}
}
为了简化实现,我们这里只进行严格匹配,也就是说模式必须匹配整个输入字符串。因此,只有在处理完所有输入字符后,我们才检查最终模式的可空性:
fn Exp::matches(self : Exp, s : String) -> Bool {
loop (self, s.view()) {
(Nil, _) => {
return false
}
(e, []) => {
return e.nullable()
}
(e, [c, .. s]) => {
continue (e.deriv(c), s)
}
}
}
虚拟机方法
虚拟机方法将正则表达式编译成简单虚拟机的字节码指令。这种方法把模式匹配问题转化为程序执行过程,虚拟机同时模拟非确定性有限自动机中所有可能的执行路径。
Ken Thompson 在 1968 年的经典论文中描述了一种将正则模式编译为 IBM 7094 机器代码的引擎。其关键思路是:通过维护多个执行线程来避免指数级回溯,这些线程同步地在输入中前进,每次处理一个字符,同时探索所有可能的匹配路径。
指令集与程序表示
该虚拟机基于四种基本指令运行,它们分别对应 NFA 的不同操作:
enum Ops {
Done
Char(Char)
Jump(Int)
Fork(Int)
} derive(Show, ToJson)
每条指令在 NFA 模拟中都有其特定作用:Done 标记匹配成功完成,对应 Thompson 原设计中的 match;Char(c) 消费输入字符 c 并跳转到下一条指令;Jump(addr) 无条件跳转至地址 addr,即 Thompson 的 jmp;Fork(addr) 创建两条执行路径——一条继续执行下一条指令,另一条跳转到 addr,对应 Thompson 的 split。
Fork 指令是处理模式非确定性的关键,比如选择和重复操作,这些情况下需要同时探索多条执行路径。这直接对应了 NFA 中的 ε-转换,即执行流可以在不消费输入的情况下发生分支。
我们定义了 Prg 类型,它封装了指令数组并提供便捷的方法来构建和操作字节码程序:
type Prg Array[Ops] derive(Show, ToJson)
fn Prg::push(self : Prg, inst : Ops) -> Unit {
self.inner().push(inst)
}
fn Prg::length(self : Prg) -> Int {
self.inner().length()
}
fn Prg::op_set(self : Prg, index : Int, inst : Ops) -> Unit {
self.inner()[index] = inst
}