深入解析解释器模式-学习、实现与高效使用的全指南

在这里插入图片描述​🌈 个人主页:danci_
🔥 系列专栏:《设计模式》
💪🏻 制定明确可量化的目标,并且坚持默默的做事。
🚀 转载自:探索设计模式的魅力:深入解析解释器模式-学习、实现与高效使用的全指南


探索设计模式的魅力:解析解释器模式学习、实现与高效使用全指南

一、案例场景🔍

1.1 经典的运用场景

在这里插入图片描述

    解释器模式在软件开发中具有多种经典应用场景,尤其是在需要处理复杂语法和表达式的场合。经典的应用场景展示了解释器模式在软件开发中的广泛应用。通过使用解释器模式,开发人员可以创建灵活、可扩展的解析器,以处理复杂语法和表达式,从而提高软件的可维护性和可扩展性。以下是一些典型的解释器模式应用示例:👇

  1. 表达式求值器:
    在处理复杂的数学表达式或逻辑表达式时,解释器模式非常有用。开发人员可以定义各种表达式类型的解释器(如加法、减法、乘法、逻辑与、逻辑或等),然后使用这些解释器来解析和计算表达式。这种方法使得表达式的解析和计算过程更加灵活和可扩展。
  2. 配置文件解析:
    当应用程序需要从配置文件中读取参数和设置时,解释器模式可以用来解析配置文件的内容。开发人员可以定义配置文件的语法规则,并使用解释器模式来解析和验证配置文件的内容。这种方法可以确保配置文件的格式正确,并且使得应用程序能够轻松地读取和解析配置文件。
  3. 编译器设计:
    解释器模式在编译器设计中非常常见。编译器需要将源代码(一种人类可读的编程语言)转换为机器代码(计算机可以执行的指令)。解释器模式允许开发人员为每种语言结构定义解释器,这些解释器可以逐一解析源代码,并生成相应的机器代码。选择解释器模式是因为它可以提供灵活性和可扩展性,允许编译器容易地支持新的语言特性和语法。

    下面我们来实现表达式求值器场景 🌐✏️。

1.2 一坨坨代码实现😻

    在不使用设计模式来实现表达式求值器情况,我们可以采用递归下降解析法或者栈的方式来处理。下面是一个简单的使用栈实现的四则运算表达式求值器的例子,这个例子没有使用解释器模式,但逻辑清晰。👇

import java.util.Stack;  
  
public class SimpleExpressionEvaluator {  
  
    public static int evaluate(String expression) {  
        Stack<Integer> values = new Stack<>();  
        Stack<Character> ops = new Stack<>();  
  
        for (int i = 0; i < expression.length(); i++) {  
            char ch = expression.charAt(i);  
  
            if (Character.isDigit(ch)) {  
                int num = 0;  
  
                while (Character.isDigit(ch)) {  
                    num = num * 10 + (ch - '0');  
                    i++;  
                    if (i < expression.length()) {  
                        ch = expression.charAt(i);  
                    } else {  
                        break;  
                    }  
                }  
  
                i--; // 因为for循环中有i++,所以这里需要减一  
                values.push(num);  
            } else if (ch == '(') {  
                ops.push(ch);  
            } else if (ch == ')') {  
                while (ops.peek() != '(') {  
                    values.push(applyOp(ops.pop(), values.pop(), values.pop()));  
                }  
                ops.pop(); // 弹出'('  
            } else if (isOperator(ch)) {  
                // 如果遇到操作符,解决所有运算符优先级更高或相等的先前操作符  
                while (!ops.empty() && precedence(ch) <= precedence(ops.peek())) {  
                    values.push(applyOp(ops.pop(), values.pop(), values.pop()));  
                }  
                ops.push(ch);  
            }  
        }  
  
        // 整个表达式已经解析完。现在应用剩下的操作符。  
        while (!ops.empty()) {  
            values.push(applyOp(ops.pop(), values.pop(), values.pop()));  
        }  
  
        // 结果现在在values的顶部  
        return values.pop();  
    }  
  
    // 返回c的优先级  
    public static int precedence(char ch) {  
        if (ch == '+' || ch == '-') {  
            return 1;  
        }  
        if (ch == '*' || ch == '/') {  
            return 2;  
        }  
        return 0;  
    }  
  
