干货|正则表达式引擎实现

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框架的作者,已逝的大牛)的一个实现库:

 
   
   
 
  1. 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"),则可以极大改善表达力。

贪心

这是正则表达式使用量词匹配字符串的一个基本需求,而且默认情况下,*, +, ?实现的是贪心匹配;而*?, +?, ??实现的是非贪心匹配。例如,

 
   
   
 
  1. many(val("b")) + val("bbc") ~= "bbbbc"

在贪心模式下,many(val("b"))匹配的子串仅包括前2'b',而不是前4b

回溯

正如上例,many(val("b"))首先贪心地尝试匹配了4b,直至'c' ~= many(val("b"))失败而止;然后,状态机将指针迁移至val("bbc"),显然'c' ~= val("bbc")也是失败的。随后,开始回溯过程,将many(val("b"))贪心匹配的4b逐一吐出来,尝试匹配val("bbc")

  • 第1轮回溯:吐出一个b,但是"bc" ~= val("bbc"),失败;

  • 第2轮回溯:再吐出一个b, 此时"bbc" ~= val("bbc"),成功。

因此,many(val("b"))不是匹配4b,而仅仅两个b

在图灵机模型中,常常使用栈来实现回溯。显然,回溯在正则表达式匹配中需要耗费很大的计算资源和存储资源。例如,每当Loop一轮,需要将现场一并压栈,包括当前节点,当前母串的游标,Loop的当前迭代次数等运行时数据。当后续节点匹配失败时,则从栈中弹出该Loop节点,并将母串中的位置后移一位,这就是回溯。当栈为空,还是不匹配,则整个模式匹配过程失败。

CPS实现

但是,在CPS模型中,其天然支持回溯过程,压栈过程由运行时的函数调用栈替代。以Java为例,探究模式匹配的规则库实现。

接口定义

Rule对外暴露boolean match(String s)的核心编程接口。cont相当于后驱规则。其中,rest -> true表示最后一条匹配规则,用于表示模式完全匹配

 
   
   
 
  1. import java.util.function.Function;

  2. public interface Rule {

  3.  boolean match(String s, Function<String, Boolean> cont);

  4.  default boolean match(String s) {

  5.    return match(s, rest -> true);

  6.  }

  7. }

流式接口

同时,append表示+拼接操作,or表示|选择操作,分别实现模式的串联和并联。其中,cont表示压栈了的后驱节点,rest表示剩余的、待匹配的母串,暗喻游标的移动。

使用default方法实现流式接口的设计,存在两个考虑的因素。

  • 其一,接口更加人性化,并具有更好的可读性;

  • 其二,使用中缀表达式更加符合常规思维。

 
   
   
 
  1. public interface Rule {

  2.  // ...

  3.  default Rule append(Rule other) {

  4.    return (s, cont) -> match(s, rest -> other.match(rest, cont));

  5.  }

  6.  default Rule or(Rule other) {

  7.    return (s, cont) -> match(s, cont) || other.match(s, cont);

  8.  }

  9. }

当然,append, or完全可以分别重命名为sequence, alternative的静态工厂方法,其实现了前缀表达式的接口风格。

原子

只有atom会向前移动母串的游标(可能会大量产生子串对象,效率未考虑)。

 
   
   
 
  1.  private static Rule atom(Predicate<Integer> spec) {

  2.    return (str, cont) -> {

  3.      if (str.isEmpty()) return false;

  4.      else return spec.test(str.codePointAt(0)) && cont.apply(str.substring(1));

  5.    };

  6.  }

语法糖(字符)

基于atom的实现,可以构造诸如val, any, oneOf等语法糖。

 
   
   
 
  1.  public static Rule val(int v) {

  2.    return atom(c -> c == v);

  3.  }

  4.  public static Rule any() {

  5.    return atom(c -> true);

  6.  }

  7.  public static Rule oneOf(String range) {

  8.    return atom(c -> range.indexOf(c) != -1);

  9.  }

val(String s)可以看成val(int)的级联操作。

 
   
   
 
  1.  public static Rule val(String v) {

  2.    Rule init = (s, cont) -> v.isEmpty() ? s.isEmpty() : true;

  3.    return v.chars().mapToObj(Rules::val).reduce(init, Rule::append);

  4.  }

量词

为了实现自动的回溯,通过递归调用rest -> many(rule).match(rest, cont)自动压栈。如果rule*匹配失败,则匹配下一个规则cont.apply(s),因传递s,它表示压栈之前母串游标的原始位置,自动实现回溯的特性。

 
   
   
 
  1.  public static Rule many(Rule rule) {

  2.    return (s, cont) ->

  3.        rule.match(s, rest -> many(rule).match(rest, cont)) || cont.apply(s);

  4.  }

同理,oneOrMore表示匹配一次或者多次,因此可以复用many

 
   
   
 
  1.  public static Rule oneOrMore(Rule rule) {

  2.    return rule.append(many(rule));

  3.  }

同理,optional表示要么匹配一次,要么匹配零次,无需像many一样递归调用。

 
   
   
 
  1.  public static Rule optional(Rule rule) {

  2.    return (s, cont) -> rule.match(s, cont) || cont.apply(s);

  3.  }

理论上,最为抽象的应该是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的中缀表达式,效果如下:

 
   
   
 
  1. Rule r = r1.append(r2).append(r3).append(r4);

可以恢复使用sequence的前缀表达式,结合变参的特性,将极大改善表达力。

 
   
   
 
  1. Rule r = sequence(r1, r2, r3, r4);

此处,强制约定至少一个,或一个以上的规则才能启动级联,在编译时安全得到了保护。

 
   
   
 
  1.  public static Rule sequence(Rule init, Rule... rules) {

  2.    return concat(Rule::append, init, rules);

  3.  }

  4.  public static Rule alternative(Rule init, Rule... rules) {

  5.    return concat(Rule::or, init, rules);

  6.  }

  7.  private static Rule concat(

  8.      BiFunction<Rule, Rule, Rule> bf, Rule init, Rule[] rules) {

  9.    return Arrays.stream(rules).reduce(init, (r1, r2) -> bf.apply(r1, r2));

  10.  }

断言

eof是一种断言操作,它不移动游标,但用于断言游标匹配至母串的末尾。

 
   
   
 
  1.  public static Rule eof(Rule rule) {

  2.    Rule end = (s, cont) -> s.isEmpty();

  3.    return sequence(rule, end);

  4.  }

标准库

在标准库实现中,一般不会如上述实现(除函数式语言的库),主要受限于性能的制约。例如,在std::regexC++11语言实现中,使用状态机实现的。

接下来,就是如何获取匹配的结果。包括是否匹配成功,子匹配结果。正如在std::regex的实现中,使用std::match_result记录匹配的运行时数据,及其最终的结果。如果要匹配多个子串,则可以使用迭代器匹配子串的结果。

因此,MatchResult, SubMatchResult, RegexIterator留待下一篇文章再做分解,敬请期待。

源代码

 
   
   
 
  1. git clone git@github.com:horance-liu/jregex.git

以上是关于干货|正则表达式引擎实现的主要内容,如果未能解决你的问题,请参考以下文章

不到40行代码构建正则表达式引擎

简易正则表达式引擎的实现

干货 | Logstash自定义正则表达式ETL实战

干货 | Logstash自定义正则表达式ETL实战

leetcode如何实现 regex 正则表达式引擎

实现了普通的正则引擎无法实现的两大功能