(二十四)深度解析领域特定语言(DSL)第五章——词法分析:文法示例与词法单元

  一、文法示例

上一篇文章对文法的基本概念进行了介绍,接下来,让我们看一下代码5-1所示DSL所对应的文法,如文法5-8所示:

文法5-8

S -> 'set' RATE 'where' CONDITIONS ';'
RATE -> 'rate' '=' 'number'
CONDITIONS -> SPEC | SPEC 'and' CONDITIONS
SPEC -> 'field' OPERATOR VALUE
OPERATOR -> '>' | '>=' | '<' | '<=' | '='
VALUE -> '"' any_string '"'

        文法5-8中,大写字母组成的符号如RATE、SPEC等为非终结符;小写字母组成的字符串以及各类运算符号如大于(>)、小于(<)等为终结符。为了方便阅读,笔者将其中的关键字(终结符)使用粗体进行表示。另外,请读者注意一下产生式VALUE 的定义,右部中的'"'表示的是由单引号括起来的一个双引号,虽然看起来有些混乱,但根据本书约定,终结符应该放到单引号之内。

        存在于文法5-8中终结符id、number、any_string很容易让人产生误解,主要原因在于我们并不知道它们所代表的内容以及格式是什么。一般来说,对于终结符的描述需要使用词法规则,有关这一方面的内容,请您参看下一篇。不过根据上述三个终结符的名字,我们还是能够大致猜测到number的格式应该是数字,any_string的格式则是任意的字符串。至于field,从目前来看还不是很清晰。

        通过上述案例可知,文法的核心内容为语法规则,而词法单元(即终结符)的识别则依赖词法规则。尽管二者的描述形式存在差异,但在工程实践中,可将其整合于同一文法文件进行统一说明——语法分析器生成器ANTLR即采用此种实现方式。对于语法规则与词法规则的区分,可遵循以下判据:词法规则定义了如何从字符流中识别并生成词法单元,而语法规则则规定了如何将词法单元组合为更高级的语法结构。值得注意的是,尽管文法中出现的诸如'set'、'where'、'>='、'<='等符号看似词法规则,实则为词法单元类型的形式化表示,真正的词法规则需要通过词法分析器的实现逻辑进行定义。

        关于文法5-8的规则构成,此处不再做过多阐释。需要再次提醒读者的是:对于特定语言而言,其对应的文法并非唯一,因此文法5-8仅体现了笔者的设计思路。但需明确的是,只要规则描述在形式化层面准确无误,不同文法结构仍可确保语法分析结果的一致性。

二、词法单元

        在执行语法分析之前,编译器首先会读入DSL代码字符串,并从字符串的最左端开始进行扫描,扫描的输出结果为词法单元或词法单元列表(具体形式取决于词法分析器的实现逻辑)。扫描操作本质上是代码的预处理过程,通常会过滤掉代码中的无意义信息,例如空白符(包括空格、制表符)、注释等。需要注意的是,这一处理方式并非绝对通用:在对Python代码进行词法分析时,需特别处理空白符,因为其可能隐含代码块的层次结构语义。

        由前文可知,词法单元的通用形式是一个二元元组:

<词法单元的类型, 词法单元的属性值>

        前半部分表示词法单元的类型,笔者个人比较倾向于使用枚举类型。后半部分的值可以直接使用词素,比如源代码中的关键字、变量名、各类界符等,不过更一般的形式是使用自定义的对象,因为您可以将更多的有用信息放到该对象之中,比如词素的行、列信息。

        出于节省空间的目的,词法单元对象中可能只包含一个类型信息,并没有单独的值部分存在。这种情况下,属性值与词法单元类型使用了相同的值。以简单运算符如+、-、*等为例,可考虑使用如下数据结构来表示词法单元:

<词法单元的类型>

        所谓“简单运算符”是指由单个字符构成的运算符,例如“=”、“>”、“<”、“+”、“-”、“*”、“/”等(需注意:“>=”、“<=”这类复合运算符不包含在内)。由于仅由单个字符组成,此类运算符可通过ASCII码值表示词法单元类型。这一做法在通用编程语言中较为常见,因为其编译代码量通常较大,而Token对象的大小会对编译效率产生直接影响。但对于DSL而言,由于代码量通常有限,采用标准形式(即<词法单元类型, 词法单元属性值>)不会产生显著负面影响。

        当采用对象形式表示词法单元属性值时,该对象应包含哪些信息?针对这一问题,目前并无标准化答案。除词素本身外,建议将诊断所需的上下文信息(如词素在源代码中的行号、列号)纳入对象结构。此举可在语法分析出错时,直接从Token对象中提取精确的错误定位信息。需说明的是,词法分析器的实现方式不同,注入提示信息的时机也存在差异,相关细节将在后文详细阐述。代码5-2展示了与代码5-1对应的Token对象定义:

