我一只上海的喵怎么到北京来了呢?……“苟利长亭生死以,岂因祸福避趋之”那么所以我就到了北京。到了北京我干了这二十几天我也没有什么别的,大概三件事:
一个,开发了一个安全产品(秘); 第二个,做了一个自动机生成工具; 第三个,就是见了一些同学,从学长那里听到了一点人生的经验。
如果说还有一点什么成绩就是收了Code Jam、百度之星、2016 Topcoder Open Regional Beijing三件T恤,但这些都是次要的。
偃师
这个自动机生成工具叫做偃师,名字取自《列子·汤问》中记载的一位工匠,善于制造能歌善舞的人偶。
示例
编辑a.ys
: 1
2
3
4
5
6
7
8
9
10
11import 'c.ys' as C
export main = goodnight C::world
action good {
puts("good");
}
goodnight = good night
good = [Gg] 'o'{2} 'd' @ good
night = ("ni" [Gg] "ht" - 'niGht') @ C::night
c.ys
: 1
2
3
4
5action night {
puts("night");
}
world = ('\u0076' .. '\u0077' & '\u0077' .. '\u0078') 'orld' > {puts("world");}
根据该文法生成表示main
的DFA,{}
里的是action。
1 | make |
输出: 1
2
3
4
5
6good
night
world
len: 14
state: 14
final: true
流程
lexer.l,parser.y
。解析命令行指定的.ys
文件,构建abstract syntax treeloader.cc
。每个文件视为一个模块,- 记录nonterminal定义
- 若有
import "filename.ys"
则递归载入 - 把每个nonterminal定义中引用的标识符(可以用模块名限定)与其定义关联。每个nonterminal代表一个顶点,依赖关系为边,建图。
- 对依赖图用post-order DFS进行拓扑排序。若有循环依赖则报错。
- 按拓扑序编译各nonterminal,每个nonterminal编译成一个automaton,若A依赖B,则B先编译。
- 对于每个标记为
export
的nonterminal,为其生成C++代码。
词法分析
使用flex进行词法分析。下面描述用到的技巧。
词法元素添加位置信息
使用%option bison-bridge
生成与bison协作的代码,lexer.l
中可以访问yylval
并设置其各个域,以向bison返回词法元素信息。
用%option extra-type="long"
记录一个额外信息,表示文件偏移。使用%option bison-locations
以访问yylloc
,定义#define YY_USER_ACTION
即可给bison提供词法元素的位置,并更新文件偏移。
1 |
|
Off-side rule
.ys
文件中可以用semicolon
或nosemicolon
指定用分号或换行符标记Stmt结束。
1 | semicolon; |
1 | {% |
偃师当前没有用到缩进。若要支持缩进文法,注意到各行缩进数形成了树,可以用栈维护当前行所有祖先顶点的缩进数,用flex规则记录换行符前空格数。
Start conditions切换词法分析模式
就像lex、yacc,偃师.ys
文件中c++ {}
大括号内可以引入任意C++代码。词法分析时要能判断是否在代码块内部,若是,那么大部分顶层规则失效,遇到配对的}
则退出代码模式。
1 | c++ { |
解决方案是使用flex的start
conditions,{
、"
、'
、(
等均会改变start
condition,改变对字符的处理方式。为了正确处理这些特殊字符的嵌套,需要把之前的状态保存在栈中。flex提供了%option stack
以使用yy_push_state
、yy_pop_state
,不过需要注意的是实际生效的状态是当前start
condition YY_START
而不是yy_top_state()
。
1 | %x EXPECT_CODE |
语法分析
位置信息
定义#define YYLTYPE Location
指定用struct Location { long start, end; };
表示位置信息。定义#define YYLLOC_DEFAULT(Loc, Rhs, N)
,在规则匹配成功后设定yyloc
,把该信息复制到abstract
syntax tree的节点中。
1 | %code requires { |
Abstract syntax tree表示
分成三类:Stmt、Expr、Action。
为构成Expr的每种语法都创建一个Expr子类,如BracketExpr
表示[0-9a-z]
、DifferenceExpr
表示差A-B
、UnionExpr
表示并A|B
等。
分析模块定义的nonterminal、关联标识符引用与定义、根据nonterminal定义生成automaton均需遍历AST,每一种遍历方式对不同Expr
子类的处理方式不同,可以应用double
dispatch的visitor pattern。
以Action为例。
Action
继承自VisitableBase
,声明virtual void accept(Visitor<T>& visitor) = 0;
,各子类实现accept
:调用visitor中以子类具体类型为参数的重载方法。
实现了Expr
遍历的visitor继承自Visitor<Expr>
,递归访问子树时应调用visit(Expr&)
,以便在pre-order和post-order时执行各个hooks。
1 | template<class T> |
1 | struct Action; |
import "filename.ys"
的处理与多文件支持
ImportStmt
import "filename.ys"
可以导入其他文件定义的nonterminal,或者用import "filename.ys" as B
带限定地导入。
解析完一个文件后,访问各个Stmt,若有nonterminal定义(DefineStmt
)则记录下来;若有ImportStmt
则递归载入其他文件。为了支持循环导入,一个文件只会被导入一次,打开文件后,判断fstat(2)获取的st_dev
和st_ino
是否之前出现过。
记录完所有模块的定义后再把产生式右边的标识符引用和定义关联。
循环依赖的检测
对依赖图用post-order DFS进行拓扑排序。每个顶点可能处于四种状态之一:
- 未访问
- 遍历中,当前顶点是它的后裔,所有状态1顶点在栈中
- 所有后裔均已访问过,不在环中
- 所有后裔均已访问过,在环中
枚举各个顶点,若状态为0则DFS,若遇到状态为1的顶点则找到了一个环,从而输出循环依赖错误。
比如如下.ys
文件 1
2
3
4
5
6
7earth = venus
venus = mars arch
mars = earth
arch = felix
felix = cat
cat = arch1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19b.ys 2:1-17 'venus': circular embedding
venus = mars arch
b.ys 1:1-13 required by earth
earth = venus
b.ys 3:1-12 required by mars
mars = earth
b.ys 2:1-17 required by venus
venus = mars arch
b.ys 7:1-10 'cat': circular embedding
cat = arch
b.ys 6:1-11 required by felix
felix = cat
b.ys 5:1-12 required by arch
arch = felix
b.ys 2:1-17 required by venus
venus = mars arch
b.ys 7:1-10 required by cat
cat = arch
Finite state automaton
src/fsa.{cc,hh}
定义了finite state
automaton的表示方式以及一些基本操作。用自然数表示状态,邻接表存储边,一条边表示为pair<pair<long, long>, long>
即(标号区间,目标顶点),\(\epsilon\)转移表示为标号区间\([-1,0)\)。有序表存储final状态。
1
2
3
4
5
6
7
8
9typedef pair<long, long> Label;
typedef pair<Label, long> Edge;
struct Fsa {
long start;
vector<long> finals; // sorted
vector<vector<Edge>> adj; // sorted
/* ...... */
};
src/fsa_anno.{cc,hh}
定义了FsaAnno
:
1
2
3
4
5
6struct FsaAnno {
bool deterministic;
Fsa fsa;
vector<vector<pair<Expr*, ExprTag>>> assoc;
/* ...... */
};
deterministic
标注是否为DFA。编译时,AST的每个节点都会对应一个automaton,AST对应了一棵automaton
tree。Automaton
tree中内部节点automaton的顶点有两种来源:来自某个孩子automaton、新引入的。assoc[i]
记录了根automaton的顶点\(i\)之前从属的所有automata以及所处的位置(start、final、inner)。
assoc
信息有三个作用:
- 判断哪些边会触发Action
- 在substring grammar中判断一个顶点是否为内部顶点
- 在recursive
automaton简化中识别
CallExpr
或CollapseExpr
DotExpr
: .
两个顶点,start为0,final为1,边\((0, (0, AB), 1)\)代表任意codepoint都会从0转移至1。
assoc[0]
包含一个元素{DotExpr*, ExprTag::start}
,表示DotExpr
对应的automaton包含顶点0且0是start。
assoc[1]
包含一个元素{DotExpr*, ExprTag::final}
,表示DotExpr
对应的automaton包含顶点1且1是final。
LiteralExpr
:
'literal'
单双引号的字串在AST中表示为LiteralExpr
。对于长为\(l\)的字串,创建\(l+1\)个顶点。
assoc[0]
包含一个元素{LiteralExpr*, ExprTag::start}
,表示LiteralExpr
对应的automaton包含顶点0且0是start。
assoc[l]
包含一个元素{LiteralExpr*, ExprTag::final}
,表示LiteralExpr
对应的automaton包含顶点\(l\)且\(l\)是start。
其他assoc[i]
包含一个元素{LiteralExpr*, ExprTag::inner}
,表示LiteralExpr
对应的automaton包含顶点\(i\)且\(l\)为内部顶点,ExprTag::inner
在substring
grammar中用到,可以避免连边时指向它。
\(\epsilon\)-closure
输入NFA顶点集,输出其\(\epsilon\)-closure,以有序NFA顶点集表示。
使用breadth-first search,结束遍历时队列中所有插入过的顶点即为结果。一个小技巧是结束后擦除顶点的访问标记,可以避免完整初始化访问标记。
1 | for (long i: src) |
运算
Thompson's construction algorithm描述了几种运算的构造方法,它产生的是normalized automaton,start和final均只有一个,且不存在指向start或从final出发的边。并、Kleene star等运算均引入两个顶点。这样做的好处是每个顶点入度、出度不超过2,各运算时间复杂度为常数。
为了减少顶点数,我尽量让结果为standardized automaton:有多个final,不存在指向start的边。
交
构造两个automata的product automaton,求NFA的product automaton会有大量\(\epsilon\)-closure计算操作,因此先把NFA转成DFA再进行交运算。
修改assoc
以标识每个顶点都在IntersectExpr
标识的automaton中。
并
引入新顶点src
,添加新边: 1
2(src, -1, A.start)
(src, -1, B.start)
src
成为新的start
,A
、B
顶点的final标记保留。这样得到的是standardized
automaton。
修改assoc
以标识每个顶点都在UnionExpr
标识的automaton中。
差
与交类似,先转成DFA再运算。
如果左侧含有CallExpr
或CollapseExpr
,结果可能不正确。
修改assoc
以标识每个顶点都在DifferenceExpr
标识的automaton中。
Kleene star
引入两个新顶点src
和sink
,添加新边:
1
2
3
4
5(src, -1, sink)
(src, -1, start)
for each final
(final, -1, sink)
(final, -1, start)
src
成为新的start,sink
成为唯一的final。这样得到的是normalized
automaton。若不存在从final出发的边可以省掉sink
,若不存在指向start的边可以省掉src
。
修改assoc
以标识每个顶点都在StarExpr
标识的automaton中。
Kleene plus
各final向start连\(\epsilon\)边。
修改assoc
以标识每个顶点都在PlusExpr
标识的automaton中。
Question
引入两个新顶点src
和sink
,建立新边:
1
2(src, -1, sink)
(src, -1, start)
src
成为新的start,给sink
设置final标记。
修改assoc
以标识每个顶点都在QuestionExpr
标识的automaton中。
编译
类似于reverse Polish calculator,每个Expr会编译成一个automaton。
每个automaton都要转成一个最小化的DFA,有三个步骤:
- Subset construction转成DFA
- Hopcroft minimization合并Nerode equivalent states
- 去除co-inaccessible states即无法达到final的状态
实践中发现最小化每个部件比只最小化最终automaton要快很多。
含递归的产生式和简化的recursive automaton
产生式中可能含有递归,比如直接递归A = A b
或间接的A = B b; B = A a
,如何用automaton描述呢?
之前fqj1994提出一个方法,对于 1
2A = a B a | c
B = b A b | d
把产生式中递归引用的nonterminal用一个字符集外的terminal表示:
1
2A = a "B" a | c
B = b "A" b | d
这里假设"B"
为字符集外的terminal,表示为含两个顶点start和final的automaton,start向final连标号为"B"
的边。"A"
同理。
A
、B
两个产生式对应的automata构造好之后,在A
的automaton上添加新边:
1
2("B".start, -1, B.start) # 对B产生式的调用
(B.final, -1, "B".final) # B匹配完后返回到B被调用的地方
当"B"
在多个产生式中出现或者一个产生式中出现多次时,就可能发生调用处和返回处不匹配,比如下面的C程序:
1
2
3
4
5
6
7
8
9
10void A()
{
B();
B();
}
void C()
{
B();
}
执行A函数中第一个B()
,返回时应该到第一个B()
调用的下一条指令处。但是按照前文描述的连边规则,返回地址可能是三个B()
中任意一个的下一条指令。
偃师支持两种调用,EmbedExpr
A = B
和CollapseExpr
A =
!B。
EmbedExpr把
B的automaton嵌入到
A的automaton
中,会产生依赖关系以防止A
被嵌入到B
中。CollapseExpr
允许递归,按照前文的连边规则构造automaton。
对于原始文法,找出循环依赖形成的强联通分量,用CollapseExpr
把环破开即得到了一种regular
approximation。
TODO 考虑 1
S = !A !A
这种方式引入的顶点数很少。
我另外查阅了其他方案,比如Regular Approximation of CFLs: A Grammatical View,\(k\)个顶点、\(l\)条边的强联通分量会变成\(4kl\)条边,不可接受。
Substring grammar
我们的另一个需求是匹配SQL子串,即SQL的substring grammar。取suffix
grammar的prefix grammar即得到substring
grammar,根据对称性,知道如何求suffix grammar后即可求prefix
grammar。Suffix grammar构造方法大致是这样:根据每个nonterminal \(A\)生成相应的\(A'\),\(A'\)的产生式为A的产生式去除proper
prefix。 1
2S = A B C
S' = A' B C | B' C | C'
不过很多suffix grammar都有歧义,无法用LR(1),得诉诸Cocke-Younger-Kasami、Backtracking LR、Generalized LR、Earley parser等通用的parser。CYK、GLR Tomita、Binary Right Nulled GLR、Earley等都是\(O(n^3)\)的,\(n\)为输入长度,在我们的应用场景下不可接受。
另一方面,根据regular grammar求substring grammar很容易,构造原始文法的finite automaton,对每个顶点添加start和final标记即可。
Action
给状态转移添加action。具体地,每个顶点有四类hook:
每个顶点所属于若干互相嵌套的Expr,由assoc
记录。每个Expr可以有四类hook:entering、finishing、leaving、transiting。从顶点\(u\)转移到顶点\(v\)时,若对于某个Expr \(e\):
- \(u\notin e, v\in e\),触发\(e\)的entering hook
- \(u\in e, v\notin e\),触发\(e\)的leaving hook
- \(u\in e, v\in finals(e)\),即\(v\)在\(e\)表示的automaton中是final顶点,触发\(e\)的finishing hook
- \(u\in e, v\in e\),触发\(e\)的transiting hook
这部分代码在src/fsa_anno.{cc,hh}
实现。
.ys文件语法
1 | toplevel: |
顶层
源文件每行是一个定义、import、action定义、或C++代码块。
import
1 | import 'a.ys' |
action定义
定义一个带名字的action: 1
2
3action foo {
puts("foo");
}
之后可以用> foo
等引用: 1
bar = 'a' > foo @ foo $ foo % foo > {puts("unnamed");}
C++定义
Contrib
contrib/
下有Vim插件和Zsh配置文件。
代码还比较乱……
此处似乎得有广告,长亭科技https://chaitin.cn。