    // 返回一个布尔值,表示字符是否是运算符  
    public static boolean isOperator(char ch) {  
        return ch == '+' || ch == '-' || ch == '*' || ch == '/';  
    }  
  
    // 对操作数a和b应用op运算符,并返回结果  
    public static int applyOp(char op, int b, int a) {  
        switch (op) {  
            case '+':  
                return a + b;  
            case '-':  
                return a - b;  
            case '*':  
                return a * b;  
            case '/':  
                if (b == 0) {  
                    throw new UnsupportedOperationException("Cannot divide by zero");  
                }  
                return a / b;  
        }  
        return 0;  
    }  
  
    public static void main(String[] args) {  
        String expression = "100*(2+12)/14";  
        System.out.println("Expression evaluates to: " + evaluate(expression));  
    }  
}

    这个例子中,我们定义了两个栈,一个用于存储操作数(values),另一个用于存储操作符(ops)。我们遍历输入的表达式字符串,根据字符的类型执行不同的操作。如果遇到数字,我们将其解析并推入values栈中。如果遇到操作符,我们会将其与ops栈顶的操作符比较优先级,并在必要时应用栈顶的操作符。如果遇到括号,我们会相应地处理它们以确保运算顺序的正确性。
    最后,当整个表达式被解析完后,我们会应用ops栈中剩余的所有操作符,并返回values栈顶的最终结果。

    注:这个例子没有处理空格和非法字符,也没有实现错误处理机制,这些在实际应用中都是需要考虑的。此外,这个例子仅支持正整数和四则运算,不支持负数、浮点数、函数调用等更复杂的功能。如果需要支持这些功能,代码将变得更加复杂。

    上述实现对于基本的四则运算表达式求值是一个简单而有效的解决方案。虽然没有使用设计模式,但也体现出了如下优点:👇

  1. 简单直观:
    👍 该实现采用了基本的栈数据结构,以及简单的字符遍历逻辑,使得整个表达式的解析和求值过程相对直观和容易理解。
  2. 效率较高:
    👍 由于该算法避免了复杂的递归调用,而是采用了迭代的方式和栈结构来处理表达式,因此在处理大型表达式时,其性能通常优于递归下降解析法。
  3. 优先级处理:
    👍 通过维护一个操作符栈,该实现能够正确地处理操作符的优先级,从而确保表达式的正确求值。
  4. 可扩展性:
    👍 虽然当前实现仅支持四则运算和整数,但可以通过扩展applyOp方法和precedence方法来支持更多的操作符和数据类型。
  5. 错误处理:
    👍 虽然上述示例中没有详细展示错误处理,但是栈的实现方式可以很容易地添加错误检测和处理机制,例如检查除零错误、无效字符等。
  6. 内存使用:
    👍 由于只使用了两个栈来存储操作数和操作符,该实现在内存使用上也是相对高效的。

1.3 痛点