代码5-2

class Token {
    TokenType type;
    String lexeme;
	...
    Token(TokenType type, String lexeme) {
        this.type = type;
        this.lexeme = lexeme;
    }
    ...
}

        尽管内容有些省略,但可以看到,其结构还是非常简单的,唯一值得注意的是词法单元类型type的类型TokenType,这是一个枚举类,其定义如代码5-3所示:

代码5-3


enum TokenType {
    SET("set"),
    WHERE("where"),
    EQ("="),
    LESS_THAN("<"),
    LE("<="),
    GREATER_THAN(">"),
    GE(">="),
    AND("and"),
    SEMICOLON(";"),
    FIELD("field"),//优惠条件名称
    NUM("number"),//数字
    QUOTATION(""),//单引号
    RATE("rate"),
    STRING("any_string"),//字符串
    OPERATOR(""),//运算符,LE、GE等符号的超类
    EOF("", ""),
    UNKNOWN("");

    String name;
}

        从代码中能够看出,Token类型的定义十分精细,每一个关键字、每一个非终结符都有与之对应的类型。不过要留意,这只是当前案例所采用的Token类型定义模式,在实际操作中,你并非一定要遵循这样的设计准则,而且也不存在普遍适用、永远正确的设计准则。

        另外,Token类型包含的信息也不是固定不变的。当前案例里,Token类型包含的仅仅是类似注释那样的类型说明。当词法分析程序运用正则表达式来识别Token时,我们也许会把用于识别词素的正则表达式(也就是词法规则)添加到Token类型的定义当中。

        对于代码5-3,最后需要说明的是枚举值EOF,其一般被用于表示文件的结束。不过针对本书案例,笔者使用它来作为词法单元列表的最后一个元素,这样做的目的是为了简化语法分析器的实现。以前文的二进制字符串分析(文法5-1)为例,假定输入的字符串为“12”。语法分析器第二次向词法分析程序索取Token的时候,后者给出的类型是UNKNOWN,这种情况下我们可以让语法分析器抛出一个异常,因为字符“2”是非法的。请您考虑一下,当输入串为“1”的时候,词法分析程序的第二次索取应该返回什么类型的Token呢?UNKNOWN明显不合适,因此语法分析器会报错,但输入的字符串其实是合法的。针对这种情况,EOF的作用就体现出来了,语法分析器只要见到这个类型就可以认为已经完成了整个输入串的扫描和分析,此时只需正常结束即可。

        根据前文所述,词法分析器的输出形式虽被称为“词法单元列表”,但这一表述并不限定其必须采用数组或列表(List)的物理结构实现。词法分析器与语法分析器之间存在协作关系,可通过两种模式实现交互:其一为“拉取模式”,即语法分析器每处理完一个Token便主动向词法分析器请求下一个Token,以此循环直至分析结束;其二为“推送模式”,即词法分析器一次性生成全部Token并传递给语法分析器,这种模式适用于脚本体量较小的场景。因此,当前的核心问题在于如何构建Token序列,即如何将DSL脚本转换为可被语法分析器处理的Token表示形式。Java代码5-4展示了这一过程的具体实现方式:

代码5-4

if (i >= 0) {
    int num;
}

        在上述代码中,存在空格、换行符、制表符等无语义的符号,它们可充当Token间的分隔符。如前文所述,词法分析器会过滤掉包括各类空白符在内的无意义信息。由此可见,空白符之间无法被移除的内容,正是Token需要包含的信息。此外,我们还需识别每个Token的类型,这是语法分析器的必需信息,也是本章的核心内容。因此,对代码5-4进行词法分析后,输出的Token列表(注:笔者对Token内容进行了简化)如下所示:

,<(>,,<>=>,<0>,<)>,<{>,,<;>,<}>

        从上述示例可见,“if”和“num”因字母间无空格而被识别为两个独立Token。而“(i”虽无空格却拆分为“(”和“i”两个Token,这一现象的本质源于Token的核心识别逻辑,笔者将在下一篇展开详细阐述。

        在继续学习前,请读者务必牢记“词素”的定义。其重要性体现在:词法单元的识别过程直接面向词素——即源程序中的字符序列。当某一词素匹配特定词法单元模式(词法规则)时,即成为该词法单元的实例。例如,“>”符合运算符模式,故属于运算符类型的Token;“if”匹配关键字模式,故属于关键字类型的Token。若Token类型定义粒度较细(如本案例将“>”设为独立类型),则“>”将被识别为专属类型的Token。

上一章  下一章

你可能感兴趣的:(DSL,领域特定语言,java,开发语言,软件构建)