DynamicParser:如何解析委托和动态 Lambda 表达式并将其转换为表达式树
描述了如何使用 C# 动态将委托转换为表达式树。
引言
除非你一直与世隔绝,否则“委托”、“Lambda 表达式”和“表达式树”这些概念对你来说应该并不陌生。它们除了其他许多有趣的功能外,还能让你将一段代码表示为一个逻辑表达式树,例如,你以后可以对其进行编译和执行。例如,在以下代码中,第一行代码为右侧 Lambda 表达式中编写的代码创建了一个逻辑表达式树,第二行代码对其进行编译和执行,并在控制台中如预期般打印出“Value = 7
”这行文字。
Expression<Action<int>> func = x => Console.WriteLine( "Value= {0}", x );
func.Compile().Invoke( 7 );
好的,但是……为什么你需要一个逻辑表达式树而不是实际的编译代码呢?因为当你意识到这些表达式树本身就很有用时,它的真正力量就显现出来了:它们为你提供了一种代码逻辑的表示形式,可用于许多其他目的。
这确实很棒,并且为许多很酷的事情打开了大门。例如,这正是 LINQ 提供商的主要功能之一:将这些表达式树转换为数据库(或类似物)可以理解的内容。
但是……该功能今天的实现方式有一个主要的限制:表达式树不支持动态对象。例如,以下代码将无法编译:
Expression<Action<dynamic>> func = x => Console. WriteLine( "Value = {0}", x );
编译器对此不满意,并会引发错误:“表达式树不能包含动态操作”。因此,我们受限于在编写表达式时使用的实际非动态类型的属性和方法。
这对我们来说很糟糕,如果我们想以一种不受特定类型束缚的、抽象的方式来表达逻辑表达式。例如,这正是我在编写“Kerosene
”库时遇到的需求,这是一个动态 ORM,允许你用纯 C# 编写任意 SQL 代码,而无需提前指定对数据库中使用的架构或类型的任何了解。因此,本质上我需要一个解决方案,让我能够编写和编译类似以下代码的内容:
Action<dynamic> func = x => x.Where( x.FirstName >= "John" );
此示例可以编译,因为它不是表达式树,而是完全有效的动态委托。请注意,我并没有假设代码的其他部分有名为“FirstName
”的属性。恰恰相反。我想要的是能够编写这样的表达式,而不考虑我使用的属性和方法,并之后以动态和后期绑定的方式进行解析。
另一个值得注意的有趣之处在于,我们概念上是用标准的 C# 运算符对字符串进行了比较,而这是编译器不允许的。这是使用这种后期绑定机制的另一个好处:这些检查是在运行时而不是编译时完成的(而且你会发现,在我们的例子中,它们甚至没有被强制执行,因为我们将只处理“概念”变量,而不是实际变量)。
好的,很酷,第一个目标已经实现。但是现在,我们如何从这种“动态委托”(定义为至少有一个动态参数的委托)中获得表达式树呢?
DynamicParser 解决方案
由于我没有在任何地方找到满足这些需求的解决方案,因此我开发了 DynamicParser
类。
它的静态方法 Parse()
接受一个动态委托作为参数,对其进行解析,然后返回一个恰好是此 DynamicParser
类实例的对象。这个创建的实例的生命任务是存储解析结果。此结果可以在其 Result
属性中找到。
通常,此属性将以 DynamicNode
的派生类的实例形式存储你的逻辑表达式树的表示,DynamicNode
是一个专门的、抽象的类,为此目的而设计。我说“通常”是因为在某些专门的情况下,存储的对象可能是其他类型,但我们稍后会看到。
另一个有趣的属性是 Arguments
。它允许枚举在解析表达式时找到的动态参数,这些参数存储为 DynamicNode.Argument
类的实例。DynamicParser
会为你创建这个枚举及其内容。请注意,使用 DynamicParser
的动态参数数量没有硬性限制——但如果你使用 Func<>
或 Action<>
的形式创建 Lambda 表达式,则动态参数的数量限制为 15 个(这是 C# 的限制)。
为了理解其用法,让我们看第一个例子:
Func< dynamic, object > fn = x => x.Id >= "Foo"; var parser = DynamicParser.Parse( fn ); Console.WriteLine( "Expression: {0}", parser.Result );
它会打印出预期的逻辑表达式:
(x.Id GreaterThanOrEqual Foo)
这很好:我们从动态委托中获得了逻辑表示,通过解析它,以一种抽象的、不绑定到任何特定类型的方式。这种逻辑表示等同于标准的 C# 表达式树。请再次记住,为了以这种抽象的方式注册逻辑,并允许以后更容易地操作,这种表示是由派生自 DynamicNode
的类的实例组成的,如上所述。
也要记住结果存储在 Result
属性中。原因是,DynamicParser
可能会从其执行中返回任意对象。此外,通过这样做,我们将拥有一个具有其他有价值信息的属性的对象。
现在,在解释如何操作这些树或 DynamicParser
如何工作之前,让我们看第二个可以解析的动态表达式的示例:
int num = 0; Func<dynamic, object> fn = x => !( x.Aplha[num++,"Hello"].Beta["ZZ"+num] >= x.Beta || x( num ) == null ); for( int i = 0; i < 5; i++ ) { var parser = DynamicParser.Parse( fn ); Console.WriteLine( "Parsed: {0}", parser.Result ); }
例如,在第四次迭代中,此代码将产生以下行:
(Not ((x.Aplha[ 3, Hello ].Beta[ ZZ4 ] GreaterThanOrEqual x.Beta) Or (x( 4 ) Equal null)))
有趣的是,我们使用了 Lambda 表达式中的外部变量,像闭包一样。我们也可以使用另一个对象的外部方法或属性。在这些情况下,当这些属性或方法未附加到动态参数时,将被视为“外部”的,并且其值将被捕获并存储在结果树中。
请注意,这些值是在表达式被解析(或调用,如果你愿意)的那一刻被捕获的。这就是为什么在每次迭代中,“num
”变量使用的值都会发生变化。
另一个有趣之处在于我们是如何“直接”调用动态参数的,例如“x( num )
”。这在 DynamicParser
中是完全有效且受支持的表达式,记录在一个特定的节点中,并且可以用于你以后可能想要的任何目的。
让我们现在看看另一个有趣的例子:
Func<dynamic, object> func = x => new { x.FirstName, x.FamilyName, Salary = x.Base + x.Variable }; var parser = DynamicParser.Parse( func ); Console.WriteLine( "Type: {0}", parser.Result.GetType().Name ); Console.WriteLine( "Parsed: {0}", parser.Result );
它将产生:
Type: <>f__AnonymousType0`3 Parsed: ( x ) => { FirstName = x.FirstName, FamilyName = x.FamilyName, Salary = (x.Base Add x.Variable) }
这非常有趣,因为正如你所见,解析的结果不一定是表达式树。在这种情况下,结果是一个具有三个属性的匿名类型,每个属性都是一个 DynamicNode
实例:前两个是“获取成员”表达式,最后一个加法运算是“二元表达式”。另外请注意,前两个是自动(动态)命名的,而最后一个我们给了它一个特定的名称。
限制
DynamicParser
解决方案只能解析至少有一个动态参数的委托。事实上,如果你不使用动态参数,那么使用常规 C# 表达式树可能效果更好。- 三元运算符“
?:
”不受支持。因为该解决方案的工作方式,它只能解析“then”部分或“else”部分,但不能同时解析两者。 - 对转换运算符的支持有限,例如“
(string)x.Name
”。实际上,字符串、可空类型以及具有无参构造函数的类型是完全支持的。所有其他类型都转换为普通的“object”,这可能会对要解析的表达式施加限制。
工作原理
基本思想是实际执行委托,使用其 DynamicInvoke()
方法,并拦截并记录 DLR(动态语言运行时)针对使用的动态参数执行的动态调用和绑定。
棘手的部分是如何在执行给定绑定后继续进行,所谓“继续进行”。如果我们要绑定的动态参数只是派生自 DynamicObject
,我发现没有办法让它发生。但经过大量研究和实验,我找到了一个方法,通过使用 IDynamicMetaObjectProvider
接口来实现。
当一个类实现此接口时,其 GetMetaObject()
方法应该返回派生自 DynamicMetaObject
的类的实例,在我们的解决方案中,它将是 DynamicMetaNode
类。DynamicParser
所做的是以一种非常特定的方式重写 BindXXX()
方法,从而使动态委托的执行能够流畅进行且不中断。因此,我能够拦截和注释每个逻辑绑定,并利用这些知识来构建逻辑树及其节点。
我在抽象的 DynamicNode
类中实现了这个机制,该类是构成逻辑树的所有节点类型的父类。用于调用委托的动态参数是 DynamicNode.Argument
类的实例,因此它们可以使用上面解释的机制来启动动态绑定及其注释。
那么,这种方法是什么?本质上,这意味着每个 BindXXX()
操作都应该返回一个新的 DelegateMetaNode
对象,该对象以一种方式构建,使其 Expression
和 Restrictions
属性允许新创建的对象用于下一个绑定操作。实际(简化)示例如下:
public override DynamicMetaObject BindGetMember( GetMemberBinder binder ) { var obj = (DynamicNode)this.Value; var node = new DynamicNode.GetMember( obj, binder.Name ) { _Parser = obj._Parser }; obj._Parser.LastNode = node; var par = Expression.Variable( typeof( DynamicNode ), "ret" ); var exp = Expression.Block( new ParameterExpression[] { par }, Expression.Assign( par, Expression.Constant( node ) ) ); return new DynamicMetaNode( exp, this.Restrictions, node ); }
如果你查看下载中的代码,你会发现实现起来最棘手、最困难的是 BindConvert()
和 BindUnary()
方法。
第一个是因为我必须返回一个 DynamicMetaNode
,它需要与转换操作所期望的类型兼容,为此,我必须创建适当的“真实”对象,类型正确。但是例如字符串没有默认的无参构造函数。我最终将字符串视为一个特殊情况,并为其他类型尝试一些默认的可能性。这就是为什么转换运算符的支持是有限的。
第二个甚至更难,因为绑定机制注入了最初我不知道的 IsFalse
和 IsTrue
表达式类型。所以整个方案一直失败,直到我意识到这些一元表达式是在我不知道或无法控制的情况下由绑定机制注入到流程中的,我学会了如何处理它们。
操作逻辑树
那么,既然我们已经获得了逻辑表示,我们该如何操作它呢?最好的方法是利用任何返回的节点都是派生自 DynamicNode
的类的实例这一事实,使用访问者模式的实现。我不会深入探讨这个模式,因为互联网上有很多关于它的信息。
这些节点中的每一个都有一些属性,用于存储你需要了解它们是如何创建的、它们引用了哪些其他对象等等的信息。请注意,除了每个派生类可能拥有的属性外,基抽象 DynamicNode
类还提供了一个名为 Host
的属性。当此属性不为 null 时,意味着该实例是更广泛的逻辑表达式的一部分,由此属性返回的节点维护。
那么,这些专门的类是什么?
DynamicNode.Argument
:它表示委托或 Lambda 表达式中使用的动态参数。其Tag
属性维护其在表达式中使用的实际名称。例如,在解析动态 Lambda 表达式“x => x.Id > 7
”时,此属性维护字符串“x
”。如上所述,你可以从其静态Parse()
方法返回的DynamicParser
实例的Arguments
属性获取使用的动态参数列表。DynamicNode.GetMember
:它表示成员访问操作,例如“x => x.BadgeId
”。在这种情况下,其Name
属性维护字符串“BadgeId
”,并且其Host
属性引用DynamicNode.Argument
实例,如上所述。让我们看另一个例子:“x => x.Employee.BadgeID
”。在这种情况下,Name
属性也包含字符串“BadgeID
”,但其Host
属性引用另一个GetMember
类实例,该实例维护“x.Employee
”访问操作。DynamicNode.SetMember
:它表示设置成员操作,例如“x => x.BadgeID = "007"
”。它基本上与GetMember
类相同,但它有一个额外的名为Value
的属性,类型为 object,用于存储已(概念上)设置到此成员的值。DynamicNode.GetIndex
:它表示索引获取操作,例如“x => x.Id[ 27 ]
”。在这种情况下,其Host
是“x.Id
”操作的GetMeber
实例,并且它没有Name
属性,而是有一个类型为 object 数组的Indexes
属性,其中存储了索引的实际值。请注意,这些索引可以是常规对象,也可以是动态表达式,例如“x => x[ x.Alpha ]
”。DynamicNode.SetIndex
:它表示索引设置操作,例如“x => x.Id[ 27 ] = DateTime.Now
”。是的,除了GetIndex
的那些之外,它还有自己的Value
属性来存储要设置到此索引属性的值。DynamicNode.Method
:它表示方法调用,例如“x => x.Property.Method( args )
”。在这种情况下,其Host
属性维护“x.Property
”访问操作,并且它有两个额外的属性:Name
,它维护被调用方法的名称,在这种情况下是字符串“Method
”,以及Arguments
属性,它维护一个包含所用参数的数组。如果没有使用参数,则此数组可以为空。DynamicNode.Invoke
:它表示动态参数的直接调用,例如“x => x( args )
”。其Host
属性表示动态参数,其Arguments
属性是参数的数组(可能为空)。DynamicNode.Binary
:它表示二元操作。例如,逻辑比较(如“x => x.Alpha >= x.Beta
”),或数字类运算符(如“x => x.Alpha + x.Beta
”),或许多其他可能性。其Host
属性为 null,因为它不属于更广泛的表达式。其Left
属性维护其左操作数,并且它始终是DynamicNode
实例(实际上是启动二元运算绑定的那个)。其Right
属性维护右操作数,类型为 object,因为它可以是任何值或对象。其Operation
属性类型为ExpressionType
,并维护已绑定的二元运算。DynamicNode.Unary
:它表示一元操作,例如“x => !x.BadgeID
”。其Host
属性为 null,因为它不属于更广泛的表达式。它也有与上面相同的Operation
属性,以及一个Target
属性,该属性维护绑定到此一元操作的DynamicNode
实例。- 最后是
DynamicNode.Convert
类:它表示转换操作,例如“x => (string)x.BadgeID
”。其Target
属性维护绑定到此转换操作的DynamicNode
实例,以及一个ConvertTo
属性,该属性维护要转换到的类型。
一个有趣的观点是,你可以直接实例化上述类,因此如果你需要,你可以从头开始创建自己的逻辑树,或者将任何可能获得的结果包含到更复杂的逻辑树中,例如。
关注点
首先要提到的是,CLR/DLR 默认情况下,当动态委托执行一次后,其编译代码将被缓存,并且不会再次解析(绑定)。显然,这是出于性能原因,但对我们来说,这种默认操作模式非常不幸。是的,假设你的表达式使用了外部变量,或者调用了外部方法。那么它的值将被缓存,而不会再次获取。
好消息是,DynamicParser
生成其内部的 DynamicMetaNodes
实例,其构建方式使得委托的编译代码不会被缓存。因此,每次调用解析时,都会调用外部变量和方法。
第二点要提到的是,在解析一元操作时,CLR/DLR 可以自动注入 IsTrue
或 IsFalse
逻辑操作。你可能不知道这一点,但它们确实存在,并且它们将解析为布尔值。DynamicParser
始终将这些场景解析为 false,以便保持流程的继续,并解析表达式的其余部分(如果存在)。
历史
- [v4, 2012 年 8 月]:此版本侧重于改进、更清晰的架构和性能。
- [v3, 2010 年 10 月]:避免了 DLR 之前执行的已解析委托的缓存。
- [v2, 2010 年 5 月]:更清晰的解决方案和一些错误修复。
- [v1, 2010 年 4 月]:初始版本。