【设计模式】解释器模式

166 阅读8分钟

本文主要介绍解释器模式的概念和用法。

模式背景

类似于于Java,Cpp这类的语言,他们无法处理类似1+2-3这种语句的直接计算,但是如果我们需要这样的功能,该怎么实现呢?这就是需要解释器模式。这是一个比较复杂同时也相对比较冷门的设计模式,实际应用中很少,因为一般涉及到解释器模式的项目应该都是比较大的工程。

如果我们用过Scala或者Python应该知道他们都带了一个Shell工具,我们可以直接在Shell中输入一些简单的语句,他们能帮我们自动计算好,比如下面所示:

$ python
Python 3.7.1 (default, Dec 14 2018, 13:28:58)
[Clang 4.0.1 (tags/RELEASE_401/final)] :: Anaconda, Inc. on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 12 * 3 + 2
38

像这种功能,如果使用Python语言编写的话,需要自己定义变量,然后再通过式子运算等一系列操作。但是Python Shell就可以很方便的求出结果。我们可以使用解释器模式来实现这种功能,可以粗略的说,解释器模式就是为了在当前的语言上层构建一个新的语言(只是这个语言都很简单),这套语言拥有自己的规则文法。

定义&概念

解释器模式:就是给定一个语言的文法表示,并且建立一个解释器,用来解释语言中的句子。解释器模式描述了怎样在有了一个简单的文法后,使用模式设计解释这些语句。解释器模式是一种类行为型模式。

原理

解释器模式比较复杂,我们奔着目的去:外部给了我们一套文法规则,以及一条语句。我们使用手头上的语言(比如Java)能够去解释这个语句(可以是按照语句执行具体操作指令,也可以是用自己的话翻译一下提供的语句)。

首先从语句表达式说起

1+2+3-4 我们假设就给了这样的一条语句。一个表达式是由一系列终结符表达式非终结符表达式组成。终结符就是最小的不可分割的单元,比如3,+这些符号都是终结符。非终结符则是相当于表达式的一个子句,以及这个表达式都可以算一个非终结符(或者叫非终结表达式),比如1+2算一个非终结符。

抽象语法树(AST)

我们可以通过给定的句子来构建一个抽象语法树,可以更直观的区分终结符表达式和非终结符表达式。比如上式子可以表示为:

可以看到加减号连接着左右,所以他们所连接在一起为非终结的表达式。而数字都是终结表达式。

解释器模式

所谓解释器模式,就是外界给定的文法,我们写出的程序能够解释出这段文法的意思。所以我们首先要明白文法的具体规则,然后就是区分好里面的终结符表达式和非终结符表达式。终结符表达式和非终结符表达式的区分是解释器模式的重点,不能粗浅的认为加减号用来连接数字的所以它就是非终结表达式,数字不起到连接的作用就是终结表达式,实际使用中还需要灵活多变。

组成要素

  • 抽象表达式(Abstract Expression)

    • 解释器统一抽象,约定解释器的解释操作,是终结符表达式和非终结符表达式的父类,主要包含解释方法 interpret()。
  • 终结符表达式(Terminal Expression)

    • 是抽象表达式的子类,用来实现文法中与终结符相关的操作,文法中的每一个终结符都有一个具体终结表达式与之相对应。
  • 非终结符表达式(Nonterminal Expression)

    • 也是抽象表达式的子类,用来实现文法中与非终结符相关的操作,里面可以包含终结符表达式,也可以继续包含非终结符表达式,让下游去处理终结符表达式。
  • 环境类(Context)

    • 通常包含各个解释器需要的数据或是公共的功能,一般用来传递被所有解释器共享的数据,后面的解释器可以从这里获取这些值。

UML

实现

这里举一个例子"LOOP 2 PRINT i SPACE PRINT am SPACE PRINT justin BREAK END PRINT hello SPACE PRINT world"。意思很简单:LOOP循环打印2次i am justin,BREAK表示换行,然后接着输出hello world

  • 第一步,分析文法整理出文法规则:
文法说明
expression : command *一个表达式是多个命令组成的
command : loop | primitive大写部分都是命令,命令分2块:LOOP以及一些基本命令
loop : 'LOOP number' expression 'END'一个LOOP命令的表达式,LOOP和END为一组成对出现
primitive : 'print string' | 'space' | 'break'基本命令:这三个基本命令可以作为不可分割的单元了

依据这规则我们可以区分出终结和非终结表达式:前三个都是非终结的,最后一个是终结的。

  • 第二步,设计Context类:
public class Context {
    //StringTokenizer将字符串分割为单个word,每个word叫做token
    private StringTokenizer tokenizer;
    //当前字符串标记
    private String currentToken;

    public Context(String text) {
        this.tokenizer = new StringTokenizer(text);
        nextToken();
    }

    //返回下一个标记
    public String nextToken() {
        if (tokenizer.hasMoreTokens()) {
            currentToken = tokenizer.nextToken();
        } else {
            currentToken = null;
        }
        return currentToken;
    }

    //返回当前的标记
    public String currentToken() {
        return currentToken;
    }

    //跳过标记,说明这里语法有问题
    public void skipToken(String token) {
        if (!token.equals(currentToken)) {
            System.out.println("错误提示:" + currentToken + "解释错误!");
        }
        nextToken();
    }

    //如果当前的标记是数字,返回其数组
    public int currentNumber() {
        int number = 0;
        try {
            number = Integer.parseInt(currentToken);
        } catch (NumberFormatException e) {
            System.err.println("错误提示:" + e);
        }
        return number;
    }
}

