【问题标题】:Is this closure combination behaviour a C# compiler bug?这种闭包组合行为是 C# 编译器错误吗?
【发布时间】:2016-02-26 22:48:03
【问题描述】:

我正在调查一些奇怪的对象生命周期问题,并遇到了 C# 编译器的这种非常令人费解的行为:

考虑以下测试类:

class Test
{
    delegate Stream CreateStream();

    CreateStream TestMethod( IEnumerable<string> data )
    {
        string file = "dummy.txt";
        var hashSet = new HashSet<string>();

        var count = data.Count( s => hashSet.Add( s ) );

        CreateStream createStream = () => File.OpenRead( file );

        return createStream;
    }
}

编译器生成以下内容:

internal class Test
{
  public Test()
  {
    base..ctor();
  }

  private Test.CreateStream TestMethod(IEnumerable<string> data)
  {
    Test.<>c__DisplayClass1_0 cDisplayClass10 = new Test.<>c__DisplayClass1_0();
    cDisplayClass10.file = "dummy.txt";
    cDisplayClass10.hashSet = new HashSet<string>();
    Enumerable.Count<string>(data, new Func<string, bool>((object) cDisplayClass10, __methodptr(<TestMethod>b__0)));
    return new Test.CreateStream((object) cDisplayClass10, __methodptr(<TestMethod>b__1));
  }

  private delegate Stream CreateStream();

  [CompilerGenerated]
  private sealed class <>c__DisplayClass1_0
  {
    public HashSet<string> hashSet;
    public string file;

    public <>c__DisplayClass1_0()
    {
      base..ctor();
    }

    internal bool <TestMethod>b__0(string s)
    {
      return this.hashSet.Add(s);
    }

    internal Stream <TestMethod>b__1()
    {
      return (Stream) File.OpenRead(this.file);
    }
  }
}

原始类包含两个 lambda:s =&gt; hashSet.Add( s )() =&gt; File.OpenRead( file )。第一个关闭局部变量hashSet,第二个关闭局部变量file。但是,编译器会生成一个包含hashSetfile 的闭包实现类&lt;&gt;c__DisplayClass1_0。因此,返回的 CreateStream 委托包含并保持对 hashSet 对象的引用,一旦 TestMethod 返回,该对象应该可供 GC 使用。

在我遇到这个问题的实际场景中,一个非常大(即>100mb)的对象被错误地包围了。

我的具体问题是:

  1. 这是一个错误吗?如果不是,为什么认为这种行为是可取的?

更新:

C# 5 规范 7.15.5.1 说:

当一个外部变量被一个匿名函数引用时, 据说外部变量已被匿名捕获 功能。通常,局部变量的生命周期仅限于 执行与其关联的块或语句 (§5.1.7)。但是,捕获的外部变量的生命周期是 至少扩展到从创建的委托或表达式树 匿名函数有资格进行垃圾回收。

这似乎对某种程度的解释是开放的,并且没有明确禁止 lambda 捕获它未引用的变量。但是,this question 涵盖了一个相关的场景,@eric-lippert 认为这是一个错误。恕我直言,我认为编译器提供的组合闭包实现是一个很好的优化,但是这种优化不应该用于编译器可以合理检测到的可能具有超出当前堆栈帧的生命周期的 lambda。


  1. 如何在不完全放弃使用 lambda 的情况下对此进行编码?值得注意的是,我如何防御性地对此进行编码,以便将来的代码更改不会突然导致同一方法中的其他未更改的 lambda 开始包含不应该包含的内容?

更新:

我提供的代码示例必然是人为的。显然,将 lambda 创建重构为单独的方法可以解决该问题。我的问题不是关于设计最佳实践(@peter-duniho 也涵盖了)。相反,鉴于 TestMethod 的内容,我想知道是否有任何方法可以强制编译器从组合闭包实现中排除 createStream lambda。


为了记录,我的目标是 .NET 4.6 和 VS 2015。

【问题讨论】:

  • 它们共享相同的词法范围。也许是因为这个。
  • Discrete Anonymous methods sharing a class? 的可能重复项。作为一个额外的好处,这个例子非常简单,但是不是做作的。
  • 这是“隐式捕获关闭”的原因吗?我想我现在更好地理解了这个警告。我一直想知道为什么在某些情况下 lambda 会捕获与它无关的东西。
  • “优化不应该用于编译器可以合理检测到的 lambdas 可能有超出当前堆栈帧的生命周期”——这应该是什么意思?根据定义,all 闭包“具有超出当前堆栈帧的生命周期”。我不清楚这种行为是否是一种“良好的优化”(在闭包类的上下文中,为每个独立的 lambda 创建一个单独的类当然不会花费太多额外费用)。但如果它是一种优化,编译器会根据什么逻辑有条件地放弃它?
  • “鉴于 TestMethod 的内容,我想知道是否有任何方法可以强制编译器从组合闭包实现中排除 createStream lambda” i> - 您似乎在问“我可以更改此代码以避免问题,但这样做不更改代码?”。我已经提供了一个清晰的示例,说明您将如何避免您发布的示例中的问题;在任何此类示例中都可以使用类似的方法。但是任何变通方法都必然涉及改变方法;不然怎么可能?

标签: c# lambda closures .net-4.6


【解决方案1】:

我不知道 C# 语言规范中有任何内容会准确规定编译器如何实现匿名方法和变量捕获。这是一个实现细节。

