在C#中,为什么在try块内声明的变量的作用域受到限制?


23

我想将错误处理添加到:

var firstVariable = 1;
var secondVariable = firstVariable;

以下内容无法编译:

try
{
   var firstVariable = 1;
}
catch {}

try
{
   var secondVariable = firstVariable;
}
catch {}

为什么try catch块必须像其他代码块一样影响变量的范围?除了保持一致性之外,对于我们而言,无需重构就能够使用错误处理包装代码是否有意义?


14
A try.. catch是一种特定类型的代码块,就所有代码块而言,就范围而言,您不能在一个变量中声明一个变量,而在另一个变量中使用同一变量。
尼尔

“是特定类型的代码块”。具体用什么方式?谢谢
JᴀʏMᴇᴇ

7
我的意思是大括号之间的任何内容都是代码块。您可以在if语句之后和for语句之后看到它,尽管概念是相同的。相对于其父范围,内容处于更高的范围。我敢肯定,如果您仅使用大括号{}而不尝试,这将是有问题的。
尼尔

提示:请注意,使用(IDisposable){}和仅{}也同样适用。与IDisposable结合使用时,无论成功或失败,它都会自动清理资源。对此有一些例外,例如并非您期望所有类都实现IDisposable ...
Julia McGuigan

1
:讨论关于在计算器上,在这里同样的问题很多stackoverflow.com/questions/94977/...
乔恩·施耐德

Answers:


90

如果您的代码是:

try
{
   MethodThatMightThrow();
   var firstVariable = 1;
}
catch {}

try
{
   var secondVariable = firstVariable;
}
catch {}

现在,firstVariable如果您的方法调用抛出异常,您将尝试使用未声明的变量()。

注意:上面的示例专门回答了原始问题,该问题指出“保持一致性”。这表明除了一致性之外还有其他原因。但是,正如彼得的回答所显示的,一致性有一个强有力的论据,这肯定会成为决策中非常重要的因素。


啊,这正是我所追求的。我知道有些语言功能使我无法提出建议,但是我无法提出任何方案。非常感谢。
JᴀʏMᴇᴇ

1
“现在,如果方法调用抛出,您将尝试使用未声明的变量。” 此外,假设通过将变量视为可能已抛出的代码之前已声明但尚未初始化的变量来避免这种情况。这样就不会取消声明它,但仍然可能未分配它,并且明确的赋值分析将禁止读取它的值(没有可能证明它发生的中间赋值)。
伊莱亚·卡根

3
对于像C#这样的静态语言,声明实际上仅在编译时相关。编译器可以轻松地将声明移到范围的前面。在运行时更重要的事实是该变量可能未初始化
jpmc26

3
我不同意这个答案。C#已经有了一条规则,即在了解某些数据流的情况下无法读取未初始化的变量。(尝试在a的情况下声明变量,switch并在其他情况下访问它们。)此规则可以在此处轻松地应用,并且无论如何都无法编译此代码。我认为以下彼得的答案更合理。
塞巴斯蒂安·雷德尔

2
未声明的未初始化和C#分别跟踪它们之间是有区别的。如果允许您在声明该变量的块之外使用变量,则意味着您可以在第一个catch块中对其进行赋值,然后在第二个try块中对其进行赋值。
svick '17

64

我知道Ben已经很好地回答了这个问题,但是我想解决被便利地搁置一旁的POV一致性问题。假设try/catch块不影响范围,那么您将得到:

{
    // new scope here
}

try
{
   // Not new scope
}

对我来说,这会导致进入“最小惊讶原则”(POLA),因为您现在要承担起{}承担双重责任,具体取决于之前的背景。

摆脱这种混乱的唯一方法是指定其他标记来描绘try/catch块。这开始增加代码的味道。因此,当您try/catch在语言上没有作用域时,将变得一团糟,以致于使用作用域版本会更好。


另一个很好的答案。而且我从来没有听说过POLA,所以请继续阅读。非常感谢队友。
JᴀʏMᴇᴇ

