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

tg7*_*g73 16 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;
    }
}
Run Code Online (Sandbox Code Playgroud)

编译器生成以下内容:

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);
    }
  }
}
Run Code Online (Sandbox Code Playgroud)

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

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

我的具体问题是:

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

更新:

C#5规范7.15.5.1说:

当外部变量由匿名函数引用时,外部变量被称为已由匿名函数捕获.通常,局部变量的生命周期仅限于执行与之关联的块或语句(第5.1.7节).但是,捕获的外部变量的生命周期至少会延长,直到从匿名函数创建的委托或表达式树符合垃圾回收的条件.

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


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

更新:

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


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

Eri*_*ert 13

这是一个错误吗?

不.编译器符合此处的规范.

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

这是不可取的.正如你在这里发现的,正如我在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网站上输入一个问题(如果还没有,可能会有).

我个人希望看到这个问题; 因为它是一个很大的"陷阱".

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

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


Pet*_*iho 7

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

规范的作用是为匿名方法及其捕获变量的行为设置一些规则.我没有C#6规范的副本,但这里是C#5规范的相关文本,在"7.15.5.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 );
}
Run Code Online (Sandbox Code Playgroud)

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

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