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

MathBinding

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (44投票s)

2014年9月11日

CPOL

20分钟阅读

viewsIcon

55931

downloadIcon

1061

了解如何创建一个数学表达式编译器和一个特殊的WPF标记扩展,该扩展能够使用它来生成绑定。

<Image 
  Source="{Binding Content}" 
  Canvas.Left="{Pfz:MathBinding [CenterX]-[Width]/2}"
  Canvas.Top="{Pfz:MathBinding [CenterY]-[Height]/2}"
  Width="{Binding Width}" 
  Height="{Binding Height}"
  />

引言

在本文中,我将介绍一个WPF标记扩展,它允许用户使用复杂的数学公式声明绑定。

文章分为两个主要部分

  1. 如何使用MathBinding
  2. 它在后台是如何工作的。

第一部分可能是大多数开发人员想要了解的,即使是那些没有WPF经验的开发者。原因很简单:使用MathBinding,开发人员可以避免创建大量的转换器类,避免将这些转换器实例化为XAML中的资源,避免在绑定中引用它们作为转换器,并使这些绑定更容易阅读。

第二部分,嗯,这是一个高级主题。它解释了如何解析数学表达式,将它们标记化,解析标记,生成LINQ表达式(这些表达式会被编译),最后解释如何使标记扩展能够使用所有这些。是的,这很多而且很复杂,但我希望我能比大多数解释如何创建编译器的文档做得更简单。

1. 使用MathBinding

从最基本的地方开始,您需要将库Pfz.MathEvaluation.dllPfz.MathEvaluation.Wpf.dll添加到您的项目中。您可以选择性地将MathParser.csMathVariable.csMathBinding.cs文件直接放入您的项目,从而避免DLL引用。

然后,在任何将要使用MathBindingXAML中,您都需要添加它的命名空间。例如,在示例应用程序中,您将在MainWindow.xaml中看到这段代码

<Window x:Class="MathExpressionEvaluatorSampleWPF.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:App="clr-namespace:MathExpressionEvaluatorSampleWPF"
        xmlns:Pfz="clr-namespace:Pfz.MathEvaluation.Wpf;assembly=Pfz.MathEvaluation.Wpf"
        Height="350" Width="525" Title="MathExpression Test Application"
        >

加粗的行显示了MathBinding所在的命名空间的用法。在这种情况下,我使用了Pfz这个名称来引用命名空间,但您也可以自由使用任何您想要的名称。另外,如果您决定将文件直接放入项目或重命名命名空间,请务必调整clr-namespaceassembly声明。

假设您保留Pfz名称,您可以通过使用Pfz:MathBinding 表达式来声明任何数学绑定。要查看实际示例,可以是这样的

<TextBlock Text="{Pfz:MathBinding [Width]*[Height]}"/>

在前面的示例中,表达式会将来自名为Width的属性的值与TextBlockDataContext中名为Height的属性的值相乘。

我们能用MathBinding做什么?

MathBinding支持5种运算符(加+、减-、乘*、除/和模%)、常量值、使用括号定义优先级、任何属性路径(只需将其写在方括号内,因此,如果[Property.SubProperty.Array[0]]在正常绑定中是有效的路径,那么它也是有效的)以及任何您注册的函数,只要该函数只使用double作为输入参数类型(如果它有输入参数的话)并返回double

要注册函数,我们应该在绑定加载之前使用MathParser.StaticRegisterFunction()(因此,在应用程序初始化期间注册所有数学函数是一个好主意)。

例如,要注册Abs函数,我们可以这样做

MathParser.StaticRegisterFunction(Math.Abs);

实际上StaticRegisterFunction有许多重载,但它们都只是为了让注册0到3个参数的函数更简单,而无需显式声明委托类型。

如果您想注册一个有更多参数的函数,例如,您可以使用StaticRegisterFunction更基本版本,它接收任何Delegate,并指定您想要的委托类型。例如

MathParser.StaticRegisterFunction(new MyDelegateWith10Parameters(MethodWith10Parameters));