在这里插入图片描述

    上述实现虽然简单且适用于基础场景,但也存在一些缺点和局限性:
    缺点(问题)下面逐一分析:👇

  1. 缺乏错误处理:
    😂 上述代码没有实现完善的错误处理机制。例如,它假设输入表达式总是合法的,并且不包含任何无效字符或格式错误。在实际应用中,这是不太可能的,因此需要添加额外的错误检查和处理逻辑。
  2. 功能有限:
    😂 该实现仅支持基本的四则运算,并且只处理整数。它不支持浮点数、其他数学函数(如幂、根号等)、变量替换或更复杂的表达式结构。
  3. 没有处理空格和分隔符:
    😂 代码假设表达式中的数字和运算符是紧挨着的,没有空格或其他分隔符。这在现实世界的应用中可能不是一个合理的假设。
  4. 括号处理有限:
    😂 虽然代码包含了括号的基本处理,但它不支持嵌套括号或更复杂的括号表达式。
  5. 代码可读性和可维护性:
    😂 由于所有逻辑都包含在一个方法中,并且没有使用更具描述性的函数或类来封装不同的功能,因此代码的可读性和可维护性可能会受到影响。
  6. 没有用户交互:
    😂 该实现是一个简单的命令行程序,没有提供用户交互界面或友好的错误消息。在实际应用中,可能需要一个更完善的用户界面来提供更好的用户体验。
  7. 性能考虑:
    😂 虽然对于大多数简单表达式来说性能是足够的,但对于非常长或复杂的表达式,该实现可能会遇到性能瓶颈。这可以通过优化算法或使用更高效的数据结构来改善。

    上述实现虽然可以工作,但在某些方面可能违背了软件设计原则,特别是当考虑到可维护性、可扩展性和可读性时。以下是一些违反的设计原则(问题)逐一分析:👇

  1. 单一职责原则(SRP):
    🤔 该方法evaluate承担了多个职责,包括解析表达式、管理操作符栈、管理操作数栈以及执行计算。这可能会使得代码难以理解和维护。更好的做法是将这些职责分配给不同的方法或类。
  2. 开闭原则(OCP):
    🤔 当前的实现不是很容易扩展以支持新的操作符或数据类型。如果需要添加新的操作符,可能需要修改evaluate方法内部的多个地方,这违背了开闭原则,即对扩展开放,对修改关闭。
  3. 里氏替换原则(LSP):
    🤔 虽然在这个简单的例子中可能不太明显,但如果尝试通过继承来扩展这个类以支持更多的功能,可能会遇到里氏替换原则的问题。特别是如果子类引入了新的行为或改变了现有行为,可能会导致父类的方法在不知情的情况下被错误地使用。
  4. 接口隔离原则(ISP):
    🤔 该方法没有使用接口来定义其行为,这使得它与其他代码的耦合度较高。如果使用接口来定义解析器、计算器等组件的行为,将更容易在不修改现有代码的情况下替换或扩展这些组件。
  5. 依赖倒置原则(DIP):
    🤔 当前的实现依赖于具体的实现细节(如操作符的优先级和计算逻辑),而不是抽象。这使得代码更加脆弱,难以适应变化。通过使用接口和抽象类来定义这些依赖关系,可以提高代码的灵活性和可维护性。

二、解决方案🚀

2.1 定义

解释器模式:定义语言的文法,并创建一个解释器来解释该语言中的句子。

2.2 案例分析🧐

在这里插入图片描述

2.3 模式结构图及说明

在这里插入图片描述

  • 抽象表达式(AbstractExpression):
    声明一个抽象的解释操作,这个接口为抽象语法树中所有的节点共享,一般是一个interpret()方法。这个方法通常是以递归的方式来调用,用来遍历整个抽象语法树。
  • 终结符表达式(TerminalExpression):
    实现了抽象表达式的接口,并且实现了与文法中的终结符相关联的解释操作。在语法中,一个终结符表示为一个具体的字符或字符串,它不再包含其他的表达式。终结符表达式在句子中的每一个终结符都是该类的一个实例。
  • 非终结符表达式(NonterminalExpression):
    也实现了抽象表达式的接口,并且实现了与文法中的非终结符相关联的解释操作。非终结符表达式通常可以包含其他的终结符表达式或非终结符表达式,因此其解释操作一般通过递归的方式来完成。非终结符表达式在语法中通常表示为一个语法规则,它包含了其他的语法元素。
  • 环境(Context)或上下文:
    环境类有时也被称为上下文,它存储了解释器之外的一些全局信息。这些全局信息可能是解释器需要的数据,或是公共的功能。上下文通常用来存储和传递解释过程中所需的状态信息。
  • 客户端(Client):
    客户端是使用解释器模式的代码部分,它构建表示特定句子的抽象语法树,并使用解释器来解释该句子。

    在实现解释器模式时,首先需要根据所定义的语法规则来创建相应的终结符表达式和非终结符表达式类。然后,客户端根据输入的句子构建抽象语法树,并调用根节点的interpret()方法来解释句子。

2.4 使用解释器模式重构示例

    为了使用解释器模式对代码进行重构,并确保重构后的代码更加健壮、易于扩展和维护,我们首先需要明确当前代码存在的缺点,然后定义解释器模式的组成部分,并按照这些部分来逐步重构代码。

    当前代码缺点

  1. 可扩展性差: 😢 每当需要添加新的运算符或功能时,都需要修改现有的代码,这违反了开闭原则。
  2. 可维护性差: 😢 由于使用了大量的条件语句,代码变得难以阅读和理解。
  3. 错误处理不足: 😢 当前实现可能无法优雅地处理错误的输入或表达式。

    重构步骤

  1. 定义抽象表达式: 🚀 创建一个接口或抽象类,用于声明解释操作。
  2. 创建终端表达式: 🚀 为语法中的终结符(如数字)创建具体类。
  3. 创建非终端表达式: 🚀 为语法中的非终结符(如运算符)创建具体类。

    重构代码如下

  1. 抽象语法树节点接口
