(四十五)深度解析领域特定语言(DSL)第八章——语法分析器组合子:案例实现(Part1)

完成组合子模式整体设计思想的学习后,接下来进入代码展示环节。在内容展开过程中,笔者将穿插介绍语法分析器设计的相关技巧与注意事项,虽篇幅有限,但力求帮助读者规避常见误区。此外,鉴于本章代码较多,笔者将其分成多个部分,以避免对您的阅读产生影响。

遵循前文章节惯例,词法分析器相关代码不再重复展示,具体实现细节可参考第5章内容。需要说明的是,本章案例的词法单元类型较之前存在差异,具体定义如代码8-8所示:

java 复制代码
代码8-8

enum TokenType {
    RULES, //rules
    ID, //id
    END, //end
    SEMICOLON, //;
    COMMA, //,
    SERVICE_TYPES, //service_types
    BIND_RULES, //bind_rules
    OPEN_BRACE, //{
    CLOSE_BRACE, //}
    EOF, //<EOF>
}

既然组合子模式使用了自底向上的设计模式,那就先从终结符所对应的子分析器进行实现,如代码8-9所示:

java 复制代码
代码8-9

static class TerminalParser extends Parser {
	TokenType targetType;

	TerminalParser(TokenType targetType) {
		super(targetType.name());
		this.targetType = targetType;
	}

	void setupCallback(Consumer<List<Token>> matchedCallback) {
		this.matchedCallback = matchedCallback;
	}

	@Override
	void parse(ParseContext context) {
		if (!context.isPreviousMatched()) {
			return;
		}
		Token current = context.tokenBuffer.nextToken();
		if (current.type == targetType) {
			context.matchSuccess(ListUtil.of(current));//代码1
			this.callback(ListUtil.of(current));
			return;
		}
		String error = this.error(current.lexeme, targetType.name());
		context.matchFailed(error);
	}
}

代码8-9中有四处值得关注的地方:

  1. TerminalParser类继承自类Parser,后者是所有子解析器的父类。
  2. setupCallback()方法用于设置回调方法。此处使用了Java(JDK 8及后续版本)中的函数式接口(Functional Interface),当终结符匹配成功后会调用该对象所指向的方法(一般定义在Parser的子类中)。有关该回调的作用,笔者会在后文中进行解释。
  3. parse()方法定义了具体的语法分析逻辑。分析成功时,会将匹配到的词法单元放置到ParseContext类型的对象context中;而如果匹配失败的话,则会将具体的错误信息和匹配状态记录到context中。context对象在整个语法分析过程中,承载了数据传输的责任,详细代码稍后进行展示。

parse()方法的入口处调用了context.isPreviousMatched()方法来判断上一个环节是否匹配成功。由图 8.4所示的语法分析器结构可知,基于组合子模式的分析器在其运作过程中会将所有的组合子对象组装成一个类似于链表的结构,如图 8.6所示。当然,所谓的"链表"其实是对语法分析树进行后序遍历的结果,并不存在物理上的链表对象。通过该图可知,当使用T2对象进行语法分析的时候,应该首先判断T1对象是否已经分析成功,调用isPreviousMatched()方法正是出于此种原因。由于不涉及回溯的处理,所以我们会在上一个子分析器执行失败的时候选择终止整个分析流程。

图 8.6 多个子分析器对象形成一条虚拟链表

代码8-9有一些奇怪之处,即"代码1"处。笔者不仅将被匹配的词法单元对象放置到了context中,还将其作为参数传递到回调方法中。此处的操作有什么目的呢?简单来说,我们这样做的目的是为了将被匹配的词法单元传递到子分析器对象外部,因为后续的分析流程会使用到这一信息,比如构建语义模型对象时。当然,此处的操作是冗余的,实践中您二选一即可。另外,上述设计属笔者有意为之,后文会向您展示这一做法的原因。

接下来继续代码的学习。下面,让我们看一下Parser类的实现,如代码8-10所示:

java 复制代码
代码8-10

abstract class Parser {
	String targetNode;
	Consumer<List<Token>> matchedCallback;

	Parser(String targetNode) {
		this.targetNode = targetNode;
	}

	void setupCallback(Consumer<List<Token>> matchedCallback) {
		this.matchedCallback = matchedCallback;
	}

	void callback(List<Token> tokens) {
		if (this.matchedCallback != null) {
			this.matchedCallback.accept(tokens);
		}
	}