而且,为了避免命名冲突(因为数学解析器不支持函数重载)或者简单地为您的函数指定一个特定的名称(如果您使用匿名委托,则需要这样做),您可以将名称作为第一个参数。例如

MathParser.StaticRegisterFunction
(
  "IsGreaterThan",
  (x, y) =>
  {
    if (x > y)
      return 1.0;

    return 0.0;
  }
);

当在数学绑定中使用一个接收多个参数的函数时,您应该将整个表达式放在单引号内,否则编译器会抱怨MathBinding没有接收2个(或更多)参数的构造函数。

因此,要使用IsGreaterThan函数,我们可以使用这一行

<Label Content="{Pfz:MathBinding 'IsGreaterThan([Width], [Height])'}"/>

数据源

默认情况下,与任何绑定一样,数据源来自对象的DataContext属性。但是我们有两种方法可以设置不同的数据源

  1. ElementName:通过设置此属性,方括号内的所有路径都将被视为来自该源。例如

    <Label Content="{Pfz:MathBinding ElementName=alternativeSource, Expression=[ActualWidth]*2}"/>
    
  2. 子标签:通过将绑定声明为子标签,我们可以自由声明更多内部绑定。这与MultiBinding的工作方式非常相似,因此每个子绑定都可以有自己的源。这个版本更冗长,但允许您在同一个最终表达式中使用许多不同的源。需要注意的是,用于保存这些子绑定值的变量将被命名为b0b1b2等。也可以混合使用方括号中声明的绑定和子绑定中声明的绑定。示例

    <Label>
      <Label.Content>
        <Pfz:MathBinding Expression="[Width]-b0">
          <Binding ElementName="labelOtherSource" Path="ActualWidth"/>
        </Pfz:MathBinding>
      </Label.Content>
    </Label>
    

C# 代码

如果您想从代码隐藏创建MathBinding,您可以做到。您应该使用MathBinding.Create()方法来实现这一点。Create()方法就是ProvideValue()调用来创建实际绑定的,因此最好直接调用它,而不是创建一个MathBinding然后调用它的ProvideValue。您在XAML中可以做的任何事情都可以在C#代码中完成,而且我确实认为C#版本在设置不同源方面有一些优势。

在示例应用程序中,您可以找到这段代码

var actualHeightBinding = new Binding("ActualHeight");
var mathBinding = MathBinding.Create("Sqrt(b0-[Width]+[Width])", actualHeightBinding);

显然,执行-[Width]+[Width]会变成一个无操作(我们减去然后加上相同的值),但它仅在示例中用于显示您可以继续使用正常的方式编写绑定,加上代码隐藏特定的绑定声明方式,在这种情况下,就是actualHeightBinding,它必须通过名称b0来访问。

MathParser 类

嗯,您可能看到了有两个DLL。一个是包含MathBinding类的DLL,它是WPF特定的,另一个是负责所有数学解析的,它不是WPF特定的。

MathBinding只负责将像[Width]这样的东西翻译成一个变量名(如b0)。真正的数学解析(实际上涉及到真正的编译)是由MathParser类完成的,这就是为什么您需要将函数注册到MathParser类,而不是MathBinding

如果您只是需要解析数学表达式用于其他非绑定相关的原因,您也可以直接使用MathParser类。

关于MathParser需要了解的重要事项是

  • 您可以将函数注册到特定实例。您不必使用StaticRegisterFunction注册所有函数;
  • 您想在表达式中使用的变量通过调用DeclareVariable方法来声明。变量名必须以小写字母开头。您必须存储该方法返回的对象,因为您需要更改该返回对象的Value以影响编译表达式的变量;
  • 如果您出于任何原因需要LINQ表达式,您可以从字符串表达式获取一个LINQ表达式,然后对其进行编译;
  • 两个或多个表达式可以使用同一个MathParser进行编译,它们将共享MathVariable实例,因此,更改一个生成委托的变量将影响另一个委托的价值(如果它被执行的话),所以您通常应该每个MathParser只编译一个字符串数学表达式,但您也可以自由地以不同方式使用它。

