实现一个 DFA 正则表达式引擎 - 1. 语法树的构建
(正则引擎已完成,Github)
语法树的构建这里分为三步:
1. 补全正则表达式的省略部分(主要是省略的 concat 和 or 连接符)并翻译七个集合字 '\w', '\W', '\s', '\S', '\d', '\D' 和 '.';
2. 转换为逆波兰表达式;
3. 转换为语法树;
这里以正则表达式 (a*b|ab*) 为例,逐步解释构建语法树的过程。
1. 补全正则表达式的省略部分
符合我们要求的正则表达式只有三个正交的运算符,或运算,连接运算,重复量词。这里将正则表达式转换为以上三种运算加上两个括号运算符。转换规则比较简单,遍历正则,在集合 '[' ']' 中的所有字符之间补全省略的 “或运算”,重复量词转换为连接运算和或运算的混合,如量词 "x{2,3}",就要转换为 (xx|xxx) 的形式;量词 "?",就要转换为 (ε|x) 的形式 (什么是 ε ? 就是什么都没有,? 代表 0 到 1 个,0 即是什么都没有)。其余连接部分补全省略的 “连接运算”,顺便把所有的转义字符和集合字符处理成 ASCII 字符互相连接的形式(比如,\s 可以处理为 (空格 | tab))。这里附上代码的一种实现。
private void normalize() { int index = 0; while (index < regex.length()) { char ch = regex.charAt(index++); switch (ch) { case '[': { tryConcat(); List<Character> all = new ArrayList<>(); boolean isComplementarySet; if (regex.charAt(index) == '^') { isComplementarySet = true; index++; } else isComplementarySet = false; for (char next = regex.charAt(index++); next != ']'; next = regex.charAt(index++)) { if (next == '\\' || next == '.') { String token; if (next == '\\') { char nextNext = regex.charAt(index++); token = new String(new char[]{next, nextNext}); } else token = String.valueOf(next); List<Character> tokenSet = CommonSets.interpretToken(token); all.addAll(tokenSet); } else all.add(next); } char[] chSet = CommonSets.minimum(CommonSets.listToArray(all)); if (isComplementarySet) { chSet = CommonSets.complementarySet(chSet); } nodeList.add(new LeftBracket()); for (int i = 0; i < chSet.length; i++) { nodeList.add(new LChar(chSet[i])); if (i == chSet.length - 1 || chSet[i + 1] == 0) break; nodeList.add(new BOr()); } nodeList.add(new RightBracket()); itemTerminated = true; break; } case '{': { int least; int most = -1; boolean deterministicLength = false; StringBuilder sb = new StringBuilder(); for (char next = regex.charAt(index++); ; ) { sb.append(next); next = regex.charAt(index++); if (next == '}') { deterministicLength = true; break; } else if (next == ',') { break; } } least = Integer.parseInt(sb.toString()); if (!deterministicLength) { char next = regex.charAt(index); if (next != '}') { sb = new StringBuilder(); for (char nextNext = regex.charAt(index++); nextNext != '}'; nextNext = regex.charAt(index++)) { sb.append(nextNext); } if (sb.length() != 0) { most = Integer.parseInt(sb.toString()); } } } else most = least; performMany(least, most); itemTerminated = true; break; } case '(': { tryConcat(); nodeList.add(new LeftBracket()); itemTerminated = false; break; } case ')': { nodeList.add(new RightBracket()); itemTerminated = true; break; } case '*': { performMany(0, -1); itemTerminated = true; break; } case '?': { performMany(0, 1); itemTerminated = true; break; } case '+': { performMany(1, -1); itemTerminated = true; break; } case '|': { nodeList.add(new BOr()); itemTerminated = false; break; } default: { tryConcat(); if (ch == '\\' || ch == '.') { String token; if (ch == '\\') { char next = regex.charAt(index++); token = new String(new char[]{ch, next}); } else token = String.valueOf(ch); List<Character> tokenSet = CommonSets.interpretToken(token); nodeList.add(new LeftBracket()); nodeList.add(new LChar(tokenSet.get(0))); for (int i = 1; i < tokenSet.size(); i++) { nodeList.add(new BOr()); nodeList.add(new LChar(tokenSet.get(i))); } nodeList.add(new RightBracket()); } else nodeList.add(new LChar(ch)); itemTerminated = true; break; } } } }
集合中若有取并集和取补集的部分,使用一个 boolean 桶去重即可。
经过这一步,以上正则表达式已经被转换成如下形式:
[[(], a, [M], {N}, [C], b, [O], a, [C], b, [M], {N}, [)]]
其中 [] 包裹的字符均为操作符 (operator),含义为 [C] : CONCAT; [M] : MANY; [O] : OR;
2. 转换为逆波兰表达式
逆波兰表达式的转换需要先定出各操作符的优先级,然后即可使用 shunting yard 算法轻松愉快地执行转换了。这里先给出优先级:
[M] > [C] > [O]
还有一个 [(] 运算符,我们给予一个特别优先级,具体原因之后再解释,先转一张表阐述一下 shunting yard 算法:
可以看出,由于括号具有不由分说的最高优先级,因此左括号要直接压入运算符栈,然而左括号却不是一个普通的双目运算符,故不能因为后入栈的低优先级运算符的到来被转压至输出栈。所以左括号需要有一个特殊的优先级使其一直留在运算符栈中等待右括号的到来。
代码实现如下:
public void visit(LeftBracket leftBracket) { branchStack.push(leftBracket); } public void visit(RightBracket rightBracket) { try { for (Node node = branchStack.pop(); !(node instanceof LeftBracket); node = branchStack.pop()) { finalStack.push(node); } } catch (EmptyStackException e) { throw new InvalidSyntaxException(e); } } public void visit(LeafNode leafNode) { finalStack.push(leafNode); } public void visit(BranchNode branchNode) { while (!branchStack.isEmpty() && branchNode.getPri() != -1 && branchNode.getPri() <= branchStack.peek().getPri()) { finalStack.push(branchStack.pop()); } branchStack.push(branchNode); } public Stack<Node> finish() { while (!branchStack.isEmpty()) { finalStack.push(branchStack.pop()); } Stack<Node> reversedStack = new Stack<>(); while (!finalStack.isEmpty()) { reversedStack.push(finalStack.pop()); } return reversedStack; }
其中 finalStack 为输出栈,branchStack 为运算符栈,这里使用了一个 visitor 模式来实现不同性质节点的不同操作。这也是一个构建语法树时常用的设计模式。
经过这一步,我们的逆波兰表达式已经构建好了:
[[O], [C], [M], {N}, b, a, [C], b, [M], {N}, a]
表达式右端存放在栈中,右端为栈顶。
3. 转换为语法树
下面就是最后一步,转换为语法树了。有了构造好的逆波兰表达式,这一步相对比较简单,直接贴上代码:
public void visit(LeafNode leafNode) { stack.push(leafNode); } public void visit(BranchNode branchNode) { Node right = stack.pop(); Node left = stack.pop(); branchNode.operate(left, right); stack.push(branchNode); }
operate 方法每种运算符都有不同的实现,总之作用都是根据运算符的语义连接节点,无需贴具体实现了。
下面给出最终的语法树结构:
|---------------[O]-------------|
|-------[C]-----| |-------[C]-----|
|---[M] b a |---[M]
a b
到这里,就完成了 DFA 正则引擎构建的第一步:语法树的构建。