2019 年 9 月

第 34 卷,第 9 期

[.NET 开发]

Visual Basic 和 C# 中的表达式树

作者:Zev Spitz

想象一下,如果你有表示某个程序各个部分的对象。你的代码可以以各种方式组合这些部分,从而创建表示新程序的对象。你可以将此对象编译为新程序并运行它;你的程序甚至可以重写自己的逻辑。你可以将此对象传递给不同的环境,其中各个部分被解析为一组指令,以便在此其他环境中运行。

欢迎使用表达式树。本文的目的是以高级别概述的方式介绍表达式树、其在代码操作建模和运行时代码编译中的用法以及创建和转换它们的方法。

关于语言需注意的一点 对于 Visual Basic 和 C# 中的许多语言功能,语法实际上只是对与语言无关的 .NET 类型和方法的精简包装器。例如,C# 的 foreach 循环和 Visual Basic 的 For Each 构造都调用 IEnumerable-implementing 类型的 GetEnumerator 方法。LINQ 关键字语法解析为 Select 和 Where 等方法,且带有相应的签名。每次在 Visual Basic 中写入“Using”或 C# 中写入“using 块”时,都会生成对包装在错误处理中的 IDisposable.Dispose 的调用。

表达式树也是如此,同时,Visual Basic 和 C# 提供对创建表达式树的语法支持,并且可以利用 .NET 基础结构(在 System.Linq.Expressions 命名空间中)来处理它们。因此,此处介绍的概念同时适用于两种语言。无论主要开发语言是 C# 还是 Visual Basic,相信你都会通过本文受益。

表达式、表达式树和 System.Linq.Expressions 中的类型

Visual Basic 和 C# 中的表达式是一段代码,在经过计算后返回一个值,例如:

42
"abcd"
True
n

表达式可以由其他表达式组成,例如:

x + y
"abcd".Length < 5 * 2

这样便形成一个由表达式组成的树,即表达式树。想一想 Visual Basic 中的表达式 n + 42 = 27,或 C# 中的 n + 42 == 27,可以将其拆分成若干部分,如图 1 所示。

Breakdown of an Expression Tree
图 1:表达式树的分解图

.NET 在 System.Linq.expression 命名空间中提供了一组类型,用于构造表示表达式树的数据结构。例如,n + 42 = 27 可以使用对象和属性来表示,如图 2 所示。(请注意,此代码不会编译 - 这些类型没有公共构造函数,并且是固定不变的,因此没有对象初始值设定项。)

图 2:使用对象表示法的表达式树对象

New BinaryExpression With {
  .NodeType = ExpressionType.Equal,
  .Left = New BinaryExpression With {
    .NodeType = ExpressionType.Add,
    .Left = New ParameterExpression With {
      .Name = "n"
    },
    .Right = New ConstantExpression With {
      .Value = 42
    }
  },
  .Right = New ConstantExpression With {
    .Value = 27
  }
}
new BinaryExpression {
  NodeType = ExpressionType.Equal,
  Left = new BinaryExpression {
    NodeType = ExpressionType.Add,
    Left = new ParameterExpression {
      Name = "n"
    },
    Right = new ConstantExpression {
      Value = 42
    }
  },
  Right = new ConstantExpression {
    Value = 27
  }
}

.NET 中的术语“表达式树”同时用于语法表达式树 (x + y) 和表达式树对象(Binary­Expression 实例)。

了解表达式树中给定节点表示的含义,节点对象的类型 - BinaryExpression、ConstantExpression、ParameterExpression - 仅描述表达式的基本框架。例如,BinaryExpression 类型指示所表示的表达式有两个操作数,但没有说明是哪个操作合并了这些操作数。操作数是相加、相乘还是比较数值?该信息包含在表达式基类中定义的 NodeType 属性中。(请注意,某些节点类型不表示代码操作 - 请参阅“元节点类型。”)

元节点类型

