在 C# 中将 lambda 函数作为命名参数传递

Posted

技术标签:

【中文标题】在 C# 中将 lambda 函数作为命名参数传递【英文标题】:Passing lambda functions as named parameters in C# 【发布时间】:2011-12-24 14:33:32 【问题描述】:

编译这个简单的程序:

class Program

    static void Foo( Action bar )
    
        bar();
    

    static void Main( string[] args )
    
        Foo( () => Console.WriteLine( "42" ) );
    

那里没有什么奇怪的。如果我们在 lambda 函数体中出错:

Foo( () => Console.LineWrite( "42" ) );

编译器返回错误信息:

error CS0117: 'System.Console' does not contain a definition for 'LineWrite'

到目前为止一切顺利。现在,让我们在对Foo 的调用中使用命名参数:

Foo( bar: () => Console.LineWrite( "42" ) );

这一次,编译器消息有些混乱:

error CS1502: The best overloaded method match for 
              'CA.Program.Foo(System.Action)' has some invalid arguments 
error CS1503: Argument 1: cannot convert from 'lambda expression' to 'System.Action'

发生了什么事?为什么不报实际错误?

请注意,如果我们使用匿名方法而不是 lambda,我们确实会收到正确的错误消息:

Foo( bar: delegate  Console.LineWrite( "42" );  );

【问题讨论】:

@MatějZábský 这篇文章的重点是 C# 编译器没有完全解释引入命名参数时的问题所在。 LineWrite 是编译错误的例子。他已经知道错误是什么;只是为什么编译器没有一直这样显示它。 使用大括号似乎会导致错误more clear(尽管我still don't 看到否定它们的问题。-- 除非单声道命令行编译器更明确) @Brad 这不是大括号;这是 ideone.com 使用的是 Mono 编译器,而不是 MSFT C# 编译器。 (这表明这可能只是一个错误。) @dlev:确实,与其说是真正的原因,不如说是出于好奇;有趣的单声道在报告真正的错误方面做得更好。 @BradChristie:同样的错误,在 Visual C# 2010 编译器上有或没有大括号。 【参考方案1】:

为什么不报告实际错误?

不,这就是问题所在;它正在报告实际错误。

让我用一个稍微复杂的例子来解释一下。假设你有这个:

class CustomerCollection

    public IEnumerable<R> Select<R>(Func<Customer, R> projection) ...

....
customers.Select( (Customer c)=>c.FristNmae );

好的,根据 C# 规范,错误是什么?您必须在这里非常仔细地阅读规范。让我们解决它。

我们调用 Select 作为函数调用,只有一个参数,没有类型参数。我们在 CustomerCollection 中的 Select 上进行查找,搜索名为 Select 的可调用事物——即,诸如委托类型的字段或方法之类的事物。由于我们没有指定类型参数,我们匹配任何泛型方法 Select。我们找到一个并从中构建一个方法组。方法组包含一个元素。

现在必须通过重载解析来分析方法组,以首先确定候选集,然后从中确定适用的候选集,然后从确定最适合的候选者,并据此确定最终验证的最适合的候选者。如果这些操作中的任何一个失败,则重载解析必须失败并出现错误。哪一个失败了?

我们从构建候选集开始。为了获得候选者,我们必须执行方法类型推断以确定类型参数 R 的值。方法类型推断是如何工作的?

我们有一个参数类型都已知的 lambda——形式参数是 Customer。为了确定R,我们必须将lambda的返回类型映射到R。lambda的返回类型是什么?

我们假设 c 是 Customer 并尝试分析 lambda 主体。这样做会在 Customer 上下文中查找 FristNmae,但查找失败。

因此,lambda 返回类型推断失败,并且没有向 R 添加边界。

分析完所有参数后,R 没有界限。因此,方法类型推断无法确定 R 的类型。

因此方法类型推断失败。

因此没有方法被添加到候选集中。

因此,候选集为空。

