这个闭包组合行为是否是C#编译器错误?

时间:2015-11-24 13:10:06

标签: c# lambda closures .net-4.6

我正在研究一些奇怪的对象生命周期问题,并且遇到了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);
    }
  }
}

原始类包含两个lambdas:s => hashSet.Add( s )() => File.OpenRead( file )。第一个关闭局部变量hashSet,第二个关闭局部变量file。但是,编译器会生成一个包含<>c__DisplayClass1_0hashSet的闭包实现类file。因此,返回的CreateStream委托包含并保持对hashSet对象的引用,该对象应该在TestMethod返回后为GC提供。

在我遇到此问题的实际场景中,一个非常实质的(即> 100mb)对象被错误地封闭。

我的具体问题是:

  1. 这是一个错误吗?如果没有,为什么这种行为被认为是可取的?
  2. 更新

    C#5规范7.15.5.1说:

      

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

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

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

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

      为了记录,我的目标是使用VS 2015瞄准.NET 4.6。

2 个答案:

答案 0 :(得分:13)

  

这是一个错误吗?

没有。编译器符合此处的规范。

  

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

这不可取。正如你在这里发现的那样,非常不幸,正如我在2007年所描述的那样:

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

C#编译器团队已考虑在C#3.0以后的每个版本中修复此问题,并且它的优先级从未高过。考虑在Roslyn github网站上输入一个问题(如果还没有,可能就是这样)。

我个人希望看到这个问题;因为它是一个很大的&#34;陷阱&#34;。

  

如何在不放弃同时使用lambdas的情况下对此进行编码?

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

答案 1 :(得分:7)

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

规范的作用是为匿名方法及其捕获变量的行为设置一些规则。我没有C#6规范的副本,但这里是C#5规范中的相关文本,在“7.15.5.1捕获的外部变量”下:

  

...捕获的外部变量的生命周期被扩展至少直到从匿名函数创建的委托或表达式树变得有资格进行垃圾收集。 [强调我的]

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

因此...

  

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

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

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

另一方面,语言设计者和编译器作者都有一个共同的目标,即帮助程序员生成工作代码。由于实现细节可能会干扰这一点,因此这种实现细节可能被认为是“不合需要的”。最终,根据潜在的竞争目标,这是每个优先级如何排名的问题。

  

2.如何在不放弃使用lambdas的情况下对此进行编码?值得注意的是,我如何在防御性方面对此进行编码,以便将来的代码更改不会突然导致同一方法中的其他一些未更改的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 );
}

通过这种方式,捕获的变量保持独立。即使编译器出于某种奇怪的原因仍然将它们放在同一个闭包类型中,它仍然不应该导致在两个闭包之间使用的那个类型的实例

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