为什么局部变量需要初始化而字段不需要?

144

如果我在类中创建一个bool变量,比如bool check,它的默认值是false。

但是当我在方法中创建相同的bool变量,用bool check(而不是在类内部创建),就会出现错误"使用了未赋值的本地变量check"。为什么会这样呢?


评论不是用于长时间讨论的;此对话已经被移至聊天室。 (http://chat.stackoverflow.com/rooms/80459/discussion-on-question-by-nachime-why-do-local-variables-require-initialization) - Martijn Pieters
14
问题比较模糊。 “因为规格书这么说”是否可以作为一个可接受的答案? - Eric Lippert
4
因为在翻译它时他们复制了Java的方式。 :P - Alvin Thompson
4个回答

181

Yuval和David的回答基本是正确的; 总结如下:

  • 使用未分配的局部变量可能是一个bug,并且这可以在编译器低成本检测到。
  • 使用未分配的字段或数组元素不太可能是一个bug,在编译器中很难检测到这种情况。因此,编译器不尝试检测未初始化变量用于字段的使用,而是依靠默认值的初始化来使程序行为确定性。

David的答案中的评论者问为什么无法通过静态分析检测未赋值字段的使用; 这是我想在这个答案中扩展的重点。

首先,对于任何变量,本地的或其他的,实际上很难准确确定变量是否已赋值或未赋值。考虑:

bool x;
if (M()) x = true;
Console.WriteLine(x);
问题“x是否被赋值?”等同于“M()是否返回true?”假设M()返回true,当且仅当费马大定理对eleventy gajillion以下的所有整数成立,否则返回false。为了确定x是否一定被分配,编译器必须基本上证明费马大定理。编译器并不那么聪明。
因此,对于本地变量,编译器实现了一种算法,该算法是快速的,并且在本地变量未确定被分配时进行过度估计。也就是说,它有一些误报,即它说:“我无法证明这个本地变量已被分配”,即使您和我知道它已经被赋值了。例如:
bool x;
if (N() * 0 == 0) x = true;
Console.WriteLine(x);

假设N()返回一个整数。你我都知道N()*0将得到0,但编译器不知道。(注意:C#2.0编译器知道这一点,但我移除了这个优化,因为规范没有说明编译器知道这一点)

总之,我们已经知道了什么?对于本地变量而言,准确的答案是不现实的,但我们可以低估未分配的情况并以廉价的价格得到一个相当不错的结果,该结果存在“强制你修复你的不明确程序”的倾向。这很好。为什么不对字段也做同样的事情?也就是说,制作一个确切赋值检查器,它便宜地高估。

嗯,一个本地变量有多少种初始化方式呢?它可以在方法文本中被赋值。它可以在方法文本中的lambda表达式中被赋值;那个lambda表达式可能永远不会被调用,所以那些赋值是不相关的。或者可以将其作为“out”传递给另一个方法,在这种情况下,我们可以假定在方法正常返回时它被赋值。这些是非常明显的本地分配点,它们“就在声明该本地变量的同一方法中”。确定本地变量的确切分配只需要“本地分析”。方法往往很短——远远少于一百万行——所以分析整个方法非常快。

那么字段呢?当然,字段可以在构造函数中初始化。或者是字段初始化器。或者构造函数可以调用一个实例方法来初始化字段。或者构造函数可以调用一个虚拟方法来初始化字段。或者构造函数可以调用另一个类中的方法(可能在库中)来初始化字段。静态字段可以在静态构造函数中初始化。静态字段可以由其他静态构造函数初始化。

实质上,字段的初始化器可以是“整个程序中的任何位置”,包括“将在尚未编写的库中声明的虚拟方法内部”:

// Library written by BarCorp
public abstract class Bar
{
    // Derived class is responsible for initializing x.
    protected int x;
    protected abstract void InitializeX(); 
    public void M() 
    { 
       InitializeX();
       Console.WriteLine(x); 
    }
}

这个库编译出错了吗?如果是,BarCorp应该如何修复这个错误?给x指定一个默认值吗?但这正是编译器已经做的事情。

假设这个库是合法的。如果FooCorp编写:

public class Foo : Bar
{
    protected override void InitializeX() { } 
}

那是一个错误吗?编译器该如何解决这个问题?唯一的方法是进行整个程序分析,追踪每个字段在程序的每条可能路径中的初始化静态值,包括涉及在运行时选择虚方法的路径。这个问题可以非常困难;它可能涉及模拟执行数百万控制路径。分析本地控制流需要微秒级别的时间,取决于方法的大小。分析全局控制流可能需要数小时,因为它取决于程序中每个方法和所有库的复杂性。

那么为什么不进行更便宜的分析,而不必分析整个程序,并更严重地高估呢?好吧,提出一种算法,使其不会让编写正确编译的程序变得太困难,设计团队可以考虑它。我不知道有任何这样的算法。

现在,评论者建议"要求构造函数初始化所有字段"。这不是一个坏主意。事实上,C#已经为结构体具有了这个功能。结构体构造函数需要在构造函数正常返回之前明确分配所有字段;默认构造函数将所有字段初始化为它们的默认值。

那类呢?那么您如何知道构造函数是否已经初始化了字段呢?构造函数可以调用虚方法来初始化字段,现在我们回到了之前的位置。结构体没有派生类;类可能有。包含抽象类的库是否需要包含初始化其所有字段的构造函数?抽象类如何知道应将字段初始化为哪些值?

John建议在字段初始化之前禁止调用构造函数中的方法。因此,总结一下,我们的选项是:

  • 使常见、安全、经常使用的编程惯例非法。
  • 进行昂贵的整个程序分析,以便在编译时花费数小时查找可能不存在的错误。
  • 依靠自动初始化默认值。

设计团队选择了第三个选项。


1
像往常一样,非常好的回答。不过我有一个问题:为什么不自动分配默认值给本地变量呢?换句话说,为什么不让 bool x; 等同于 bool x = false; 即使在方法内部 - durron597
9
因为经验表明,忘记给本地变量赋值可能是一个 bug。如果这很可能是一个 bug 而且很容易检测到,那么就有很好的动机把这种行为设为非法或警告。 - Eric Lippert
在Yuval的下面的答案中,它说本地变量会自动初始化为默认值。如果开发人员无论如何都必须初始化变量,为什么要进行这种自动初始化呢? - David Klempfner

27
当我在方法中创建相同的bool变量时,即在类内部之外创建bool check,我会得到一个错误“使用未赋值的本地变量check”。为什么?
因为编译器试图防止您犯错误。
在这个特定的执行路径中,将变量初始化为false是否改变任何内容?可能不会,考虑到default(bool)已经是false了,但它强制你意识到这一点。.NET环境会防止你访问"垃圾内存",因为它会将任何值初始化为它们的默认值。但是,想象一下这是一个引用类型,并且您将未初始化(null)的值传递给期望非null的方法,并在运行时获得NRE。编译器只是试图防止这种情况发生,接受这可能有时会导致bool b = false语句。
Eric Lippert在博客文章中谈到了这个问题。
我们想要禁止这种写法的原因并不是像很多人认为的那样,因为局部变量将会初始化为垃圾值,我们要保护你免受垃圾值的影响。实际上,我们会自动将局部变量初始化为默认值。(尽管C和C++编程语言不会这样做,它们会轻易地让你从未初始化的局部变量中读取垃圾值。)相反,这种代码路径存在的原因可能是一个bug,我们希望将你扔进质量的深渊;你应该努力工作来避免写出这个bug。

为什么这个不适用于类字段?我想这是因为需要划定一个界限,而本地变量的初始化要容易得多,也更容易诊断和正确处理,而不是类字段。编译器可能会这样做,但想想它需要进行所有可能的检查(其中一些与类代码本身无关),以评估类中每个字段是否已初始化。我不是编译器设计师,但我确定它肯定会更难,因为有很多情况要考虑,并且必须及时完成。对于每个功能,都需要设计、编写、测试和部署,而实施这一点所需的价值与付出的努力相比是不值得且复杂的。


“想象一下这是一个引用类型,并且您将传递此未初始化的对象到期望初始化的方法中。” 您是否意味着:“想象一下这是一个引用类型,而您正在传递默认值(null)而不是对象的引用?” - Deduplicator
@Deduplicator 是的。一个期望非空值的方法。已经编辑了那部分。希望现在更清晰了。 - Yuval Itzchakov
我认为这不是因为画线的原因。每个类都应该有一个构造函数,至少是默认构造函数。因此,当您坚持使用默认构造函数时,您将获得默认值(非常透明)。在定义构造函数时,您应该知道您在其中做什么以及您想以何种方式初始化哪些字段,包括对默认值的了解。 - Peter
相反地,方法内的字段可以在执行的不同路径中声明并赋值。可能会有一些异常情况被忽视,直到您查看所使用的框架的文档甚至是您不维护的代码的其他部分。这可能会引入非常复杂的执行路径。因此编译器会提示。 - Peter
@Peter,我不是很理解你的第二条评论。关于第一条评论,没有要求在构造函数中初始化任何字段。这是一种常见的惯例。编译器的工作不是强制执行这样的惯例。您不能依赖于构造函数的任何实现并说“好了,所有字段都准备就绪”。Eric在他的答案中详细阐述了初始化类字段的方法,并展示了计算所有逻辑初始化方式需要很长时间的方式。 - Yuval Itzchakov
显示剩余4条评论

26
为什么局部变量需要初始化,而字段不需要?
简短的答案是,通过静态分析,编译器可以可靠地检测访问未初始化的局部变量的代码,而这种情况并不适用于字段。因此,编译器强制执行第一种情况,但不强制执行第二种情况。
为什么局部变量需要初始化?
这只是C#语言的一个设计决策,正如Eric Lippert所解释的那样。CLR和.NET环境并不要求这样做。例如,VB.NET将使用未初始化的局部变量进行编译,而实际上CLR将所有未初始化的变量初始化为默认值。
C#也可以这样做,但语言设计师选择不这样做。原因是初始化的变量是错误的巨大源泉,因此,通过强制初始化,编译器有助于减少意外错误。
为什么字段不需要初始化?
因为在类中,这种强制的显式初始化可能会在构造期间发生,通过对象初始化程序调用属性,甚至在事件之后调用方法。编译器无法使用静态分析来确定代码中的每个可能路径是否导致变量在我们之前被显式初始化。如果出错将很麻烦,因为开发人员可能会留下无法编译的有效代码。因此,C#根本不强制执行它,CLR则自动将字段初始化为默认值(如果未明确设置)。
那么集合类型呢?
C#对局部变量初始化的执行是有限制的,这经常会让开发人员感到困惑。考虑以下四行代码:
string str;
var len1 = str.Length;
var array = new string[10];
var len2 = array[0].Length;

第二行代码无法编译,因为它试图读取未初始化的字符串变量。但第四行代码可以编译通过,因为array已经被初始化,但仅使用默认值。由于字符串的默认值是null,在运行时我们会得到异常。在Stack Overflow上花费时间的任何人都将知道这种显式/隐式初始化不一致性会导致许多“为什么我会收到“对象引用未设置为对象实例”的错误?”的问题。


编译器无法使用静态分析来确定代码中的每个可能路径是否都导致变量在使用之前被显式初始化。我不确定这是正确的。你能否提供一个抵抗静态分析的程序示例? - John Kugelman
没错,但我不会期望在编译f时生成错误。它将在编译构造函数时生成。如果您让一个构造函数带有可能未初始化的字段,那就是一个错误。在所有字段初始化之前调用类方法和getter也可能有限制。 - John Kugelman
@JohnKugelman:我会发布一个回答,讨论你提出的问题。 - Eric Lippert
4
不公平,我们正试图进行一场争论! - John Kugelman
@JohnKugelman 当你在分配值之前尝试使用局部变量时,就会出现局部变量的问题。如果你将相同的逻辑应用于字段,那么在它们被赋值之前不使用它们,你最终会遇到至少一个 NP 困难问题。当然,你可以解决这个问题,但是你可能需要永远运行编译,并且在完成之前需要多长时间是不可预知的。 - Rune FS
显示剩余4条评论

12

以上回答很好,但我想为那些懒得读长篇回答(比如我)的人发布一个更简单/更短的答案。

类别

class Foo {
    private string Boo;
    public Foo() { /** bla bla bla **/ }
    public string DoSomething() { return Boo; }
}

属性Boo可能在构造函数中已经初始化,也可能没有。因此,当它找到return Boo;时,并不会假设它已经被初始化。它只是抑制了错误。

功能

public string Foo() {
   string Boo;
   return Boo; // triggers error
}

{ } 字符定义了代码块的范围。编译器遍历这些 { } 块的分支,跟踪相关内容。它可以轻松地知道 Boo 没有初始化。然后就会触发错误。

为什么这个错误存在?

引入这个错误是为了减少制作源代码时所需的行数,使其更加安全。如果没有这个错误,上面的代码将会变成这样:

public string Foo() {
   string Boo;
   /* bla bla bla */
   if(Boo == null) {
      return "";
   }
   return Boo;
}

根据手册:

C#编译器不允许使用未初始化的变量。如果编译器检测到可能未被初始化的变量的使用,它将生成编译器错误CS0165。有关更多信息,请参阅字段(C#编程指南)。请注意,即使您的特定代码没有,当编译器遇到可能导致使用未分配变量的结构时,也会生成此错误。这避免了过于复杂的确定赋值规则的必要性。

参考: https://msdn.microsoft.com/en-us/library/4y7h161d.aspx


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