public interface Expression {  
    int interpret();  
}  
  1. 数字节点
public class Number implements Expression {  
    private int value;  
  
    public Number(int value) {  
        this.value = value;  
    }  
  
    @Override  
    public int interpret() {  
        return value;  
    }  
}  
  1. 加法节点
public class Plus implements Expression {  
    private Expression left;  
    private Expression right;  
  
    public Plus(Expression left, Expression right) {  
        this.left = left;  
        this.right = right;  
    }  
  
    @Override  
    public int interpret() {  
        return left.interpret() + right.interpret();  
    }  
}  
  
// 类似的,可以定义减法、乘法和除法节点...  
  1. 现在我们需要一个解释器来构建抽象语法树并执行解释操作:
public class Interpreter {  
    private Expression expression;  
  
    public Interpreter(String expr) {  
        // 这里简化了解析过程,实际情况下可能需要一个更复杂的解析器来构建AST  
        // 假设expr是一个已经格式化好的后缀表达式(逆波兰表示法),如 "3 4 + 2 *"  
        // 在真实场景中,你可能需要一个词法分析器和语法分析器来构建AST  
        this.expression = parsePostfixExpression(expr.split(" "));  
    }  
  
    // 解析后缀表达式并构建抽象语法树(简化版)  
    private Expression parsePostfixExpression(String[] tokens) {  
        Stack<Expression> stack = new Stack<>();  
        for (String token : tokens) {  
            if (isOperator(token)) {  
                Expression right = stack.pop();  
                Expression left = stack.pop();  
                switch (token) {  
                    case "+":  
                        stack.push(new Plus(left, right));  
                        break;  
                    case "-":  
                        // 类似地处理减法...  
                        break;  
                    case "*":  
                        // 类似地处理乘法...  
                        break;  
                    case "/":  
                        // 类似地处理除法(注意处理除数为0的情况)...  
                        break;  
                }  
            } else {  
                stack.push(new Number(Integer.parseInt(token)));  
            }  
        }  
        return stack.pop();  
    }  
  
    // 判断是否是操作符(简化版)  
    private boolean isOperator(String token) {  
        return token.equals("+") || token.equals("-") || token.equals("*") || token.equals("/");  
    }  
  
    // 执行解释操作并返回结果  
    public int execute() {  
        return expression.interpret();  
    }  
}

    注:上述代码中的parsePostfixExpression方法是一个简化版的解析器,它假设输入的表达式已经是后缀形式的,并且没有空格分隔符和错误处理。在实际应用中,你需要一个更健壮的词法分析器和语法分析器来处理各种边界情况和错误。

  1. 现在,你可以这样使用解释器:
public class Main {  
    public static void main(String[] args) {  
        // 创建一个解释器实例,传入一个后缀表达式字符串(这里应该是经过词法分析和语法分析后的结果)  
        Interpreter interpreter = new Interpreter("3 4 + 2 *"); // 注意:这里的表达式格式仅作示例,并不符合实际的后缀表达式规则  
        // 执行解释操作并打印结果  
        System.out.println("Result: " + interpreter.execute()); // 应该输出 14 ((3 + 4) * 2),但这里的示例代码会出错,因为"+"和"*"被视为同时出现而没有操作数分隔它们。  
    }  
}

    然而,上面的示例代码存在问题,因为它不是一个正确的后缀表达式解析器。在实际应用中,需要先定义一个正确的表达式格式(如中缀表达式或后缀表达式),然后编写相应的解析器来将字符串表达式解析成抽象语法树。此外,还需要添加错误处理机制来处理无效的表达式和运行时错误(如除数为零)。这通常涉及到更复杂的代码和算法,可能包括递归下降解析器、LL(1)解析器、LR(1)解析器等高级技术。由于这些内容超出了本文的范围,建议深入研究编译原理和解释器设计的相关资料。