大多数节点类型都表示代码操作。但是,还有三种“元”节点类型,这种节点类型提供有关树的信息,而不直接映射到代码。

ExpressionType.Quote 此类型的节点总是包装有 LambdaExpression,并指定 LambdaExpression 定义一个新的表达式树,而不是一个代理。例如,从以下 Visual Basic 代码生成的树:

Dim expr As Expression(Of Func(Of Func(Of Boolean))) = Function() Function() True

或以下 C# 树:

Expression<Func<Func<bool>>> expr = () => () => true;

表示生成另一个代理的代理。如果要表示可生成另一个表达式树的代理,需要将内部代理包装在 Quotenode 中。编译器使用以下 Visual Basic 代码自动执行此操作:

Dim expr As Expression(Of Func(Of Expression(Of Func(Of Boolean)))) =
  Function() Function() True

或使用以下 C# 代码:

 

Expression<Func<Expression<Func<bool>>>> expr = () => () => true;

ExpressionType.DebugInfo 此节点发出调试信息,因此当调试已编译的表达式时,特定点的 IL 可以映射到源代码中的正确位置。

ExpressionType.RuntimeVariablesExpression 考虑 ES3 中的参数对象;它可在函数中使用,且无需声明为显式变量。Python 公开局部变量函数,该函数返回本地命名空间中定义的变量字典。这些“虚拟”变量在表达式树中使用 RuntimeVariablesExpression 进行描述。

需要注意的另一个表达式属性是 Type 属性。在 Visual Basic 和 C# 和 中,每个表达式都具有一种类型 - 将两个 Integer 相加将得到一个 Integer,而将两个 Double 相加将得到一个 Double。Type 属性返回表达式的 System.Type。

可视化表达式树的结构时,关注这两个属性非常有用,如图 3 所示。

NodeType, Type, Value and Name Properties
图 3:NodeType、Type、Value 和 Name 属性