性能

对于单个评估,该算法相当慢。这是因为创建LINQ表达式比直接计算最终值要慢,因为表达式生成涉及创建新对象。此外,LINQ表达式不是立即可执行的,所以我们需要编译该表达式,再次浪费时间。

然而,最终有一个委托可以执行编译后的代码,避免了任何新的解析,并且速度极快。所以,如果您只编译一次表达式,然后执行它很多很多次,它将比每次都重新解析表达式要快。如果您只需要一次执行,那么,目前没有为此进行优化。它会很慢。然而,慢是相对的,因为如果您只编译5到10个表达式,您可能根本注意不到。如果您编译了数千个表达式并且只使用一次,那会很慢。

幸运的是,绑定只编译一次它们的表达式,并在绑定属性发生变化时重新利用编译后的表达式来重新计算它们的值。

2. 在后台

容易的部分已经完成了。如果您只想使用该库,您可以下载示例应用程序,然后开始编写您自己的MathBinding表达式。

现在是时候解释如何创建这样的解决方案了。我正在努力使其简单化,但像任何编译器一样,这是一个高级主题。

它是如何工作的?

算法分为三个步骤

  1. 字符串表达式被分割成标记;
  2. 访问标记生成LINQ表达式;
  3. LINQ表达式被编译。

如果LINQ表达式不存在,我可能会创建自己的类来表示相同的东西,并且我需要让它们所有都能将内容编译成DynamicMethod。即使这看起来很难,实际上比解析要容易,但会很无聊。幸运的是,我通过使用LINQ表达式避免了这一疲惫的步骤。

那么,让我们来理解每一步。

步骤1 - 标记化

-x * Sqrt(y) + 5.7 / (-y + x)

第一步是标记化。在此步骤中,我们希望分离和分类每个实体。例如,在前面的表达式中,Sqrt由四个字符组成,但我们将其视为单个标记,类型为函数调用。5.7也处于类似的情况,由3个字符组成,只代表一个标记,类型为值。

有时,单个字符确实代表一个单个标记,无论是小的数值,如1(然而数字的大小是可变的),还是在特定情况下,如运算符和括号。重要的是我们将这些标记分开,以便在下一步中,我们可以在不处理字符串的情况下从一个标记移动到下一个(甚至到上一个),因此我们可以通过加一或减去从索引来前进到下一个标记或返回到上一个标记。

另外,需要注意的是,空格不被视为标记(但它们会结束一个标记,所以1 1不会被解释为11)。

所以,一个像

1+     57.12345/Sqrt(x)
// I know, it has many unused spaces

将生成以下标记

类型 内容(如果存在)
1
加法  
57.12345
除法  
FunctionName Sqrt
OpenParenthesis  
VariableName x
CloseParenthesis  

为了使事情变得简单,我能够立即识别函数名和变量名,而无需评估下一个标记是否是开括号。首字母大写的单词始终是函数名,而首字母小写的单词始终是变量名。这是我的选择,以简化下一步。大多数编程语言对这两种字符串类型(首字母小写和大写单词)使用相同的标记类型,这使得下一步更加复杂。

注意:一些高度优化的解析器可以在第二步中读取每个标记,如果它们需要返回到前面的标记,则具有特殊的逻辑。但我更喜欢先读取标记,生成一个列表,然后在第二步中处理标记列表。这允许我轻松地阅读自己的代码,并无问题地向前和向后导航。

步骤2 - LINQ表达式生成和优先级

-x * Sqrt(y) + 5.7 / (-y + x)

我将回到这个奇怪的表达式。它实际上对我来说没有产生任何有意义的结果。我只使用它,因为它具有我想要处理的所有重要特征。

它以一个符号开始。表达式通常由一个数学变量或值加上一个可变数量的运算符后跟另一个子表达式组成。但当我们以-开始时,我们是以一个运算符开始的。