2.5 重构后解决的问题👍

    优点
    上述通过解释器模式进行的代码重构解决了以下已知缺点:👇

  1. 可扩展性差:
    👏 在原始的实现中,如果需要添加新的运算符或功能,可能需要修改一个巨大的条件语句集合。这违反了开闭原则,即软件实体应该对扩展开放,对修改关闭。通过解释器模式,我们定义了抽象表达式和具体的终端表达式、非终端表达式,使得添加新的运算符或功能变得简单,只需要创建新的表达式类并实现解释操作,而无需修改现有的代码。
  2. 可维护性差:
    👏 原始代码中的大量条件语句使得代码难以阅读和理解,维护起来非常困难。重构后的代码结构清晰,每个表达式类都负责处理一种特定的语法结构,使得代码更加模块化,易于阅读和维护。
  3. 错误处理不足:
    👏 原始的实现可能无法优雅地处理错误的输入或表达式。在解释器模式中,我们可以在上下文类中存储错误信息,或在表达式类中抛出异常来实现更强大的错误处理机制。虽然上述示例代码中没有明确展示这一点,但解释器模式为错误处理提供了更好的框架。

  遵循的设计原则
    上述通过解释器模式进行的代码重构遵循了以下设计原则:

  1. 单一职责原则(Single Responsibility Principle, SRP):
    ✈️ 每个类(如终端表达式、非终端表达式)都只负责一项功能或行为。例如,Number 只负责解释数字,而 Plus 只负责加法运算。
  2. 开闭原则(Open-Closed Principle, OCP):
    ✈️ 代码对扩展开放,对修改关闭。通过定义抽象表达式和具体的表达式类,我们可以在不修改现有代码的情况下添加新的运算符或功能,只需创建新的类并实现相应的接口。
  3. 里氏替换原则(Liskov Substitution Principle, LSP):
    ✈️ 在解释器模式中,子类型(具体的表达式类)必须能够替换它们的基类型(抽象表达式)而不会产生错误或异常行为。这意味着客户端代码可以无差别地使用抽象表达式或任何具体的表达式类。
  4. 接口隔离原则(Interface Segregation Principle, ISP):
    ✈️ 解释器模式中的抽象表达式通常定义了一个小而专一的接口,这使得具体的表达式类只需要实现它们真正关心的方法,而不是一个大而全的接口。
  5. 依赖倒置原则(Dependency Inversion Principle, DIP):
    ✈️ 高层模块(如客户端或抽象的非终端表达式)不应该依赖于低层模块(如具体的终端或非终端表达式),它们都应该依赖于抽象。在上述实现中,客户端通过抽象表达式接口与具体的表达式类交互,而不直接依赖于具体的实现。
  6. 组合复用原则(Composite Reuse Principle, CRP):
    ✈️ 这一原则鼓励通过组合(而非继承)来实现代码复用。在解释器模式中,非终端表达式类(如加法、减法等)通过组合其他表达式对象(可能是终端表达式或非终端表达式)来构建复杂的表达式。

    缺点
    通过解释器模式进行的代码重构确实有其优点,如提高代码的可扩展性、灵活性和可维护性等。然而,这种模式也并非没有缺点。以下是一些可能存在的缺点:👇

  1. 执行效率较低:
    💔 解释器模式通常使用大量的循环和递归调用来解释和执行语句。当要解释的句子较为复杂时,其运行速度可能会变慢,且代码的调试过程也可能比较麻烦。此外,递归调用可能导致栈溢出等问题。
  2. 可能引起类膨胀:
    💔 在解释器模式中,每条规则至少需要定义一个类。当包含的文法规则很多时,类的数量将急剧增加,这可能导致系统难以管理与维护。类膨胀不仅增加了系统的复杂性,还可能影响系统的性能。
  3. 可应用场景有限:
    💔 解释器模式通常适用于需要定义语言文法的场景。然而,在软件开发中,这样的应用实例相对较少。因此,这种模式可能并不适用于所有情况,需要根据具体需求进行选择。
  4. 难以处理复杂的文法:
    💔 当文法规则变得非常复杂时,使用解释器模式可能会变得非常困难。此时,可能需要引入更多的类和接口来处理各种语法规则和语义动作,这进一步增加了系统的复杂性和维护成本。

    注:本文只转载部分内容,三连 或 更多请跳转原文。

  • 28
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值