规范所做的是为匿名方法及其捕获变量的行为方式设置一些规则。我没有 C# 6 规范的副本,但这里是来自 C# 5 规范的相关文本,位于“7.15.5.1 Captured external variables”下:

...延长捕获的外部变量的生命周期至少直到从匿名函数创建的委托或表达式树符合垃圾回收条件。 [强调我的]

规范中没有任何内容限制变量的生命周期。编译器只需要确保变量的生存时间足够长,以便在匿名方法需要时保持有效。

那么……

1.这是一个错误吗?如果不是,为什么认为这种行为是可取的?

不是错误。编译器符合规范。

至于它是否被认为是“可取的”,这是一个加载的术语。什么是“可取的”取决于您的优先事项。也就是说,编译器作者的一个优先事项是简化编译器的任务(这样做,使其运行得更快并减少错误的机会)。这种特定的实现可能在这种情况下被认为是“可取的”。

另一方面,语言设计者和编译器作者也有一个共同的目标,那就是帮助程序员生成可工作的代码。由于实现细节可能会干扰这一点,因此这种实现细节可能被认为是“不受欢迎的”。归根结底,问题在于如何根据潜在的竞争目标对每个优先级进行排序。

2.如何在不完全放弃使用 lambda 的情况下对此进行编码?值得注意的是,我如何防御性地对此进行编码,以便将来的代码更改不会突然导致同一方法中的其他未更改的 lambda 开始包含不应该包含的内容?

如果没有一个不那么做作的例子,很难说。一般来说,我会说明显的答案是“不要像那样混合你的 lambdas”。在您的特定(当然是人为的)示例中,您有一种方法似乎在做两件完全不同的事情。由于各种原因,这通常不受欢迎,在我看来,这个示例只是添加到该列表中。

我不知道解决“两个不同的事情”的最佳方法是什么,但一个明显的替代方法是至少重构该方法,以便“两个不同的事情”方法将工作委托给另一个两种方法,每一种都以描述性方式命名(它的额外好处是帮助代码自我记录)。

例如:

CreateStream TestMethod( IEnumerable<string> data )
{
    string file = "dummy.txt";
    var hashSet = new HashSet<string>();

    var count = AddAndCountNewItems(data, hashSet);

    CreateStream createStream = GetCreateStreamCallback(file);

    return createStream;
}

int AddAndCountNewItems(IEnumerable<string> data, HashSet<string> hashSet)
{
    return data.Count( s => hashSet.Add( s ) );
}

CreateStream GetCreateStreamCallback(string file)
{
    return () => File.OpenRead( file );
}

这样,捕获的变量保持独立。即使编译器出于某种奇怪的原因仍然将它们都放入相同的闭包类型中,它仍然不应该导致两个闭包之间使用该类型的相同 instance

您的TestMethod() 仍然做了两个不同的事情,但至少它本身不包含这两个不相关的实现。代码更具可读性和更好的划分,这是一件好事,即使它修复了变量生命周期问题。

【讨论】:

  • 关于C#规范,7.15.5.1,第一段开始“当一个外部变量被匿名函数引用时,就说这个外部变量已经被匿名函数捕获了” 。但是,lambda () =&gt; File.OpenRead( file ) 没有引用外部变量 hashSet,因此 hashSet 的生命周期不应被此 lambda 的生命周期延长。关于 两个不同的事情 - 正如您所注意到的,这确实是一个人为的例子。该问题似乎会影响任何使用捕获 lambda 的方法做一些工作并创建一个长寿命的捕获 lambda。
  • @tg73: “所以 hashSet 的生命周期不应该被这个 lambda 的生命周期延长” -- 恕我直言,你没有仔细阅读规范。 hashSet 变量other lambda 表达式捕获,并且规范中没有对此类捕获变量的生命周期设置上限限制.如果编译器愿意,它可以通过将变量设为static 变量并从不 丢弃它来实现捕获。虽然我理解这种行为对您的目的不方便,但它完全符合规范规定的要求。
  • @tg73: “这个问题似乎会影响任何方法...创建一个长寿命的捕获 lambda” -- 但只有当你有两个不相关的匿名方法时每个捕获不同局部变量的方法。方法应该简单;一个大到足以包含两个独立的逻辑位和不相关的变量生命周期的方法无论如何都应该进行重构。恕我直言,无论如何,通过将方法分解成更小的部分,解决这个问题应该很容易。我无法评论我没见过的例子,但不能轻易做到这一点是不寻常的。
【解决方案2】:

这是一个错误吗?

没有。编译器符合这里的规范。

为什么认为这种行为是可取的?

这是不可取的。正如您在此处发现的以及我在 2007 年所描述的那样,这非常不幸:

http://blogs.msdn.com/b/ericlippert/archive/2007/06/06/fyi-c-and-vb-closures-are-per-scope.aspx

自 C# 3.0 以来,C# 编译器团队已考虑在每个版本中修复此问题,但它从未得到足够高的优先级。考虑在 Roslyn github 站点上输入一个问题(如果还没有;很可能有)。

我个人希望看到这个问题得到解决;就目前而言,这是一个很大的“陷阱”。

如何在不完全放弃使用 lambda 的情况下对此进行编码?

变量是被捕获的东西。完成后,您可以将 hashset 变量设置为 null。那么唯一消耗的内存是变量的内存,四个字节,而不是它所引用的东西的内存,它将被收集。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2012-01-15
    • 1970-01-01
    • 2014-06-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-04-24
    相关资源
    最近更新 更多