	String error(String current, String expected) {
		return String.format("syntax error, lexeme:%s, expected:%s", current, expected);
	}

	abstract void parse(ParseContext context);
}

抽象类Parser内部包含了两个重要的字段:targetNode和matchedCallback。前者表示当前正在处理的节点,其最重要的作用之一是调试,通过将其包含的信息打印出来能够让人们知道当前的分析工作进行到了哪个环节;后者表示回调对象,语法分析成功的话会开启回调流程。

除上述两个字段之外,Parser中还定义了一些能够被子类共享的方法,其中以parse()方法最为核心,不过被定义成了抽象的,这意味着具体的分析逻辑会由子类来承担。该方法使用了ParseContext类型的对象作为参数,正如前文所说,该对象用于在子解析器之间传递信息。如代码8-11所示:

java 复制代码
代码8-11

class ParseContext {
	private MatchResult matchedResult = MatchResult.success(new ArrayList<>());
	TokenBuffer tokenBuffer;
	NameContainer nameContainer;
	ServiceTypeContainer serviceTypeContainer;
	BindingConfig bindingConfig;

	void matchSuccess(List<Token> matchedTokens) {
		matchedResult = MatchResult.success(matchedTokens);
	}

	void matchFailed(String error) {
		matchedResult = MatchResult.failed(error);
	}

	boolean isPreviousMatched() {
		return matchedResult.matchSuccess;
	}

	List<Token> matchedTokens() {
		return matchedResult.matchedTokens;
	}

	String getError() {
		return this.matchedResult.error;
	}
}

ParseContext类中包含的内容较多,包括MatchResult(匹配结果)、TokenBuffer以及三个语义模型对象。前两类对象都比较好理解,此处加入三个语义模型是什么意思呢?这又是与前文案例不同的地方,设计初衷有二:

  1. 支撑语义模型的实例化。针对当前案例,笔者省略了语法树的构建环节,而是在语法分析过程中进行语义模型的构建。这样设计的原因在于DSL语言自身比较简单,构建语法树的过程自然就成了可选项,尽管笔者曾经一再推荐引入语法树,但这毕竟只是最佳实践,不是规范。
  2. 语义模型作为符号表。省却语法树之后,必然会导致语法分析树节点之间传递信息的难度增加。因此,通过将语义模型对象放置到ParseContext中,可以用于存储分析过程中出现的一些临时信息,起到了符号表的作用。

关于符号表的概念,笔者在第二十一篇文章已做详细阐述,前文所述为严格意义上的符号表,其功能更全面、通用性更强,例如可解决标识符作用域问题,这类符号表更适用于通用编程语言。而对于DSL而言,多数场景无需采用如此复杂的设计方案。以本章案例为例,借助临时变量即可实现信息传递,且未显著增加语法分析器的复杂度。鉴于此,无需沿用严格意义上的符号表。这一设计原则与语法树的使用逻辑一致,均需依据具体场景灵活处理。

代码8-11中引用了MatchResult类型来表示语法分析的结果,其实现如代码8-12所示:

java 复制代码
代码8-12

class MatchResult {
	List<Token> matchedTokens = new ArrayList<>();
	boolean matchSuccess = true;
	String error;

	MatchResult(List<Token> matchedTokens, String error,
					   boolean matchSuccess) {
		this.matchedTokens = matchedTokens;
		this.matchSuccess = matchSuccess;
		this.error = error;
	}

	static MatchResult success(List<Token> matchedTokens) {
		return new MatchResult(matchedTokens, null, true);
	}

	static MatchResult failed(String error) {
		return new MatchResult(new ArrayList<>(), error, false);
	}
}

字段matchedTokens存储了每一次成功匹配后的词法单元,该信息主要被用于构建语义模型,后文会对此进行说明。

至此为止,终结符子分析器、分析器抽象类及分析结果等相关类型的代码已介绍完毕,这些构成了其他子分析器的实现基础。接下来将学习与rules代码块对应的语法分析器,在此过程中还会引入其他类型的子分析器。事实上,组合子模式与抽象语法树存在相似性:前者需为每个程序构造设计对应的语法分析器;后者则以节点表示程序构造。读者可试想,若为代码8-1所示的DSL构建语法树,其结构会如何?显然,rules代码块应有对应的节点。因此,为rules代码块设计专用的语法分析器符合组合子模式的设计逻辑,这也是该模式的典型特征之一。

rules代码块语法分析器的实现如代码8-13所示:

java 复制代码
代码8-13

