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

评估引擎

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (158投票s)

2008年5月23日

CC (ASA 2.5)

29分钟阅读

viewsIcon

333422

downloadIcon

5032

评估引擎是一个解析器和解释器,可用于构建业务规则引擎。它支持数学和布尔表达式、操作数函数、变量、变量赋值、注释和短路评估。还包括一个语法编辑器。

评估引擎概述

我正在创建一个规则引擎。我将规则引擎设计为允许规则“预编译”或“动态”。“预编译”规则只是用 .Net 语言编程的规则,并由规则引擎使用反射调用。“动态”规则在执行时由规则引擎解释。“动态”规则提供了极大的灵活性,因为它们可以非常容易地更改。

本文不是关于规则引擎的;它是关于规则引擎中内置的用于评估动态规则的解析器/解释器。评估动态规则的这部分代码称为评估引擎。下载内容包括评估引擎源代码和测试应用程序。主测试应用程序(称为规则计算器)允许您输入规则语法、解析语法并评估结果。

评估引擎

在深入了解评估引擎的工作原理之前,我认为最好先向您展示它的功能。在您了解评估引擎的功能之后,我将解释它的工作原理。如前所述,随附的测试应用程序称为规则计算器。规则计算器只是评估引擎的“包装器”。规则计算器允许您键入规则语法(由于没有更好的名称,我将其称为“Rules Basic”)。接下来,规则计算器将 Rules Basic 语法传递给评估引擎进行评估。

解析和标记

评估引擎的工作是获取 Rules Basic 字符串(或 .Rule 文件),评估内容并执行操作。大多数编译器和解释器都会经历一个称为解析的过程。解析过程需要查看 Rules Basic 字符串并提取重要的信息片段。解析器识别的重要信息片段称为“标记”。每个标记都被识别为特定类型的标记(或分类)。每个标记都被分类为以下之一

Token_Operand

操作数是 Rules Basic 语法中的值或变量。例如,在数学方程式“1 + 2 = 3”中,项目 1、2 和 3 是操作数。类似地,在方程式“a + -b >= c”中,操作数是 a、b 和 c。显然,操作数 1、2、3 和 a、b、c 之间存在差异。也就是说,第一组操作数是整数值,第二组是变量。关键是操作数标记可以按类型进一步分类。评估引擎将操作数标记分类为以下之一

  • Token_DataType_Variable:操作数标记是一个变量。
  • Token_DataType_Int:操作数标记是一个整数/整数。
  • Token_DataType_Date:操作数标记是一个日期。
  • Token_DataType_Double:操作数标记是数字/双精度。
  • Token_DataType_String:操作数标记是文本。
  • Token_DataType_Boolean:操作数标记是真或假。
  • Token_DataType_NULL:操作数标记为空。

Token_Operand_Function_Start

Rules Basic 语法允许对操作数进行函数调用。此标记表示解析器已找到操作数函数的开始。Rules Basic 语法中的所有操作数函数都使用方括号。例如,Avg[]、Year[]、Join[] 是 Rules Basic 中可用的 3 个操作数函数的示例。操作数函数可以将操作数作为参数。评估引擎执行一些数据类型检查。例如,Year[5.20.1999] 操作数函数需要类型为 Token_DataType_Date 的操作数标记(请注意日期分隔符是“.”)。具有错误数据类型操作数的相同操作数函数将抛出错误,即 Year["Hello"] 将导致错误。

Token_Open_Parenthesis

