65.9K
CodeProject 正在变化。 阅读更多。
Home

使用解释器模式解析代数表达式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (20投票s)

2008 年 3 月 5 日

CPOL

12分钟阅读

viewsIcon

104824

downloadIcon

1127

如何使用解释器模式解析代数表达式。

引言

解释器模式用于使用一组类来建模语法,这些类代表语法中可能的表达式。例如,对于代数表达式 4 + 4,可以使用 AddExpression 类来表示该表达式。AddExpression 类将包含对两个操作数的引用以及评估表达式并基于两个操作数返回值的能力。

不深究文字,您可以看到代数表达式的各种可能形式如何使用类来表示。此外,表达式本身可以作为其他表达式的操作数,这取决于定义的运算顺序。

当涉及表示变量表达式时,情况会变得棘手一些,因为变量表达式的求值会根据变量的当前值而改变。解释器模式通过定义一个上下文类来提供解决此问题的简洁方案,该类提供可能出现在正在求值的表达式中的变量的当前值。

在本文中,我将展示如何使用解释器模式开发一个功能齐全的代数表达式解析器。这是一个易于处理且非微不足道的任务,使其成为演示解释器模式的绝佳候选。此外,还会自然出现使用其他设计模式(如工厂、模板方法和组合模式)的实例,这使其成为演示设计模式总体纪律使用的更好候选。

不应忘记关于设计模式的标准警告。它们只能在适当的地方应用。对于非微不足道的复杂问题,好的解决方案几乎不可能遵循理想的面向对象设计。权衡取舍是不可避免的。通常,简单的设计比复杂的设计更好,而包含一堆设计模式的设计通常是最复杂的。

总体结构

项目的结构遵循编译器术语中解析器的通用结构。在高层,解析器将定义明确的语法的字符序列转换为可以在以后求值的数据中间结构,同时根据输入的变化而变化。在低层,解析过程的第一步是将字符序列转换为标记序列。此步骤称为词法分析。下一步是将标记序列转换为可求值的数据中间结构。

AXTokenizer 类负责将初始字符序列转换为标记序列。如果字符序列不是有效的标记序列,则会抛出异常。如果字符序列是有效的标记序列,下一步是 AXValidator 类执行的常规验证。

一旦获得有效的标记序列,ExpressionFactory 将用于从标记序列创建一系列 Expression 对象。然后,AXParser 类中的代码会对表达式序列进行求值,以通过 Expression 对象树的形式生成一个 Expression 对象,该对象表示输入的代数表达式。这个表达式树是解析器生成的数据中间结构。ExpressionContext 类作为表达式树的输入,并传递给叶子表达式,通过替换的方式进行求值。

表达式基类

所有 Expression 类都包含在 Expressions.cs 文件中。每个 Expression 类都代表我们语法中可能的表达式,在本例中是代数。所有 Expression 类都派生自抽象基类 Expression。我选择抽象基类而不是接口,因为我想提供一些通用实现,同时要求派生类实现某些函数。

Expression 类为 IsBoundBindEvaluate 方法提供了实现,并期望在派生类中定义 InnerBindInnerEvaluate 方法。InnerBind 方法接受参数并将它们绑定到将由 InnerEvaluate 方法求值。IsBound 方法指示是否已将参数绑定到操作数。当解析时,IsBound 方法的实用性将显而易见。至于 EvaluateInnerEvaluate 方法,它们都接受一个 ExpressionContext 对象作为参数。最终会利用 VariableExpression 对象,如下文所述。

一个障碍是 System.Math 库的运行方式。通常,超越函数(无法通过代数方法求值的函数,如三角函数)通过项的总和来近似。从微积分的角度来看,可以通过相应的无限级数获得三角函数的精确值。由于计算机无法计算无限级数的结果,因此必须近似三角函数的值。问题是,结果是 Math.Cos(Math.PI/2.0) 返回一个非常小的非零数,这是不正确的。

我选择使用模板方法模式。在这种情况下,模板方法是 InnerEvaluate。每个 Expression 派生类都可以实现它选择的任何数学运算,并返回结果,而无需担心零问题。Evaluate 方法本身会将 ZERO_THRESHOLD 常量以下的所有值转换为零。Bind 方法遵循相同的模式,InnerBind 方法作为模板方法。这提供了一定的对称性,并确保正确设置了 _isBound 成员。