因此不可能有合适的候选人。

因此,此处的正确错误消息类似于“重载解决方案无法找到最终验证的最佳适用候选,因为候选集为空。”

客户会对该错误消息非常不满。我们已经在错误报告算法中构建了大量启发式算法,试图推断出用户可以实际采取行动的更“基本”错误修复错误。我们推理:

实际错误是候选集为空。为什么候选集为空?

因为方法组中只有一个方法,类型推断失败。

好的,是否应该报错“overload resolution failed because method type inference failed”?同样,客户会对此不满意。相反,我们再次问这个问题“为什么方法类型推断失败?”

因为 R 的绑定集是空的。

这也是一个糟糕的错误。为什么边界设置为空?

因为我们可以确定 R 的唯一参数是无法推断其返回类型的 lambda。

好的,我们是不是应该报错“overload resolution failed because lambda return type inference failed to infer a return type”? 再次,客户会对此不满意。相反,我们会问“为什么 lambda 无法推断返回类型?”

因为客户没有名为 FristNmae 的成员。

是我们实际报告的错误。

所以你看到了我们必须经历的绝对曲折的推理链才能给出你想要的错误信息。我们不能只说出了什么问题——重载决策被赋予了一个空的候选集——我们必须追溯过去以确定重载决策是如何进入那个状态的。

这样做的代码非常复杂;它处理的情况比我刚才介绍的更复杂,包括有 n 个不同的泛型方法并且类型推断由于 m 个不同的原因而失败的情况,我们必须从所有这些情况中找出什么是给出的“最佳”理由用户。回想一下,实际上有十几种不同的 Select 和重载解决方案,它们可能因不同的原因或相同的原因而失败。

编译器的报错中有启发式,用于处理各种重载解析失败;我描述的只是其中之一。

现在让我们看看您的具体情况。真正的错误是什么?

我们有一个方法组,其中有一个方法,Foo。我们可以建立一个候选集吗?

是的。有一个候选人。方法 Foo 是调用的候选方法,因为它提供了每个 必需 参数 -- bar -- 并且没有额外的参数。

好的,候选集中只有一个方法。候选集中有适用的成员吗?

没有。 bar 对应的实参无法转换为形参类型,因为 lambda body 包含错误。

因此适用候选集为空,因此没有最终验证的最佳适用候选,因此重载决议失败。

那么错误应该是什么?同样,我们不能只说“重载解决方案未能找到最终验证的最佳适用候选人”,因为客户会讨厌我们。我们必须开始挖掘错误信息。为什么重载解析失败?

因为适用的候选集为空。

为什么是空的?

因为其中的每个候选人都被拒绝了。

有没有最好的候选人?

是的,只有一位候选人。

为什么会被拒绝?

因为它的参数不能转换为形参类型。

好的,在这一点上,显然处理涉及命名参数的重载解决问题的启发式方法决定我们已经挖掘得足够远,这是我们应该报告的错误。如果我们没有命名参数,那么其他一些启发式方法会问:

为什么参数不能转换?

因为 lambda 主体包含错误。

然后我们报告该错误。

错误启发式并不完美;离得很远。巧合的是,我本周正在对“简单”重载解决错误报告启发式进行重架构——就像什么时候说“没有采用 2 个参数的方法”和什么时候说“你想要的方法是私有的” " 以及何时说“没有与该名称对应的参数”,等等;完全有可能您正在调用带有两个参数的方法,没有该名称的具有两个参数的公共方法,有一个是私有的,但其中一个具有不匹配的命名参数。快,我们应该报告什么错误?我们必须做出最好的猜测,有时我们可以做出更好的猜测,但还不够复杂。

即使做到这一点也被证明是一项非常棘手的工作。当我们最终重新构建大型重型启发式方法时——比如如何处理 LINQ 表达式中方法类型推断的失败——我将重新审视您的案例,看看我们是否可以改进启发式方法。

