显式局部作用域 - 是否真的有益?

18

我正在整理一些代码,并删除了一个不再必要的 if 语句。但是,我意识到我忘记删除括号。当然,这是有效的,并且只创建了一个新的局部作用域。现在这让我想起来。在我多年的 C# 开发中,我从未遇到过使用它们的原因。实际上,我有点忘记我可以这么做。

定义一个局部作用域是否有任何实际好处?我明白我可以在一个作用域中定义变量,然后在另一个无关的作用域(如 for、foreach 等)中重新定义相同的变量,如下所示:

void SomeMethod()
{    
    {
        int i = 20;
    }

    int i = 50; //Invalid due to i already being used above.
}

void SomeMethod2()
{    
    {
        int i = 20;
    }
    {
        int i = 50; //Valid due to scopes being unrelated.
    }
    {
        string i = "ABCDEF";
    }
}

定义本地范围的真正目的是什么?是否实际上会有任何性能提升(或潜在的损失)?我知道你可以在C++中做到这一点并帮助你管理内存,但因为这是.NET,是否真的会有好处?这只是语言的副产品,让我们定义随机范围,即使没有真正的好处吗?


+1,令人惊讶的是C#编译器允许在没有if的情况下使用{},不知道是否有任何原因。 - cuongle
除了能够重复使用相同的变量名称,我看不到任何其他的。 - nawfal
4
如果你发现自己处在一个需要这种方法的地方,那么你可能应该将代码拆分为单独的方法... - Oded
@Oded 这正是我的想法。我绝对不会仅仅为了重复使用变量名而使用它。我喜欢用描述性的名称 =) - TyCobb
6
它们可能有用的一个地方是这里 - Martin Smith
1
@MartinSmith 那可能值得进一步扩展并发布为答案,因为这实际上是显式作用域的一个合法用途。 - Matthew Watson
5个回答

9
在C#中,将一组语句转换为单个语句纯粹是语法问题。对于任何希望跟随单个语句的关键字(如if、for、using等)都是必需的。但有几种特殊情况:
- switch语句中的case关键字很特殊,因为它不要求是单个语句。使用break或goto关键字来结束它。这就解释了为什么可以使用大括号来插入变量声明。 - try和catch关键字也很特殊,即使只有一个语句跟随,它们也需要大括号。这非常不寻常,但可能是为了迫使程序员考虑块内部声明的作用域。由于异常处理的工作方式,catch块无法引用try块内的变量。
在C++中,使用它来限制局部变量的作用域是徒劳的。这是一个大问题,因为结尾的括号是编译器注入作用域块内变量的析构函数调用的地方。这通常被滥用于RAII模式,让一个标点符号在程序中产生如此严重的副作用并不美观。
C#团队没有太多选择,局部变量的生命周期严格受JIT控制。JIT对方法生成机器代码时无视任何分组结构,它只知道IL。除了try/except/finally之外,IL没有任何分组结构。无论在何处编写,任何局部变量的作用域都是方法体。当您在已编译的C#代码上运行ildasm.exe时,可以看到局部变量提升到方法体的顶部。这也部分解释了为什么C#编译器不允许您在另一个作用域块中使用相同名称声明另一个局部变量。
JIT对局部变量生命周期有有趣的规则,完全由垃圾回收器的工作方式控制。当它jit方法时,它不仅会为该方法生成机器代码,还会创建一个表,描述每个局部变量的实际范围,它被初始化的代码地址和它不再使用的代码地址。垃圾回收器使用该表来确定对象引用是否有效,基于活动执行地址。
这使得它在收集对象方面非常高效。有时过于高效,在与本地代码交互时可能会出现问题,您可能需要使用神奇的GC.KeepAlive()方法来延长生命周期。这是一个非常值得注意的方法,它根本不生成任何代码。它的唯一用途是让JIT更改表并插入变量生命周期的较大地址。

1
词法作用域可能是方法,但强调一下,GC并不关心它。一旦你停止使用它,GC就可以将其删除,即使它在方法中间,甚至在对象本身的方法调用中间也是如此。这就是为什么GC.KeepAlive很重要的原因。 - Mark Sowul
此外,这是合法的,尽管我无法格式化它: M() { { int x = 5; Console.WriteLine(x); } { int x = 6; Console.WriteLine(x); } } - Mark Sowul

6