该表达式还使用了变量、函数调用、值(如果愿意,就是常量)、括号(强制优先级)以及具有不同优先级的运算符(我们不能忘记除法比加法具有更高的优先级,所以5.7必须在加到其左侧的子表达式之前进行除法)。

好吧,也许最有趣的特征是我们从左到右开始处理标记。那么,我们如何使除法和括号具有比它们左侧的项更高的优先级呢?

这就是我们使用递归编码的地方。我不会在这里写实际的代码,但我会尝试提出一个简化的算法

Parse
  GetTokens and then
  ParseOneOrMoreIncludingSigns
ParseOneOrMoreIncludingSigns
  if currentToken is addition
    AdvanceToken, effectively ignoring this token
    return ParseOneOrMoreLowPriority
  
  if currentToken is subtraction
    AdvanceToken
	leftLinqExpression = Expression.Subtract(0, the result from GetValue)
	return ParseOneOrMoreLowPriorityLoop giving our left LINQ expression as the initial expression.

  // In all other situations we don't advance (so we don't
  // lose the first token) and...
  return the ParseOneOrMoreLowPriority directly
ParseOneOrMoreLowPriority
  leftLinqExpression = ParseOneOrMoreHighPriority
  return ParseOneOrMoreLowPriorityLoop giving our left LINQ expression as the initial expression.
ParseOneOrMoreLowPriorityLoop(currentLinqExpression)
  while currentToken is a low priority operator
    AdvanceToken
    otherExpression = ParseOneOrMoreHighPriority
    currentLinqExpression = makeLinqExpression(currentLinqExpression, operator, otherExpression)

  return currentLinqExpression
ParseOneOrMoreHighPriority(currentLinqExpression)
  currentLinqExpression = GetValue
  while currentToken is a high priority operator
    AdvanceToken
    otherExpression = GetValue
    currentLinqExpression = makeLinqExpression(currentLinqExpression, operator, otherExpression)

  return currentLinqExpression
GetValue
  switch(currentToken type)
    case variableName: Generate a LINQ expression that access a MathVariable
	case functionName: for each parameter the function expects, call a ParseOneOrMoreIncludingSigns, looking for a comma as separator
	case value: return a LINQ Expression.Constant(the value itself)
	case parenthesisOpening: calls ParseOneOrMoreIncludingSigns telling that the end character is a parenthesis closing.
	anything else: an exception is thrown.

这种方法实际上解决了所有排序问题。每次我们执行类似的操作时

a + b / c + d

低优先级解析方法将处理x +,并将要求高优先级方法执行其解析。这种高优先级方法将能够解析b / c,但将无法处理下一个+ 运算符,返回一个LINQ表达式,该表达式已经“完成”了除法任务,然后返回给低优先级解析器,低优先级解析器将整个除法子表达式作为它正在等待的右表达式,并继续循环,能够执行+ d操作。

作为替代解释,我们可以看到流程如下

  • 我们开始分析是否有信号(+或-)。没有,所以我们直接进入低优先级方法。
  • 低优先级方法要求高优先级方法运行,高优先级方法接着要求读取一个值,该值返回一个读取变量的表达式(a)。当它返回时,我们又回到了高优先级方法,但是,因为+ 运算符不是高优先级运算符,它返回到低优先级方法。
  • 然后,低优先级方法进入其循环,再次要求高优先级方法执行其工作。这次,高优先级方法能够在返回之前执行b/c
  • 无论如何,循环有一个“currentLinqExpression”,最初是用对a变量的访问填充的,它将该表达式与新的结果b / c表达式结合起来,变成一个正确排序的a + (b/c)。整个表达式成为循环的当前表达式,并且遇到一个新的加法。它再次调用高优先级方法,但最终只得到一个变量,因此循环将它所拥有的当前表达式与新的操作和变量结合起来,变得等同于(a + (b/c)) + d