class RuleBlockParser extends SequenceParser {
	RuleBlockParser(String targetNode) {
		super(targetNode,
			new TerminalParser(TokenType.RULES),
			new ListParser(new NameMappingParser("NAME_MAPPING"), TokenType.END),
			new TerminalParser(TokenType.END));
	}
}

相信读者一定会惊讶于它的代码量居然会如此之少,同时也许会好奇RuleBlockParser所继承的父类SequenceParser到底是干什么的。针对第一个问题,通过代码8-13可以看到,RuleBlockParser类只是组装了一些子解析器,并没有包含具体的解析逻辑,出现代码量少的情况实属正常。通常情况下,大语法分析器的分析逻辑通常都是由更小的分析器来代理的,这也正是组合子模式最显著的特点之一。至于第二个问题,有关SequenceParser类的具体实现,笔者会在后文中给出说明。

让我们再回到RuleBlockParser的实现上来。语法分析器的组合过程发生在构造阶段,通过代码8-13可知,其需要组合两个对终结符进行解析的分析器对象(TerminalParser)和一个对列表进行解析的分析器对象(ListParser)。出现这样的组合,与RuleBlockParser所对应的语言构造有关。请读者再回看一下rules代码块的文法"RULE_BLOCK -> rules NAME_MAPPING* end",其由三部分组成:rules、 NAME_MAPPING*和end,其中rules和end是终结符,所以需要使用TerminalParser对象进行解析;NAME_MAPPING*表示0个或多个非终结符NAME_MAPPING,所对应的正好是一个列表结构,使用ListParser对象进行解析也在情理之中。同时,细心的读者应该也发现了,代码8-13中对子解析器的组合顺序和文法中符号声明的顺序是完全一致的,这也是实现组合子模式的一个重要技巧。

接下来,让我们开始SequenceParser和ListParser类型的学习。前者表示顺序结构,后者表示列表结构。笔者在前文中曾经说过,我们应该为每一个程序构造都指定一个对应的语法分析器。列表结构相对而言比较好理解,顺序结构是什么意思呢?简单来说,"顺序"意味着有序,不过这里的"有序"包含了如下两层含义:

  1. 构成顺序结构的子分析器是有序的。
  2. 内部子分析器的调度和执行是有序的。

既然顺序如此重要,那么应该在哪里对顺序信息进行指定呢?很明显,是在组合子分析器的时候。以代码8-13为例,我们是在RuleBlockParser的构造函数中进行组合操作的。读者一定要注意子分析的顺序,不可以随意变更它们的位置。幸运的是,发现顺序信息并没有什么困难,读者只需以文法作为参考即可。SequenceParser类的完整实现如代码8-14所示:

java 复制代码
代码8-14

abstract class SequenceParser extends Parser {
	Parser[] parsers;

	SequenceParser(String targetNode, Parser... parsers) {
		super(targetNode);
		this.parsers = parsers;
	}

	@Override
	void parse(ParseContext context) {
		if (!context.isPreviousMatched()) {
			return;
		}
		List<Token> matchedTokens = new ArrayList<>();
		for (Parser parser : parsers) {
			parser.parse(context);
			if (!context.isPreviousMatched()) {
				return;
			}
			matchedTokens.addAll(context.matchedTokens());
		}
		context.matchSuccess(matchedTokens);
		this.callback(matchedTokens);
	}
}

顺序结构分析器的核心逻辑全部都在parse()方法中,不过实现过程也比较简单,只需按顺序调用其包含的每一个子语法分析器的parse()方法即可。任何一个子分析器执行失败的话,都会终止当前分析流程。而当所有子分析都正确执行时,则会将匹配成功的token列表传入到context对象中进行暂存以及执行回调。那么什么是"匹配成功的"token呢?以代码8-1为例,当使用RuleBlockParser分析器对rules代码块进行分析时,匹配成功的token其实就是整个rules块所对应的词法单元(列表),如代码8-15所示:

java 复制代码
代码8-15

[
	{"type": "RULES"},
	{"type": "ID","lexeme": "resourceIsNotFreezed"},
	{"type": "ID","lexeme": "ResNotF"},
	...
	{"type": "END"}
]

需要注意的是,匹配成功的token仅仅是被暂存到context中。具体来说,当对rules解析时,暂存的内容为代码8-15所示数据;同样的道理,当对service_types代码块解析的时候,暂存的词法单元则是针对这段代码的。

未完待续......

上一章 下一章