以下是 Expression 类的实现。

public abstract class Expression
{
    protected readonly double ZERO_THRESHOLD = 1.0 * Math.Pow(10.0, -14.0);

    protected bool _isBound;

    public Expression()
    {
        _isBound = false;
    }

    public bool IsBound()
    {
        return _isBound;
    }

    public void Bind(params object[] arguments)
    {
        InnerBind(arguments);
        _isBound = true;
    }

    public double Evaluate(ExpressionContext context)
    {
        double result = InnerEvaluate(context);

        if (Math.Abs(result) <= ZERO_THRESHOLD)
            return 0;
        else
            return result;
    }

    protected abstract void InnerBind(params object[] arguments);
    protected abstract double InnerEvaluate(ExpressionContext context);
}

这里的情况并不十分完美。专业的代数表达式解析器将不得不使用一个更好的数学库,该库从三角函数返回已知的零值。这也可以在各个表达式类中完成,但这会使工作不必要地复杂化。

此外,Expression 类的设计也可以受到质疑。装饰器模式是一种将一个类包装在另一个类中,该类实现相同的接口,但只是将调用委托给内部类,然后添加自己的行为到结果中的好模式。在这种情况下,装饰器模式不能使用,因为它隐藏了它所装饰类的实际类型。通常,OOD 文档会警告不要编写与类类型相关的代码。然而,在这种情况下,利用 ExpressionFactory 就没有那么简洁了。这是一个决策不是非黑即白,并且正在做出权衡的领域。

表达式派生类

Expression 派生类包括 ConstantExpressionNumericExpressionBinaryExpressionFunctionExpression。这些类中的每一个都实现了 InnerBind 方法,该方法接受可变数量的参数并将它们绑定到内部维护的操作数。当调用 Bind 方法时,会依次调用 InnerBind 方法,并将 Expression 视为已绑定。当调用 Evaluate 方法时,会依次调用 InnerEvaluate 方法,此时会对绑定的操作数进行运算以产生结果。

例如,以下是派生自 BinaryExpressionAddExpression 类的定义。

public sealed class AddExpression : BinaryExpression
{
    protected override double InnerEvaluate(ExpressionContext context)
    {
        return _operand1.Evaluate(context) + _operand2.Evaluate(context);
    }
}

另一个例子是,以下是派生自 FunctionExpressionCosExpression 类的定义。

public sealed class CosExpression : FunctionExpression
{
    protected override double InnerEvaluate(ExpressionContext context)
    {
        return Math.Cos(_operand.Evaluate(context));
    }
}

ConstantExpression 对象始终被视为已绑定,并且它们的 InnerEvaluate 方法始终返回相同的值。NumericExpression 对象同样简单,只有一个操作数被绑定,并且是 InnerEvaluate 方法返回的值。

VariableExpression 和 ExpressionContext

总的来说,Expression 派生类都很简单,除了 VariableExpression,它是唯一一个利用 Evaluate 方法的 ExpressionContext 参数而不是简单地将其传递给操作数的 Evaluate 方法的表达式。

ExpressionContext 类维护一个字典,该字典将一个双精度值与一个 Variable 枚举值关联起来。虽然 ExpressionContext 不派生自 Expression,但它有自己的 Bind 方法来关联变量的值。可以通过在 ExpressionContext 对象上执行查找来获取与变量关联的值。以下是 Lookup 方法的代码。

public double Lookup(Variable variable)
{
    if (_bindings.ContainsKey(variable))
        return _bindings[variable];
    else
        return double.NaN;
}

当在 VariableExpression 对象上调用 Evaluate 方法时,会在 ExpressionContext 参数上执行查找,然后返回该值。以下是 VariableExpression 类的定义。

public sealed class VariableExpression : Expression
{
    private Variable _variable;

    protected override void InnerBind(params object[] arguments)
    {
        _variable = (Variable)arguments[0];
    }

    protected override double InnerEvaluate(ExpressionContext context)
    {
        return context.Lookup(_variable);
    }
}