“摆脱这种混乱的唯一方法是指定其他标记来划定try/ catch阻止。” -您的意思是:try { { // scope } }?:)
CompuChip

@CompuChip仍然有{}双重职责作为范围,而不是根据上下文创建范围。try^ //no-scope ^将是其他标记的示例。
Leliel

1
我认为,这是更根本的原因,更接近“真实”的答案。
杰克·艾德利

@JackAidley,尤其是因为您已经可以在使用未分配变量的地方编写代码。因此,尽管Ben的答案有一点关于这是有用的行为,但我不认为这是行为存在的原因。Ben的回答确实指出了OP所说的“除了一致性之外”,但是一致性是一个很好的理由!范围狭窄还有其他各种好处。
Kat

21

除了保持一致性之外,对于我们而言,无需重构就能够使用错误处理包装代码是否有意义?

要回答这个问题,不仅要考虑变量的作用域还需要研究

即使变量仍在范围内,也不会明确赋值

在try块中声明变量可以向编译器和人类读者表达,它仅在该块内部有意义。对于编译器强制执行该操作很有用。

如果希望变量在try块之后,则可以在块外部声明它:

var zerothVariable = 1_000_000_000_000L;
int firstVariable;

try {
    // Change checked to unchecked to allow the overflow without throwing.
    firstVariable = checked((int)zerothVariable);
}
catch (OverflowException e) {
    Console.Error.WriteLine(e.Message);
    Environment.Exit(1);
}

这表示变量在try块之外可能是有意义的。编译器将允许这样做。

但是,这也表明了另一个原因,即在将try块中引入变量之后,将变量保持在范围内通常没有用。C#编译器执行定值分配分析,并禁止读取尚未证明已赋予其值的变量的值。因此,您仍然无法读取变量。

假设我尝试在try块之后读取变量:

Console.WriteLine(firstVariable);

这将产生一个编译时错误

CS0165使用未分配的局部变量“ firstVariable”

我在catch块中调用了Environment.Exit,所以知道在调用Console.WriteLine之前已分配了变量。但是编译器不会推断出这一点。

为什么编译器这么严格?

我什至不能这样做:

int n;

try {
    n = 10; // I know this won't throw an IOException.
}
catch (IOException) {
}

Console.WriteLine(n);

解决此限制的一种方法是说C#中的明确赋值分析不是很复杂。但是另一种看待它的方式是,当您在带有catch子句的try块中编写代码时,您正在告诉编译器和任何人类读者都应该将其视为可能无法全部运行。

为了说明我的意思,请假设编译器是否允许上面的代码,但是您在try块中添加了一个对您自己知道不会引发异常的函数的调用。无法保证所调用的函数不会抛出IOException,编译器无法知道n已分配该函数,然后必须进行重构。

就是说,通过前面的高度复杂的分析来确定在try块中使用catch子句分配的变量之后是否已明确分配,编译器可帮助您避免编写可能在以后中断的代码。(毕竟,捕获异常通常意味着您认为可能会抛出异常。)

您可以确保通过所有代码路径分配变量。

您可以通过在try块之前或catch块中为变量提供一个值来使代码编译。这样,即使try块中的分配没有发生,它仍将被初始化或分配。例如:

var n = 0; // But is this meaningful, or just covering a bug?

try {
    n = 10;
}
catch (IOException) {
}

Console.WriteLine(n);

要么:

int n;

try {
    n = 10;
}
catch (IOException) {
    n = 0; // But is this meaningful, or just covering a bug?
}

Console.WriteLine(n);

那些编译。但它是最好的,如果默认值,你给它有意义的只有做这样的事情*,并产生正确的行为。

请注意,在第二种情况下,您在try块和所有catch块中分配了变量,尽管您可以在try-catch之后读取变量,但是仍然无法读取附加finally块内的变量,因为在比我们经常想的更多的情况下,执行可能会留下尝试障碍

* 顺便说一下,某些语言(例如C和C ++)都允许未初始化的变量,并且没有明确的赋值分析以防止从中读取数据。由于读取未初始化的内存会导致程序以不确定和不稳定的方式运行,因此通常建议避免不提供初始化程序的情况下以这些语言引入变量。在具有明确赋值分析的语言(例如C#和Java)中,编译器使您免于读取未初始化的变量,也避免了使用无意义的值对其进行初始化(其后会被误解为有意义)的较小危害。

您可以使未分配变量的代码路径引发异常(或返回)。

如果您打算执行某些操作(例如记录日志)并重新抛出该异常或引发另一个异常,并且这种情况发生在未分配变量的任何catch子句中,则编译器将知道该变量已被分配:

int n;

try {
    n = 10;
}
catch (IOException e) {
    Console.Error.WriteLine(e.Message);
    throw;
}

Console.WriteLine(n);

编译后,很可能是一个合理的选择。但是,在实际的应用程序中,除非仅在甚至无法尝试恢复*的情况下抛出该异常,否则应确保仍在某个地方捕获并正确处理了该异常。

(在这种情况下,您也无法读取finally块中的变量,但是感觉不应该,毕竟,finally块基本上总是运行,在这种情况下,变量并非总是被分配)

* 例如,许多应用程序没有catch子句来处理一个OutOfMemoryException异常,因为任何他们做些什么可能是至少一样糟糕崩溃

也许你真的想要重构代码。

在你的榜样,你介绍firstVariablesecondVariable在try块。就像我说过的那样,您可以在分配了它们的try块之前定义它们,以便它们随后仍在作用域内,并且您可以通过确保始终分配它们来满足/欺骗编译器以允许您读取它们。

但是这些块之后出现的代码大概取决于它们是否已正确分配。如果是这种情况,那么您的代码应反映并确保这一点。

首先,您可以(并且应该)实际处理那里的错误吗? 存在异常处理的原因之一是,即使在错误发生的地方附近,也可以更轻松地在可以有效处理的地方处理错误。

如果您实际上无法处理初始化并使用这些变量的函数中的错误,则也许try块根本不应该位于该函数中,而应该位于某个更高的位置(即,在调用该函数的代码中,或在代码中调用代码)。只要确保您没有意外捕获到其他地方抛出的异常,并错误地假定在初始化firstVariableand 时抛出了该异常secondVariable

另一种方法是将使用变量的代码放在try块中。这通常是合理的。同样,如果您从其初始值设定项捕获的异常也可能从周围的代码中抛出,则应确保在处理它们时不要忽略这种可能性。

(我假设您要使用比示例中所示的表达式更复杂的表达式来初始化变量,以使它们实际上可能引发异常,并且您实际上并没有计划捕获所有可能的异常,而是仅捕获任何特定的异常您可以预期并有意义地进行处理。确实,现实世界并不总是那么好,生产代码有时也可以做到这一点,但是由于您的目标是处理初始化两个特定变量时发生的错误,因此您为该特定变量编写的任何catch子句目的应特定于那些错误。)

第三种方法是将可能失败的代码以及处理失败的try-catch 提取到其自己的方法中。如果您可能想先完全处理错误,然后又不必担心无意中捕获了应该在其他地方处理的异常,则这很有用。

例如,假设您要在未能分配任何变量时立即退出应用程序。(显然,并非所有异常处理都是针对致命错误的;这仅是示例,并且可能不是您希望应用程序对问题做出反应的方式。)您可以这样做:

// In real life, this should be named more descriptively.
private static (int firstValue, int secondValue) GetFirstAndSecondValues()
{
    try {
        // This code is contrived. The idea here is that obtaining the values
        // could actually fail, and throw a SomeSpecificException.
        var firstVariable = 1;
        var secondVariable = firstVariable;
        return (firstVariable, secondVariable);
    }
    catch (SomeSpecificException e) {
        Console.Error.WriteLine(e.Message);
        Environment.Exit(1);
        throw new InvalidOperationException(); // unreachable
    }
}

// ...and of course so should this.
internal static void MethodThatUsesTheValues()
{
    var (firstVariable, secondVariable) = GetFirstAndSecondValues();

    // Code that does something with them...
}

该代码返回并使用C#7.0的语法解构一个ValueTuple以返回多个值,但是,如果您仍在使用C#的早期版本,则仍可以使用此技术。例如,您可以使用out参数,或者返回提供两个值的自定义对象。此外,如果这两个变量实际上并不紧密相关,那么最好还是使用两个单独的方法。

特别是如果您有多种方法,则应该考虑集中代码以通知用户致命错误并退出。(例如,您可以编写Die带有message参数的方法。)throw new InvalidOperationException();行从未真正执行过,因此您不必(也不应)为其编写catch子句。

除了在发生特定错误时退出之外,如果抛出包装原始异常的其他类型的异常,有时您可能还会编写类似以下的代码。(在这种情况下,你会不会需要一秒钟,可达throw表达式。)

结论:范围只是图片的一部分。

仅通过将变量的声明与其赋值分开,就可以实现不进行重构(或者,如果愿意,几乎不进行重构)的带有错误处理的代码包装效果。如果满足C#的明确赋值规则,则编译器允许这样做,并且在try块之前声明一个变量将使其更大的范围变得清晰。但是进一步重构可能仍然是您的最佳选择。


“当您在带有catch子句的try块中编写代码时,您是在告诉编译器和任何人类读者都应该将其视为可能无法全部运行。” 编译器关心的是,即使先前的语句引发异常,控制也可以到达后面的语句。编译器通常假设,如果一个语句引发异常,则下一条语句将不会执行,因此不会读取未分配的变量。添加一个“ catch”将使控件能够到达以后的语句-重要的是catch,而不是try块中的代码是否抛出。
Pete Kirkham
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.