评估引擎遵循数学运算顺序。此运算顺序可以通过使用括号来更改。此标记表示评估引擎找到一个左括号: (

Token_Close_Parenthesis

评估引擎已找到一个右括号。

Token_Operand_Function_Stop

Rules Basic 语法允许操作数函数。操作数函数的参数在方括号中标识。此标记表示评估引擎找到操作数函数的结尾。

Token_Operand_Function_Delimiter

操作数函数可以有多个参数。操作数函数中的参数用逗号分隔。此标记表示评估引擎在操作数函数中找到一个逗号

Token_Operator

运算符应用于操作数。例如,当将“+”运算符应用于操作数 1 和 1 时,结果是 2。此标记表示评估引擎已找到一个运算符。

Token_Assignemt_Start

Rules Basic 语法允许变量赋值。变量可以赋值静态值(例如“Hello”)或 Rules Basic 语法(例如“1 + 1”)。赋值的变量也可以参与其他 Rules Basic 语法。赋值运算符是 :=。赋值的示例见下文。因此,当评估引擎在 Rules Basic 字符串中遇到 := 时,会在标记集合中创建 Token_Assignment_Start。

Token_Assignment_Stop

分号 (;) 表示 Rules Basic 语法中赋值的结束。当评估引擎检测到 ; 时,会创建 Token_Assignment_Stop 标记。

还有一个重要的信息:当评估引擎解析 Rules Basic 字符串时,每个标记都会被分配一个标记类型和一个标记数据类型。例如,标记“Hello”将被分配标记类型 = Token_Operand 和标记数据类型 = Token_DataType_String。如果您忘记在字符串“Hello”周围加上双引号,解析器会将 Hello 分类为 Token_Operand 和数据类型 Token_DataType_Variable。由于每个标记都使用标记类型和标记数据类型进行分类,那么解析器如何分类 = 标记、+ 标记或 ( 标记的数据类型呢?标记 + 被分类为标记类型 Token_Operator 和数据类型 Token_DataType_None。我没有在上面的表格中提及“None”数据类型,因为我想先提供这个示例。因此,操作数标记可以分类为整数、日期、双精度、字符串、布尔值、空值、变量或无。

我们来通过一个解析和标记的例子。我们将 Rules Basic 语法保持简单:(1 + 3) * Avg[3,4,2+3]

这是上面示例中识别出的标记

Token

标记类型

标记数据类型

(

Token_Open_Parenthesis

Token_DataType_None

1

Token_Operand

Token_DataType_Int

+

Token_Operator

Token_DataType_None

3

Token_Operand

Token_DataType_Int

)

Token_Close_Parenthesis

Token_DataType_None

*

Token_Operator

Token_DataType_None

Avg[

Token_Operand_Function_Start

Token_DataType_None

3

Token_Operand

Token_DataType_Int

,

Token_Operand_Function_Delimiter

Token_DataType_None

4

Token_Operand

Token_DataType_Int

,

Token_Operand_Function_Delimiter

Token_DataType_None

2

Token_Operand

Token_DataType_Int

+

Token_Operator

Token_DataType_None

3

Token_Operand

Token_DataType_Int

]

Token_Operand_Function_Stop

Token_DataType_None

在评估引擎识别并分类 Rules Basic 语法中的所有标记后,这些标记会按特定顺序排列(稍后会详细介绍),以便于评估。

示例

是时候举一些例子了。我将从简单的例子开始,然后引入一些更复杂的例子

Sample1-1.png

这是一个简单的示例,向您展示规则计算器测试应用程序。规则计算器允许您创建多个选项卡来运行多个表达式。我这样做是为了您可以将一个大型复杂表达式分解为更小的表达式,并在自己的选项卡中评估这些更小的表达式。

Rules Basic 语法允许您执行数学计算。它允许您执行运算顺序。有效的数学运算符是

^

指数:5 ^ 2 = 25

*

乘法:5 * 5 = 25

/

除法:25 / 5 = 5

%

模数/余数:10 % 3 = 1

+

加法:5 + 5 = 10

-

减法:10 - 5 = 5。- 符号也用于表示负数。例如 1+-1=0

当然,Rules Basic 语法也允许逻辑和布尔运算符

<

小于

<=

小于或等于

>

大于

>=

大于或等于

=

等于

<>

不等于

逻辑与

逻辑或

评估引擎允许您执行数学计算、字符串操作、日期计算和布尔逻辑。操作数函数可以嵌入到操作数函数中(在操作数函数中……)。数学表达式(带运算顺序)可以嵌入到操作数函数中。在下面的示例中,请注意数学表达式嵌入到操作数函数中,这些函数又嵌入到其他操作数函数中,并与整数值 2000 进行比较

Sample2.png

这是另一个使用变量的简单示例。解析 Rules Basic 语法后,规则计算器会识别表达式中包含变量,因此它会自动将您带到变量表。您可以通过双击列表视图中的变量并设置其值来设置变量的值。设置所有变量后,您可以单击结果选项卡并执行“单次评估”。请注意,您可以随时更改变量的值并重新评估结果。但是,无论何时更改语法,都需要单击主工具栏中的“解析”按钮

Sample3.png

评估引擎允许您根据需要添加任意数量的“空白”。也就是说,您可以添加制表符和换行符;解析器将忽略这些。此外,评估引擎支持注释。注释放在 ~(波浪号)之间,将被忽略。请注意,注释可以跨多行。这是一个使用赋值的示例。请注意,赋值运算符是 :=,并且每个赋值必须以 ;(分号)结尾。Rules Basic 语法规定先进行赋值声明,然后是求值的最终表达式。请注意,求值的最终表达式可以使用任何已赋值的变量。此外,已赋值的变量可以使用任何先前声明的已赋值变量。任何内容都可以赋值给变量,包括日期、字符串、数字、布尔值、表达式、操作数函数、其他变量等等。这是一个简单的示例(请阅读图像中的注释以获取更多详细信息)

Sample4.png

在继续介绍一个更真实的最终示例之前,让我列出我在评估引擎中编程的操作数函数。如果您需要添加自己的自定义操作数函数……这非常简单,但它需要对评估引擎进行代码更改。我曾长时间认真考虑是否应该将操作数函数放在评估引擎外部,以便可以轻松添加新的操作数函数。解析器和评估器的编码已经足够复杂,所以我决定将操作数函数编程为评估引擎中的内部方法。此外,我希望评估引擎尽可能快,所以我认为内部操作数函数会比让评估引擎使用反射调用外部程序集中的方法更快。这是列表。“函数”工具栏按钮也在规则计算器中显示此列表

操作数函数

语法

描述

Average

avg[p1, ..., pn],其中 p1,...,pn 可以转换为双精度浮点数。

计算数字列表的平均值。列表项必须能够转换为双精度浮点数。

绝对值

abs[p1],其中 p1 可以转换为双精度浮点数。

计算数值参数的绝对值。

如果-否则-结束

iif[c, a, b],其中 c 是条件,必须求值为布尔值。如果 c 为真,则返回值 a,否则返回 b。

执行 if-else-end

小写

LCase[a]

将字符串转换为小写

左侧

left[s, n],其中 s 是字符串,n 是字符数

返回字符串参数左侧的字符数。

长度

len[a],其中 a 是字符串变量

返回字符串的长度

中位数

mid[p1, ..., pn],其中 p1, ..., pn 是数值

计算数字列表的中位数

右侧

right[s, n],其中 s 是字符串,n 是字符数。

返回字符串参数右侧的字符数

Round

round[n, d],其中 n 是要四舍五入的数值,d 是小数位数。

将数值四舍五入到指定的小数位数

平方根

sqrt[a],其中 a 是数值参数

计算数字的平方根。

大写

ucase[a]

将字符串转换为大写

为空或空字符串

IsNullOrEmpty[a]

指示参数是否为空或空字符串。

为真或空

isTrueorNull[a]

指示参数的值是否为真或为空;

为假或空

IsFalseOrNull[a]

指示参数的值是假还是空

修剪

trim[a]

修剪整个字符串中的空格

右修剪

rtrim[a]

修剪字符串右侧的空格

左修剪

ltrim[a]

修剪字符串左侧的空格

日期添加

dateadd[date, "type", amount],其中 date 是有效日期,type 是 "y"、"m"、"d" 或 "b"(分别表示年、月、日或工作日),amount 是整数

向日期添加一个数量。请注意,数量可以是负数。

串联

concat[p1, ..., pn]

此操作数函数将参数连接在一起以形成一个字符串。

日期

date[m, d, y],其中 m 是月份的整数,d 是日期的整数,y 是年份的整数

创建新的日期数据类型

右填充

rpad[a, b, n],其中 a 和 b 是字符串值,n 是数值。参数 p 将在参数 a 的右侧追加 n 次。

在字符串右侧用新值填充

左填充

lpad[a, b, n],其中 a 和 b 是字符串值,n 是数值。参数 p 将在参数 a 的左侧追加 n 次。

在字符串左侧用新值填充

Join

join[a, b1, ..., bn],其中 a 是分隔符,b1, ..., bn 是要连接的项目。

使用分隔符连接列表中的项目

搜索字符串

SearchString[a, n, b],其中 a 是被搜索的字符串,b 是要查找的字符串,n 是 a 中的起始位置

在另一个字符串中指定起始位置搜索字符串

day[d1],其中 d1 是日期值

返回日期的日

month[d1],其中 d1 是日期值

返回日期的月份

年份

year[d1],其中 d1 是日期

返回日期的年份

子字符串

SubString[s, a, b],其中 s 是字符串,a 是起始点,b 是提取的字符数。

从字符串中提取子字符串

数值最大值

NumericMax[p1, ..., pn]

查找列表中的最大数值

数值最小值

NumericMin[p1, ..., pn]

查找列表中的最小数值

日期最大值

datemax[d1, ..., dn],其中 d1, ..., dn 是日期

返回列表中最大日期

日期最小值

datemin[d1, ..., dn],其中 d1, ..., dn 是日期。

返回列表中的最小日期。

字符串最大值

StringMax[p1, ..., pn]

查找列表中最大的字符串

字符串最小值

StringMin[p1, ..., pn]

查找列表中最小的字符串

Contains

contains[p1, p2, ...., pn] 如果 p1 在列表 p2, ..., pn 中,则此函数返回“true”,否则返回“false”。

指示项目是否包含在列表中。

之间

between[var, val1, val2],其中 var, val1 和 val2 是整数。如果 var >= val1 且 var <= val2,则函数返回“true”,否则函数返回“false”。

指示一个值是否在其他值之间。请注意,比较是包含的。

索引

indexof[a, b1, ..., bn] 如果列表 b1, ..., bn 包含值 a,则返回该值的索引,否则返回 -1。请注意,这是从零开始索引的

返回列表项的索引。

现在

now[] 此操作数函数不带参数

返回当前日期

替换

Replace[a, b, c],其中 a 是搜索字符串,b 是被替换的值,c 是被插入的值

用另一个字符串替换一个字符串

评估

eval[r],其中 r 是任何有效的 Rule Basic 字符串规则

评估一个 Rule Basic 字符串。我认为这是一个有趣的操作数函数。在我的规则引擎中,我可能有返回 Rule Basic 字符串的规则。然后可以用这个操作数函数评估结果。也就是说,有规则可以创建其他规则。示例:eval[concat["a:=5;", "b:=6;", "a+b"]]

移除

remove[a, b],其中 a 和 b 是字符串

从字符串中删除指定的字符

双引号

quote[]

返回双引号

首字母大写

pcase[a],其中 a 是字符串

将字符串转换为首字母大写。例如,"buFFalo" 的首字母大写是 "Buffalo"

正弦波

sin[a]

计算数字的正弦

布尔非

not[a]

对参数 a 执行布尔非运算符。例如,Not[true] = false。

是否全为数字

IsAllDigits[a]

返回 true/false,指示参数是否全为数字。例如,IsAllDigits["1234"] = true

这是一个稍微更真实的示例,说明保险公司可能用来计算人寿保险保单月付款的规则。以下是此人寿保险保单的业务规则

  • 要获得此产品资格,需要使用“评分”系统。尝试获得此产品资格的客户被分配初始分数 100 分。
  • 如果客户年龄超过 50 岁,他们会失去 10 分。如果客户年龄超过 70 岁,他们会失去 40 分
  • 如果客户是吸烟者,他们会失去 45.6 分
  • 如果客户体重超过 180 磅,他们会失去 10 分。如果客户体重超过 250 磅,他们会失去 40 分
  • 客户的最终分数从 105.65 中扣除。然后将此金额乘以 5.25 美元以获得最终付款。

上述逻辑的 Rules Basic 语法保存在文件“InsPolicyA.rule”中。您可以在规则计算器中打开此文件,设置年龄、吸烟者和体重的变量值,然后单击结果选项卡中的“单次评估”按钮运行。最终付款将计算并以字符串形式返回,例如“每月付款 = $374.00 (USD)”。

规则引擎非常强大。在上面的示例中,我们有一个复杂的规则来计算客户产品的每月付款。一家真正的保险公司可能有数百种产品,所有产品都有特殊的业务规则。一家金融机构可能有数百种客户可以获得资格的产品。一家航运公司可能有一套业务规则来规定何时发货或重新订购物品。如果业务规则集中存放,规则管理将变得更容易。如果规则发生变化,只需要在一个地方更改规则,而不是在所有嵌入规则的客户端应用程序中更改。此外,如果规则引擎在规则服务器上运行,还可以获得额外的收益,例如可伸缩性、容错性和性能提升。

客户端应用程序

创建客户端应用程序以使用评估引擎是一个简单的过程。只涉及两个对象

  • EvaluationEngine.Parser.Token 对象。此对象将允许您打开已保存的 .Rule 文件(或传入 Rules Basic 字符串)并设置变量的值。
  • EvaluationEngine.Evaluate.Evaluator 对象:此对象将令牌对象作为构造函数中的参数。Evaluate() 方法执行所有工作以评估令牌。

在上述保险示例中,我们如何使用自定义客户端评估该规则?这是一个小的控制台应用程序,它将加载规则,设置变量值并返回结果

// create the token object from the .Rule file
EvaluationEngine.Parser.Token token = new EvaluationEngine.Parser.Token(
     new System.IO.FileInfo("InsPolicyA.rule"));

// set the values for the variables
token.Variables["Age"].VariableValue = "38";
token.Variables["Smoker"].VariableValue = "false";
token.Variables["Weight"].VariableValue = "175";

// create the evaluator object and pass in the token object in the constructor
EvaluationEngine.Evaluate.Evaluator eval = 
    new EvaluationEngine.Evaluate.Evaluator(token);

// run the evaluation
string ErrorMsg = "";
string result = "";
if (eval.Evaluate(out result, out ErrorMsg) == true)
     Console.WriteLine(result);

下载文件包含一个示例客户端控制台应用程序。这是该应用程序的屏幕截图

Sample5.png

规则组

假设您使用规则计算器创建了一些规则并将规则保存到 .Rule 文件中。您定义的一些变量可以在多个规则中找到。例如,再次考虑保险公司。我们创建了一个规则来查看客户是否符合人寿保险保单的资格,并且该规则需要 3 个参数:年龄、吸烟者和体重。保险公司也可能有另一个规则,提供汽车保险费率报价。此汽车保险费率报价规则可以有 3 个参数:年龄、吸烟者和性别。因此,在这 2 个规则(人寿保险保单和汽车保险)中,有 4 个参数:年龄、吸烟者、体重和性别。如果我们有客户的这 4 个参数,那么同时运行这两个规则会很好。这就是 EvaluationEngine.Parser.TokenGroup 对象的使用之处。

规则计算器提供了 TokenGroup 对象的实现。要在规则计算器测试应用程序中添加新的规则组,请单击下拉菜单项中的“规则组”

Sample6.png

这将创建一个新选项卡并加载规则组界面。

您不能在规则组界面中创建新规则;您只能打开已保存的 .Rule 文件。单击“打开”工具栏按钮并打开多个 .Rule 文件。打开规则后,双击变量并设置其值以设置变量的值。最后,单击主工具栏中的“解析”按钮以执行所有规则。各个规则的结果显示在列表视图的“结果”列中。

Sample7-1.png

它是如何工作的?

如果您查看源代码,您会发现我在代码中添加了大量的注释。评估引擎代码主要分为两部分:解析器和评估器。解析器扫描 Rules Basic 语法并创建标记集合。然后它以逆波兰表示法顺序对标记集合进行排序。最后,排序后的标记集合被传递给评估器对象。我将详细描述每个过程。

解析器

解析代码位于 Token 对象的 GetTokens() 方法中。此方法启动一个 Do 循环,并开始从 RulesBasic 语法字符串中一次提取一个字符。每个字符都连接到一个名为“currentToken”的局部字符串变量。然后分析 currentToken 变量,并根据分析创建一个 TokenItem 对象。

在任何时候,解析器都知道它正在寻找什么。例如,如果解析器遇到 ~ 字符,它就知道它正在开始一个注释。因此,解析器知道它可以丢弃(或处理)它找到的所有字符,直到找到下一个 ~(结束注释指示符)。解析器通过使用枚举(和局部变量)ParseState 来管理其当前状态。解析器只需管理 5 种状态

状态

描述

Parse_State_Operand

此状态表示解析器正在寻找操作数。请记住,操作数可以是变量、整数、字符串、布尔值、日期、双精度或 null。这是解析状态的默认值;也就是说,解析器开始时首先寻找的是操作数。

Parse_State_Operator

这表示解析器正在寻找运算符。运算符可以是数学运算符、逻辑运算符或布尔运算符。

Parse_State_Quote

这表示解析器正在寻找双引号。通常,当找到结束引号时,会创建一个 TokenItem 对象并将其数据类型设置为字符串。

Parse_State_OperandFunction

这表示解析器已找到有效的操作数函数,并且正在解析操作数函数的参数。您会注意到代码中维护了多个解析状态。例如,当前解析状态表示我们正在解析一个操作数函数。但是,会维护一个单独的解析状态来解析操作数函数的参数。参数需要单独的解析状态,因为操作数函数的参数也可以是有效的表达式。

Parse_State_Comment

此解析状态表示我们处于注释中。在“注释模式”中遇到的任何字符都将被忽略。解析器正在扫描字符并寻找结束注释指示符。

现在,在解析状态的帮助下,解析器知道它正在寻找什么。例如,如果解析器正在寻找一个操作数,并且它遇到了一个“+”字符(也就是说,它正在寻找一个操作数,并且它发现了一个运算符字符),则语法可能存在问题,因为操作数中不允许使用“+”字符。关键是解析器知道它正在寻找什么,同样重要的是,它知道接下来要寻找什么。也就是说,如果解析器正在寻找一个操作数,那么它知道接下来将寻找一个运算符(或 Rules Basic 语法的结尾)。

GetTokens() 方法中有很多代码,因此很难在本文中详细介绍所有内容(我在代码中添加了大量注释,以帮助您理解解析器正在做什么)。在解析器提取一个字符后,您会看到一些嵌套的 switch 语句和带有 log if-elseif 语句的 switch 语句。这就是我编程解析器以了解它当前正在寻找什么以及接下来要寻找什么的方式。以下是 GetTokens() 中的一些代码,其中包含一些有关空格和逗号处理的详细信息。请注意,switch 语句查看当前的解析状态。在下面的代码示例中,如果我们在寻找操作数并找到一个空格,则会创建一个标记并将下一个解析状态设置为寻找运算符。类似地,如果我们在寻找操作数并找到一个逗号,则会返回错误。

    

switch (parseState)
{
  case ParseState.Parse_State_Operand:
    #region Parse_State_Operand
    if (c == '"')
    {
      ....
    }
    else if (c == ' ')
    {
      #region Space Handling
      try
      {
        // we are looking for an operand and we found a space.  
        // We are not currently looking for a closing quote.
        // Assume that the space indicates that the operand is completed 
        // and we now need to look for an operator                            
        tokenCreated = CreateTokenItem(currentToken, false, false, 
            out tempParseState, out isError, out lastErrorMessage);
        if (isError) return;

        if (tokenCreated == true)
        {
          // set the next parse state
          parseState = tempParseState;

          // reset the token
          currentToken = "";
        }
      }
      catch (Exception err)
      {
        lastErrorMessage = "Error in GetTokens() in 
            Operand Space Handling: " + err.Message;
        return;
      }

      #endregion                     
    }
    else if (c == '(')
    {
      ....
    }
    else if (c == ')')
    {
      ....
    }
    else if (c == '[')
    {
      ....
    }
    else if (c == ']')
    {
      ....
    }
    else if (c == ',')
    {
      // we should never be looking for an operand and find a , (comma)
      lastErrorMessage = "Error in Rule Syntax: 
           Found a , (comma) while looking for an operand.";
      return;                     
    }
    else if (c == '-')
    {
      ....
    }
    else if (c == ':')
    {
      ....
    }
    else if (c == ';')
    {
      ....
    }
    else
    {
      ....
    }
    #endregion
    break;
                                                
  case ParseState.Parse_State_OperandFunction:
    #region Parse_State_Operand
    ....
    #endregion
    break;
                                                
  case ParseState.Parse_State_Operator:                                                
    #region Parse_State_Operand
    ....
    #endregion
    break;

  case ParseState.Parse_State_Quote:
    #region Parse_State_Operand
    ....
    #endregion
    break;
}

创建操作数标记比找到空格字符并创建操作数标记(如语法“1 + 1”所示)要困难一些。之所以更困难,是因为 Rules Basic 语法中不需要空格。也就是说,“1+1”是一个不包含任何空格的有效表达式。在这种情况下,每次将字符附加到字符串 currentToken 时,解析器都会检查 currentToken 字符串变量的末尾是否包含运算符。例如,当 currentToken = "1+" 时,CreateTokenItem() 方法会发现 currentToken 字符串以运算符结尾。此外,CreateTokenItem() 变得更复杂,因为它需要“提前查看”。为了理解我的意思,请考虑以下示例:“1>=2”。“>”和“>=”都是有效的运算符。因此,当 currentToken 字符串变量当前为“1>”时,CreateTokenItem() 方法需要等待下一个字符(=)才能创建操作数和运算符。查看 CreateTokenItem() 以了解这是如何实现的。

标记的排序和评估

人类很容易看到数学表达式“1 + 2 * 3”并知道答案是 7(使用运算顺序)。但计算机评估这个相同的表达式却很困难。计算机需要重新排列表达式中的项目/标记,以便更容易评估。事实上,计算机算法应该重新排列表达式,使其读作“2 3 * 1 +”。这告诉计算机,2 和 3 需要先相乘,然后将 1 添加到结果中。人类的表示法“1 + 2 * 3”被称为中缀表示法,而排序后的计算机表示法“2 3 * 1 +”被称为逆波兰表示法(RPN)。计算机可以通过简单地从堆栈抽象数据类型中压入和弹出项目来轻松评估 RPN。以下是评估 RPN 的简单算法

  1. 对于 RPN 语句中的每个项目
    • 如果项目/令牌是数字,则将其压入堆栈
    • 如果项目/令牌是运算符,则从堆栈中弹出两个项目并执行操作。将操作结果压回堆栈。
  2. 在评估完 RPN 语句中的所有项目/标记后,堆栈中应该只剩下一个项目,即最终答案。

在我们的简单示例“2 3 * 1 +”中,2 和 3 将被压入堆栈。然后 2 和 3 将从堆栈中弹出并相乘。结果 6 将被压回堆栈。接下来,1 将被压入堆栈。最后,6 和 1 将从堆栈中弹出,然后相加,7 将被压回堆栈。这就是我们的最终答案。

显然,这是一种巨大的过度简化。评估引擎必须处理操作数函数、运算顺序、变量、变量赋值等等。幸运的是,一位著名的计算机科学家发明了一种将中缀表示法转换为逆波兰表示法的算法。此算法称为 Dijkstra 调度场算法。有关该算法的更多信息可在维基百科上找到:http://en.wikipedia.org/wiki/Shunting_yard_algorithm。我的调度场算法实现在 Token.MakeRPNQueue() 方法中。规则计算器在“RPN 令牌”选项卡中显示每个表达式的 RPN。例如,(1 + 2) * 3 的 RPN 是

Sample8.png

请注意,(1 + 2) * 3 的括号在 RPN 列表中消失了。这是因为 RPN 以一种不再需要括号的方式对标记进行排序。RPN 堆栈中的运算顺序通过排序顺序来实现。再次提醒,如果您想了解排序算法的详细信息,请查阅 Token.MakeRPNQueue() 方法。

添加额外操作数函数

在本文的最后一部分,我想通过一个练习,向评估引擎添加一个新的操作数函数。让我们向评估引擎添加三角函数余弦。余弦操作数函数将接受 1 个数值参数。以下是添加新操作数函数所涉及的步骤

  1. 在 DataTypeCheck 对象的 OperandFunctions 字符串数组中添加操作数函数的名称
    public static string[] OperandFunctions = { ..., "cos" };
    
  2. 在 Evaluator.EvaluateOperandFunction() 方法中的 switch 语句中添加一个 "case:" 条件
    case "cos[":
      try
      {
        success = Cos(Parameters, out Result, out ErrorMsg);
      }
      catch (Exception err)
      {
        ErrorMsg = "Failed to evaluate the operand function " + 
         OperandFunction.TokenName.Trim().ToLower() + 
         ": " + err.Message;
        success = false;
      }
      break;
    
  3. 编写 Evaluator.Cos() 方法
    private bool Cos(Parser.TokenItems Parameters, out Parser.TokenItem Result, out string ErrorMsg)
    {
        // initialize the outgoing variables
        ErrorMsg = "";
        Result = null;
    
        // make sure we have at least 1 parameter
        if (Parameters.Count != 1)
        {
            ErrorMsg = "Error in operand function Cos[]: 
              Operand Function requires 1 parameter.";
            return false;
        }
    
        // we can only take a Cos of an item that can be converted to a double
        if (Support.DataTypeCheck.IsDouble(Parameters[0].TokenName) == true)
        {
            double temp = Parameters[0].TokenName_Double;
            double cos_temp = Math.Cos(temp);
    
            Result = new Parser.TokenItem(cos_temp.ToString(),
                EvaluationEngine.Parser.TokenType.Token_Operand, 
                EvaluationEngine.Parser.TokenDataType.Token_DataType_Double,
                false);
        }
        else
        {
            ErrorMsg = "Error in operand function Cos[]: Operand Function 
             can only evaluate parameters that can be converted to 
             a double.";
            return false;
        }
    
        return true;
    }
    
  4. 如果您想为新的 Cos[] 操作数函数提供帮助,请在 DataTypeCheck.FunctionDescription() 方法的 switch 语句中添加一个新的“case:”条件
    case "cos":
      Description = "Calculates the cosine of a number.";
      Syntax = "cos[p1] where p1 can be converted to doubles.";
      Example = "cos[90] < 0";
      break;
    

就是这样;您现在可以在 Rules Basic 语法中调用 cos[] 操作数函数。如果您向评估引擎添加任何操作数函数,我恳请您将操作数函数代码和描述代码发布到 Code Project 上的这篇文章中,我将把该操作数函数包含在原始源代码中。

短路评估

我编写的许多业务规则都是“if-else”语句的形式。例如,如果客户具有某个属性,他们会获得某个分数。如果驾驶员有某些驾驶违规行为,他们的保费会增加 X%。如果医疗索赔处于某个状态/条件,则延迟付款。由于许多规则都是这种“if-else”类型,我尝试优化 iif[] 操作数函数。回想一下,iif[] 操作数函数接受 3 个参数:iif[c, a, b]。如果条件 c 评估为真,则结果为 a,否则结果为 b。

短路评估的正式定义(摘自维基百科)是“指某些编程语言中某些布尔运算符的语义,其中第二个参数仅在第一个参数不足以确定表达式值时才执行或评估”。这对我们来说并不新鲜,我们已经使用短路多年了……看看 || 的 msdn 帮助,它写道:“条件或运算符 (||) 对其布尔操作数执行逻辑或运算,但仅在必要时才评估其第二个操作数”

以下是评估引擎中短路的工作方式

  1. 当解析器扫描 RulesBasic 语法时,它会找到所有不包含其他 iif[] 操作数函数的 iif[] 操作数函数。然后解析器设置以下属性:tokenItem.CanShortCircuit = true;
  2. 当排序算法将标记按 RPN 顺序放置时,作为短路 iif[] 操作数函数参数的标记将放置在它们自己的 RPN 堆栈中(事实上,这些标记放置在 3 个 RPN 堆栈中,分别表示条件参数、真参数和假参数)。
  3. 当评估器对象评估标记时,该对象会知道哪些操作数函数是短路连接的,因为有了新的 tokenItem.CanShortCircuit 属性。当评估器对象遇到这些短路标记中的一个时,它知道应该转到条件 RPN 堆栈而不是通用 RPN 堆栈。如果条件 RPN 堆栈评估为真,则评估真 RPN 堆栈,否则评估假 RPN 堆栈。由于这是一个复杂的过程,我创建了一个 ShortCircuit 类来封装上述所有逻辑。

在评估引擎中利用短路最重要的语法是不要嵌套 iif[] 操作数函数。如果您嵌套 iif[] 函数,只有最内层的 iif[] 操作数函数会短路。与其嵌套 iif[] 语句,不如将它们分解为单个语句并将结果赋给一个变量。

这是一个简单的例子

score := iif[10<5, (2 + 3) * 8 + (2 ^ 4) / 6, 1];

在上面的示例中,条件“10<5”将评估为 false,解释器将立即跳转到最后一个参数并返回 1。也就是说,true 表达式 (2 + 3) * 8 + (2 ^ 4) / 6 将永远不会被评估,从而节省您的时间。

我注意到许多支持“if-else”函数的数学评估器会在执行“if-else”语句之前评估所有参数。这会导致某些“if-else”语句出现问题,例如

iif[x = 0, 0, 100 / x],其中 x 是一个整数变量。

在上面的示例中,如果评估器不支持短路且变量 x 设置为 0,则即使“100 / x”不需要评估,许多评估器也会返回“除以零”错误。幸运的是,评估引擎中的短路机制可以避免我们遇到这个问题。

Sample9.png

结论

我确信开发人员发布其源代码的原因有很多。我发布此代码的动机是为了征求您的反馈。评估引擎是我正在开发的一个更大项目的重要组成部分:规则引擎和规则服务器。如果评估引擎中存在错误/缺陷,该错误将“渗透”到依赖评估引擎的其他组件中。如果您喜欢这个库,请告诉我。更重要的是,如果您不喜欢它,请告诉我(我乐于接受建设性批评)。如果您发现错误或有建议,我也希望得到这些信息(我可能会进行更改并重新发布代码)。

谢谢

规则计算器应用程序使用了来自文章 在 RichTextBox 中启用语法高亮 的控件。谢谢 Patrik。

修订

日期 描述
2008年5月23日 评估引擎的第 1 版发布在 Code Project 上。
2008年6月1日
  • 添加了一个 not[a] 操作数函数
  • 添加了 IsAllDigits[a] 操作数函数
  • 添加了短路逻辑
© . All rights reserved.