分词

在介绍了 Expression 类之后,是时候看看字符序列如何精确地转换为表达式序列了。如上所述,此过程的第一步是标记化。AXTokenizer 类负责标记化字符序列。总而言之,AXTokenizer 是一个如此简单的类,以至于将此步骤称为词法分析似乎是错误的。

标记被定义为匹配给定正则表达式模式的字符序列。AXTokenizer 类有两种用于添加和删除模式的方法,分别是 AddPatternRemovePattern。它还有一个额外的用于根据当前模式集执行实际标记的方法,即 TokenizeTokenize 接受一个字符串并返回一个字符串列表,表示原始字符串中的标记序列。

我希望此步骤完全确定性,忽略可能出现的任何警告。例如,区分数字的否定运算和减法运算。考虑到这一点,Tokenize 方法首先倾向于匹配较长的字符串到模式,然后迭代地尝试匹配越来越小的子字符串到模式。在此阶段,我没有发现需要关注性能,因为 Tokenize 不是一个需要重复调用的方法。

以下是 Tokenize 方法的实现。

public List<string> Tokenize(string text)
{
    List<string> tokens = new List<string>();

    for (int i = 0; i < text.Length; i++)
    {
        bool matched = false;
        for (int j = text.Length - i; j > 0 && !matched; j--)
        {
            foreach(KeyValuePair<string,Regex> pair in _patterns)
            {
                if (pair.Value.IsMatch(text.Substring(i, j)))
                {
                    matched = true;
                    tokens.Add(text.Substring(i, j));
                    i += j - 1;
                    break;
                }
            }
        }

        if (!matched)
        {
            throw new AXTokenException(i, 
                "Unrecognized character sequence starting at index " + 
                i.ToString() + ".");
        }
    }

    return tokens;
}

ExpressionFactory

一旦从 AXTokenizer.Tokenize 方法获得标记序列,就需要将其转换为相应的 Expression 派生类对象的序列。这借助 ExpressionFactory 类来完成。

就像 AXTokenizer 类有用于模式的添加和删除方法一样,ExpressionFactory 类也有用于添加和删除模式与 Expression 派生类类型之间关联的方法,分别是 AddAssociationRemoveAssociation。此外,它定义了一个 Create 方法,该方法基于标记参数创建一个 Expression 派生类类型的实例。

通过迭代 AXTokenizer 返回的标记列表,并对适当初始化的 ExpressionFactory 对象调用 Create 方法,就可以获得一个表达式序列。从技术上讲,ExpressionFactory简单工厂模式的一个实例。以下是 Create 方法的代码。

public Expression Create(string token)
{
    if (!string.IsNullOrEmpty(token))
    {
        foreach (KeyValuePair<string, KeyValuePair<Regex,Type>> pair in _associations)
        {
            if (pair.Value.Key.IsMatch(token))
            {
                ConstructorInfo info = pair.Value.Value.GetConstructor(Type.EmptyTypes);
                return (Expression)info.Invoke(null);
            }
        }
    }

    return null;
}

解析

您可能已经注意到 AXTokenizerExpressionFactoryAXValidator 类都标记为 internal。那么,它们的设施是如何使用的呢?AXParser 类将所有这些联系在一起。

AXParser 有一个 AXTokenizer 成员和一个 ExpressionFactory 成员。在构造时,这些成员被初始化以支持最常见的代数表达式。AddAssociation 方法允许客户端通过为 ConstantExpressionFunctionExpression 类型添加新关联来扩展此功能。

除了 AddAssociation 方法之外,AXParser 还有一个公共方法 ParseParse 接受一个字符串并返回一个 Expression 对象。这个 Expression 对象就是表达式树,代表作为参数传入的完全解析的代数表达式。现在可以为独立变量的任何赋值来求值代数表达式。这通过构造一个 ExpressionContext 对象,为每个独立变量调用 ExpressionContext.Bind 方法,并将对象作为参数传递给返回的 Expression 对象的 Evaluate 方法来完成。

Parse 方法要做的第一件事是删除输入字符串中的空格。之后,它使用 AXTokenizer.Tokenize 方法将字符串转换为标记序列。然后,如上所述,它将标记序列转换为表达式序列。一旦获得表达式序列,就会使用 AXValidator 来验证它。此时,表达式序列已准备好构建成一个单一的表达式树。

