表达式树
在创建 LINQ 查询时,通常使用 Lambda 表达式编写函数参数。 在典型的 LINQ 查询中,这些函数参数会被转换为编译器创建的委托。
当想要进行更丰富的交互时,需要使用表达式树。 表达式树将代码表示为可以检查、修改或执行的结构。 这些工具让你能够在运行时操作代码。 可以编写检查正在运行的算法的代码,或插入新的功能。 在更加高级的方案中,你可以修改正在运行的算法,甚至可以将 C# 表达式转换为另一种形式从而可在另一环境中执行。
表达式树说明
让我们来举一个简单的示例。 以下是一个代码行:
1 | var sum = 1 + 2; |
直观地看,整个语句是一棵树:应从根节点开始,浏览到树中的每个节点,以查看构成该语句的代码:
- 具有赋值 (var sum = 1 + 2;) 的变量声明语句
- 隐式变量类型声明 (var sum)
- 隐式 var 关键字 (var)
- 变量名称声明 (sum)
- 赋值运算符 (=)
- 二进制加法表达式 (1 + 2)
- 左操作数 (1)
- 加法运算符 (+)
- 右操作数 (2)
- 隐式变量类型声明 (var sum)
表达式树的结构非常一致。 了解基础知识后,你甚至可以理解以表达式树形式表示的最复杂的代码。 优美的数据结构说明了 C# 编译器如何分析最复杂的 C# 程序并从该复杂的源代码创建正确的输出。
不能执行的操作是修改表达式树。 表达式树是不可变的数据结构。 如果想要改变(更改)表达式树,则必须创建基于原始树副本但包含所需更改的新树。
支持表达式树的框架类型
这一切都始于 System.Linq.Expression
1 | Expression<Func<int, int>> addFive = (num) => num + 5; |
例如,此代码将打印变量访问表达式的变量的名称。 我的做法是,先查看节点类型,再转换为变量访问表达式,然后查看特定表达式类型的属性:
创建表达式树
System.Linq.Expression 类还包含许多创建表达式的静态方法。
1 | // Addition is an add expression for "1 + 2" |
导航 API
存在映射到 C# 语言的几乎所有语法元素的表达式节点类型。 每种类型都有针对该种语言元素的特定方法。 需要一次性记住的内容很多。 我不会记住所有内容,而是会采用有关使用表达式树的技巧,如下所示:
- 查看 ExpressionType 枚举的成员以确定应检查的可能节点。 如果想要遍历和理解表达式树,这将非常有用。
- 查看 Expression 类的静态成员以生成表达式。 这些方法可以从其子节点集生成任何表达式类型。
- 查看 ExpressionVisitor 类,以生成一个经过修改的表达式树。
执行表达式树
表达式树是表示一些代码的数据结构。 它不是已编译且可执行的代码。 如果想要执行由表达式树表示的 .NET 代码,则必须将其转换为可执行的 IL 指令。
Lambda 表达式到函数
可以将任何 LambdaExpression 或派生自 LambdaExpression 的任何类型转换为可执行的 IL。
其他表达式类型不能直接转换为代码。 此限制在实践中影响不大。
Lambda 表达式是你可通过转换为可执行的中间语言 (IL) 来执行的唯一表达式类型。
LambdaExpression 类型包含用于将表达式树转换为可执行代码的 Compile 和 CompileToMethod 成员。 Compile 方法创建委托。 CompileToMethod 方法通过表示表达式树的已编译输出的 IL 更新 MethodBuilder 对象。
请注意,CompileToMethod 仅在完整的桌面框架中可用,不能用于 .NET Core。
使用下面的代码将表达式转换为委托:
1 | Expression<Func<int>> add = () => 1 + 2; |
请注意,该委托类型基于表达式类型。 如果想要以强类型的方式使用委托对象,则必须知道返回类型和参数列表。 LambdaExpression.Compile() 方法返回 Delegate 类型。 必须将其转换为正确的委托类型,以便使任何编译时工具检查参数列表或返回类型。
在此提醒你不要通过避免不必要的编译调用尝试创建用于提高性能的任何更复杂的缓存机制。 比较两个任意的表达式树,以确定如果它们表示相同的算法,是否也会花费很长的时间来执行。 你可能会发现,通过避免对 LambdaExpression.Compile() 的任何额外调用所节省的计算时间将多于执行代码(该代码确定可导致相同可执行代码的两个不同表达式树)所花费的时间。
注意事项
将 lambda 表达式编译为委托并调用该委托是可对表达式树执行的最简单的操作之一。 但是,即使是执行这个简单的操作,也存在一些必须注意的事项。
Lambda 表达式将对表达式中引用的任何局部变量创建闭包。 必须保证作为委托的一部分的任何变量在调用 Compile 的位置处和执行结果委托时可用。
一般情况下,编译器会确保这一点。 但是,如果表达式访问实现 IDisposable 的变量,则代码可能在表达式树仍保留有对象时释放该对象。
例如,此代码工作正常,因为 int 不实现 IDisposable:
1 | private static Func<int, int> CreateBoundFunc() |
委托已捕获对局部变量 constant 的引用。 在稍后执行 CreateBoundFunc 返回的函数之后,可随时访问该变量。
但是,请考虑实现 IDisposable 的此(人为设计的)类:
1 | public class Resource : IDisposable |
如果将其用于如下所示的表达式中,则在执行 Resource.Argument 属性引用的代码时将出现 ObjectDisposedException:
1 | private static Func<int, int> CreateBoundResource() |
从此方法返回的委托已对释放了的 constant 对象闭包。 (它已被释放,因为它已在 using 语句中进行声明。)
现在,在执行从此方法返回的委托时,将在执行时引发 ObjectDisposedException。
定义表达式时,请谨慎访问局部变量,且在创建可由公共 API 返回的表达式树时,谨慎访问当前对象(由 this 表示)中的状态。
表达式中的代码可能引用其他程序集中的方法或属性。 对表达式进行定义、编译或在调用结果委托时,该程序集必须可访问。 在它不存在的情况下,将遇到 ReferencedAssemblyNotFoundException。
生成表达式树
到目前为止,你所看到的所有表达式树都是由 C# 编译器创建的。 你所要做的是创建一个 lambda 表达式,将其分配给一个类型为 Expression<Func
由于这些表达式树是不可变的,所以生成表达式树很复杂。 不可变意味着必须以从叶到根的方式生成表达式树。 用于生成表达式树的 API 体现了这一点:用于生成节点的方法将其所有子级用作参数。 让我们通过几个示例来了解相关技巧。
创建节点
让我们再次从相对简单的内容开始。 我们将使用在这些部分中一直使用的加法表达式:
生成树
这是在内存中生成表达式树的基础知识。 更复杂的树通常意味着更多的节点类型,并且树中有更多的节点。 让我们再浏览一个示例,了解通常在创建表达式树时创建的其他两个节点类型:参数节点和方法调用节点。
生成一个表达式树以创建此表达式:
1 | Expression<Func<double, double, double>> distanceCalc = |
首先,创建 x 和 y 的参数表达式:
1 | var xParameter = Expression.Parameter(typeof(double), "x"); |
按照你所看到的模式创建乘法和加法表达式:
1 | var xSquared = Expression.Multiply(xParameter, xParameter); |
接下来,需要为调用 Math.Sqrt 创建方法调用表达式。
1 | var sqrtMethod = typeof(Math).GetMethod("Sqrt", new[] { typeof(double) }); |
最后,将方法调用放入 Lambda 表达式,并确保定义 Lambda 表达式的参数:
1 | var distanceLambda = Expression.Lambda( |
在这个更复杂的示例中,你看到了创建表达式树通常使用的其他几种技巧。
首先,在使用它们之前,需要创建表示参数或局部变量的对象。 创建这些对象后,可以在表达式树中任何需要的位置使用它们。
其次,需要使用反射 API 的一个子集来创建 MethodInfo 对象,以便创建表达式树以访问该方法。 必须仅限于 .NET Core 平台上提供的反射 API 的子集。 同样,这些技术将扩展到其他表达式树。
深度生成代码
不仅限于使用这些 API 可以生成的代码。 但是,要生成的表达式树越复杂,代码就越难以管理和阅读。
让我们生成一个与此代码等效的表达式树:
1 | Func<int, int> factorialFunc = (n) => |
请注意上面我未生成表达式树,只是生成了委托。 使用 Expression 类不能生成语句 lambda。 下面是生成相同的功能所需的代码。 它很复杂,这是因为没有用于生成 while 循环的 API,而是需要生成一个包含条件测试的循环和一个用于中断循环的标签目标。
1 | var nArgument = Expression.Parameter(typeof(int), "n"); |
用于生成阶乘函数的表达式树的代码相对更长、更复杂,它充满了标签和 break 语句以及我们在日常编码任务中想要避免的其他元素。