因此,即使我们在表达式中没有添加任何括号,它也会生成与我们真正用括号完全排序的表达式相同的LINQ表达式。所以,对于编译后的表达式,使用过多的括号不会影响性能,因为括号根本不存在。

步骤3 - 编译

好吧,我希望我已经清楚了LINQ表达式是如何生成的,因为最后一步只是将其包装在Expression<Func<double>>中并要求其编译。正如我已经说过的,我通过使用LINQ表达式避免了使用IL代码生成DynamicMethod的步骤。

至此,我们完成了MathParser,并编译了一个作为字符串给出的数学表达式。

MathBinding

在我看来,MathParser可能是最难的逻辑部分,但我认为MathBinding是最难的,因为我缺乏关于WPF绑定和标记扩展如何工作的知识。也许是我的问题,也许是文档确实不足,我不知道。

我见过许多WPF的“数学解析器”,但从来没有见过像MathBinding那样真正编译表达式的。然而,通常让我避免它们的是以下因素之一

  • 它们只处理单个属性,所以我可以添加、减去、乘以或除以常量值,但仅此而已;
  • 它们使用一种非常冗长的方法,必须将每个属性设置声明为子标签,将绑定声明为该标签的子标签,然后为每个子绑定添加一个对象。例如,而不是这样

    <Image 
      Source="{Binding Content}" 
      Canvas.Left="{Pfz:MathBinding [CenterX]-[Width]/2}"
      Canvas.Top="{Pfz:MathBinding [CenterY]-[Height]/2}"
      Width="{Binding Width}" 
      Height="{Binding Height}"
      />
        

    我们需要写类似这样的东西

    <Image Source="{Binding Content}" Width="{Binding Width}" Height="{Binding Height}">
      <Canvas.Left>
        <Pfz:MathBinding Expression="x-y/2">
          <Binding Path="CenterX" />
          <Binding Path="Width" />
        </Pfz:MathBinding>
      </Canvas.Left>
      <Canvas.Top>
        <Pfz:MathBinding Expression="x-y/2">
          <Binding Path="CenterY" />
          <Binding Path="Height" />
        </Pfz:MathBinding>
      </Canvas.Left>
    </Image>
        

好吧,我想您理解了为什么我想要一些不同的东西。

正如我已经解释过的,MathParser独立于WPF。它不知道对象属性、依赖属性或类似的东西。我不想让它成为WPF特定的,因为我认为数学解析能力在其他情况下非常强大。

我们与已编译表达式的唯一通信是变量,并且考虑到代码的实际版本,我们有责任在编译表达式之前声明变量。变量也是小写的,所以我们不能简单地写一个像

CenterX-Width/2

因此,我决定添加一个预解析步骤。为了使这个预解析器在不复制整个标记化逻辑的情况下工作,我决定使用[]作为转义字符。事实上,我想使用{}字符,这在许多不同的字符串插值算法中是最常见的,但这些字符在XAML中已经有了特殊含义。

所以,属性路径必须这样写

[Property]

[Property.SubProperty.AnotherSubProperty]

这就是为什么我们写

[CenterX]-[Width]/2

而不是写:

CenterX-Width/2

即使它有一些额外的字符,我真的认为它仍然很小而且清晰,避免了大量的样板代码。

MarkupExtension, MultiBinding 和预解析

普通绑定只能绑定到单个属性,但像[CenterX]-[Width]/2这样的表达式必须绑定到两个表达式。

嗯,WPF支持MultiBinding,所以我的最初想法是继承它。显然,我不知道Binding是如何工作的,也没有找到谈论它的文档。我不应该继承BindingMultiBinding,即使这些类不是密封的。没有有用的方法可以重写。继承BindingBase,它是Binding的基础类,也是无用的,因为可重写的方法是内部的。

然而,一般的BindingMarkupExtension,我们可以创建自己的MarkupExtensionMarkupExtension有一个非常重要的方法:ProvideValue。该方法负责,嗯,提供将填充属性的值。然而,Binding不提供单个值,并且在我们要观察的属性值每次更改时,ProvideValue不会神奇地被调用。