表达式树的构建在一个 `while` 循环中进行,该循环一直迭代到列表中只剩下一个 Expression。在进入循环之前和每次循环迭代之后,都会通过调用 RemoveExcessParens 方法来删除多余的括号。在每次循环迭代中,最高优先级运算的索引由调用 DetermineHighestPrecedence 方法确定,然后将该索引作为参数传递给 CollapseExpression 方法。

为了对解析过程有一个高层视图,以下是 Parse 方法的定义。

public Expression Parse(string text)
{
    string copy = text.Replace(" ", string.Empty).Trim();

    List<string> tokens = _tokenizer.Tokenize(copy);
    List<Expression> expressions = TokensToExpressions(tokens);

    AXValidator.Validate(expressions); //throws

    RemoveExcessParens(expressions);
    while (expressions.Count > 1)
    {
        int i = DetermineHighestPrecedence(expressions);
        CollapseExpression(expressions, i);
        RemoveExcessParens(expressions);
    }

    return expressions[0];
}

前面我提到了 Expression.IsBound 函数。通过查看下面的 CollapseExpression 方法的实现,现在可以理解为什么这个函数是必需的。首先,一旦一个表达式被绑定,你就可以对其 Evaluate 方法进行有效调用。考虑到这一点,一个已绑定的表达式就等同于一个 ConstantExpression 对象,并在后续解析中应如此看待。例如,注意if语句中对 IsBound 方法的调用。

private void CollapseExpression(List<Expression> expressions, int i)
{
    Expression current = expressions[i];
    Expression previous = new NullExpression();
    Expression next = new NullExpression();
    if (i - 1 >= 0)
        previous = expressions[i - 1];
    if (i + 1 < expressions.Count)
        next = expressions[i + 1];

    if (current is SubtractExpression && !previous.IsBound() && !(
        previous is RightParenExpression))
    {
        SubtractExpression expression = (SubtractExpression)current;
        NumericExpression zero = new NumericExpression();
        zero.Bind(0.0);
        expression.Bind(zero, next);
        expressions.RemoveAt(i + 1);
    }
    else if (current is FunctionExpression)
    {
        FunctionExpression expression = (FunctionExpression)current;
        expression.Bind(next);
        expressions.RemoveAt(i + 1);
    }
    else if (current is BinaryExpression)
    {
        BinaryExpression expression = (BinaryExpression)current;
        expression.Bind(previous, next);
        expressions.RemoveAt(i + 1);
        expressions.RemoveAt(i - 1);
    }
}

IsBound 方法的相同考虑因素也体现在 DetermineHighestPrecedence 方法的实现中。一个已绑定的 Expression 对象有效地被视为一个 ConstantExpression

另一个需要认识到的重要点是 DetermineHighestPrecedenceCollapseExpression 方法如何处理否定情况。否定就是从零中减去。这个决定遵循奥卡姆剃刀原则,“实体不应被不必要地增加。” 我认为我在这里呈现的内容已尽可能接近最优的简洁。

如何使用

在文章的开头,我包含了一个指向项目文件的链接。该项目编译成一个名为 AXLibrary.dll 的类库。我没有包含演示项目,因为我希望在关于 The Code Project 的 WPF 3D 图形计算器文章中使用 AXLibrary 库。此外,使用该库非常简单。以下是如何使用该库的示例。

AXParser parser = new AXParser();

Expression expression = parser.Parse("cos(PI/4)*(5+x)");

ExpressionContext context = new ExpressionContext();

context.Bind(Variable.x, 4);

MessageBox.Show(expression.Evaluate(context).ToString());

该项目在 Visual Studio 2008 中针对 .NET Framework 版本 3.5 进行编译。但是,由于它使用了 C# 语言的标准功能,该项目可以轻松转换为任何其他 .NET Framework 版本。不幸的是,您将不得不先创建一个容器项目并手动复制文件,因为据我所知,没有将项目转换为旧版本的工具。

历史

  • 2008 年 3 月 5 日 -- 发布原始版本
© . All rights reserved.