表达式计算器再探(100% 托管 .NET 中的 Eval 函数)






4.98/5 (162投票s)
这篇关于 .NET 中表达式求值的第二篇文章介绍了一个预编译表达式的解析器
引言
大多数应用程序需要在运行时计算公式。.NET Framework 尽管支持高级编译,但并没有提供一个快速轻量级的 eval
函数。本文介绍了一个可用的 eval
函数,它具有一些不常见的强大功能:
- 快速、单遍解析器
- 高度可扩展;您可以添加自己的变量和函数,而无需更改库本身
- 支持常见优先级:2+3*5 返回 2+(3*5)=17
- 支持布尔运算,即“and”、“not”、“or”
- 支持比较运算符,即 <=、>=、<>
- 支持数字、日期、字符串和对象
- 支持调用对象属性、字段和方法
- 无需重新解析即可多次运行表达式
- 可以自动检测何时需要重新计算表达式
- 在开始计算之前,对表达式语法进行完整检查
- 完全人工可读代码——不是使用 Lex/Yacc 工具生成的——因此如果需要,允许修改计算器的核心语法
本文还尝试解释整个工作原理。
为什么要用解释器?
人们经常告诉我,他们的应用程序中没有计算器的一席之地,因为它对用户来说太复杂了。我不同意这种观点。计算器是一种廉价的方式,可以为普通用户隐藏复杂性,并为高级用户提供强大的功能。让我们举个例子。
在您的应用程序中,您允许用户选择窗口的标题。这很方便和简单;它只是一个文本框,他们可以在其中输入任何内容。当一些用户想要更多时,困难就来了。假设他们想要查看他们的用户 ID 或时间。那么您有 3 种选择
- 您在程序中添加一个表单,并为用户提供更多可显示内容的选项。
- 您不这样做。
- 您使用一个计算器(即我的计算器)。
第一种选择需要您做大量工作,并且可能会让更基础的用户感到困惑。第二种选择不会让您的用户感到困惑,但可能会失去更高级的用户。第三种选择是理想的,因为您可以保留文本框,并让高级用户输入他们想要的任何内容。
用户窗口标题:%[USERID],时间是 %[NOW]
这样就完成了。界面仍然使用常规文本框,并不复杂。在编码方面,需要添加的并不多。在功能方面,您每天都可以添加一个新变量,只要您全部记录下来,您的用户就会保持满意。
为什么不使用 .NET Framework 内置编译器?
使用 .NET Framework 编译功能似乎是制作计算器最明显的方法。然而,在实践中,这种技术有一个令人讨厌的副作用。它看起来像是在每次计算函数时都在内存中创建一个新的 DLL,并且似乎几乎不可能卸载该 DLL。您可以参阅文章末尾的评论通过在运行时编译 C# 代码来计算数学表达式以获取更多详细信息。
如果您想要完整的 VBScript 或 C# 语法,使用其他引擎或应用程序域是一个选择。如果您需要编写类和循环,这可能就是您需要做的。此计算器既不使用 CodeDOM,也不尝试编译 VB 源。它逐字符解析表达式,并在不使用任何第三方 DLL 的情况下计算其值。
使用代码
计算器只需两行代码即可运行
在VB中
Dim ev As New Eval3.Evaluator
MsgBox(ev.Parse("1+2+3").value)
在C#中
Eval3.Evaluator ev = new Eval3.Evaluator(
Eval3.eParserSyntax.c,/*caseSensitive*/ false);
MessageBox.Show(ev.Parse("1+2+3").value.ToString());
为计算器提供变量或函数
默认情况下,计算器不再定义任何函数或变量。这样,您就可以真正决定您希望计算器理解哪些函数。要扩展计算器,您需要创建一个类。下面是一个 VB 示例;Zip 文件中提供了 C# 版本。
Public Class class1 Public field1 As Double = 2.3 Public Function method2() As Double Return 3.4 End Function Public ReadOnly Property prop3() As Integer Get Return 4.5 End Get End Property End Class
请注意,只有公共成员可见。
用法
Dim ev As New Eval3.Evaluator ev.AddEnvironmentFunctions(New class1) MsgBox(ev.Parse("field1*method2*prop3").value.ToString)
您还可以使用更动态的版本。我不太喜欢这种方法,但它可能很有用。请注意,扩展的值一旦解析就可以更改,但类型不应更改。
Public Class class2 Implements iVariableBag Public Function GetVariable( ByVal varname As String) As Eval3.iEvalTypedValue _ Implements Eval3.iVariableBag.GetVariable Select Case varname Case "dyn1" Return New Eval3.EvalVariable("dyn1", 1.1, _ "Not used yet", GetType(Double)) End Select End Function End Class ev.AddEnvironmentFunctions(New class2) Dim code As opCode = ev.Parse("dyn1*field1") MsgBox(code.value & " " & code.value)
可识别的类型
计算器可以处理任何对象,但它只允许在常用类型上使用通用运算符(+ - * / and or)。在内部,我使用这些类型:
enum evalType number will convert from integer, double, single, byte, int16... boolean string date equivalent to datetime object anything else
计算器中有一个共享函数可以以字符串形式返回所有这些类型
Evaluator.ConvertToString(res)此函数将使用默认格式返回每种类型。
这一切是如何运作的?
如果您只想使用该库,请参阅“使用代码”部分。以下部分仅适用于好奇并想了解其工作原理的人。我使用的技术相当传统,我希望它们能很好地介绍编译理论。
该计算器由一个经典的 Tokenizer 后面跟着一个经典的 Parser 组成。我用 VB 编写了它们,没有使用任何 Lex 或 Bisons 工具。目标是可读性而不是速度。分词、解析和执行都在一次通过中完成。这很优雅,同时效率也很高,因为计算器从不向前或向后看超过一个字符。
分词
计算器需要做的第一件事是将您提供的字符串拆分为一组标记。此操作称为分词,在我的库中,它由一个名为 tokenizer
的类完成
分词器逐个字符地读取,并根据遇到的字符改变其状态。当它识别出其中一种标记类型时,它将其返回给解析器。如果它不识别某个字符,它将引发语法错误异常。一旦使用此命令创建了该类,
tokenizer = new Tokenizer("1+2*3+V1")
...计算器将只访问 tokenizer.type 来读取字符串中第一个标记的类型。返回的类型是下表列出的类型之一。请注意,分词器不会读取整个字符串。为了提高性能,它一次只读取一个标记并返回其类型。要访问下一个标记,计算器将调用方法 tokenizer.nextToken()。当分词器到达字符串末尾时,它会返回一个特殊标记 end_of_formula。
enum eTokenType operator_plus + operator_minus - operator_mul * operator_div / operator_percent % open_parenthesis ( comma , dot . close_parenthesis ) operator_ne <> operator_gt <= operator_ge >= operator_eq = operator_le <= operator_lt < operator_and AND operator_or OR operator_not NOT operator_concat & any word starting with a letter or _ value_identifier value_true TRUE value_false FALSE any number starting 0-9 or . value_number any string starting ' or " value_string open_bracket [ close_bracket ] Initial state none State once the last character is reached end_of_formula |
解析器
此版本中解析器已完全重写。解析器使用分词器提供的信息(大棕色框)从中构建一组对象(右侧的堆栈)。在我的库中,每个这样的对象都称为 OpCode。每个 OpCode 返回一个值,并且可以有参数也可以没有参数。
Opcode 1
Opcode 2
Opcode 3
Opcode *
Two Opcode +
and Opcode +
操作码 + 和 * 有两个参数。其余的操作码没有。解析器更复杂的概念之一是优先级。在我们的表达式中……
1 + 2 * 3 + v1
...计算器必须理解我们真正的意思是
1 + (2 * 3) + v1
换句话说,我们需要先进行乘法运算。那么,这如何在一次遍历中完成呢?在任何时候,解析器都知道它的优先级级别
enum ePriority
none = 0
concat = 1
or = 2
and = 3
not = 4
equality = 5
plusminus = 6
muldiv = 7
percent = 8
unaryminus = 9
当解析器遇到运算符时,它会递归调用解析器以获取右侧部分。当解析器返回右侧部分时,运算符可以应用其操作(例如,+),并且解析继续。有趣的是,在计算右侧部分时,分词器已经知道其当前的优先级级别。因此,在解析右侧部分时,如果它检测到优先级更高的运算符,它将继续解析并仅返回结果值。
解释
评估过程的最后一部分是解释。多亏了 OpCode,这一部分现在运行得快多了。
要从操作码堆栈中获取结果,您只需调用根操作码值。在我们的示例中,根操作码是 + 运算符。Value 属性将依次调用每个操作数的值,结果将被添加并返回。从这张图片中可以看出,现在的评估速度是相当可接受的。下面的程序为图像中的每个像素需要进行 3 次完整的表达式评估。对于此图像,它需要 196,608 次评估,尽管如此,它在不到一秒的时间内返回。
这个新项目的核心类是 OpCode 类。OpCode 类中的关键属性是属性“value”。
Public MustInherit Class opCode Public Overridable ReadOnly Property value( ) As Object Implements iEvalValue.value MustOverride ReadOnly Property ReturnType( ) As evalType Implements iEvalValue.evalType ... End Class
每个操作码都通过它返回其值。对于运算符 +,值按以下方式计算:
Return DirectCast(mParam1.value, Double) + DirectCast(mParam2.value, Double)
这真的更快吗?
如果您需要多次评估函数,它会更快。如果您只需要评估函数一次,您可能无论如何都不关心速度。所以,在任何一种情况下,我都会推荐这个新版本。正如您从上图中看到的,对于图像的每个像素,都会评估 3 个公式。图像为 256x256 像素,计算器必须计算 196,608 个表达式。因此,简单表达式在不到 5 微秒的时间内返回。我认为这对于大多数应用程序来说是可以接受的。
动态变量
动态变量是一个有趣的概念。其思想是,如果您的应用程序中使用多个公式,您不希望在变量更改时重新计算所有公式。计算器内置了执行此操作的功能。在此页面上,程序使用动态功能
解析表达式后,使用此功能
mFormula3 = ev.Parse(tbExpression3.Text)
您只需等待事件 mFormula3.ValueChanged
Private Sub mFormula3_ValueChanged( _ ByVal Sender As Object, _ ByVal e As System.EventArgs) _ Handles mFormula3.ValueChanged Dim v As String = Evaluator.ConvertToString(mFormula3.value) lblResults3.Text = v LogBox3.AppendText(Now.ToLongTimeString() & ": " & v & vbCrLf) End Sub
你说它支持对象吗?
是的,计算器支持 .
运算符。如果您输入表达式 theForm.text
,那么计算器将返回表单的标题。如果您输入表达式 theForm.left
,它将返回其运行时的左侧位置。此功能仅为实验性,尚未经过测试。这就是我将此代码放在此处的原因,希望其他人会发现其功能有价值并提交改进。
这是如何工作的?
事实上,对象是免费获得的。我使用 System.Reflection
来评估自定义函数。相同的代码用于访问对象的方法和属性。当解析器遇到一个没有意义的关键字标识符时,它会尝试反射 CurrentObject
以查看是否可以找到同名的方法或属性。
mi = CurrentObject.GetType().GetMethod(func, _
_Reflection.BindingFlags.IgnoreCase _
Or Reflection.BindingFlags.Public _ Or Reflection.BindingFlags.Instance)
如果找到方法或属性,它将为其提供参数。
valueleft = mi.Invoke(CurrentObject, _ _ System.Reflection.BindingFlags.Default, Nothing, _ DirectCast(parameters.ToArray(GetType(Object)), Object()), Nothing)
有什么已知的错误或请求吗?
以下是原始项目的请求/错误
有人报告说,您需要“比较文本”选项才能使评估器正常工作。我认为现在已经修复了。如果您希望评估器区分大小写,可以在评估器构造函数中要求它。
还有人报告说,在 Windows 国际设置中,评估器不喜欢将逗号用作小数点。我相信这也已修复。
我的请求:如果您觉得这个库有用或有趣,请不要忘记投票给我。:-)
关注点
速度测试:我希望我有时间比较各种评估方法。如果有人想帮忙,请联系我。据我所知,这是 CodeProject 上唯一一个具有独立分词器、解析器和解释器的公式评估器。由于内部使用了 System.Reflection
,可扩展性非常容易。
历史
2007 年 5 月 18 日
- 文章已编辑并发布到 CodeProject.com 主文章库。
2006 年 5 月 4 日
- 修复了上一版本中引入的函数无法正确识别的错误。
- 在 C# 和 VB 示例程序中添加了更多使用数组和默认成员 (Controls.Item) 的示例。
2006 年 4 月 27 日
- 实现数组
- 开始区分 C# 和 Vb
2006 年 4 月 20 日
- 尝试用更多图片改进文章。
2006 年 4 月 19 日
- C# 兼容性(一些变量和成员被重命名以避免与 C# 关键字冲突)。
- C# 示例
- 将核心评估器移入 DLL 中
- 通过名为“iVariableBag”的新接口允许“即时变量”
2006 年 4 月 13 日
- 新文章(因为原始文章无法编辑)。
- 完全使用操作码的新解析器
- 2005 年 2 月 10 日
- 大幅增加了文章的长度和细节
2005 年 2 月 7 日
- 首次发布。