在第I部分,我们熟悉了ANTLR,并在一个比较高的层次上了解了语法以及语言程序。现在,我们将要放慢速度来学习下实现更实用任务的一些细节上的技巧,例如建立内部数据结构,提取信息,生成输入对应的翻译内容等。在我们开始的第一步,首先,就是需要学习怎样建立语法。在这一章,我们会着眼于语言学结构中最通用的词法和句法,并且学习怎样用ANTLR来描述这些词法和句法。以这些ANTLR建立的结构为基础,在下一章我们会将它们组合起来并建立一些实际的语法。
在学习简历语法的时候,我们不能仅仅是从头到尾学一遍众多的ANTLR结构就可以了。首先,我们需要学习一些通用的语言模式,并了解如何从一些计算机语言的句子中识别它们。这也是我们需要一个总体的语言结构的原因。(语言模式是一个反复出现的语法结构,比如英语中的句子中的主谓宾的结构,或者是日语中的主宾谓的结构。)最终,我们需要从一系列的代表性输入文件中分析出语言学上的结构,分析出来之后,我们就可以用ANTLR的语法来描述这个语言了。
值得庆幸的是,虽然在过去的50年里有数不清的语言被创造了出来,但是只有极少数的几种语言模式是我们需要处理的。这个现象是因为我们在设计语言的时候,都会倾向于受到我们大脑中所了解的自然语言的限制。我们希望符号按照有效的顺序排列,并且符号之间拥有着特定的依赖关系。举个例子,{(}) 就是不符合语法的,因为符号的顺序不对。而 (1+2 这样的写法会让我们发疯,因为我们总是会想去给它补上一个 ) 。大多数语言看上去都差不多,因为设计者在设计的时候通常都是用数学上的通用符号来设计的。就算是在词法这个层次上来说,语言更偏向于重复利用同样的结构,比如标识符,整数,字符串等。
单词之间的顺序和依赖性约束是来自于自然语言的,基本上可以总结成四种抽象的计算机语言模式。
l 序列:是由许多元素组成的一个序列,比如数组初始化句法中的数值。
l 选项:是指在多个可选的短语中的选择,比如编程语言中的不同语句。
l 单词约束:一个单词的出现需要对应另一个其它短语的出现,比如左括号和右括号。
l 嵌套短语:这是一种自相似的语言结构,例如编程语言中的嵌套算术表达式或嵌套语句块。
为了实现这些模式,我们只需要由可选项、符号引用和规则引用组成的语法规则(巴克斯范式,BNF)就可以了。为了表述起来更加方便,我们通常会使用子规则来组织这些元素。子规则就是用括号括起来的内部规则。比如,我们可以利用标记子规则(例如,可选的(?),重复零次到多次的(*),或重复一次到多次的(+))来识别一些重复多次的相似的语法片段(扩展的巴克斯范式,EBNF)。
理论上说,许多人已经在语法描述或者最起码在正则表达式上已经了解过这种表现形式了,无论如何,为了考虑到更多的读者,我们还是从非常开始的地方说起。
5.1 从语言样本中派生语法
编写一个语法和编写一个软件差不多,不同的是我们需要处理的是规则而不是函数或者处理过程。(记住,ANTLR会给每一个你的语法中的规则生成一个函数。)然而,在我们深入研究规则之前,很有必要讨论下语法的整体结构以及如何形成一个初步的语法框架。这是任何一个语言程序的至关重要的第一步,所以,这一节主要讨论的内容就是这个。如果你着急着想建立并执行第一个语法分析器,你可以重新翻阅下第4章,或者跳到下一章的第1个例子。但是上一章或者下一章的例子的基本原理都将在这里讨论。
语法是由一个命名语法的头信息和一系列的规则组成的,这些规则可以互相调用。
grammar MyG;
rule1 : «stuff» ;
rule2 : «more stuff» ;
...
注意 grammar 后边的语法名应该与该文件名相同,例如该文件的文件名为MyG.g4
就跟写软件一样,我们必须指出哪些规则是我们的语法所需要的,必须说明<<stuff>>的内容是什么,并且必须说明哪一条规则是起始规则(就好像main()函数一样)。
要写出给定语言的所有规则,我们要么必须把这个语言掌握得非常得透,要么拥有一系列代表性的输入样例。当然,从一个参考手册或者另一个编译器生成器的格式中生成一个语言的语法会非常方便。不过现在,我们假定并没有现成的可以参考的语法。
在编程的世界中,正确的语法设计应该能反映功能分解的思想或者自顶向下的设计思想。这句话的意思就是我们的工作顺序应该是从粗糙到精细,即从识别语言结构到将其编码成语法规则。所以,第一步工作应该是找一个最宏观的语法结构的名字,这就是我们的起始规则。在英语中,我们的起始规则叫做句子。对于一个XML文件,我们把起始规则叫做文档(document)。对于一个Java文件,我们的起始规则叫做可编译单元(compilationUnit)。
设计起始规则的内容将相当于用英语伪代码描述所有可能的输入形式,就像我们写软件的时候那样。例如,“逗点分割文件(CSV)是一个由一系列以换行符结束的行组成。”这句话中,“逗点分割文件(comma-separated-value file)”就是规则的名称(用file表示),而剩下的部分都是这个规则的定义。
file : «sequence of rows that areterminated by newlines» ;
然后,我们继续向下走一层,描述我们的定义项:由一系列以换行符结束的行。定义中的名词一般要引用另一个符号或者是一个将被定义的规则。符号是指那些我们大脑能直接联想成单词、标点符号或运算符的元素。就像单词是英语句子中最根本的元素一样,符号也是一个分析器语法的最根本的元素。可以参考其它语言结构的规则引用需要被进一步向下分解,比如“行”。
向下走一层,我们描述所谓“行”就是一系列由逗号分隔开的字段。进一步的,字段要么是一个数字,要么是一个字符串。所以,我们的伪代码看起来应该是这个样子的:
file : «sequence of rows that are terminated by newlines» ;
row : «sequence of fields separated by commas» ;
field : «number or string» ;
我们过一遍需要定义的规则之后,就会有一个粗略的语法的草案。
让我们看看这个过程在描述Java文件的一些关键结构中是怎样工作的。(我们会把规则名称突出表示出来。)在最上面的一层,我们定义,一个Java可编译单元是由一个后面跟着一个或多个类定义的包说明符组成。往下走一层,一个类定义是由关键字class,标识符,一个可选的父类说明符,一个可选的实现分句,以及一个类主体组成的。一个类主体是由花括号括起来的一系列成员组成。成员可以是一个嵌套的类定义,或者是一个字段,或者是一个函数。从这里开始,我们就该开始描述什么是字段以及什么是函数,更进一步的,在描述函数之后,我们还将描述什么是语句。现在,你应该有个整体的思路了,从最高的层次开始不断往下描述,将每一个大块的子短语(例如Java类定义)作为一个规则,并在后面描述它。这个过程用伪代码描述大致如下:
compilationUnit : «optional packageSpecthen classDefinitions»;
packageSpec : 'package' identifier ';' ;
classDefinition :
'class' «optional superclassSpec optionalimplementsClause classBody»;
superclassSpec : 'super' identifier ;
implementsClause:
'implements' «one or moreidentifiers separated by comma»;
classBody : '{' «zero-or-more members»'}';
member : «nestedclassDefinition or field or method» ;
...
如果我们对一个大型语言(比如Java)比较熟悉的话,以这样的语言作为参考设计一个新的语法会轻松很多。但是,你也必须要小心,盲目地引用一个现有的语法会将你带入歧途,我们会在接下来仔细讨论这个问题。
5.2 使用现有的语法作为参考
研究一个非ANTLR格式的现有的语法,我们可以掌握别人是怎样将语言中的短语进行拆分的。最起码的,一个现有的语法可以给出所有的我们需要用到的规则名称作为参考。但是,你仍然需要小心。我反对直接从一个语法的参考手册中直接将语法复制粘贴到ANTLR中,应该将其看作是一种参考,而不是一段现成的代码。
为了将语法表现清晰,参考手册通常描述的非常宽松,这意味着其语法可能会识别很多不再语言中的句子,或者,语法是具有歧义的,存在多种方法对同样的输入进行理解。例如,一个语法表述,一个表达式可以是调用一个构造函数或者是调用一个函数。问题在于,像T(i)这样的输入可以同时匹配构造函数调用和函数调用。理想的情况下,我们设计的语法中不存在这样的歧义。我们对特定的输入句子只有唯一的规则去翻译这个输入或者去执行这个输入。
从另一个方面去考虑,参考手册上的语法也可能会过分约束规则。有一些约束是需要我们在完成输入分析的时候去检查的,而不是在分析时从语法结构上去检查。举个例子,我们考虑下第12.4节上面的那个关于XML的例子,我过了一遍W3C XML的语法定义,里面的语法细节让我头疼不已。举个普通的例子,XML语法严格地指出了标签中什么地方必须要有空格以及什么地方空格又是可以省略的。这样对于读懂XML大有好处,但是我们的词法分析器只是简单地把输入流中标签间的空格剔除之后才传给语法分析器。实际上我们的语法并不需要测试空格。
XML的语法也规定了“<?xml…>”标签要有两个特殊的属性:encoding和standalone。我们需要知道这个约束,但是更简单的做法是,我们在分析语法的时候允许让这个位置出现任何属性,在语法分析完成之后,我们可以通过分析语法树再来检查这个约束是否被满足。最后,XML一些嵌套的文本标签,因此它的语法结构是相当简单的。最大的难点在于,怎样分别处理什么在标签的内部什么在标签的外部。我们会在12.3节中详细介绍这些。
怎样准确地识别语法规则,并将其用伪代码准确地表述出来,对于刚开始来说是一个很有挑战性的工作,但是随着你建立的语法数量的增加,这个过程也会变得越来越简单。你可以在这本书中找到很多例子。
我们写出伪代码之后,我们需要将其翻译成ANTLR能够识别的语法。在下一节,我们将所有定义语言中都会经常出现的四种语言模式,并了解怎样用ANTLR来表述这四种模式。在那之后,我们将学习怎样定义我们设计的语法中会使用到的符号,比如整数或标识符。记住,这一章我们的重点在于语言程序开发的基础部分。这会给我们在下一章建立一些实际应用的例子的时候打下坚实的基础。
5.3 用ANTLR语法来识别通用语言模式
现在,我们已经知道了怎样用自顶向下的方法来获得一个语法的轮廓了。现在,我们需要着眼于一些通用的语言模式:序列,选项,符号约束和嵌套短语。在之前的章节中我们曾经见过一些例子,但是现在我们会从众多的语言中见到更多的例子。在学习这个的同时,我们会学到一些基本的ANTLR语法,并用这个语法来作为表达这些特殊模式的正式语法规则。那么,就让我们从最通用的语言模式开始吧。
模式:序列
计算机语言中的这种结构最常见的例子就是元素序列,比如类定义中的函数列表。就算是一些简单的语言,比如HTTP,POP和SMTP网络协议,也能找到这种序列模式。协议中最常出现的是命令序列。例如,下面是登录到一个POP服务器并获得第一条消息的命令序列:
USER parrt
PASS secret
RETR 1
而命令本身也往往是一个序列。大部分的命令都是由以下部分组成一个序列的:关键字(保留的标识符,比如USER和RETR),一个操作数,然后是一个换行符。举个例子,在语法中,我们规定,一个获取命令是由一个关键字加上一个整数加上一个换行符组成的。要识别这样的语法,我们只需要按照顺序列出这些元素就可以了。在ANTLR中,获取命令的序列表示为‘RETR’ INT ‘ ’,其中,INT代表了整数符号类型。
BNF文件中空格无实际意义,但用户输入的串中却有意义,作为一个符号,单引号引起来的是终结符,没有被引起来的是非终结符
定义BNF范式时必须定义相应的开始符号,编译原理中称之为S。
retr :'RETR' INT ' ';// match keywordinteger newline sequence
注意,在我们的简单序列中能够包含语法中的任何字母,比如关键字或标点符号,或者直接的一个字符串,像“RETR”。(我们会在5.5节进一步探索诸如INT这样关键字的词法结构。)
类似于我们在编程语言中用函数来标记语句块,我们使用语法规则来标记语言结构。因而,我们将RETR序列标记为retr规则。这样,在语法的其它地方,我们可以通过规则名称来快速引用RETR序列。
考虑下任意长的序列,比如简单的整数列表,像matlab中的向量,诸如[1 23]之类的。对于有限序列,我们希望这些元素一个接着一个出现,但是,我们又不可能像INT INT INT INT INT INT INT INT INT…这样列出所有可能出现的情况。
要表示一个序列中的某个元素需要出现一次或多次,我们可以使用“+”子规则操作符。例如,(INT)+代表一个由整数组成的任意长的序列。为了书写方便,写成INT+也是可以的。如果希望这个序列也可以是空的,也就是某个元素可以出现零次或多次,我们可以使用“*”操作符:INT*。这个操作符类似于编程语言中的循环结构,当然,ANTLR生成编译器的时候就是用循环结构实现的。
序列模式有很多中情况,其中包括两个常见的情况,带终结符的序列和带分隔符的序列。CSV文件很好地展示了这两种情况。下面是我们将上一节中描述的CSV伪代码用ANTLR语法编写的代码:
file : (row ' ')* ; // sequence with a ' ' terminator
row : field (',' field)*; // sequence with a ',' separator
field: INT ; // assume fields are just integers
file规则使用终结符模式来匹配零个或多个“row ‘ ’”序列列表。符号’ ’是每个序列结束的标志。row规则使用分隔符模式来匹配由零个或多个’,’隔开field序列列表。符号’,’分隔开了所有的field。row能够识别像1以及1,2以及1,2,3这样的序列。
我们能在编程语言中找到类似的结构。例如,下面是一个识别编程语言中语句序列的例子,像Java语言那样,每条语句以分号作为结束符:
stats : (stat ';')* ; // match zero or more ';'-terminatedstatements
下面展示了如何指定一个逗号分割开的表达式列表,这种结构在函数的调用参数列表中会被用到:
exprList : expr (',' expr)* ;
不仅如此,连ANTLR的元语言也是用序列模式。下面展示了ANTLR是怎样用自己的语法来规定ANTLR的规则定义语法的:
// match 'rule-name :' followed by at leastone alternative followed
// by zero or more alternatives separatedby '|' symbols followed by ';'
rule : ID ':' alternative('|'alternative )*';' ;
最后,再介绍一种特殊的出现零次或一次的序列模式,用符号“?”表示,这种模式通常用来表示可选项结构。例如,在Java的语法中,我们可以发现可以使用(‘extends’ identifier)? 这样的序列来识别可选的父类继承说明。类似的,要匹配变量声明的同时可选择的初始化,我们可以使用 (‘=’ expr)? 来实现。可选操作符就是选择有或者没有。下一节将会提到,(‘=’ expr)? 就等价于 (‘=’ expr |) 。
模式:选项(替换项)
一个只有一种句式的语言会显得很枯燥无味。就算是最简单的语言,比如网络协议,也会有很多不同的句式,比如POP协议中的USER命令和RETR命令。这时我们就得选择选项模式。我们在Java的语法伪代码描述中见过这种模式,比如member规则中的“嵌套类定义或字段或方法”。
为了表达语言中选项这个概念,在ANTLR规则中,我们使用“|”操作符来表示“或”这个语法选择概念,我们称这种语法选择概念为选项。语法中到处都可以看到选项。
回到我们的CSV语法中,我们可以定义一个更加灵活的field规则,让其可以选择是整数还是字符串。
field : INT | STRING ;
试着翻看下下一章中的语法,我们可以发现很多选项模式的例子,比如6.4节中提到的一个type规则中列举的类型名称。
type: 'float' | 'int'|'void' ; // user-defined types
在6.3节,我们还会看到一个图表描述中的所有可能的语句列表。
stmt: node_stmt
| edge_stmt
| attr_stmt
| id '=' id
| subgraph
;
不管什么时候,当你描述“语言结构x可以是这样也可以是那样”的时候,你其实是在描述一个选择模式。那么,在你的规则x中使用“|”操作符吧。
语法中的序列和选项模式已经能够让我们实现大多数的语言结构了,但是,仍然有两个很关键的模式需要我们留意:符号约束和嵌套短语。他们通常会在语法中混合使用,所以,让我们开始继续学习符号约束吧。
模式:符号约束
在前面,我们使用INT+来表示matlab向量中的非空的整数序列,比如[1 2 3]。要识别一个方括号括起来的向量,我们还需要表述符号之间的依赖关系。这种依赖关系是指,一旦我们在句子中看到一个符号,我们就必须能在句子的其它地方能够找到其匹配对应的符号。要表示这样的语法,我们可以在序列中同时指定这些对应的符号,通常情况下,这些对应符号会包含或分开一些其它元素。基于这些,我们能够完成向量的识别:
vector : '[' INT+ ']';// [1], [1 2], [1 2 3], ...
回顾一下所有你熟悉的语言,你会发现,几乎所有的分组符号都是成对出现的:(…),[…]以及{…}。在6.4节中,我们可以看到表述函数调用和数组下标时候使用到的括号符号约束关系。
expr: expr '(' exprList?')' // func call like f(), f(x), f(1,2)
| expr '[' expr ']' // array index like a[i], a[i][j]
...
;
我们在函数的声明中也能够看到左右括号之间的这种符号约束关系。
examples/Cymbol.g4
functionDecl
: type ID '(' formalParameters?')'block //"void f(int x) {...}"
;
formalParameters
: formalParameter(',' formalParameter)*
;
formalParameter
: type ID
;
在6.2节的例子中介绍的JSON语法,则匹配了大括号包含的元素定义语法,例如 {“name”:”part”, ”passwd”:”secret”} 。
examples/JSON.g4
object
: '{' pair (','pair)*'}'
| '{' '}' // empty object
;
pair: STRING ':' value;
在6.5节中可以看到更多这样的匹配对应符号的例子。
还有一点必须引起你的注意,并不是所有的依赖性符号都是像括号那样相互对应的。C风格的语言中,有一种像 a?b:c 这样的三目运算符,其中就要求遇到了“?”符号之后必须要有一个“:”和其组成完整的运算符。
到目前为止,因为我们只是单纯地匹配符号,所以没有涉及到嵌套短语。比如,一个向量是不允许内部嵌套一个向量的。但是更一般地来看,被成对符号(比如括号)括起来的短语通常都可以是嵌套的。比如我们通常可以见到这样的结构:a[(i)],{while(b){i=1;}}。这个时候,我们就需要用到最后一个语言模式了。
模式:嵌套短语
嵌套短语有一种自相似的语言结构,即其子短语和其本身有着同样的结构。表达式是一种典型的自相似语言结构,表达式是由运算符分隔开的嵌套子表达式组成的。类似的,while语句块也是一个嵌套在其它外层语句块中的语句块。我们在语法上用递归规则来表示自相似的语言结构。所以,当我们的伪代码在表述一个规则的时候引用到了自己,我们就需要使用递归规则(自引用)。
让我们看看对于代码块语法,嵌套是怎么工作的。一个while语句是由一个关键字“while”,跟一个括号括起来的条件表达式,跟一个语句组成的。当然,我们把用大括号括起来的多条语句看成是一条语句。那么,要表达这样的语法,就应该像这样:
stat: 'while' '(' expr')' stat // match WHILE statement
| '{' stat* '}' // match block of statements in curlies
... // and other kinds of statements
;
while语句的循环体可以是一条语句也可以是用大括号括起来的多条语句。stat规则是一个直接递归的规则,因为它在其第一个选项和第二个选项中都直接引用了自己。如果我们把stat的第二个选项改动一下,让第二个选项单独成为一个规则block,那么规则stat和block之间就形成了非直接递归。
stat: 'while' '(' expr')' stat // match WHILE statement
|block // match a block of statements
... // and other kinds of statements
;
block: '{' stat* '}'; // match block of statements in curlies
大多数平凡语言都有许多自相似的结构,进而会产生许多递归规则。我们以一个简单的语言例子为例,这个例子中表达式只有三种类型:下标数组,括号表达式,整数。下面是我们如何在ANTLR中表述这种表达式语法:
expr: ID '[' expr ']' // a[1], a[b[1]], a[(2*b[1])]
| '('expr ')' // (1), (a[1]), (((1))), (2*a[1])
| INT // 1, 94117
;
仔细观察递归都是怎么产生的。下标数组中,下标本身就是一个表达式,所以我们直接用expr来替换下标元素就可以了。数组的下标本身就是一个表达式,这应该并不难理解。语言的结构自然而然地就决定了规则引用正好是递归的。
下面展示了两个输入样例的语法树:
正如我们在2.1节看到的那样,语法树的内部节点就是规则引用,而叶子节点就是符号引用。从树的根节点开始到任何节点的路径都表示了那个节点所表示的元素的调用栈(或者叫做ANTLR生成的递归下降分析器的调用栈)。路径代表了递归,对于同样的规则,不同嵌套的子树会有不同的引用。我个人喜欢把规则节点看成对其下方子树的标签。例如根节点是expr,所以整棵树所表示的就是一个表达式。而数字1上面的那个expr子树则表示这个整数1是一个表达式。
并不是每种语言都含有表达式,比如数据格式语言就没有,但是大多数你可以用来写程序的语言基本上都带有非常复杂的表达式语法(详见6.5节)。此外,识别表达式语法并不都是一件显而易见的事情,所以非常有必要花点时间好好研究下怎样识别表达式。我们接下来会好好讨论这个的。
首先,下表列出了ANTLR核心语法的一个小总结:
语法 |
描述 |
x |
匹配一个符号,规则或子规则x |
x y … z |
匹配一个规则序列 |
(…|…|…) |
带有多个选项的子规则 |
x? |
匹配零次或一次x |
x* |
匹配零次或多次x |
x+ |
匹配一次或多次x |
r:…; |
定义规则r |
r:…|…|…; |
定义一个带有多个选项的规则r |
表1 — ANTLR核心语法
下表总结了到目前为止我们所学习过的所有计算机语言模式:
模式名称 |
描述 |
序列 |
这是由符号和子短语组成的任意长的有限的序列,例如变量声明语法(类型后面加上标识符)以及整数列表等。下面是一些实现这种模式的例子: x y ... z // x followed by y, ..., z '[' INT+ ']' // Matlab vector of integers |
带终结符的序列 |
这是由符号和子短语组成的任意长的,可能是空的序列,以一个符号结束,通常情况系这个符号是分号或换行符,例如C风格的编程语言中的语句以及以换行符终结的数据行。下面是一些实现这种模式的例子: (statement ';')* // Java statement list (row ' ')* // Lines of data |
带分隔符的序列 |
这是由符号的子短语组成的任意长的非空的序列,用一个特定的符号分隔开,通常这个符号是逗号,分号或句号。例如函数参数列表,函数调用列表,或者是分开却不终止的程序语句。下面是一些实现这种模式的例子: expr (',' expr)* // function call arguments ( expr (',' expr)* )? // optional function call arguments '/'? name ('/'name)* // simplified directory name stat ('.' stat)* // SmallTalk statement list |
选项 |
这是由一系列可选择的短语组成的,例如类型说明,语句,表达式或者XML的标签。下面是一些实现这种模式的例子: type : 'int' | 'float'; stat : ifstat | whilestat | 'return'expr ';' ; expr : '(' expr ')'| INT | ID ; tag : '<' Name attribute* '>'| '<' '/' Name '>'; |
符号约束 |
一个符号的出现需要另一个或多个子序列符号的出现来对应,例如小括号,中括号,大括号,尖括号的匹配等。下面是一些实现这种模式的例子: '(' expr ')' // nested expression ID '[' expr ']' // array index '{' stat* '}' // statements grouped in curlies '<' ID (','ID)* '>' // generic type specifier |
递归短语 |
这是一种自相似的语言结构,例如表达式结构,Java类嵌套,代码块嵌套以及Python中的函数嵌套定义等。下面是一些实现这种模式的例子: expr : '(' expr ')'| ID ; classDef : 'class' ID '{'(classDef|method|field) '}' ; |
5.4 处理优先级,左递归以及相关性
在自顶向下的语法中,利用递归下降分析器来识别表达式一直都是一件很麻烦的事情,首先,因为很多自然语法都是模糊不清的,其次,大部分自然定义都使用一种特殊的递归——左递归。我们会在后面仔细地讨论左递归,但是现在,我们只需要知道在传统模式下,自顶向下的语法分析器是无法处理左递归的。
为了更好地表述这个问题,想象一个简单的数学表达式语言,其只包含乘法和加法操作符,且只有整数作为原子项。表达式是自相似的,所以我们很自然能够想到一个乘法表达式是由乘号(*)连接两个子表达式组成的。类似的,一个加法表达式是由加号(+)连接两个子表达式组成的。我们也可以把一个单独的整数算作一个表达式。从字面上来将这个想法转换成语法规则,那么这个规则看起来应该像下面这样:
expr : expr '*' expr // match subexpressions joined with '*' operator
| expr '+' expr // match subexpressions joined with '+' operator
| INT // matches simple integer atom
;
但问题是,这样的语法定义对于一些特定的输入是具有歧义的。换句话说,这样的规则可能存在对同一条输入的多种理解方式,具体看以参阅2.3节。对于只有一个整数或者只有一个操作符的表达式,例如1+2和1*2这种,这个规则可以正常工作。例如,这个规则可以匹配1+2,只需要使用第二条规则就行,如图3的左边所示。
图3 语法树解析
问题在于,当识别输入像1+2*3这样的表达式时,这条规则会存在2种不同的方式来解析,如图3中的中间和右边的语法树所示。这两种解析是不一样的,因为中间的语法树的表示先计算2*3的结果再加上1,而右边的语法树表示先计算1+2的结果再乘以3。这涉及到一个算符优先级的问题,传统的语法将无法识别这种优先级。许多语法工具,比如Bison,使用额外的符号来指定这种算符优先级。
与此不同的是,ANTLR通过这些算符规则定义的顺序来解决这种歧义,允许我们通过这种方式来隐式地指定算符优先级。expr规则先定义了乘法选项后定义了加法选项,所以ANTLR在处理类似于1+2*3这样的语法歧义的时候会先选择乘法选项。
默认情况下,ANTLR是从左到右来结合运算符的,这正好符合*和+的运算规律(从左到右计算)。但是,一些运算符(比如幂运算符)却是需要从右到左结合的,这种情况下,我们就需要用到assoc选项来手动来指定运算符的结合方向。例如,下面是一个幂运算表达式规则的例子,能够正确地将2^3^4这样的输入识别成2^(3^4):
expr : expr '^'<assoc=right>expr// ^ operator is right associative
| INT
;
下面的语法分析树展示了“^”运算符的左结合和右结合这两种结合方式的差别。右边的语法树才是我们通常对幂运算的理解方式,但是作为语言设计者而言,可以根据自己的设计指定任意一种结合方式。
将幂运算也加入我们的表达式规则中,我们需要将“^”规则选项放在“*”和“+”的前面,因为它的优先级比乘号和加号要高(例如,1+2^3的结果应该是9)。
expr : expr '^'<assoc=right>expr// ^ operator is right associative
| expr '*'expr // match subexpressions joined with '*'operator
| expr '+'expr // match subexpressions joined with '+'operator
| INT // matches simple integer atom
;
看到这里,很多熟悉ANTLR v3的读者就应该迫不及待地要向我指出,ANTLR应该和传统的自顶向下分析器生成器一样,是无法处理左递归的。然而,ANTLR v4的一个很重要的改进就是它能支持直接左递归。左递归是指在规则的最左边能够直接或者间接调用自身的规则。例如,expr规则就是一个直接左递归规则,因为除了INT选项外,每一个选项一开始都是直接调用expr自身。(expr同样也是一个右递归规则,因为expr在一个选项的右边也直接调用了自己。)
虽然ANTLR v4能够支持直接左递归,但是目前还不能处理间接左递归。这意味着如果我们想讲expr因子化为一些等价的规则会出问题。
expr : expo // indirectlyinvokes expr left recursively via expo
| ...
;
expo : expr '^'<assoc=right>expr ;
优先爬山算法表达式分析器 |
很多有经验的编译器开发员在自己手写编译器的时候总是想尽一切办法提升程序性能,并且尝试能够去完全实现对错误恢复的控制。相比于编写老长老长的表达式规则分析代码,他们更加偏向于使用算符优先文法分析器。 ANTLR使用一个和算符优先类似但是更具有效率策略,这种策略最初是Keith Clarke在1986年提出来的。随后,Theodore Norvell使用术语“优先爬山”(precedence climbing)来命名这种方法。与之类似的,ANTLR使用预先定义好的循环来替换直接左递归,从而进行前后运算符的优先级比较。详情参见第11章。 |
用ANTLR v3来处理表达式的时候,我们需要将expr规则拆分成多个规则来解决左递归的问题,每条规则都代表一个优先级。例如,在之前,我们需要像下面这样定义从而正确识别乘法操作符和加法操作符:
expr : addExpr ;
addExpr : multExpr ('+' multExpr)* ;
multExpr: atom ('*' atom)* ;
atom : INT ;
像C语言或Java中的表达式至少包括15条这样的规则,如果我们自己去编写自顶向下语法或者递归下降分析器的话,这么多规则处理起来是一件很麻烦的事情。
ANTLR v4中简化了直接左递归规则的处理过程,这不仅仅是一个更有效率的特性,更重要的是表达式规则也将变得更简单且更容易理解。例如,在Java语法中,关于表达式预测的语法行数几乎减少了一半(从172行减少到91行)。
实际上,我们几乎能处理所有我们关心的左递归的语言结构。例如,下面的例子能够识别C语言声明语句的子集,包括像“*(*a)[][]”这样的输入:
decl : decl '[' ']' // match [] suffixes using direct left-recursion
| '*' decl // *x, *x[], **x
| '(' decl ')' // (x), (x[]), (*x)[]
| ID
;
要了解更多的关于ANTLR支持的左递归(使用语法转换实现)结构,请参见第14章。
到这里为止,我们已经学习了计算机语言中通用的语言模式,并知道了怎样用ANTLR的语法来表示这些语言结构。但是,在我们真正可以开始实现一些完整的例子之前,我们还需要掌握怎样在我们的语法规则中描述符号引用,同时,我们还将学习一些通用到极点的词法结构。创建一个完整的语法主要就是将这一节学习到语法规则和下一节要学习的词法规则结合起来的过程。
5.5 识别通用词法结构
计算机语言在词法上看起来非常相似。举个例子,如果加上一些特定的语法信息来规定顺序,“)”“10”“(”“f”这几个符号几乎能从最原始的语言到最新的语言中都能组成有效的短语。15年前,我们可以在LISP中看到“(f10)”这样的写法,也可以在Algol中看到“f(10)”这种写法。当然,“f(10)”这种写法几乎从Prolog到Java再到最新的Go语言都支持。从词法上来看,函数式的、过程式的、声明式的以及面向对象的语言看起来都非常得一致。多么令人惊讶!
这一点意味着我们只需要学习怎样描述标识符和整数,然后做一点小改动,就可以将其应用到大多数的编程语言中了。从分析器的角度来看,词法分析器也是使用规则来描述各种各样的语言结构的。在描述中,我们几乎使用完全一样的符号和规则。语法分析器和词法分析器的唯一差别在于,语法分析器是从符号流中识别语法结构的,而词法分析器是从字符流中识别语法结构的。
鉴于词法规则和语法规则拥有着类似的结构,ANTLR中允许将这两个规则写在同一个语法文件中。但是,毕竟词法分析和语法分析在语言识别中是两个完全不同的阶段,所以我们必须告诉ANTLR哪条规则应该属于哪个阶段。我们通过规则名称的第一个字符来识别这两种规则,第一个字符是大写字母的是词法过则,第一个字符是小写的是语法规则。例如,ID是一个词法规则名称,而expr则是一个语法规则名称。
当我开始编写一个新的语法的时候,我习惯从现有的语法中复制规则,比如从Java的规则中复制一些最通用的词法结构:标识符,数字,字符串,注释以及空白字符。然后,通过一些巧妙的调整,就可以直接建立并使用了。几乎所有的语言,包括那些非编程的语言,比如XML和JSON,都拥有很多这样词法符号。例如,一个C语言的词法规则同样也可以用来识别下面这段JSON代码:
{
"title":"Catwrestling",
"chapters":[{"Intro":"..."}, ... ]
}
另一个例子,我们看一下块注释。在Java中,块注释是用“/*…*/”表示的,而在XML则是使用“<!--…-->”表示的。但是它们除了起始和终止符号不一样意外几乎是完全相同的。
再看关键字,运算符和标点符号,我们并不需要为这些创建规则,因为我们能够在语法规则中直接通过用单引号将这些符号引起来的方式来直接匹配。比如’while’,’*’,’++’等。一些程序员更喜欢用词法规则来创建引用,比如用MULT来代替符号’*’。这么做的好处就是当需要修改乘法符号时,只需要修改MULT的规则定义就可以了。这两种方式都可以使用,它们最终都会生成同样的符号类型。
为了说明词法规则的样子,我们来看一些通用符号的简化版本,就从我们的老朋友“标识符”开始吧。
匹配标识符
在语法的伪代码中,一个基本的标识符被描述为一个非空的,由大写和小写字母组成的字母序列。使用一下我们之前刚学到的新技能,我们知道可以使用“(…)+”的方式来表示这样的序列模式。因为组成标识符的元素可以是小写字母也可以是大写字母,所以这里应该有一个选择操作符来描述这种模式。
ID : ('a'..'z'|'A'..'Z')+ ; // match 1-or-more upper or lowercaseletters
在这里我们遇到了一个新的ANTLR表述语法,即区间运算符:’a’.. ‘z’,这个短语代表了一个’a’到’z’之间的任意字符。这个区间也就是ASCII码上面的97到122。如果需要用到Unicode来指定字符,我们需要用到’uXXXX’这种形式,其中的XXXX就是十六进制的Unicode字符编码。
作为字符集的缩写形式,ANTLR也支持一些类似于正则表达式的写法。
ID : [a-zA-Z]+ ; // match 1-or-more upper or lowercase letters
但是,像ID这样的规则有时候会和其它的词法规则或者符号引用发生冲突,比如ID会和引用’enum’冲突。
grammar KeywordTest;
enumDef : 'enum' '{' ... '}' ;
...
FOR : 'for' ;
...
ID : [a-zA-Z]+ ; // does NOT match 'enum' or 'for'
规则ID同时也能匹配上一些关键字,比如enum和for,换句话说,同一个输入串可能会被多条规则同时匹配。为了让这个问题更加清楚,我们来考虑下ANTLR是怎样来处理这种混合的词法/语法规则的。首先,ANTLR会将所有的词法规则(包括词汇引用)和语法规则,然后,像’enum’这样的词汇引用会转换成词法规则,并插入到语法规则之后,明确定义的词法规则之前。
ANTLR的词法分析器是通过词法规则出现的顺序来解决这种歧义的。也就是说,你的ID规则必须定义在所有的关键字规则后面,就像上面例子中定义在for后面那样。ANTLR会将所有的词汇引用隐含地生成词法规则并将这些规则放在明确定义的词法规则之前,所以直接词汇引用一般都有较高的优先级。在这种情况下,’enum’将自动比ID的优先级要高。
由于ANTLR自动将所有词法规则都放在语法规则后面,像下面的例子这样重新调整顺序依然能够获得同样的解析结果:
grammar KeywordTestReordered;
FOR : 'for' ;
ID : [a-zA-Z]+ ; // does NOT match 'enum' or 'for'
...
enumDef : 'enum' '{' ... '}' ;
...
到目前为止,我们的标识符的定义中还不允许出现数字,你可以在6.3节和6.5节看到完整的关于ID的定义。
匹配数字
要描述像10这样的整数是一件非常容易的事情,因为它仅仅是数码的序列。
INT : '0'..'9'+ ; // match 1 or more digits
或
INT : [0-9]+ ; // match 1 or more digits
然而,浮点数的表达方式就显得非常复杂了。但是,我们可以通过忽略指数形式来简化浮点数的表示。(在6.5节中可以看到完整的浮点数表示,甚至还能看到像3.2i这样的复数的表示。)浮点数就是一个数码的序列,后面跟上一个小数点,然后跟上一个可选择的小数部分,或者是由小数点开始,后面跟上一连串的数码序列。只有一个小数点这样的表示方法是非法的。所以,我们使用选择模式和序列模式来表示浮点数规则。
FLOAT: DIGIT+ '.' DIGIT* // match 1. 39. 3.14159 etc...
| '.' DIGIT+ // match .1 .14159
;
fragment
DIGIT : [0-9] ; // match single digit
这里,我们使用了一个协助规则DIGIT,这样我们只需要写一遍[0-9] 就可以了。使用fragment前缀来定义,ANTLR就会知道这条规则仅仅用来组成其它词法规则,而不会单独使用。DIGIT本身并不是我们需要的符号,也就是说,我们在语法分析器中是看不到DIGIT这个符号的。
匹配字符串
计算机语言中最经常出现的下一个通用结构就是字符串,比如”Hello”。大多数情况下都是使用双引号,但是也有些语言会使用单引号,甚至像Python这样的语言两种符号都使用。不管使用什么符号来分割字符串,我们使用同样的规则来表示字符串内部。在伪代码中,字符串就是在双引号之间由任意字符组成的序列。
STRING : '"' .*? '"' ; // match anything in "..."
其中,通配符“.”匹配任意一个字符。所以,“.*”就能匹配任意长的可以为空的字符串。当然,这种匹配也可以直接匹配到文件末尾,但是这么做往往是没有意义的。所以,ANTLR使用标准正则表达式符号(后缀:?)来表示采用非贪婪子规则的策略。非贪婪的意思是“不断匹配字符,直到匹配上词法规则中子规则的后面跟着的元素”。更精确地说,非贪婪子规则值匹配尽可能少的字符,尽管这条规则有可能匹配更多的字符。更多细节查看第15.6节。相比之下,“.*”就被认为是贪婪子规则,因为它会匹配掉所有循环内部的字符(这种情况下,作为通配符使用)。如果你现在对“.*?”不太理解,也没有关系,现在你只需要知道这种写法是用来表示值匹配双引号引起来的内部所有字符就可以了。我们会在后面讨论注释的时候再次讨论非贪婪循环的。
我们的STRING规则还是不完整的,因为目前我们的规则中不允许出现双引号。为了支持字符串中出现双引号,许多语言都会定义一种以反斜杠开头的转义序列。要让双引号引起来的字符串中也出现双引号,我们使用“””。我们需要如下定义来支持转义字符:
STRING: '"' (ESC|.)*?'"' ;
fragment
ESC : '\"' | '\\' ; // 2-char sequences " and \
ANTLR本身支持转义字符,所以需要对反斜杠进行转义,所以我们可以看到上面使用“\”来代表一个反斜杠。
现在,我们的STRING规则的循环中可以通过片段规则ESC来匹配转义序列,以及通过“.”通配符匹配任意单个字符了。而“*?”子规则操作符会在匹配规则中后跟元素(没有转义的双引号)时结束匹配“(ESC|.)*?”的循环。
匹配注释和空白字符
当词法分析器匹配了我们定义的符号之后,就会通过符号流将识别到的符号提交给语法分析器。然后,语法分析器就会根据语法结构来检查这个符号流。但是,当词法分析器遇到注释和空白字符时,我们希望词法分析器直接忽略它们,这样的话,语法分析器就不用考虑怎么处理这些到处都可能会出现的注释和空白字符了。例如,WS代表是一个词法规则中的空白字符,像下面这么定义语法规则的话是一件非常可怕的事情:
assign : ID (WS|COMMENT)? '=' (WS|COMMENT)? expr (WS|COMMENT)? ;
定义这些需要丢弃的符号和定义非丢弃的符号是一样的,我们只需要简单地使用skip命令来指明词法分析器需要将这些符号忽略就行了。例如,下面是匹配类C语言中单行注释和多行注释的规则:
LINE_COMMENT : '//' .*? ' '? ' ' -> skip ; // Match"//" stuff ' '
COMMENT : '/*' .*? '*/' ->skip ; // Match "/*" stuff "*/"
在LINE_COMMENT规则中,“.*?”匹配“//”后面直到第一个出现的换行符(回车符前面的那个换行符是为了匹配Windows下的换行符)之前的所有字符。在COMMENT规则中,“.*?”匹配了“/*”和“*/”之间的所有字符。
词法分析器通过“->”操作符来接收命令,skip只是诸多命令中的一种。例如,我们还可以通过channel命令将这些符号传递给语法分析器的“隐藏通道”。关于符号通道请查阅第12.1节。
下面来看看怎样处理我们这一解中的最后一个通用符号,空白字符。一些编程语言把空白字符作为符号的分隔符,而其他语言则是直接忽略空白字符。(Python是一个例外,因为Python将空白字符作为语法结构的一部分了:换行符作为命令的结束符,缩进(Tab或者空格)作为指定嵌套结构的符号。)下面是让ANTLR忽略空白字符的例子:
WS : (''|' '|' '|' ')+ -> skip ; // match 1-or-more whitespace but discard
或者
WS : [ ]+ -> skip ; // match 1-or-more whitespace but discard
当换行符既是需要忽略的空白字符又是作为命令的结束符时,我们就会遇到一个问题。换行符是上下文相关的。在一个语法结构中,我们需要忽略换行符,但是再另外一个语法结构中,我们又需要将换行符传递给语法分析器,这样语法分析器才能知道一条命令是否结束。例如,在Python中,f()后面跟一个换行符意味着我们需要执行指令,调用f()。但是我们又可以在圆括号中插入一个额外的换行符。Python会等到遇到“)”后面的换行符才会执行函数调用。
➾$ python
➾>>> def f(): print "hi"
<...
➾>>> f()
<hi
➾>>> f(
➾... )
<hi
关于这个问题的详细讨论,请参见第12.2节。
那么,现在我们知道了怎样实现最基本的通用词法结构:标识符,数字,字符串,注释,以及空白字符。信不信由你,这对于一个大语言程序的词法部分而言,是一个很好的开始。下面是一个词法新人工具包,我们以后可以将其作为参考:
符号类别 |
描述和例子 |
标点符号 |
对标点符号和运算符最简单的处理就是直接在语法规则中引用它们。 call : ID '(' exprList ')' ; 当然一些程序员更喜欢定义符号的标签规则,例如定义LP来代表左括号。 call : ID LP exprList RP ; LP : '(' ; RP : ')' ; |
关键字 |
关键字就是保留的标识符,和标点符号的处理一样,我们可以直接引用也可以定义标签规则。 returnStat : 'return' expr ';' |
标识符 |
标识符在几乎所有语言中看起来都差不多,可以再加一些改动,比如规定首字符以及设定是否可以使用Unicode字符。 ID : ID_LETTER (ID_LETTER | DIGIT)* ; // From C language fragment ID_LETTER : 'a'..'z'|'A'..'Z'|'_' ; fragment DIGIT : '0'..'9' ; |
数字 |
例子中是整数和简单浮点数的定义。 INT : DIGIT+ ; FLOAT : DIGIT+ '.' DIGIT* | '.' DIGIT+ ; |
字符串 |
匹配使用双引号引起来的字符串。 STRING : '"' ( ESC | . )*? '"' ; fragment ESC : '\' [btnr"\] ; // , , etc... |
注释 |
识别并丢弃注释。 LINE_COMMENT : '//' .*? ' ' -> skip ; COMMENT : '/*' .*? '*/' -> skip ; |
空白字符 |
匹配词法中的空白字符并丢弃这些字符。 WS : [ ]+ -> skip ; |
现在,我们已经能够将输入文件传递给词法和语法分析器并准备好来解决下一章中的一些例子了。在我们继续之前,还需要考虑两个重要的问题。首先,什么时候该用词法匹配,什么时候改用语法匹配,这两者之间的界限并没有那么清晰。其次,ANTLR对于我们的语法规则有一些限制,我们最好了解下这些限制。
5.6 词法分析和语法分析之间的界限
由于ANTLR的词法分析规则也可以使用递归,所以词法分析器几乎和语法分析器一样智能。换句话说,我们能够在词法分析器中实现语法结构。或者,从另一个极端上说,我们可以把字符当作符号,然后利用语法分析器将语法规则运用在字符流上。(这样的语法分析器叫做无扫描语法分析器,可以参考一个匹配小型的C和SQL混合的语法,详见code/extras/CSQL.g4)。
词法分析和语法分析的界限部分依赖于语言的功能以及预期应用的功能。值得庆幸的是,有一些总结可以帮助我们区分。
l 应该在词法分析器中匹配那些需要丢弃的元素,这样语法分析器就无需关心这些内容了。比如编程语言中的空白字符和注释就需要识别并剔除。否则的话,语法分析器就不得不花很大的功夫来检查两个符号之间的空白字符或注释了。
l 应该在词法分析中匹配通用符号,比如标识符,关键字,字符串,以及数字。语法分析器的开销比词法分析器要大,所以我们最好不要给语法分析器太大压力,也就是说,把数码组合在一起并识别出数字再传给语法分析器。
l 将语法分析器不需要区分的词法结构合并成一个词法结构。举个例子,如果我们的程序处理整数和浮点数的过程是一样的话,那么我们最好就把整数和浮点数合并一个符号:数字。在这种情况下,将分的那么细的词法规则传给语法分析器没有任何意义。
l 将所有语法分析器可以等同对待的内容合并成一个内容。例如,如果我们的语法分析器并不关心一个XML标签的内容,那么词法分析器就应该将两个尖括号之间的所有内容都合并成一个单独的符号类型:标签。
l 从另一方面来说,如果语法分析器需要量一个文本分成几部分来处理,那么词法分析器就应该传递已经分好的各元素符号给语法分析器。例如,如果语法分析器需要解析IP地址的各个元素,词法分析器就应该传递IP地址的各个部分(整数和点)。
我们所说的语法分析器不需要区分某些词法结构,或者语法分析器并不关心一个词法结构内部组成,我们实际上是指我们最终的程序不关心这些。即我们的程序对这些词法结构有着同样的处理或者翻译。
为了更好地说明预期应用是怎样影响我们的词法和语法识别的,我们以处理一个Web服务器上的日志文件为例,这种文件每行一条记录。我们将通过不断增加程序的需求来观察词法分析和语法分析之间的界限是怎样变动的。假设这个日志文件每一行由请求IP,HTTP协议命令以及返回代码组成。下面是一行日志记录的例子:
192.168.209.85 "GET /download/foo.html HTTP/1.0" 200
这时候,会有很多词法结构出现在我们的脑海里。然而,如果我们的程序只需要计算这个文件中一共有多少条记录,除了换行符为终结符的序列外,我们完全可以忽略其它所有词法结构。
file : NL+ ; // parser rulematching newline (NL) sequence
STUFF : ~' '+-> skip ; // match and discard anything but a ' '
NL : ' ' ; // return NL to parser or other invoking code
词法分析器并不需要识别那么多类型的结构,并且语法分析器也只需要识别由换行符结束的序列就可以了。(“~x”操作符匹配除了x外所有符号。)
接下来,我们给程序加一个需要从日志文件中识别IP地址列表的功能。这意味着我们需要一条词法规则来匹配IP地址,同时,我们也就需要一条规则来匹配一条记录剩下来的部分。
IP : INT '.' INT '.' INT '.' INT ; // 192.168.209.85
INT : [0-9]+ ; // match IP octet or HTTP result code
STRING: '"' .*? '"' ; // matches the HTTP protocol command
NL : ' ' ; // match log file record terminator
WS : ' ' ->skip ; // ignore spaces
然后我们通过组合完整的符号组合来让语法分析器识别日志文件中的一条记录。
file : row+ ; // parser rule matching rows of log file
row : IP STRING INT NL ; // match log file record
再添加一点我们程序的功能,我们需要将文本的IP地址转换为32位整数。使用一些很方便的库函数,例如split(‘.’),我们可以将识别到的文本IP地址传递给语法分析器并在语法分析器中完成分离和转换。但是,我们最好在词法分析过程中识别IP地址的各个部分,让后将这些部分作为符号传给语法分析器。
file : row+ ; // parser rule matching rows of log file
row : ip STRING INT NL ; // match log file record
ip : INT '.' INT '.' INT '.' INT ; // match IPs in parser
INT : [0-9]+ ; // match IP octet or HTTP result code
STRING: '"' .*? '"' ; // matches the HTTP protocol command
NL : ' ' ; // match log file record terminator
WS : ' ' ->skip ; // ignore spaces
从我们将词法规则IP改为语法规则ip可以看出,这两者之间的分割线可以很容易发生变动。(将四个INT符号转换成32位数字需要一些嵌入到语法中的程序代码,目前我们还不需要了解这个,所以就先忽略之。)
继续添加功能。如果程序需要处理HTTP协议命令字符串中的内容的时候,我们将遵循类似的思维过程。如果我们的程序并不关系字符串是由哪些部分组成的,那么我们直接将整个字符串作为一个单独的符号传递给语法分析器就可以了。但是,我们如果需要分离出其中具体的各个部分,最好在我们的词法分析器中就识别这些个部分再传递给语法分析器。
根据程序的需求以及语言的特征来区分词法分析和语法分析的任务并不是一件特别困难的事情。下一章中的例子能够帮你更好地掌握这一章学到的规则。有了这些坚实的基础之后,我们就可以尝试一些第12章中令人头疼的词法问题了。例如,Java编译器既需要忽略又需要处理Javadoc注释,以及,XML文件对于标签的内部和外部有着不一样的词法结构。
在这一章中,我们学到了如何从一些具有代表性的语言或语言手册来创建语法伪代码,并用ANTLR语法来形成一个正式的语法。同时,我们也学习了一些通用的语言模式:蓄力,选项,符号依赖以及嵌套短语。在词法层次,我们学习了最通用的词法结构的实现:标识符,数字,注释,以及空白字符。现在,是时候该将这些知识应用到一些实际的语言上去了。