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

16

我正在调查一些奇怪的对象生命周期问题,发现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 => hashSet.Add( s )() => File.OpenRead( file )。第一个闭合了局部变量hashSet,第二个闭合了局部变量file。然而,编译器生成了一个单一的闭包实现类<>c__DisplayClass1_0,其中包含hashSetfile。因此,返回的CreateStream委托包含并保持对hashSet对象的引用,而该对象应该在TestMethod返回后可供GC。

在我遇到这个问题的实际情况中,有一个非常大的(即>100mb)对象被错误地封闭。

我的具体问题是:

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

更新:

C# 5规范7.15.5.1说:

当匿名函数引用外部变量时,外部变量被捕获了。通常,局部变量的生命周期仅限于与其关联的块或语句的执行(§5.1.7)。但是,被捕获的外部变量的生命周期至少延长到从匿名函数创建的委托或表达式树成为垃圾回收的候选对象。

这似乎在某种程度上是开放解释的,并且并未明确禁止lambda捕获它不引用的变量。但是,这个问题涵盖了相关场景,Eric Lippert认为它是一个bug。我个人认为,编译器提供的组合闭包实现是一种很好的优化,但是这种优化不应该用于编译器可以合理检测到可能具有超出当前堆栈帧寿命的lambda。


  1. 我怎样才能避免放弃使用lambda而对抗这种情况?特别是我怎样才能以防御性的方式编写代码,以便未来的代码更改不会突然导致同一方法中的某个其他未更改的lambda开始封闭它不应该封闭的内容?

更新:

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


记录一下,我正在使用.NET 4.6和VS 2015。


它们共享相同的词法作用域。也许因为这个。 - eran otzap
1
可能是离散匿名方法共享一个类?的重复问题。作为额外的奖励,这个例子非常简单,但并不是人为制造的。 - Brian
这是“隐式捕获闭包”的原因吗?我现在更好地理解了那个警告。我一直想知道为什么有时候 lambda 会捕获与其无关的东西。 - Dave Cousineau
"优化不应该应用于编译器可以合理检测到可能存在当前堆栈帧之外生命周期的 lambda 表达式" -- 这句话是什么意思?根据定义,"所有"闭包都"具有超过当前堆栈帧的生命周期"。对我来说不太清楚这种行为是否是一个"好的优化"(在闭包类的上下文中,仅为每个独立的 lambda 创建一个单独的类不会带来太大的额外开销)。但如果这确实是一种优化,根据什么逻辑编译器会有条件地放弃它? - Peter Duniho
鉴于TestMethod的内容,我想知道是否有任何方法可以强制编译器从组合闭包实现中排除createStream lambda。你似乎在问“我能否更改此代码以避免问题,但又不更改代码?”。我已经清楚地提供了如何避免您发布的示例中的问题的明显示例;类似的方法可以在任何这样的示例中完成。但是,任何解决方法都必须涉及更改该方法;否则怎么可能呢? - Peter Duniho
2个回答

13

这是一个bug吗?

不是。编译器在这里符合规范。

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

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

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

C#编译器团队在每个版本中都考虑过修复此问题,但它从未成为高优先级。考虑在Roslyn Github网站上输入问题(如果还没有一个的话;很可能已经有了)。

个人而言,我希望看到这个问题得到解决;目前它是一个很大的"坑点"。

我该如何在不完全放弃lambda的情况下编写代码?

被捕获的东西是变量。当你用完这个变量后,你可以将hashset变量设置为null。然后,只有变量的内存被消耗,四个字节,而不是它所引用的内容的内存,后者将被回收。


7
我不知道C#语言规范中是否有规定编译器如何实现匿名方法和变量捕获。这是一个实现细节。
规范确立了匿名方法及其捕获变量必须遵循的一些规则。我没有C# 6规范的副本,但以下是来自C# 5规范的相关文本,在“7.15.5.1 Captured outer variables”下:
“……捕获的外部变量的生命周期至少要延长到从匿名函数创建的委托或表达式树可以进行垃圾回收为止。”
规范中没有限制变量的生命周期。编译器只需要确保变量在匿名方法需要时仍然有效即可。
那么……
1. 这是一个错误吗?如果不是,为什么认为这种行为是可取的?
不是错误。编译器正在遵守规范。
至于它是否被认为是“可取”的,这是一个带有主观色彩的术语。什么是“可取”的取决于您的优先级。话虽如此,编译器作者的一个优先级是简化编译器的任务(从而使其运行更快,并减少出错的机会)。在这种情况下,这个特定的实现可能被认为是“可取”的。
另一方面,语言设计师和编译器作者都有一个共同的目标,那就是帮助程序员生成可工作的代码。在某种程度上,如果实现细节会干扰这一点,则可能被认为是“不可取”的。最终,这取决于如何根据它们潜在的竞争目标对每个优先级进行排名。
2. 如何针对此编码而不放弃使用lambda?特别是如何防御性地编写代码,以便将来的代码更改不会突然导致同一方法中的其他未更改的lambda开始包含它不应该包含的内容?
没有一个不刻意的例子很难回答。总的来说,我会说显而易见的答案是“不要混合使用lambda”。在您的特定(尽管是人为制造的)示例中,您有一个似乎正在做两件完全不同的事情的方法。出于各种原因,这通常是不被赞同的,而且我认为这个例子只是增加了这个列表。
我不知道修复“两件不同的事情”的最佳方法是什么,但一个明显的替代方案至少是重构该方法,使“两个不同的事情”方法将工作委托给另外两个命名描述性的方法(这有额外的好处,可以帮助代码自我描述)。
例如:
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() 仍然执行两个不同的操作,但至少它本身不包含这两个无关的实现。代码更易读且更好地分隔,这是一件好事,除了修复变量生存期问题之外。

关于C#规范7.15.5.1,第一段开始是“当匿名函数引用外部变量时,外部变量被称为已被匿名函数捕获”。然而,lambda () => File.OpenRead( file ) 没有引用外部变量 hashSet,因此 hashSet 的生命周期不应该由这个 lambda 的生命周期延长。关于“两件不同的事情” - 正如您所指出的,这确实是一个人为的例子。这个问题似乎影响使用捕获 lambda 做一些工作并创建长期捕获 lambda 的任何方法。 - tg73
1
@tg73: IMHO,“hashSet”的生命周期不应该由此lambda表达式的生命周期延长”,你可能没有仔细阅读规范。hashSet变量是被其他lambda表达式所捕获的,并且规范中没有对这种捕获变量的生命周期上限做出规定。如果编译器想要,它可以通过将变量设置为静态变量并永远不丢弃来实现捕获。虽然我理解这种行为对于你的目的来说可能不方便,但它完全符合规范要求。 - Peter Duniho
@tg73:“这个问题似乎影响到任何创建长期捕获lambda的方法”,但只有当您在方法中有两个不相关的匿名方法,每个方法都捕获不同的局部变量时才会出现。方法应该简单;一个足够大以包含两个独立逻辑位和不相关变量生命周期的方法需要重构。在任何情况下,通过将方法分解成较小的部分来轻松解决此问题。我无法评论我没有看到的示例,但通常很容易做到这一点。 - Peter Duniho

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