Expression.Quote()能做什么,Expression.Constant()不能做到呢?(涉及IT技术)

103

注意:我知道之前有一个问题:“LINQ的Expression.Quote方法的目的是什么?”,但如果你继续阅读,你会发现它并没有回答我的问题。

我理解 Expression.Quote() 的表面意义。然而,Expression.Constant() 也可以用于相同的目的(除了 Expression.Constant() 已经被用于所有其他目的)。因此,我不明白为什么需要 Expression.Quote()

为了证明这一点,我写了一个快速示例,在通常情况下应该使用 Quote 的地方(在标记为惊叹号的行),但我改用了 Constant,结果同样良好:

string[] array = { "one", "two", "three" };

// This example constructs an expression tree equivalent to the lambda:
// str => str.AsQueryable().Any(ch => ch == 'e')

Expression<Func<char, bool>> innerLambda = ch => ch == 'e';

var str = Expression.Parameter(typeof(string), "str");
var expr =
    Expression.Lambda<Func<string, bool>>(
        Expression.Call(typeof(Queryable), "Any", new Type[] { typeof(char) },
            Expression.Call(typeof(Queryable), "AsQueryable",
                            new Type[] { typeof(char) }, str),
            // !!!
            Expression.Constant(innerLambda)    // <--- !!!
        ),
        str
    );

// Works like a charm (prints one and three)
foreach (var str in array.AsQueryable().Where(expr))
    Console.WriteLine(str);
expr.ToString()的输出结果也相同(无论是使用Constant还是Quote)。鉴于以上观察结果,似乎Expression.Quote()是多余的。C#编译器本来可以将嵌套的lambda表达式编译为涉及Expression.Constant()而不是Expression.Quote()的表达式树,任何想要将表达式树处理成其他查询语言(例如SQL)的LINQ查询提供程序都可以查找类型为Expression<TDelegate>ConstantExpression,而不是具有特殊Quote节点类型的UnaryExpression,其他所有内容都将保持不变。

我错过了什么?为什么要发明Expression.Quote()和特殊的UnaryExpressionQuote节点类型?

5个回答

205

简短回答:

引用操作符是一种 操作符,它在其操作数上 引入封闭语义。常量只是值。

引用和常量具有不同的 含义,因此在表达树中具有 不同的表示形式。对于两个非常不同的东西具有相同的表示形式是 极为 令人困惑和容易出错的。

详细回答:

考虑以下内容:

(int s)=>(int t)=>s+t

外部 lambda 是一个工厂,用于绑定到外部 lambda 参数的加法器。
现在,假设我们希望将其表示为表达式树,以便稍后编译和执行。表达式树的主体应该是什么?这取决于您是否希望编译状态返回委托还是表达式树。
让我们先排除不感兴趣的情况。如果我们希望返回委托,则使用 Quote 还是 Constant 的问题无关紧要:
        var ps = Expression.Parameter(typeof(int), "s");
        var pt = Expression.Parameter(typeof(int), "t");
        var ex1 = Expression.Lambda(
                Expression.Lambda(
                    Expression.Add(ps, pt),
                pt),
            ps);

        var f1a = (Func<int, Func<int, int>>) ex1.Compile();
        var f1b = f1a(100);
        Console.WriteLine(f1b(123));

lambda函数内部嵌套了一个lambda函数;编译器将内部lambda函数生成为一个委托,该委托闭合了外部lambda函数的状态。我们不需要再考虑这种情况。

假设我们希望编译后的状态返回内部lambda函数的表达式树。有两种方法可以实现:简单方法和困难方法。

困难方法是说,我们需要改变原来的代码:

(int s)=>(int t)=>s+t