如果没有这些类型的公共构造函数,该如何创建它们?您有两种选择。如果在需要 expression 类型的位置编写 lambda 语法,编译器可以自动生成这些对象。或者,可以在 Expression 类上使用共享(C# 中为静态)工厂方法。

构造表达式树对象 I:使用编译器

构造这些对象的简单方法是让编译器来生成它们,如前所述,方法是:在任何需要 Expression(TDelegate)(C# 中为 Expression<TDelegate>)的地方使用 lambda 语法:要么赋值给类型化变量,要么用作类型化参数的自变量。在 Visual Basic 中引入 Lambda 语法需要使用 Function 或 Sub 关键字,其后跟参数列表和方法体;在 C# 中,“=>”运算符指示 lambda 语法。

用于定义表达式树的 Lambda 语法称为表达式 lambda(相对于语句 lambda),在 C# 中类似于:

Expression<Func<Integer, String>> expr = i => i.ToString();
IQueryable<Person> personSource = ...
var qry = qry.Select(x => x.LastName);

在 Visual Basic 中是这样的:

Dim expr As Expression(Of Func(Of Integer, String))  = Function(i) i.ToString
Dim personSource As IQueryable(Of Person) = ...
Dim qry = qry.Select(Function(x) x.LastName)

以这种方式生成表达式树有一些限制:

  • 受 lambda 语法的限制
  • 仅支持单行 lambda 表达式,无多行 lambda
  • 表达式 lambdas 只能包含表达式,而不能包含 If…Then 或 Try…Catch 等语句
  • 无晚期绑定(C# 中为动态绑定)
  • 在 C# 中,没有命名的参数或省略的可选参数
  • 无 null 传播运算符

构造的表达式树的完整结构(即节点类型、子表达式类型和方法的重载解析)在编译时便确定下来。因为表达式树不可变,所以结构一旦创建就无法修改。

另请注意,编译器生成的树会模仿编译器的行为,因此在给定原始代码的情况下,生成的树可能会与预期不同(图 4)。一些示例:

Compiler-Generated Trees vs. Source Code
图 4:由编译器生成的树与源代码

闭合变量 为了在进出 lambda 表达式时引用变量的当前值,编译器会创建一个隐藏类,其成员对应于每个引用的变量;lambda 表达式的函数变成类的方法(请参阅“闭合变量”)。相应的表达式树将闭合变量呈现为隐藏的实例上的 MemberAccess 节点。在 Visual Basic 中,$VB $Local_ 前缀也将附加到变量名称中。

遮蔽变量

当 lambda 表达式引用在其外部定义的变量时,我们说 lambda 表达式“遮蔽”了该变量。编译器须特别注意此变量,因为可能会在变量的值更改后使用 lambda 表达式,lambda 表达式应引用该新值。反之亦然,lambda 表达式可能会更改该值,且此更改应在 lambda 表达式外可见。例如,在以下 C# 代码中:

var i = 5;
Action lmbd = () => Console.WriteLine(i);
i = 6;
lmbd();

或以下 Visual Basic 代码中:

Dim i = 5
Dim lmbd = Sub() Console.WriteLine(i)
i = 6
lmbd()

预期输出为 6,而不是 5,因为 lambda 表达式应该在 i 已设置为 6 且 lambda 表达式被调用时,使用 i 的值。

C# 编译器通过创建一个隐藏类来执行此操作,并将所需变量作为类的字段,lambda 表达式作为类上的方法。然后,编译器将该变量的所有引用替换为类实例上的成员访问。类实例不会更改,但其字段的值可以更改。在 C# 中生成的 IL 类似于以下内容:

[CompilerGenerated]
private sealed class <>c__DisplayClass0_0 {
  public int i;
  internal void <Main>b__0() => Console.WriteLine(i);
}
var @object = new <>c__DisplayClass0_0();
@object.i = 5;
Action lmbd = @object.<Main>b__0;
@object.i = 6;
lmbd();

Visual Basic 编译器的执行内容相似,只有一个区别:属性名称前附加 $VB$Local,如下所示:

 

<CompilerGenerated> Friend NotInheritable Class _Closure$__0-0
  Public $VB$Local_i As Integer
  Sub _Lambda$__0()
    Console.WriteLine($VB$Local_i)
  End Sub
End Class
Dim targetObject = New _Closure$__0-0 targetObject With { .$VB$Local_i = 5 }
Dim lmbd = AddressOf targetObject. _Lambda$__0
targetObject.i = 6
lmbd()

NameOf 运算符 NameOf 运算符的结果呈现为常量字符串值。

字符串内插和装箱转换 这会解析为对 String.Format 的调用和常量格式字符串。由于内插表达式的类型为值类型 - Date(DateTime 在 Visual Basic 中的别名),而 String.Format 对应的参数需要一个 Object,因此编译器还会将内插表达式与 Convert 节点包装到 Object。

扩展方法调用 这些实际上是对模块级方法(C# 中的静态方法)的调用,它们在表达式树中呈现为这种形式。模块级方法和共享方法没有实例,因此相应的 MethodCallExpression 的 Object 属性将返回“无”(或 null)。如果 MethodCallExpression 表示一个实例方法的调用,则 Object 属性将不是“无”。

表达式树中表示扩展方法的方式突显了表达式树和 Roslyn 编译器语法树之间的一个重要区别,后者保留了原始语法;表达式树不太注重精确的语法,而更注重基本的操作。扩展方法调用的 Roslyn 语法树看上去类似于标准实例方法调用,而非共享或静态方法调用。

转换 当表达式的类型与预期类型不匹配,并且存在转换源为表达式类型的隐式转换时,编译器会将 Convert 节点中的内部表达式包装为预期类型。在生成表达式树且有一个表达式的类型会实现或继承自预期类型时,Visual Basic 编译器会执行相同的操作。例如,在图 4 中,Count 扩展方法需要 IEnumerable(Of Char)(字符型),但表达式的实际类型为 String。

构造表达式树对象 II:使用工厂方法

还可以通过在 System.Linq.Expressions.Expression 处使用共享(C# 中为静态方法)工厂方法构造表达式树。例如,如要在 Visual Basic 中为 i.ToString 构造表达式树对象,其中 i 为整数(或 C# 中的 int ),使用如下所示的代码:

' Imports System.Linq.Expressions.Expression
Dim prm As ParameterExpression = Parameter(GetType(Integer), "i")
Dim expr As Expression = [Call](
  prm,
  GetType(Integer).GetMethods("ToString", {})
)

在 C# 中,代码看上去应类似于:

// Using static System.Linq.Expressions.Expression
ParameterExpression prm = Parameter(typeof(int), "i");
Expression expr = Call(
  prm,
  typeof(int).GetMethods("ToString", new [] {})
);

虽然以这种方式构建表达式树通常需要大量的反射和对工厂方法的多次调用,但可以更灵活且精确定制所需的表达式树。使用编译器语法,需要写出程序可能需要的所有可能的表达式树变体。

此外,工厂方法 API 支持生成编译器当前不支持生成的一些表达式。请看几个示例:

类似于 System.Void-returning Conditional­Expression 的语句,它相当于 If..Then 和 If..Then..Else..End If(在 C# 中为 if (...) { ...} else { ... })或表示 Try..Catch 或 try { ... } catch (...) { ... } 块的 TryCatchExpression。

Assignments Dim x As Integer: x = 17.

用于将多个语句组合在一起的块。

例如,考虑以下 Visual Basic 代码:

Dim msg As String = "Hello!"
If DateTime.Now.Hour > 18 Then msg = "Good night"
Console.WriteLine(msg)

或以下等效的 C# 代码:

string msg = "Hello";
if (DateTime.Now.Hour > 18) {
  msg = "Good night";
}
Console.WriteLine(msg);

你可以在 Visual Basic 或 C# 中使用工厂方法构造相应的表达式树,如图 5 所示。

图 5:表达式树中的块、赋值和语句

' Imports System.Linq.Expressions.Expression
Dim msg = Parameter(GetType(String), "msg")
Dim body = Block(
  Assign(msg, Constant("Hello")),
  IfThen(
    GreaterThan(
      MakeMemberAccess(
        MakeMemberAccess(
          Nothing,
          GetType(DateTime).GetMember("Now").Single
        ),
        GetType(DateTime).GetMember("Hour").Single
      ),
      Constant(18)
    ),
    Assign(msg, Constant("Good night"))
  ),
  [Call](
    GetType(Console).GetMethod("WriteLine", { GetType(string) }),
    msg
  )
)
// Using static System.Linq.Expressions.Expression
var msg = Parameter(typeof(string), "msg");
var expr = Lambda(
  Block(
    Assign(msg, Constant("Hello")),
    IfThen(
      GreaterThan(
        MakeMemberAccess(
          MakeMemberAccess(
            null,
            typeof(DateTime).GetMember("Now").Single()
          ),
          typeof(DateTime).GetMember("Hour").Single()
        ),
        Constant(18)
      ),
      Assign(msg, Constant("Good night"))
    ),
    Call(
      typeof(Console).GetMethod("WriteLine", new[] { typeof(string) }),
      msg
    )
  )
);

使用表达式树 I:将代码构造映射到外部 API

表达式树最初的设计目的是将 Visual Basic 或 C# 语法映射到不同的 API。典型的用例是生成 SQL 语句,如下所示:

SELECT * FROM Persons WHERE Persons.LastName LIKE N'D%'

此语句可以派生自类似于以下代码段的代码,该代码段使用成员访问(“.”运算符)、表达式内的方法调用和 Queryable.Where 方法。下面是 Visual Basic 中的代码:

Dim personSource As IQueryable(Of Person) = ...
Dim qry = personSource.Where(Function(x) x.LastName.StartsWith("D"))

下面是 C# 中的代码:

IQueryable<Person> personSource = ...
var qry = personSource.Where(x => x.LastName.StartsWith("D");

这是如何实现的呢?有两个可与 lambda 表达式一起使用的重载 - Enumerable.Where 和 Queryable.Where。但是,重载解析首选 lambda 表达式是 expression lambda - 即 Queryable.Where - 的重载,而不是采用代理的重载。然后,编译器将 lambda 语法替换为对相应工厂方法的调用。

在运行时,Queryable.where 方法将传入的表达式树与一个 Call 节点包装,该节点的 Method 属性引用 Queryable.Where 本身,并采用两个参数 - personSource 和 lambda 语法中的表达式树**(图 6**)。(Quote 节点指示内部表达式树正在作为表达式树(而非代理)被传递给 Queryable.Where。)

Visualization of Final Expression Tree
图 6:最终表达式树的可视化

LINQ 数据库提供程序(如实体框架、LINQ2SQL 或 NHibernate)可以采用这样的表达式树,并将不同部分映射到本节开头的 SQL 语句中。以下是具体方式:

  • 对 Queryable.Where 的 ExpresssionType.Call 调用解析为 SQL WHERE 子句
  • Person 实例上 LastName 的 ExpressionType.MemberAccess 变为读取 Persons 表中的 LastName 字段 - Persons.LastName
  • 对带有常量参数的 StartsWith 方法的 ExpressionType.Call 调用将转换为 SQL LIKE 运算符(按照一个匹配常量字符串开头部分的模式):
LIKE N'D%'

因此,可以使用代码构造和约定控制外部 API,并具备编译器的所有优点 - 类型安全性、表达式树各部分的语法正确以及 IDE 的自动完成。一些其他示例:

创建 Web 请求 使用 Simple.OData.Client 库 (bit.ly/2YyDrsx),可以通过将表达式树传递给各种方法来创建 OData 请求。该库将输出正确的请求(图 7 显示了 Visual Basic 和 C# 的代码)。

图 7:使用 Simple.OData.Client 库和表达式树的请求

Dim client = New ODataClient("https://services.odata.org/v4/TripPinServiceRW/")
Dim people = Await client.For(Of People)
  .Filter(Function(x) x.Trips.Any(Function(y) y.Budget > 3000))
  .Top(2)
  .Select(Function(x) New With { x.FirstName, x.LastName})
  .FindEntriesAsync
var client = new ODataClient("https://services.odata.org/v4/TripPinServiceRW/");
var people = await client.For<People>()
  .Filter(x => x.Trips.Any(y => y.Budget > 3000))
  .Top(2)
  .Select(x => new {x.FirstName, x.LastName})
  .FindEntriesAsync();

传出的请求:

 

> https://services.odata.org/v4/TripPinServiceRW/People?$top=2 &amp;
  $select=FirstName, LastName &amp; $filter=Trips/any(d:d/Budget gt 3000)

反射示例 无需使用反射来获取 MethodInfo,可以在表达式中编写方法调用,并将该表达式传递给某个函数,该函数提取在调用中使用的特定重载。以下是 Visual Basic 的反射代码:

Dim writeLine as MethodInfo = GetType(Console).GetMethod(
  "WriteLine", { GetType(String) })

以及 C# 中的等效代码:

MethodInfo writeLine = typeof(Console).GetMethod(
  "WriteLine", new [] { typeof(string) });

以下是 Visual Basic 中此函数的样子及其用法:

Function GetMethod(expr As Expression(Of Action)) As MethodInfo
  Return CType(expr.Body, MethodCallExpression).Method
End Function
Dim mi As MethodInfo = GetMethod(Sub() Console.WriteLine(""))

它在 C# 中的样子如下所示:

public static MethodInfo GetMethod(Expression<Action> expr) =>
  (expr.Body as MethodCallExpression).Method;
MethodInfo mi = GetMethod(() => Console.WriteLine(""));

此方法还简化了处理扩展方法和构造封闭的泛型方法的过程,如 Visual Basic 中所示:

Dim wherePerson As MethodInfo = GetMethod(Sub() CType(Nothing, IQueryable(Of
  Person)).Where(Function(x) True)))

它还保证编译时该方法和重载的存在,如 C# 中所示:

 

// Won’t compile, because GetMethod expects Expression<Action>, not Expression<Func<..>>
MethodInfo getMethod = GetMethod(() => GetMethod(() => null));

网格列配置 如果你有某种网格 UI,并且希望允许以声明性的方式定义列,则可以使用一种基于数组文本(使用 Visual Basic)中的子表达式来生成列的方法:

grid.SetColumns(Function(x As Person) {x.LastName, x.FirstName, x.DateOfBirth})

或一种基于匿名类型(C# 中)中的子表达式来生成列的方法:

grid.SetColumns((Person x) => new {x.LastName, x.FirstName, DOB = x.DateOfBirth});

使用表达式树 II:在运行时编译不可撤销的代码

表达式树的第二个主要用例是在运行时生成可执行代码。还记得以前的主体变量吗?可以将其包装在 LambdaExpression 中,将其编译为代理并调用该代理,一切均在运行时完成。相应代码在 Visual Basic 中为:

Dim lambdaExpression = Lambda(Of Action)(body)
Dim compiled = lambdaExpression.Compile
compiled.Invoke
' prints either "Hello" or "Good night"

在 C# 中为:

var lambdaExpression = Lambda<Action>(body);
var compiled = lambdaExpression.Compile();
compiled.Invoke();
// Prints either "Hello" or "Good night"

将表达式树编译为可执行代码对于在 CLR 之上实现其他语言非常有用,因为使用表达式树比直接操作 IL 要容易得多。但是,如果你正在 C# 或 Visual Basic 中编程,并且在编译时知道程序的逻辑,那么为什么不在编译时将该逻辑嵌入到现有方法或程序集中?这样就无需在运行时进行编译了。

不过,如果你在设计时不知道最佳路径或正确的算法,那么运行时编译确实是不错的选择。使用表达式树和运行时编译,在运行时根据实际情况或字段数据反复重写和改进程序的逻辑会相对容易一些。

自重写代码:动态类型化语言中的调用站点缓存 例如动态语言运行时 (DLR) 中的调用站点缓存,它支持在面向 CLR 的语言实现中进行动态类型化。它利用表达式树提供强大的优化,在需要时以迭代方式重写分配给特定调用站点的代理。

C# 和 Visual Basic 都是静态类型语言(在大多数情况下),对于语言中的每个表达式,都可解析一个在程序生命周期内不会更改的固定类型。换句话说,如果变量 x 和 y 已声明为 Integer(或 C# 中为 int ),并且程序包含一行代码 x + y,则该表达式值的解析将始终对两个整数使用“添加”指令。

但是,动态类型化语言没有此类保证。通常,x 和 y 没有固有类型,因此对 x + y 的计算必须考虑到 x 和 y 可以为任何类型,比如 String,在这种情况下,解析 x + y 意味着使用 String.Concat。另一方面,如果程序第一次命中表达式时识别 x 和 y 是整数,那么很有可能接下来命中时会为 x 和 y 使用相同类型。DLR 通过调用站点缓存来利用这一点,每次遇到新类型时,都会使用表达式树来重写代理。

每个调用站点都会被分配一个 CallSite(Of T)(C# 中为 CallSite<T>)实例,该实例具有一个指向已编译的代理的 Target 属性。每个站点还会获取一组测试,以及每个测试成功时应执行的操作。最初,Target 代理只有用于更新其自身的代码,如下:

‘ Visual Basic code representation of Target delegate
Return site.Update(site, x, y)

在第一次迭代中,Update 方法将从语言实现中检索可应用的测试和操作(例如,“如果两个参数都是整数,则使用“添加”指令)。然后,会生成一个表达式树,该表达式树仅在测试成功时执行操作。生成的表达式树的代码等效项可能如下所示:

‘ Visual Basic code representation of expression tree
If TypeOf x Is Integer AndAlso TypeOf y Is Integer Then Return CInt(x) + CInt(y)
Return site.Update(site, x, y)

然后,表达式树将编译为新的代理,并存储在 Target 属性中,而测试和操作将存储在调用站点对象中。

在后续迭代中,调用站点将使用新代理来解析 x + y。在新代理中,如果测试通过,将使用解析的 CLR 操作。仅当测试失败时(在本例中,如果 x 或 y 不是 Integer),Update 方法才需要再次转到语言实现。但是,当调用 Update 方法时,它将添加新的测试和操作,并重新编译 Target 代理来影响它们。此时,Target 代理将包含以前遇到的所有类型对的测试,以及每种类型的值解析策略,如以下代码所示:

If TypeOf x Is Integer AndAlso TypeOf y Is Integer Then Return CInt(x) + CInt(y)
If TypeOf x Is String Then Return String.Concat(x, y)
Return site.Update(site, x, y)

如果不能在运行时从表达式树编译代码,那么根据实际情况重写运行时代码将非常困难,甚至完全无法实现。

动态编译比较:使用表达式树和使用 Roslyn 你还可以使用 Roslyn 相对轻松地从简单字符串(而不是从表达式树)动态编译代码。事实上,如果从 Visual Basic 或 C# 语法开始,或需要保留自己生成的 Visual Basic 或 C# 语法,则更适合使用此方法。如前所述,Roslyn 语法树为语法建模,而表达式树仅表示代码操作,而不考虑语法。

此外,如果尝试构造一个无效的表达式树,唯一的结果是发生异常。使用 Roslyn 分析字符串并将其编译为可运行的代码时,可以获取有关不同编译部分的多个诊断信息,就像在 Visual Studio 中编写 C# 或 Visual Basic 时一样。

另一方面,对项目而言,Roslyn 是一个庞大而复杂的依赖项。你可能已拥有一组并非来自 Visual Basic 或 C# 源代码的代码操作;可能不必重写到 Roslyn 语义模型中。此外,请记住,Roslyn 要求多线程,如果不允许使用新增线程(例如在 Visual Studio 调试可视化工具中),则无法使用它。

重写表达式树:实现 Visual Basic 的 Like 运算符

我曾提到表达式树是不可变的;但可以创建一个重用部分原始表达式树的新表达式树。假设你要查询一个人员数据库,查询名字中包含一个“e”且后接一个”i”的人。 Visual Basic 有一个 Like 运算符,如果字符串与模式匹配,则返回 True,如下所示:

Dim personSource As IQueryable(Of Person) = ...
Dim qry = personSource.Where(Function(x) x.FirstName Like "*e*i*")
For Each person In qry
  Console.WriteLine($"LastName: {person.LastName}, FirstName: {person.FirstName}")
Next

但是,如果你在 Entity Framework 6 DbContext 上尝试此操作,会收到一个异常,并得到以下消息:

“LINQ to Entities 无法识别 'Boolean LikeString(System.String, System.String, Microsoft.VisualBasic.CompareMethod)'方法,且此方法无法转换为存储表达式。”

Visual Basic 的 Like 运算符解析为 LikeOperator.Like-­String 方法(在 Microsoft.VisualBasic.CompilerServices 命名空间中),EF6 无法将其转换为 SQL LIKE 表达式。因此,会发生错误。

现在,EF6 通过 DbFunctions.Like 方法支持提供类似的功能,EF6 可以将其映射到相应的 LIKE。你需要将表达式树从使用 Visual Basic Like 替换为使用 DbFunctions.Like,但不更改树的任何其他部分。执行此操作的常见做法是从 .NET ExpressionVisitor 类继承,并重写感兴趣的 Visit* base 方法。在例子中,由于我想替换方法调用,所以重写了 VisitMethodCall,如图 8 所示。

图 8:ExpressionTreeVisitor 将 Visual Basic Like 替换为 DbFunctions.Like

Class LikeVisitor
  Inherits ExpressionVisitor
  Shared LikeString As MethodInfo =
    GetType(CompilerServices.LikeOperator).GetMethod("LikeString")
  Shared DbFunctionsLike As MethodInfo = GetType(DbFunctions).GetMethod(
    "Like", {GetType(String), GetType(String)})
  Protected Overrides Function VisitMethodCall(
    node As MethodCallExpression) As Expression
    ' Is this node using the LikeString method? If not, leave it alone.
    If node.Method <> LikeString Then Return MyBase.VisitMethodCall(node)
    Dim patternExpression = node.Arguments(1)
    If patternExpression.NodeType = ExpressionType.Constant Then
      Dim oldPattern =
        CType(CType(patternExpression, ConstantExpression).Value, String)
      ' partial mapping of Visual Basic's Like syntax to SQL LIKE syntax
      Dim newPattern = oldPattern.Replace("*", "%")
      patternExpression = Constant(newPattern)
    End If
    Return [Call](DbFunctionsLike,
      node.Arguments(0),
      patternExpression
    )
  End Function
End Class

Like 运算符的模式语法与 SQL LIKE 的模式语法不同,因此我将把 Visual Basic Like 中使用的特殊字符替换为 SQL LIKE 使用的相应字符。(此映射不完整 - 它不映射 Visual Basic Like 的所有模式语法;也不转义 SQL LIKE 特殊字符,或取消转义 Visual Basic Like 特殊字符。完整的实现可以在 GitHub 上的 bit.ly/2yku7tx 中找到,其中还有 C# 版本的。)

请注意,只有当模式为表达式的一部分且表达式节点为 Constant 时,才能替换这些字符。如果模式是另一种表达式类型(如方法调用的结果,或连接其他两个字符串的 BinaryExpression 的结果),则模式的值在计算表达式之前并不存在。

现在可以将表达式替换为重写的表达式,并在查询中使用新表达式,如下所示:

Dim expr As Expression(Of Func(Of Person, Boolean)) =
  Function(x) x.FirstName Like "*e*i*"
Dim visitor As New LikeVisitor
expr = CType(visitor.Visit(expr), Expression(Of Func(Of Person, Boolean)))
Dim personSource As IQueryable(Of Person) = ...
Dim qry = personSource.Where(expr)
For Each person In qry
  Console.WriteLine($"LastName: {person.LastName}, FirstName: {person.FirstName}")
Next

理想情况下,此类转换将在 LINQ to Entities 提供程序中完成,其中整个表达式树(可能包括其他表达式树和 Queryable 方法调用)可以一次性重写,而不必在将每个表达式传递到 Queryable 方法之前重写每个表达式。但是,其核心的转换是相同的 - 某个访问所有节点并在需要时插入到替换节点中的类或函数。

总结

表达式树为各种代码操作建模,并可用于公开 API ,且无需开发人员学习新语言和词汇 - 开发人员可以利用 Visual Basic 或 C# 驱动这些 API,同时编译器提供类型检查和语法校正功能,IDE 提供 Intellisense 功能。可以创建表达式树修改后的副本,对其中的节点进行添加、删除或替换。表达式树还可用于在运行时动态编译代码,甚至可用于自重写代码;即使是在 Roslyn 更适合动态编译的情况下也是如此。

本文的代码示例可在 bit.ly/2yku7tx 中找到。

详细信息


Zev Spitz编写了一个库,用于将表达式树呈现为多种格式(C#、Visual Basic 和工厂方法调用)的字符串;还编写了一个用于表达式树的 Visual Studio 调试可视化工具。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Kathleen Dollard


在 MSDN 杂志论坛讨论这篇文章