这堂课将较完整地讲解K语言中生产的语法和语义,以及生产如何与其他的句子结合,生成符合语言逻辑规范的篇章,从而可以很好地解析规则和程序。
K的解析方法
- K语言的语法规范被分成两大组件:
- 外在语法
- 内在语法
- 外在语法,是指关于K定义中requires、模块(modules)、imports和语句(sentences)的解析。
- 内在语法,则是指关于规则(rules)和程序(programs)的解析。
- 其中外在语法,是K语言事先就确定了的;而K语言内在语法,是需要开发者自己定义的。
- 规则和程序的解析是基于模块的上下文进行的:
- 规则的上下文,就是它所在模块的上下文;
- 而程序的上下文则是一个K定义的主语法模块(main syntax module)。
- 生产和其它语法句子则用于构建模块中的行文规范,以便用于完成规则和程序的解析。
基本的BNF生产
作为演示,下面将用一个简单的K定义:它定义好一个基础的计算器,用来评估布尔表达式(该表达式中包含了and
、or
、not
和xor
运算符)。整个程序代码实现在文件:lesson-03-a.k
:
module LESSON-03-A
syntax Boolean ::= "true" | "false"
| "!" Boolean [function]
| Boolean "&&" Boolean [function]
| Boolean "^" Boolean [function]
| Boolean "||" Boolean [function]
endmodule
你会发现上面例子的生产,与上堂课中的有些不一样。
K语言定义生产,有两种不同的机制:
- 上堂课采用的是第一种机制:
::=
操作符后跟着一个由数字和字母组成的名字符号(identifier),而在该符号后面是一对小括号,小括号里面是由逗号分隔的分类(sorts)列表。 - 这里的例子则采用的是第二种机制:它采用BNF(巴科斯范式)的变体来定义生产。
读者如想对对BNF有更多了解的话,可以自行查阅Wiki:BNF Form
在上堂课中,我们所采用的生产如下:
module LESSON-03-B
syntax Color ::= Yellow() | Blue()
syntax Fruit ::= Banana() | Blueberry()
syntax Color ::= colorOf(Fruit) [function]
endmodule
同样也可以采用BNF来描述同样的语法意义:
module LESSON-03-C
syntax Color ::= "Yellow" "(" ")" | "Blue" "(" ")"
syntax Fruit ::= "Banana" "(" ")" | "Blueberrry" "(" ")"
syntax Color ::= "colorOf" "(" Fruit ")" [function]
endmodule
在这个例子中,除了函数的参数分类是保持原样,而其它部分则都用“”
包裹起来了。这是因为在BNF注解中,我们要区分两份种生产项(production items):
- terminals,它代表了字符串的简单字面量,并作为生产语法的词法而存在;
- noterminals,它则代表了分类名,意味着生产语法是要接收一个有效的短语(当然要属于指定的分类)放到这个位置上。
这也正是我们编写了程序colorOf(Banana())
,然后krun
可以执行它的根本原因所在:
- 该程序代表了一个
Color
分类,它是为K解析器所认识,并解析和翻译的; - krun就会参照开发人员定义好的这个语法规范,进行解析和翻译,将其转换为对应单词(term 词法位置)的AST(抽象语法树);
- 最终colorOf函数就会基于K定义中提供的规则,被给出评估的结果。
好,我们再回到文件:lesson-03-a.k
。其中已给出了一个简单的BNF规范的布尔表达式:
- 定义了对应着布尔值(true 和false)的构造器;
- 定义了对应着布尔运算符(
and
、or
、not
和xor
)的函数。
我们编写一个程序and.bool
,它的内容如下:
true && false
现在我们还不能执行这个程序,因为还没有定义关于&&
函数的规则。但我们已经可解析这个程序了:
kast --output kore and.bool
- kast是K语言的just-in-time解析器,它可以根据你的K定义生成相应的语法规范,并基于该规范解析其命令后的程序。
- 其后的
--output
用来控制所产生的AST的格式;先不关心它有哪些值可以选,就用kore就好。
该命令执行完后,会产生以下的AST输出:
inj{SortBoolean{}, SortKItem{}}(
Lbl'UndsAnd-And-UndsUnds'LESSON-03-A'Unds'Boolean'Unds'Boolean'Unds'Boolean{}(
Lbltrue'Unds'LESSON-03-A'Unds'Boolean{}(),
Lblfalse'Unds'LESSON-03-A'Unds'Boolean{}()
)
)
也先不关心上面输出到底代表着什么意思,只要知道它们代表着基于程序解析出的AST就好。
当然我们应对上会输出的大体形状有所认识,并注意到其的关键词:true
、false
和And
。这就是kore,也就是K语言的中间表示,后面我们会对它展开更详细的讲解。
如果你想让kast输出理直接可以代表最初K的AST格式,可以采用以下的命令:
kast --output kast and.bool
它将输出如下:
`_&&__LESSON-03-A_Boolean_Boolean_Boolean`(
`true_LESSON-03-A_Boolean`(.KList),
`false_LESSON-03-A_Boolean`(.KList)
)
可以看到,这种AST格式还会维持着AST结构。同时也会注意,前一个kast输出很大程度上是第二个kast输出的名称混乱版本。唯一的区别是在KORE输出中出现了’ inj '符号。我们将在以后的课程中更多地讨论这一点。
练习
用kast来解析程序false || true
模棱两可
现在,让我们再来看一个更高级点的例子程序 and-or.bool
:
true && false || false
当我们解析这个程序时,将会看到以下错误:
[Error] Inner Parser: Parsing ambiguity.
1: syntax Boolean ::= Boolean "||" Boolean [function]
`_||__LESSON-03-A_Boolean_Boolean_Boolean`(`_&&__LESSON-03-A_Boolean_Boolean_Boolean`(`true_LESSON-03-A_Boolean`(.KList),`false_LESSON-03-A_Boolean`(.KList)),`false_LESSON-03-A_Boolean`(.KList))
2: syntax Boolean ::= Boolean "&&" Boolean [function]
`_&&__LESSON-03-A_Boolean_Boolean_Boolean`(`true_LESSON-03-A_Boolean`(.KList),`_||__LESSON-03-A_Boolean_Boolean_Boolean`(`false_LESSON-03-A_Boolean`(.KList),`false_LESSON-03-A_Boolean`(.KList)))
Source(./and-or.bool)
Location(1,1,1,23)
这个错误是说“kast”无法解析这个程序,因为它是不明确的。K的即时解析器是一个GLL解析器,这意味着它会处理上下文无关语法的全部一般性,包括那些不明确的语法。而模棱两可的语法是使得一个字符串可以被解析为多个不同的AST。在这个例子中,kast就不能确定它应该将程序解析为’ (true && false) || false ‘,还是’ true && (false || false) '。结果,它向用户报告错误。
方括号(Bracket)
- 如果有办法解决上述的模棱两可,就会导致K语言不能编写复杂些的表达式;这显然是不行的。
- 在大多数编程语言中的标准解决方案就是用小括号来实现想要地分组。
- K语言中把这种注解泛化为一种生产类型:方括号(bracket):
- 一个方括号生产,就是有bracket属性的生产;
- 它限定该生产只会有一个non-terminal,而且生产的分类要与该non-terminal分类相同;
- 这个唯一的non-terminal要被诸terminals(
()
,[]
,{}
,<>
等等)所包裹。
下面我们举个例子,来看看常用的bracket类型。这个例子就是对课程最开始 K定义的的简单修改,重新生成文件lesson-03-d.k
:
module LESSON-03-D
syntax Boolean ::= "true" | "false"
| "(" Boolean ")" [bracket]
| "!" Boolean [function]
| Boolean "&&" Boolean [function]
| Boolean "^" Boolean [function]
| Boolean "||" Boolean [function]
endmodule
基于这个定义,如果用户在程序中不显示的使用小括号,语法规范仍然会认为是模棱两可的,kast解析器还是会报出错误。但是由于有了上面的定义,我们就可以使用小括号来消除上面的歧义了。可以试下程序and-or-left.bool
:
(true && false) || false
还可以试下程序and-or-right.bool
:
true && (false || false)
可以看到kast对上面两个程序的解析不会再报错了。同时你也会注意要小括号并并不会在AST中出现。
事实上,这是方括号特有的属性:具有方括号属性的结果不会在AST中呈现为一个短语(term,占据一个用词位置),方括号中的子项会被立即解析到父项中。这正是要求方括号生产类型中只有一个non-terminal的原因所在。
练习
用--output kast
格式再解析一下上面两个程序,并注意:
- 所输出的AST是否与你期望一致。
- 确认方括号生产是否出现在了输出的AST中。
标识符(Tokens)
现在我们可以来定义一个语言的语法规范了。然而, 该规范并不是解析语言的唯一相关部分。语言的词汇语法也是相关的部分。到目前为止,我们已经隐式地使用K语言的自动词汇生成 来产生标识符(Token);这个生成是基于K语言的语法规范扫描到每一个terminal时,都会发生。
但是,我们有时会希望有更复杂的词汇语法。例如我们来看C语言中的整数类型:一个整数可以用十进制,八进制或十六进制等来表示,为了区分这些不同的进制类型,在数字后面往往要加一个后缀字面量来指明该数据类型。
从理论上讲,通过语法规范来定义这种语法是可能的,但这不仅会非常麻烦和乏味,而且还必须为该字面量生成相应的AST;这种方案执行起来,将会非常麻烦。
所以K语言引入了标识符生产(token productions):
- 该生产表达式后面跟着
token
属性。 - 该生产解析的AST中会有个类型字符串,其中会包含该正则表达式所认识的值。
还是来看个例子吧:
syntax Int ::= r"[\\+-]?[0-9]+" [token]
r
放在terminal的前面,表示该terminal双引号里面的内容是一个正则表达式。- 上述的正则表达式中,我们定义了一个可选的符号,后面跟着一个阿拉伯数字。
- 而
token
属性则用来告诉解析器,要把这个生产中的词汇(term)转换成一个token。
也可以不使用正则表达式来定义tokens。当您希望在语义中声明特定的名字标识符(identifiers)时,这可能非常有用。例如:
syntax Id ::= "main" [token]
这样,我们就为Id
分类声明了一个叫main
的token。上面的语句在被解析后,不会被解析为符号,而是会被解析为一个token,在AST中就是会产生一个类型化的字符串。这个例子对于C的语法很有用,因为解析器并不会特别地对待C中的main
函数,但语法会。
当然,每种语言可能都会有不同词汇语法,有些可能会更复杂。例如,如果你想C语言中的整数,你将会使用以下生产:
syntax IntConstant ::= r"(([1-9][0-9]*)|(0[0-7]*)|(0[xX][0-9a-fA-F]+))(([uU][lL]?)|([uU]((ll)|(LL)))|([lL][uU]?)|(((ll)|(LL))[uU]?))?" [token]
从上面可以看到,长而复杂的正则表达式可能很难阅读。与语法规范不同,它们还存在一个问题,即它们不够模块化。我们可以通过声明显式正则表达式,给它们一个名称,然后在生产中引用它们,进而绕过这个局限性。
看看下面在C中定义integers词法语法的方法:
syntax IntConstant ::= r"({DecConstant}|{OctConstant}|{HexConstant})({IntSuffix}?)" [token]
syntax lexical DecConstant = r"{NonzeroDigit}({Digit}*)"
syntax lexical OctConstant = r"0({OctDigit}*)"
syntax lexical HexConstant = r"{HexPrefix}({HexDigit}+)"
syntax lexical HexPrefix = r"0x|0X"
syntax lexical NonzeroDigit = r"[1-9]"
syntax lexical Digit = r"[0-9]"
syntax lexical OctDigit = r"[0-7]"
syntax lexical HexDigit = r"[0-9a-fA-F]"
syntax lexical IntSuffix = r"{UnsignedSuffix}({LongSuffix}?)|{UnsignedSuffix}{LongLongSuffix}|{LongSuffix}({UnsignedSuffix}?)|{LongLongSuffix}({UnsignedSuffix}?)"
syntax lexical UnsignedSuffix = r"[uU]"
syntax lexical LongSuffix = r"[lL]"
syntax lexical LongLongSuffix = r"ll|LL"
正如您所看到的,这是相当冗长的,但它的好处是更容易阅读和理解,而且还提高了模块化。请注意,我们通过将名称放在大括号中来引用命名的正则表达式。还要注意,只有第一个句子实际上声明了该语言中的一段新语法。当用户写syntax lexical
时,他们只是声明了一个正则表达式。要在语法规范中声明一段实际的语法,您仍然必须实际声明一个显式的token产品。
最后提醒一点: K语言使用Flex来实现它的词法分析。因此,您可以参考Flex Manual以获得所支持的正则表达式语法的详细描述。请注意,出于性能原因,Flex的正则表达式实际上是一种正则语言,因此缺乏现代“正则表达式”库在语法上的一些便利。如果您需要的特性不是Flex正则表达式语法的一部分,建议你通过语法规范来表达它们。
提前的解析器生成
到目前为止,我们主要关注的是K语言关于即时解析的支持,即假设解析器是在使用之前已动态生成的。但如果必须使用相同的解析器,时不时重复解析字符串,是很影响性能的。出于这个原因,通常鼓励在解析程序时使用K语言的提前解析器生成。K语言使用GNU Bison来生成解析器。
你可以在调用 kompile
时,通过——gen-bison-parser
flag来启用提前解析生成。这将使用Bison的LR(1)解析器生成器。因此,如果您的语法规范不是LR(1),那么它解析的内容可能与您使用即时解析器解析的结果不完全相同,因为每当Bison遇到shift-reduce或reduce-reduce冲突时,它将自动选择一个可能的分支。在这种情况下,你可以修改你的语法规范为LR(1),或者你可以通过传递——gen-glr-bison-parser
给kompile
, 来启用Bison的GLR支持。请注意,如果您的语法是二义性的,那么此时提前解析器将不会为您提供特别易读的错误消息。
如果你有一个K定义foo.k
。当你运行kompile
时,它会生成一个名为’ foo-kompile '的目录,你可以通过在一个文件上运行foo-kompiled/parser_PGM <file>
来调用你生成的提前解析器。
练习
- 编译的
lesson-03-d.k
,启用了提前解析器生成。然后运行’ lesson-03-d-kompile /parser_PGM and-or left.bool ‘,与’ kast(输出kore and-or-left)运行所花费的时间进行比较。请确认两者产生的结果是否相同,前者是否更快。 - 定义一个由整数、括号、加、减、乘、除和一元否定组成的简单语法。整数应该是十进制形式,并且在词法上没有符号,而负数可以通过一元否定表示。确保能够使用提前生成的解析器解析一些基础的算术表达式。这个K定义实现时,不用关心消除语法歧义,也不用关心规则编写。
- 基于上面K定义的语法规范(存在着模棱两可情况),根据算术表达式的含义编写一个程序;然后修改程序,使用方括号表示每个想要表达的含义。