干货|正则表达式引擎实现
Posted 中兴开发者社区
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了干货|正则表达式引擎实现相关的知识,希望对你有一定的参考价值。
每天读一篇一线开发者原创好文
需求
实现一个正则表达式的引擎,完成如下需求:
字面值:
字符:
val("a")
,匹配字符"a"
;字符串:
val("abc")
,匹配字符串"abc"
;字符集:
one_of("abc")
,匹配abc
中的任意字符;any
: 匹配任意一个字符;拼接操作:
sequence(val("abc"), val("def"))
,匹配abcdef
;选择操作:
alternative(val("abc"), val("def"))
,匹配abc
,或def
;量词操作:
many(val("abc"))
,贪心匹配零次或多次abc
;one_or_more(val("abc"))
,贪心匹配一次或多次abc
;optional(val("abc"))
,贪心匹配零次或一次abc
。断言:
eof
: 匹配母串的末尾位置,它不消耗母串的字符。
此外,还要实现关于匹配的其他额外操作。例如,包括regex_search, regex_match, split
等,但核心算法在于模式匹配的算法实现,让我们开始尝试求解吧。
破冰
当拿到这个需求时,第一个自然反应我打开了std::regex
的库实现。但是,一分钟后我便立即放弃了这个想法,因为要深入理解std::regex
的设计和实现,估计并非一时能够搞定。
然后,第二个自然反应是让我想起了Jim Weirich
(一个Ruby
语言大师,Rake
框架的作者,已逝的大牛)的一个实现库:
git clone git@github.com:jimweirich/re.git
这个库实现非常简单,几乎可以秒懂。但是,它基于Ruby
语言自带的Regex
实现的一套命名的正则表达式,并使用漂亮的DSL
描述。不幸的是,题目要求不允许使用编程语言(或第三方)的Regex
库实现。
纠结之后,我随即打开了www.google.com
搜索,无意中找到一篇博客:Regular Expression Parser in C Using Continuation Passing。这个库相对于std::regex
的实现显得格外简单,也可以秒懂。在有限的时间里,要想搞定这件事情,看来这个方法是捷径哦。
入坎
众所周知,正则表达式引擎的核心领域模型是语法树。例如,从正则表达式[a-zA-Z_][0-9a-zA-Z_]*
到Regex
对象的创建过程,其实就是使用递归下降或其他算法,通过解析字符串的语法,构造出语法树的过程。
而字符串的匹配过程,常常设计状态机,用于控制诸如字符串匹配回溯等复杂的过程。
根据题目要求,不应该解析字符串的正则表达式,而是通过直接使用一些基本原语直接完成语法树的构造。
如果从字符串解析开始搞这个题目,显然已经完全入坎了,相信这次认证的结果势必“惨不忍睹”。
无状态
从字符串的正则表达式到语法树的构造,这个解析过程是相当消耗时间成本的。因此,构造出来的Regex
对象最好可以重用,而不是在匹配过程中再重复解析。此外,因为需要重用Regex
对象,Regex
对象应该是无状态的,它仅仅包含基本元数据;否则,每次匹配完一轮,则需要reset
中间缓存状态,这相当讨厌。
例如:使用Loop
节点实现量词*, +, ?
的语义,其中表示最小/最大迭代次数的元数据min, max
,可以由Loop
持有;而在匹配字符串过程中,记录循环的次数num
则不应该放在Loop
上;否则,匹配完成后,Loop
需要被reset
,使得num
清为0
。
原子规则
在正则表达式的语法树中,诸如val, one_of, any
是叶子节点。从严格意义上讲,正则表达式的原子操作是一个字符的匹配,多个字符通过sequence
操作连接起来,从而实现了字符串的匹配规则。
在数学上,如果显式使用sequence('a', 'b')
操作表达字符的连接,其一,显得笨拙,冗长啰嗦(即使换成seq
的缩写);其二,前缀表达式,在此处显得不够自然和直观。因此,数学描述常常使用隐式的空白连接的操作符:ab
。
在编程语言实现中,如果使用内部DSL
描述正则表达式。其一,使用字符串val("abc")
,显然比sequence(val('a'), val('b'), val('c'))
要更直观;也可以将前者看成后者的语法糖表示。其二,如果语言支持操作符重载,那么val("abc") + val("def")
,则可以极大改善表达力。
贪心
这是正则表达式使用量词匹配字符串的一个基本需求,而且默认情况下,*, +, ?
实现的是贪心匹配;而*?, +?, ??
实现的是非贪心匹配。例如,
many(val("b")) + val("bbc") ~= "bbbbc"
在贪心模式下,many(val("b"))
匹配的子串仅包括前2
个'b'
,而不是前4
个b
。
回溯
正如上例,many(val("b"))
首先贪心地尝试匹配了4
个b
,直至'c' ~= many(val("b"))
失败而止;然后,状态机将指针迁移至val("bbc")
,显然'c' ~= val("bbc")
也是失败的。随后,开始回溯过程,将many(val("b"))
贪心匹配的4
个b
逐一吐出来,尝试匹配val("bbc")
。
第1轮回溯:吐出一个
b
,但是"bc" ~= val("bbc")
,失败;第2轮回溯:再吐出一个
b
, 此时"bbc" ~= val("bbc")
,成功。
因此,many(val("b"))
不是匹配4
个b
,而仅仅两个b
。
栈
在图灵机模型中,常常使用栈来实现回溯。显然,回溯在正则表达式匹配中需要耗费很大的计算资源和存储资源。例如,每当Loop
一轮,需要将现场一并压栈,包括当前节点,当前母串的游标,Loop
的当前迭代次数等运行时数据。当后续节点匹配失败时,则从栈中弹出该Loop
节点,并将母串中的位置后移一位,这就是回溯。当栈为空,还是不匹配,则整个模式匹配过程失败。
CPS实现
但是,在CPS
模型中,其天然支持回溯过程,压栈过程由运行时的函数调用栈替代。以Java
为例,探究模式匹配的规则库实现。
接口定义
Rule
对外暴露boolean match(String s)
的核心编程接口。cont
相当于后驱规则。其中,rest -> true
表示最后一条匹配规则,用于表示模式完全匹配。
import java.util.function.Function;
public interface Rule {
boolean match(String s, Function<String, Boolean> cont);
default boolean match(String s) {
return match(s, rest -> true);
}
}
流式接口
同时,append
表示+
拼接操作,or
表示|
选择操作,分别实现模式的串联和并联。其中,cont
表示压栈了的后驱节点,rest
表示剩余的、待匹配的母串,暗喻游标的移动。
使用default
方法实现流式接口的设计,存在两个考虑的因素。
其一,接口更加人性化,并具有更好的可读性;
其二,使用中缀表达式更加符合常规思维。
public interface Rule {
// ...
default Rule append(Rule other) {
return (s, cont) -> match(s, rest -> other.match(rest, cont));
}
default Rule or(Rule other) {
return (s, cont) -> match(s, cont) || other.match(s, cont);
}
}
当然,append, or
完全可以分别重命名为sequence, alternative
的静态工厂方法,其实现了前缀表达式的接口风格。
原子
只有atom
会向前移动母串的游标(可能会大量产生子串对象,效率未考虑)。
private static Rule atom(Predicate<Integer> spec) {
return (str, cont) -> {
if (str.isEmpty()) return false;
else return spec.test(str.codePointAt(0)) && cont.apply(str.substring(1));
};
}
语法糖(字符)
基于atom
的实现,可以构造诸如val, any, oneOf
等语法糖。
public static Rule val(int v) {
return atom(c -> c == v);
}
public static Rule any() {
return atom(c -> true);
}
public static Rule oneOf(String range) {
return atom(c -> range.indexOf(c) != -1);
}
val(String s)
可以看成val(int)
的级联操作。
public static Rule val(String v) {
Rule init = (s, cont) -> v.isEmpty() ? s.isEmpty() : true;
return v.chars().mapToObj(Rules::val).reduce(init, Rule::append);
}
量词
为了实现自动的回溯,通过递归调用rest -> many(rule).match(rest, cont)
自动压栈。如果rule*
匹配失败,则匹配下一个规则cont.apply(s)
,因传递s
,它表示压栈之前母串游标的原始位置,自动实现回溯的特性。
public static Rule many(Rule rule) {
return (s, cont) ->
rule.match(s, rest -> many(rule).match(rest, cont)) || cont.apply(s);
}
同理,oneOrMore
表示匹配一次或者多次,因此可以复用many
。
public static Rule oneOrMore(Rule rule) {
return rule.append(many(rule));
}
同理,optional
表示要么匹配一次,要么匹配零次,无需像many
一样递归调用。
public static Rule optional(Rule rule) {
return (s, cont) -> rule.match(s, cont) || cont.apply(s);
}
理论上,最为抽象的应该是times(rule, m, n), m <= n
;感兴趣的可以实现一下times
,如此便彻底消除了重复设计了。
optional(rule): times(rule, 0, 1)
many(rule): times(rule, 0, Interger.MAX_VALUE)
oneOrMore(rule): times(rule, 1, Interger.MAX_VALUE)
至此,大家应该明白非贪心算法的实现了吧,就是将many
中的||
两个操作数颠倒一下求值顺序,就是这么简单,留给大家自行实验。
串(并)联
如果串联多个规则,如果使用append
的中缀表达式,效果如下:
Rule r = r1.append(r2).append(r3).append(r4);
可以恢复使用sequence
的前缀表达式,结合变参的特性,将极大改善表达力。
Rule r = sequence(r1, r2, r3, r4);
此处,强制约定至少一个,或一个以上的规则才能启动级联,在编译时安全得到了保护。
public static Rule sequence(Rule init, Rule... rules) {
return concat(Rule::append, init, rules);
}
public static Rule alternative(Rule init, Rule... rules) {
return concat(Rule::or, init, rules);
}
private static Rule concat(
BiFunction<Rule, Rule, Rule> bf, Rule init, Rule[] rules) {
return Arrays.stream(rules).reduce(init, (r1, r2) -> bf.apply(r1, r2));
}
断言
eof
是一种断言操作,它不移动游标,但用于断言游标匹配至母串的末尾。
public static Rule eof(Rule rule) {
Rule end = (s, cont) -> s.isEmpty();
return sequence(rule, end);
}
标准库
在标准库实现中,一般不会如上述实现(除函数式语言的库),主要受限于性能的制约。例如,在std::regex
的C++11
语言实现中,使用状态机实现的。
接下来,就是如何获取匹配的结果。包括是否匹配成功,子匹配结果。正如在std::regex
的实现中,使用std::match_result
记录匹配的运行时数据,及其最终的结果。如果要匹配多个子串,则可以使用迭代器匹配子串的结果。
因此,MatchResult, SubMatchResult, RegexIterator
留待下一篇文章再做分解,敬请期待。
源代码
git clone git@github.com:horance-liu/jregex.git
以上是关于干货|正则表达式引擎实现的主要内容,如果未能解决你的问题,请参考以下文章