但是由于你得到的错误信息是完全正确,这不是编译器的错误;相反,这只是特定情况下错误报告启发式的缺点。

【讨论】:

tl; dr:重载解析失败的错误报告启发式方法因是否使用命名参数而异。我想。 @Eric Lippert:您的评论+1 很棒!我拿着手电筒在黑暗中摸索着;你的答案是一个大焦点 埃里克,这是一个绝妙的答案。像我这样的应用程序开发人员很容易。当我开发一个 LOB 应用程序时,我会在第一次出现错误时抛出一个异常,其中包含所有相关的详细信息。我不必为了报告错误而编写极其复​​杂的代码。我从来没有意识到 C# 编译器团队为向我们展示有意义的错误消息付出了多少努力(90% 的错误消息都是正确的)。 @SolutionYogi:确实,与分析错误代码相比,分析正确代码容易 @configurator:是的。我们对此有一个有点弱和原始的机制,因为您可以产生一个错误和一个链接的“先前错误中的符号位置”错误。我个人很想得到您的建议:一个真正结构化的错误消息,您可以“深入研究”以追踪导致错误的整个逻辑链。我也非常希望重载解决错误,而不是仅仅说“最好的方法转换错误”,列出方法组中的 每个 方法以及 为什么 它不是被选为最佳方法。 (我也想要一匹小马。)【参考方案2】:

编辑:Eric Lippert 的回答描述了(好多更好)这个问题 - 请参阅他对“真正的交易”的回答

最终编辑: 对于一个人来说,在野外公开展示自己的无知是令人不快的,但在按下删除按钮后掩盖无知并没有任何好处。希望其他人可以从我的不切实际的回答中受益:)

感谢 Eric Lippert 和 svick 耐心地纠正我有缺陷的理解!


您在此处收到“错误”错误消息的原因是类型的差异和编译器推断以及编译器如何处理命名参数的类型解析

主要例子的类型 () =&gt; Console.LineWrite( "42" )

通过类型推断和协变的魔力,这与

具有相同的最终结果

Foo( bar: delegate Console.LineWrite( "42" ); );

第一个块可以是LambdaExpressiondelegate 类型;这取决于用法和推理。

考虑到这一点,难怪编译器在传递一个应该是Action 但可能是不同类型的协变对象的参数时会感到困惑? 错误消息是指出类型解析是问题的主要关键。

让我们看看 IL 以获得更多线索: 给出的所有示例都在 LINQPad 中编译为此:

IL_0000:  ldsfld      UserQuery.CS$<>9__CachedAnonymousMethodDelegate1
IL_0005:  brtrue.s    IL_0018
IL_0007:  ldnull      
IL_0008:  ldftn       UserQuery.<Main>b__0
IL_000E:  newobj      System.Action..ctor
IL_0013:  stsfld      UserQuery.CS$<>9__CachedAnonymousMethodDelegate1
IL_0018:  ldsfld      UserQuery.CS$<>9__CachedAnonymousMethodDelegate1
IL_001D:  call        UserQuery.Foo

Foo:
IL_0000:  ldarg.0     
**IL_0001:  callvirt    System.Action.Invoke**
IL_0006:  ret         

<Main>b__0:
IL_0000:  ldstr       "42"
IL_0005:  call        System.Console.WriteLine
IL_000A:  ret

注意调用System.Action.Invoke 周围的**:callvirt 正是它看起来的样子:一个虚拟方法调用。

当您使用命名参数调用 Foo 时,您是在告诉编译器您正在传递 Action,而您真正传递的是 LambdaExpression。通常,它会被编译(注意在Action 的ctor 之后调用的IL 中的CachedAnonymousMethodDelegate1)到Action,但是由于您明确告诉编译器您正在传递一个动作,它会尝试使用LambdaExpression作为 Action 传入,而不是将其视为表达式!

简短:命名参数解析失败,因为 lambda 表达式中的错误(这本身就是一个硬故障)

这是另一个告诉:

Action b = () => Console.LineWrite("42");
Foo(bar: b);

产生预期的错误消息。

我在某些 IL 方面可能不是 100% 准确,但我希望我传达了大致的想法

编辑:dlev 在 OP 的 cmets 中提出了一个很好的观点,即过载解决的顺序也发挥了作用。

【讨论】:

如果代码实际上与LambdaExpression 一起工作,正如您所声称的,IL 将包含实际创建表达式的代码,使用像Expression.Lambda 这样的调用。但事实并非如此。 这不是我声称它使用 LambdaExpression,这是事实。错误消息说“无法从 'lambda 表达式' 转换为 'System.Action'”——IL 不需要代码来创建 Expression.Lambda,因为它认为表达式已经创建。 虽然是勇敢的尝试,但这里的问题与方差无关。 不是吗?当您可以使用 x =&gt; ... 来表示 a) Action、Func、(inc. Lambda)、Expression 等中的任何一个时,正确的术语是什么?也许我的词汇需要更新 不,我的意思是问题在于编译器的错误报告启发式在这里做得不够好。这与 lambda 是否可转换为多种类型无关。【参考方案3】:

注意:不是真正的答案,但对于评论来说太大了。

当你加入类型推断时会得到更有趣的结果。考虑这段代码:

public class Test

    public static void Blah<T>(Action<T> blah)
    
    

    public static void Main()
    
        Blah(x =>  Console.LineWrite(x); );
    

它不会编译,因为没有很好的方法来推断 T 应该是什么。错误消息

方法'Test.Blah&lt;T&gt;(System.Action&lt;T&gt;)'的类型参数不能 从用法推断。尝试指定类型参数 明确的。

有道理。让我们明确指定x 的类型,看看会发生什么:

public static void Main()

    Blah((int x) =>  Console.LineWrite(x); );

现在事情出了问题,因为 LineWrite 不存在。错误消息

“System.Console”不包含“LineWrite”的定义

也很明智。现在让我们添加命名参数,看看会发生什么。一、不指定x的类型:

public static void Main()

    Blah(blah: x =>  Console.LineWrite(x); );

我们希望收到关于无法推断类型参数的错误消息。我们做到了。 但这还不是全部错误消息

方法'Test.Blah&lt;T&gt;(System.Action&lt;T&gt;)'的类型参数不能 从用法推断。尝试指定类型参数 明确的。

“System.Console”不包含“LineWrite”的定义

整洁。类型推断失败,并且我们被告知了 lambda 转换失败的确切原因。好的,让我们指定x 的类型,看看我们得到了什么:

public static void Main()

    Blah(blah: (int x) =>  Console.LineWrite(x); );

错误信息

方法'Test.Blah&lt;T&gt;(System.Action&lt;T&gt;)'的类型参数不能 从用法推断。尝试指定类型参数 明确的。

“System.Console”不包含“LineWrite”的定义

现在是出乎意料的。类型推断仍然失败(我假设是因为 lambda -> Action&lt;T&gt; 转换失败,因此否定了编译器对 Tint 的猜测)报告了失败的原因。

TL; DR:当 Eric Lippert 开始研究这些更复杂案例的启发式方法时,我会很高兴。

【讨论】:

你是对的,在最后一种情况下,编译器已经偏离了轨道。 应该在这里推断出与 T 对应的类型参数,我们不应该报告类型推断失败。我认为这是命名参数映射和方法类型推断之间交互的一个错误。命名参数使规范和实现都变得非常复杂,我们在实现它们时不小心引入了许多类似这样的细微错误。

以上是关于在 C# 中将 lambda 函数作为命名参数传递的主要内容,如果未能解决你的问题,请参考以下文章

为什么在Kotlin中将变量传递给lambda有效?

C# 将 Lambda 表达式作为方法参数传递

在c#中将引用数组作为参数传递[重复]

lambda表达式

高级函数

kotlin lambda 表达式作为可选参数