我一只上海的喵怎么到北京来了呢?……“苟利长亭生死以,岂因祸福避趋之”那么所以我就到了北京。到了北京我干了这二十几天我也没有什么别的,大概三件事:
一个,开发了一个安全产品(秘); 第二个,做了一个自动机生成工具; 第三个,就是见了一些同学,从学长那里听到了一点人生的经验。
如果说还有一点什么成绩就是收了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
。每个文件视为一个模块,import "filename.ys"
则递归载入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 | #define YY_USER_ACTION \ |
.ys
文件中可以用semicolon
或nosemicolon
指定用分号或换行符标记Stmt结束。
1 | semicolon; |
1 | {% static bool semicolon; %} "semicolon" semicolon = true; "nosemicolon" semicolon = false; ";" if (semicolon) return '\n'; "\n" if (YY_START == INITIAL && ! semicolon) return '\n'; |
偃师当前没有用到缩进。若要支持缩进文法,注意到各行缩进数形成了树,可以用栈维护当前行所有祖先顶点的缩进数,用flex规则记录换行符前空格数。
就像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 %x IN_CODE %s IN_PAREN %x IN_Q_STRING %x IN_QQ_STRING <EXPECT_CODE>{ "{" BEGIN IN_CODE; tmp_bracket.clear(); /* ...... */ } <IN_CODE>{ "'" { tmp_bracket += '\''; yy_push_state(IN_Q_STRING, yyscanner); } "\"" { tmp_bracket += '"'; yy_push_state(IN_QQ_STRING, yyscanner); } "{" { tmp_bracket += '{'; yy_push_state(IN_CODE, yyscanner); } "}" { yy_pop_state(yyscanner); if (YY_START == INITIAL || YY_START == IN_PAREN) { yylval->str = new string(tmp_bracket); return BRACED_CODE; } else tmp_bracket += '}'; } .|"\n" tmp_bracket += yytext[0]; <<EOF>> yy_pop_state(yyscanner); unexpected_eof(yylval, "}"); } ' tmp_str.clear(); yy_push_state(IN_Q_STRING, yyscanner); "\"" tmp_str.clear(); yy_push_state(IN_QQ_STRING, yyscanner); <IN_Q_STRING>{ ' { yy_pop_state(yyscanner); if (YY_START == INITIAL || YY_START == IN_PAREN) { yylval->str = new string(tmp_str); return STRING_LITERAL; } tmp_bracket += yytext; } <<EOF>> yy_pop_state(yyscanner); unexpected_eof(yylval, "'"); } <IN_QQ_STRING>{ "\"" { yy_pop_state(yyscanner); if (YY_START == INITIAL || YY_START == IN_PAREN) { yylval->str = new string(tmp_str); return STRING_LITERAL; } tmp_bracket += yytext; } <<EOF>> yy_pop_state(yyscanner); unexpected_eof(yylval, "\""); } <IN_Q_STRING,IN_QQ_STRING>{ \\[0-7]{1,3} /* ...... */ \\x[0-9a-fA-F]+ /* ...... */ .|\n tmp_str += yytext[0]; tmp_bracket += yytext[0]; /* ...... */ } |
定义#define YYLTYPE Location
指定用struct Location { long start, end; };
表示位置信息。定义#define YYLLOC_DEFAULT(Loc, Rhs, N)
,在规则匹配成功后设定yyloc
,把该信息复制到abstract syntax tree的节点中。
1 | %code requires { /* struct Location { long start, end; ...... }; */ #define YYLTYPE Location #define YYLLOC_DEFAULT(Loc, Rhs, N) \ do { \ if (N) { \ (Loc).start = YYRHSLOC(Rhs, 1).start; \ (Loc).end = YYRHSLOC(Rhs, N).end; \ } else { \ (Loc).start = YYRHSLOC(Rhs, 0).end; \ (Loc).end = YYRHSLOC(Rhs, 0).end; \ } \ } while (0) } %locations %% stmt: define_stmt { $$ = $1; } | IMPORT STRING_LITERAL AS IDENT { $$ = new ImportStmt(*$2, *$4); delete $2; delete $4; $$->loc = yyloc; } | IMPORT STRING_LITERAL { string t; $$ = new ImportStmt(*$2, t); delete $2; $$->loc = yyloc; } | ACTION IDENT BRACED_CODE { $$ = new ActionStmt(*$2, *$3); delete $2; delete $3; $$->loc = yyloc; } | CPP BRACED_CODE { $$ = new CppStmt(*$2); delete $2; $$->loc = yyloc; } |
分成三类: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进行拓扑排序。每个顶点可能处于四种状态之一:
枚举各个顶点,若状态为0则DFS,若遇到状态为1的顶点则找到了一个环,从而输出循环依赖错误。
比如如下.ys
文件 1
2
3
4
5
6
7earth = venus
venus = mars arch
mars = earth
arch = felix
felix = cat
cat = arch
会生成如下错误: 1
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
src/{fsa,fsa_anno}.{cc,hh}
用自然数表示状态,邻接表存储边,一条边表示为pair<long, long>
即(标号,目标顶点),\(\epsilon\)转移标号为-1。有序表存储final状态。 1
2
3
4
5
6struct Fsa {
long start;
vector<long> finals; // sorted
vector<vector<pair<long, long>>> adj; // sorted
/* ...... */
};
输入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再进行交运算。
引入新顶点src
,添加新边: 1
2(src, -1, A.start)
(src, -1, B.start)
src
成为新的start
,A
、B
顶点的final标记保留。这样得到的是standardized automaton。
与交类似,先转成DFA再运算。
引入两个新顶点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
。
各final向start连\(\epsilon\)边。
引入两个新顶点src
和sink
,建立新边: 1
2(src, -1, sink)
(src, -1, start)
src
成为新的start,给sink
设置final标记。
类似于reverse Polish calculator,每个Expr会编译成一个automaton。
每个automaton都要转成一个最小化的DFA,有三个步骤:
实践中发现最小化每个部件比只最小化最终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。
这种方式引入的顶点数很少。
我另外查阅了其他方案,比如Regular Approximation of CFLs: A Grammatical View,\(k\)个顶点、\(l\)条边的强联通分量会变成\(4kl\)条边,不可接受。
我们的另一个需求是匹配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。具体地,每个顶点有四类hook:
每个顶点所属于若干互相嵌套的Expr。每个Expr可以有四类hook:entering、finishing、leaving、transiting。从顶点\(u\)转移到顶点\(v\)时,若对于某个Expr \(e\):
这部分代码在src/fsa_anno.{cc,hh}
实现。
contrib/
下有Vim插件和Zsh配置文件。
代码还比较乱……
此处似乎得有广告,长亭科技https://chaitin.cn。
听说这个东西有望开源?