我们的真正意思是:
(int s)=>Expression.Lambda(Expression.Add(...

然后为生成表达式树,得到了这个混乱

        Expression.Lambda(
            Expression.Call(typeof(Expression).GetMethod("Lambda", ...

大量的反射代码用来生成 lambda 表达式。 引号运算符的目的是告诉表达式树编译器,我们希望给定的 lambda 被视为一个表达式树,而不是一个函数,而无需显式生成表达式树生成代码

简单的方法是:

        var ex2 = Expression.Lambda(
            Expression.Quote(
                Expression.Lambda(
                    Expression.Add(ps, pt),
                pt)),
            ps);

        var f2a = (Func<int, Expression<Func<int, int>>>)ex2.Compile();
        var f2b = f2a(200).Compile();
        Console.WriteLine(f2b(123));

实际上,如果您编译并运行此代码,您将获得正确的答案。

请注意,引用运算符是引起闭包语义的运算符,用于使用外部变量的内部 lambda 和 外部lambda的形式参数。

问题是:为什么不消除引用操作符并使其执行相同的操作?

        var ex3 = Expression.Lambda(
            Expression.Constant(
                Expression.Lambda(
                    Expression.Add(ps, pt),
                pt)),
            ps);

        var f3a = (Func<int, Expression<Func<int, int>>>)ex3.Compile();
        var f3b = f3a(300).Compile();
        Console.WriteLine(f3b(123));

该常量不会引起闭包语义。为什么要这样呢?你说过这是一个常量,它只是一个值。它应该作为编译器的输入完美无缺;编译器应该能够将该值的转储生成到需要的堆栈中。
由于没有引入闭包,如果您这样做,将在调用时得到“未定义类型为'System.Int32'的变量's'”异常。
(顺便说一下:我刚刚审查了引用表达式树创建的委托代码生成器,不幸的是,我在2006年把一个注释放进了代码里。FYI,当引用表达式树被运行时编译器实例化为委托时,提升的外部参数会被快照为一个常量。我写代码的原因我此时并不记得了,但它确实有一个讨厌的副作用,即引入对外部参数的值而不是变量的闭包。显然,继承了那段代码的团队决定不修复这个缺陷,因此,如果您依赖于编译后引用的内部lambda中观察到的封闭外部参数的突变,您将会感到失望。但是,既然同时(1)突变形式参数和(2)依赖外部变量的突变是一种非常糟糕的编程实践,我建议您更改程序,不要使用这两个糟糕的编程实践,而不是等待一个看起来不会出现的修复。对错误表示歉意。)
那么,重复问题:
引用嵌套lambda表达式可以编译成涉及Expression.Constant()而不是Expression.Quote()的表达式树,任何想要将表达式树处理为其他查询语言(如SQL)的LINQ查询提供程序都可以查找类型为Expression的ConstantExpression而不是具有特殊Quote节点类型的UnaryExpression,其余所有内容都相同。
你是正确的。我们可以通过“使用常量表达式的类型作为标志”来编码意味着“在此值上引入闭包语义”,以便“常量”具有“使用此常量值”的含义,除非类型恰好是表达式树类型并且该值是有效的表达式树,在这种情况下,使用从重写给定表达式树的内部以在当前外部lambda的上下文中引入闭包语义的表达式树结果的值。
但是为什么我们要做这样疯狂的事情呢?引用运算符是一个极其复杂的运算符,并且应该明确地使用它。您建议为了不增加额外的工厂方法和节点类型而节俭,因此将奇怪的角落案例添加到常量中,以便有时常量在逻辑上是常量,有时它们是带有闭包语义的重写lambda。
它也会产生一些奇怪的影响,即常量并不意味着“使用这个值”。假设由于某种奇怪的原因,您希望第三种情况编译一个表达式树成为一个委托,该委托分发一个具有未重写对外部变量的引用的表达式树?为什么?也许是因为您正在测试编译器,并希望只需将常量传递下去,以便稍后执行其他一些分析。您的提议将使这种情况不可能发生;任何恰好是表达式树类型的常量都将被重写。人们有合理的期望,“常量”意味着“使用这个值”。 “常量”是一个“按我说的做”节点。常量处理器的工作不是根据类型猜测您的意图。

当然,请注意,您现在要承担理解的负担(也就是理解常量具有复杂的语义,在一种情况下是“常量”,在基于类型系统的标志引导下是“引入闭包语义”),不仅仅是微软提供者,而是每个进行表达式树语义分析的提供者。这些第三方提供者中有多少会出错呢?

“引用”正在挥舞着一个大红旗,上面写着“嘿伙计,看这里,我是一个嵌套的lambda表达式,如果我在外部变量上关闭,我的语义就很奇怪!”而“常量”则表示“我只是一个值;按您的意愿使用我。”当某些东西变得复杂和危险时,我们希望它挥舞着红旗,而不是通过使用户在类型系统中查找来隐藏这一事实,以便找出这个值是否是特殊值。

此外,避免冗余甚至是一个目标都是错误的。当然,避免不必要的、令人困惑的冗余是一个目标,但大多数冗余是有益的;冗余会产生清晰度。新的工厂方法和节点种类是廉价的。我们可以根据需要创建尽可能多的节点种类,以便每个节点种类都代表一个操作。我们没有必要采用像“这意味着一件事,除非将该字段设置为这个东西,否则它就意味着其他事情”的恶心技巧。


13
我现在感到尴尬,因为我没有考虑到闭包语义,并且未能测试嵌套的 lambda 捕获外部 lambda 的参数的情况。如果我这样做了,我就会注意到差异。再次感谢您的回答。 - Timwi
@Eric Lippert 您好,请看一下这个问题,谢谢 https://stackoverflow.com/questions/72702081/why-lambdaexpression-has-to-be-wrapped-in-unaryexpression-using-expression-quote - user16276760

19

这个问题已经得到了一位优秀回答。此外,我想指出一个资源,可以帮助解决关于表达式树的问题:

微软曾有一个名为Dynamic Language Runtime 的CodePlex项目。该项目的文档中包括了名为"Expression Trees v2 Spec" 的文件,它正是.NET 4中LINQ表达式树的规范。

更新: CodePlex已经关闭。 Expression Trees v2 Spec (PDF) 已经移至GitHub.

例如,关于Expression.Quote,它在文档中解释如下:

4.4.42 Quote

在UnaryExpressions中使用Quote表示具有类型为Expression的“常量”值的表达式。与Constant节点不同,Quote节点特殊处理包含的ParameterExpression节点。如果包含的ParameterExpression节点声明了将在生成的表达式中闭合的局部变量,则Quote会替换引用位置中的ParameterExpression。当评估Quote节点时,它会用闭合变量引用替换ParameterExpression引用节点,然后返回引用的表达式。 […] (p. 63–64)


1
这是一种非常好的教人钓鱼的回答。我想补充一下,文档已经迁移,并且现在可以在https://learn.microsoft.com/en-us/dotnet/framework/reflection-and-codedom/dynamic-language-runtime-overview上获得。特别是引用的文档可以在GitHub上找到:https://github.com/IronLanguages/dlr/tree/master/Docs。 - relatively_random

3

在这个非常出色的回答之后,语义变得清晰明了。但是为什么它们被设计成这样并不是很清楚,请考虑:

Expression.Lambda(Expression.Add(ps, pt));

当这个lambda函数被编译和调用时,它会计算内部表达式并返回结果。这里的内部表达式是一个加法,所以ps+pt会被计算并返回结果。按照这个逻辑,下面的表达式:

Expression.Lambda(
    Expression.Lambda(
              Expression.Add(ps, pt),
            pt), ps);

当调用外部 lambda 时,应返回内部 lambda 的编译方法引用(因为我们说 lambda 编译为方法引用)。那么,我们为什么需要一个 Quote?!为了区分返回方法引用与引用调用结果的情况。具体来说:
let f = Func<...>
return f; vs. return f(...);

由于某些原因,.Net设计者选择在第一种情况下使用,而在第二种情况下使用普通的。在我看来,这造成了很大的困惑,因为在大多数编程语言中,返回值是直接的(不需要或任何其他操作),但调用确实需要额外的编写(括号+参数),这在MSIL级别上会转换成某种。.Net设计者在表达式树中将其反过来了。有趣的是要知道原因。

1

我认为这更像是给定的:

Expression<Func<Func<int>>> f = () => () => 2;

你的树是 Expression.Lambda(Expression.Lambda)f 代表了一个返回 Func<int> 的 lambda 表达式的 Expression Tree,该函数返回 2

但如果你想要的是一个返回一个返回 2 的 lambda 表达式的 Expression Tree,那么你需要:

Expression<Func<Expression<Func<int>>>> f = () => () => 2;

现在你的树是 Expression.Lambda(Expression.Quote(Expression.Lambda))f 代表了一个 lambda 表达式的 Expression Tree,该表达式返回一个 Expression<Func<int>>,这是一个返回 2Func<int> 的 Expression Tree。


-3

我认为这里的重点在于树的表现力。包含委托的常量表达式实际上只是包含一个恰好是委托的对象。这比直接分解为一元和二元表达式的表现力要差。


这是吗?它确切地添加了什么表现力?使用UnaryExpression(这也是一种奇怪的表达式)可以“表达”什么,而ConstantExpression不能表达的呢? - Timwi

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接