好吧,我的第一次尝试是返回一个MultiBinding而不是直接返回值。这不起作用。同样,这是我对Binding工作方式的无知。

无论如何,我可以配置一个MultiBinding,然后从我正在实现的ProvideValue调用它的ProvideValue。它实际上会返回一个BindingExpression。我实际上没有研究过那个类是如何工作的。它奏效了,我的MathBinding通过将实际的Binding工作委托给一个私有的multi-binding而不是继承它来工作。这是组合,对于某些事情来说,它比继承要好得多,因为我不想让用户将MathBinding当作一个普通的MultiBinding来操作。

所以,我还没有解释的重要一点是:我们如何创建那个MultiBinding?

这项任务实际上分为三个步骤

  1. 预解析数学表达式,生成所需的尽可能多的子绑定;
  2. 构建一个新表达式,用变量名替换路径属性;
  3. 创建一个IMultiValueConverter,它使用参数填充数学变量并调用原始MathParser生成的已编译委托。

预解析和新表达式构建

我之前已经对预解析做了一些解释。绑定路径被编码在[]字符内。编写表达式如CenterX-Width/2是可能的,但这需要另一个表达式解析器,我想要一些非常简单的东西。实际的解析器已经完成,并且独立于WPF。我不想更改它,也不想在这里复制部分代码。因此,预解析器进入一个循环以查找[字符。如果找到它,它还会搜索]。如果找不到,它将抛出异常,因为这是它需要找到的。

写在[]之间的文本直接用作路径来创建普通的Binding,该绑定被添加到私有的MultiBinding中。同样的搜索也会声明一个新的MathVariable,或者如果之前已经使用了相同的路径,则重新使用它。同时,使用变量名而不是[属性路径]构建了一个新的字符串。

[]字符内的表达式可以包含更多的[]字符,只要每个开头的[都与一个结尾的]匹配。普通绑定需要这种匹配,所以我认为它永远不会成为问题,它允许我们访问索引属性。例如:[Array[0]]

我选择将给MathParser生成的变量命名为b0b1b2等。b来自binding,并且是小写的,因为数学解析器只接受小写名称。

MultiBinding 转换器

MultiBinding只负责在其任何一个属性值发生变化时调用其Converter,并提供所有新的属性值。这里没有很大的秘密。如果我们使用一个绑定到两个属性的表达式,我们的转换器将在一个数组中接收两个值。

考虑到用MathParser编译的表达式使用MathVariables,转换器的任务是读取所有这些值,将它们转换为double(因为它们可能是任何类型),并填充相应的MathVariables,这些变量在编译MathExpression时生成的数组中顺序相同。

填充变量后,调用它并接收值就足够了。作为最后的细节,转换器对该值使用Convert.ChangeType()将其转换为预期的值类型,因此即使MathParser只处理double,也可以使用MathBinding处理decimalint属性。

示例

示例应用程序有一个窗口,显示一个表格,左列由使用其自身Width和Height的小公式组成,右列显示这些公式的实际结果。

您可以调整窗口大小来查看值的变化。显然,该应用程序没有做什么有用的事情,其唯一目的是作为一个源代码示例,展示如何使用许多不同的公式,包括用户声明的函数和在C#代码中完成的绑定。

我希望您喜欢使用MathBinding,甚至直接使用MathParser

版本历史

  • 2016年6月22日。添加了Store Apps的下载。不幸的是,只有MathParser可用,MathBinding不可用;
  • 2014年9月16日。MathBinding现在支持设置ElementName,这将应用于表达式中声明的所有内部绑定,以及支持声明子绑定,就像MultiBinding工作方式一样,这些子绑定被命名为b0、b1等;
  • 2014年9月14日。使RegisterFunction方法能够使用具有不同参数数量的委托实现的。这很少见,但可能发生,作为静态方法的优化或在使用开放委托时;
  • 2014年9月11日。初始版本。
© . All rights reserved.