就像函数一样,这些“块”基本上只是为了隔离函数内(大多数)不相关的代码和它们的本地变量。

例如,如果您需要一些临时变量仅在两个函数调用之间传递,则可以使用它。

int Foo(int a) {

    // ...

    {
        int temp;
        SomeFuncWithOutParam(a, out temp);

        NowUseThatTempJustOnce(temp);            
    }

    MistakenlyTryToUse(temp);    // Doesn't compile!

    // ...

}

然而,有人会认为,如果需要这种词法作用域,则内部块应该是自己的函数。

至于性能等方面,我非常怀疑它是否真的有影响。编译器会将整个函数视为一个整体,并在确定堆栈帧大小时收集所有局部变量(甚至是在行内声明的变量)。因此,所有局部变量都被合并在一起。这只是一个纯粹的词法技术,可以对变量的使用给予更多的限制。


你提供的例子不太好。C#编译器不允许在块之前或之后声明temp变量。因此,使用该块没有意义。 - Hans Passant
@HansPassant 为什么不能在代码块之前,在函数顶部呢? - Jonathon Reinhart
如果“它”指的是“另一个名为temp的变量”,那是因为它无法编译。你试过了吗?我在我的答案中部分解释了它。 - Hans Passant
1
啊,你的编辑解释了一切。(现在编辑我的评论已经太晚了。)没错,在内部和外部作用域中不能重复使用变量名。我举这个例子的整个意图就是那个块防止你在块之外的任何地方使用temp。 - Jonathon Reinhart

3

有一个地方,局部作用域可能会很有用:在switch语句的case语句内部。默认情况下,所有case共享与switch语句相同的作用域。 在多个case语句内部声明具有相同名称的局部临时变量是不允许的,并且您最终可能只在第一个case语句中或甚至在switch语句之外声明变量。 您可以通过为每个case语句提供局部作用域并在作用域内部声明临时变量来解决此问题。 然而,不要使你的情况过于复杂,这个问题可能表明更好的方法是调用一个单独的方法来处理case语句。


一个展示它有用的代码示例将非常有帮助。 - psubsee2003

2
优点主要在于它使语言的定义更加简单。现在,定义只需说明 if、while、for等应该跟随一个单独的语句,而括号内的一组语句仅仅是另一种可能的语句而已。禁止如你所示的语句块并没有真正的益处。有时候,它们有用于避免名称冲突,但你也可以不用它们来解决问题。这也会不必要地引入与C、C++和Java等语言相比的语法规则差异。如果你想知道,它也不会改变引用对象的生存周期。

0

至少在发布模式下不会有性能提升:垃圾收集器可以在知道或者至少认为某个对象将不再被使用时回收它,而不考虑其作用域。这可能会因非托管互操作而出现问题:http://blogs.msdn.com/b/oldnewthing/archive/2010/08/13/10049634.aspx

话虽如此,我有时会利用这个“特性”来确保方法后面的代码不能使用一些临时对象。在大多数情况下,这可能可以通过拆分成其他方法来实现,但有时会变得过于笨重,或者由于某种原因无法实现(例如在设置只读成员的构造函数中)。偶尔我也会使用它来重用变量名,但这通常和“临时对象”原因相结合。


你的第一段直接反驳了汉斯的回答。 - Jonathon Reinhart
请详细说明。如果您指的是我所说的“发布模式”限定符,编译器会添加nop指令,以便在Debug模式下对大括号设置断点。Hans本人在这里说过:https://dev59.com/gmvXa4cB1Zd3GeqPMsYm#13752156 - Mark Sowul
不,你所说的当它超出范围时就有资格进行GC收集。在代码中,“作用域”是本地块。但是Hans在他的答案中说明了所有局部变量都被提升到函数作用域,而不管本地块。这意味着它们直到函数返回才有资格进行GC。 - Jonathon Reinhart
嗯,我读了你的段落几遍,但现在让我有点困惑 :-P - Jonathon Reinhart
现在我看到了我和汉斯之间似乎存在分歧的地方。汉斯谈论的是你可以使用变量的范围,但我谈论的是GC关心你正在使用变量的范围。换句话说,我认为变量可以使用的范围对性能没有影响。请看这个链接:http://blogs.msdn.com/b/oldnewthing/archive/2010/08/10/10048149.aspx - Mark Sowul

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