Context的类的作用主要就是处理字符串,提供一系列的方法方便我们获取当前处理到哪个字符命令上,以及跳过改名了,取的下一个命令等操作。

  • 第三步,定义抽象表达式
public abstract class Node {
    //解释语句的方法
    public abstract void interpret(Context context);
    //执行命令
    public abstract void execute();
}
  • 第四步,定义终结符表达式:
// 基本命令,最简单的命令,终结符表达式
public class PrimitiveCommandNode extends Node {
    private String name;
    private String text;

    @Override
    public void interpret(Context context) {
        name = context.currentToken();
        context.skipToken(name);
        if (!name.equals("PRINT") && !name.equals("BREAK") && !name.equals("SPACE")) {
            System.err.println("非法命令!");
        }
        if (name.equals("PRINT")) {
            text = context.currentToken();
            context.nextToken();
        }
    }

    @Override
    public void execute() {
        if (name.equals("PRINT")) {
            System.out.print(text);
        } else if (name.equals("SPACE")) {
            System.out.print(" ");
        } else if (name.equals("BREAK")) {
            System.out.println();
        }
    }
}

我们对终结符表达式做了一个通用模板,终结符表达式涉及到了程序真正的输出,所有输出行为在execute中执行。一个PrimitiveCommandNode即命令和该命令对应的输出组成的一个tuple。

  • 第五步,定义非终结符表达式

非终结符这里有三层:

  1. 第一层表示一个子句,一个END结果我们可以看做是一个子句
  2. 第二层是命令,从Context获取到当前的命令类型是普通命令,还是LOOP命令来创建下一个子级。
  3. 第三层是循环,获取循环需要的信息,执行循环的逻辑。

这三层将一个非终结符不断的拆分更细,一直到解释到非终结符上去。

ExpressionNode:

public class ExpressionNode extends Node {

    //存储一个子表达式的命令
    private ArrayList<Node> list = new ArrayList<>();

    //第一级别解释,循环的取出所有的token
    @Override
    public void interpret(Context context) {
        while (true) {
            if (context.currentToken() == null) {
                //空的串,那就退出。
                break;
            } else if ("END".equals(context.currentToken())) {
                context.skipToken("END");
                break;
            } else {
                Node commandNode = new CommandNode();
                commandNode.interpret(context);
                list.add(commandNode);
            }
        }
    }

    @Override
    public void execute() {
        Iterator iterator = list.iterator();
        while (iterator.hasNext()) {
            ((Node) iterator.next()).execute();
        }
    }
}

这个Node主要就是不断遍历输入表达式的词项(token),然后交给CommandNode,让CommandNode来对命令做分类处理。最终处理完,会将所有的命令存在list中,我们只需要迭代执行这个list,将结果输出。

CommandNode:

public class CommandNode extends Node {
    private Node node;

    @Override
    public void interpret(Context context) {
        if (context.currentToken().equals("LOOP")) {
            node = new LoopCommandNode();
            node.interpret(context);
        } else {
            node = new PrimitiveCommandNode();
            node.interpret(context);
        }
    }

    @Override
    public void execute() {
        node.execute();
    }
}

这个类的主要作用就是ExpressionNode遍历到了一个命令,给到该类来区分这个命令是普通命令还是LOOP命令。

LoopCommandNode:

public class LoopCommandNode extends Node {

    private int number;
    private Node commandNode;

    @Override
    public void interpret(Context context) {
        context.skipToken("LOOP");
        number = context.currentNumber();
        context.nextToken();
        commandNode = new ExpressionNode();
        commandNode.interpret(context);
    }

    @Override
    public void execute() {
        for (int i = 0; i < number; i++) {
            commandNode.execute();
        }
    }
}

当命令是LOOP的时候,我们需要获取后面的一个数字表示要循环几次,然后还需要获取后面遍历的表达式,所以递归去使用ExpressionNode获取后面表达式,一旦遇到END这个递归栈就结束了,然后回到ExpressionNode处理下面的命令。

  • 最后测试
public class InterpreterDemo {
    public static void main(String[] args) {
        String text = "LOOP 2 PRINT i SPACE PRINT am SPACE PRINT justin BREAK END PRINT hello SPACE PRINT world";
        Context context = new Context(text);
        Node node = new ExpressionNode();
        node.interpret(context);
        node.execute();
    }
}
/** output:
i am justin
i am justin
hello world
*/
  • UML结构

这是书上的一个例子,个人觉得这个例子有点深了,看的有点晕晕乎乎的,对解释器模式的描述不是很直观。但是这个应该更贴近实际使用场景,所以还是用了这个例子。

优缺点

优点

  • 易于改变和扩展文法,实现文法比较容易。
  • 解释器扩展性好,如果添加新的表达式,只需要添加对应的终结和非终结表达式即可。

缺点

  • 对于复杂文法难以维护。
  • 执行效率太低,里面存在大量循环和递归,执行速度可能会很慢。

使用场景

  • 可以将一个需要解释执行的语言中的句子表示为一个抽象语法树。
  • 一些重复出现的问题可以用一种简单的语言来表达。
  • 一个简单语法需要解释的场景。

总之,这些场景其实也很特殊,一般项目中我们并不会用到,而且这个模式写起来也很复杂。

总结

解释器模式为自定义语言的设计和实现提供了一种解决方案,它用于定义一组文法规则并通过这组文法规则来解释语言中的句子。虽然解释器模式的使用频率不是特别高,但是它在正则表达式XML文档解释等领域还是得到了广泛使用。

相关代码:github.com/zhaohaoren/…

如有代码和文章问题,还请指正!感谢!