什么是空引用异常(NullReferenceException),我该怎么修复它?

2024年 3月 4日 112.3k 0

在C#中,空引用异常(NullReferenceException)是最常见的异常之一。它发生在你尝试访问或操作一个空对象时。换句话说,当你没有为变量分配一个有效的对象引用,却尝试使用它时,就会抛出空引用异常。

引发的原因是什么

您正在尝试使用空值(或 VB.NET 中的“Nothing”)。这意味着您将其设置为 null,或者根本没有设置任何值。

与其他任何东西一样,空值会传递。如果在方法“A”中为 null,则可能是方法“B”将 null 传递给了方法“A”。

空值可能有不同的含义:

  • 未初始化的对象变量,因此指向空值。在这种情况下,如果您访问此类对象的成员,会导致 NullReferenceException。
  • 开发人员故意使用null来表示没有可用的有意义的值。请注意,C#有变量的可空数据类型的概念(就像数据库表可以有可空字段一样)-您可以将null分配给它们,以指示其中没有存储值,例如int? a = null;(这是Nullable a = null;的快捷方式;)其中问号表示允许在变量a中存储null。您可以使用if(a.HasValue){...}或if(a==null){...}来检查它。可空变量,比如这个例子,允许通过a.Value显式访问值,或者像正常情况下一样通过a访问。

请注意,通过a.Value访问它如果a为空会引发InvalidOperationException而不是NullReferenceException-您应该事先进行检查,即如果您有另一个非空变量int b;然后您应该进行赋值,如if(a.HasValue){b = a.Value;}或更短的if(a!=null){b = a;}。

这篇文章的其余部分会更详细地介绍,并展示许多程序员经常犯的错误,这些错误可能导致空引用异常。

更具体地

运行时抛出 NullReferenceException 总是意味着同样的事情:你试图使用一个引用,但该引用没有被初始化(或者曾经被初始化过,但现在已经不再被初始化)。

这意味着该引用是空的,你无法通过空引用访问成员(比如方法)。这是最简单的情况:

string foo = null;
foo.ToUpper();

这将在第二行抛出NullReferenceException,因为你不能在指向null的字符串引用上调用实例方法ToUpper()。

调试 Debugging

你如何找到 NullReferenceException 的源头?除了查看异常本身会在发生异常的位置抛出之外,Visual Studio 调试的一般规则也适用:设置策略性的断点并检查你的变量,可以通过将鼠标悬停在它们的名称上、打开(快速)监视窗口或者使用诸如本地变量和自动变量等各种调试面板来进行。

如果你想找出引用是在哪里设置或未设置,右键单击它的名称并选择“查找所有引用”。然后你可以在每个找到的位置设置断点,并使用附加了调试器的程序运行。每当调试器在这样的断点上中断时,你需要确定你是否期望引用是非空的,检查变量,并验证它在你期望的时候指向一个实例。

通过这种方式跟踪程序流程,你可以找到实例不应为 null 的位置,以及为什么它没有被正确设置。

案例 Examples

一些常见的异常抛出场景:

通用

ref1.ref2.ref3.member

如果ref1或ref2或ref3为空,那么你会得到一个NullReferenceException。如果你想解决这个问题,那么找出哪一个是空的,通过将表达式重写为更简单的等价形式来解决。

var r1 = ref1;
var r2 = r1.ref2;
var r3 = r2.ref3;
r3.member

在HttpContext.Current.User.Identity.Name中,HttpContext.Current可能为null,或者User属性可能为null,或者Identity属性可能为null。

间接

public class Person 
{
    public int Age { get; set; }
}
public class Book 
{
    public Person Author { get; set; }
}
public class Example 
{
    public void Foo() 
    {
        Book b1 = new Book();
        int authorAge = b1.Author.Age; // You never initialized the Author property.
                                       // there is no Person to get an Age from.
    }
}

如果你想避免子对象(People)的空引用,你可以在父对象(BooK)的构造函数中对其进行初始化。

嵌套对象初始化

同样的规则也适用于嵌套对象初始化器:

Book b1 = new Book 
{ 
   Author = { Age = 45 } 
};

转换为:

Book b1 = new Book();
b1.Author.Age = 45;

虽然使用了新关键字,它只创建了 Book 的一个新实例,而不是 Person 的新实例,所以 Author 属性仍然为空。

嵌套集合初始化器

public class Person 
{
    public ICollection Books { get; set; }
}
public class Book 
{
    public string Title { get; set; }
}

嵌套集合初始化器的行为相同:

Person p1 = new Person 
{
    Books = {
         new Book { Title = "Title1" },
         new Book { Title = "Title2" },
    }
};

转换为:

Person p1 = new Person();
p1.Books.Add(new Book { Title = "Title1" });
p1.Books.Add(new Book { Title = "Title2" });

使用 new Person 只会创建一个 Person 的实例,但 Books 集合仍然为空。集合初始化器语法不会为 p1.Books 创建一个集合,它只是将其转换为 p1.Books.Add(...) 语句。

Array

int[] numbers = null;
int n = numbers[0]; // numbers is null. There is no array to index.

Array Elements

Person[] people = new Person[5];
people[0].Age = 20 // people[0] is null. The array was allocated but not
                   // initialized. There is no Person to set the Age for.

Jagged Arrays

long[][] array = new long[1][];
array[0][0] = 3; // is null because only the first dimension is yet initialized.
                 // Use array[0] = new long[2]; first.

Collection/List/Dictionary

Dictionary agesForNames = null;
int age = agesForNames["Bob"]; // agesForNames is null.
                               // There is no Dictionary to perform the lookup.

范围变量(间接/延迟)

public class Person 
{
    public string Name { get; set; }
}
var people = new List();
people.Add(null);
var names = from p in people select p.Name;
string firstName = names.First(); // Exception is thrown here, but actually occurs
                                  // on the line above.  "p" is null because the
                                  // first element we added to the list is null.

Events (C#)

public class Demo
{
    public event EventHandler StateChanged;
    
    protected virtual void OnStateChanged(EventArgs e)
    {        
        StateChanged(this, e); // Exception is thrown here 
                               // if no event handlers have been attached
                               // to StateChanged event
    }
}

(注意:VB.NET 编译器会在事件使用时插入空值检查,因此在 VB.NET 中不需要检查事件是否为 Nothing。)

糟糕的命名规范:

如果你将字段命名与局部变量不同,你可能会意识到你从未初始化该字段。

public class Form1
{
    private Customer customer;
    
    private void Form1_Load(object sender, EventArgs e) 
    {
        Customer customer = new Customer();
        customer.Name = "John";
    }
    
    private void Button_Click(object sender, EventArgs e)
    {
        MessageBox.Show(customer.Name);
    }
}

这个问题可以通过遵循在字段前加下划线的命名约定来解决:

private Customer _customer;

ASP.NET Page Life cycle:

public partial class Issues_Edit : System.Web.UI.Page
{
    protected TestIssue myIssue;

    protected void Page_Load(object sender, EventArgs e)
    {
        if (!IsPostBack)
        {
             // Only called on first load, not when button clicked
             myIssue = new TestIssue(); 
        }
    }
        
    protected void SaveButton_Click(object sender, EventArgs e)
    {
        myIssue.Entry = "NullReferenceException here!";
    }
}

ASP.NET Session Values

// if the "FirstName" session value has not yet been set,
// then this line will throw a NullReferenceException
string firstName = Session["FirstName"].ToString();

ASP.NET MVC empty view models

如果在 ASP.NET MVC 视图中引用 @Model 的属性时发生异常,你需要明白 Model 是在你的操作方法中设置的,当你返回一个视图时。当你从控制器返回一个空模型(或模型属性)时,视图在访问它时会发生异常。

// Controller
public class Restaurant:Controller
{
    public ActionResult Search()
    {
        return View();  // Forgot the provide a Model here.
    }
}

// Razor view 
@foreach (var restaurantSearch in Model.RestaurantSearch)  // Throws.
{
}
    

@Model.somePropertyName

WPF Control Creation Order and Events

WPF 控件在调用 InitializeComponent 时按照它们在可视树中出现的顺序创建。如果在 InitializeComponent 中引用了在后续创建阶段的控件的早期创建控件中的事件处理程序等,就会引发 NullReferenceException。

例如:


    
    
       
       
       
    
        
    
    

在这里,comboBox1 在 label1 之前创建。如果 comboBox1_SelectionChanged 尝试引用 `label1,它可能尚未被创建。

private void comboBox1_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
    label1.Content = comboBox1.SelectedIndex.ToString(); // NullReferenceException here!!
}

改变 XAML 中声明的顺序(即,在 comboBox1 之前列出 label1,忽略设计哲学的问题)至少会解决这里的 NullReferenceException。

"as" 进行类型转换

var myThing = someObject as Thing;

这种方法在类型转换失败时不会抛出 InvalidCastException,而是返回 null(当 someObject 本身为 null 时也是如此)。所以请注意这一点。

LINQFirstOrDefault()andSingleOrDefault()

普通的 First() 和 Single() 方法在没有匹配项时会抛出异常。而带有 "OrDefault" 后缀的版本会返回 null。所以请注意这一点。

foreach

当尝试对空集合进行迭代时,foreach 会抛出异常。这通常是由返回空集合的方法意外返回 null 导致的。

List list = null;    
foreach(var v in list) { } // NullReferenceException here

更现实的例子是从 XML 文档中选择节点。如果未找到节点,会抛出异常,但初始调试显示所有属性都是有效的。

foreach (var node in myData.MyXml.DocumentNode.SelectNodes("//Data"))

避免的方法

显式检查 null 并忽略 null 值

如果你期望引用有时会是 null,可以在访问实例成员之前检查它是否为 null:

void PrintName(Person p)
{
    if (p != null) 
    {
        Console.WriteLine(p.Name);
    }
}

显式检查 null 并提供默认值

你调用的方法可能返回 null,例如当寻找的对象找不到时。在这种情况下,你可以选择返回一个默认值:

string GetCategory(Book b) 
{
    if (b == null)
        return "Unknown";
    return b.Category;
}

显式检查从方法调用返回的 null,并抛出自定义异常

你还可以抛出自定义异常,然后在调用代码中捕获它:

string GetCategory(string bookTitle) 
{
    var book = library.FindBook(bookTitle);  // This may return null
    if (book == null)
        throw new BookNotFoundException(bookTitle);  // Your custom exception
    return book.Category;
}

使用 `Debug.Assert` 来检查一个值是否永远不会为 null,以便在异常发生之前更早地捕获问题

当你在开发过程中知道一个方法可能会返回 null,但实际上不应该返回 null 时,你可以使用 Debug.Assert(),以便在出现这种情况时尽快中断程序。

string GetTitle(int knownBookID) 
{
    // You know this should never return null.
    var book = library.GetBook(knownBookID);  

    // Exception will occur on the next line instead of at the end of this method.
    Debug.Assert(book != null, "Library didn't return a book for known book ID.");

    // Some other code

    return book.Title; // Will never throw NullReferenceException in Debug mode.
}

尽管这个检查不会出现在发布版本中,但在运行时(在发布模式下)当 `book == null` 时,它会再次引发 NullReferenceException。

使用 `GetValueOrDefault()` 方法来处理可空值类型,在其为 null 时提供一个默认值

DateTime? appointment = null;
Console.WriteLine(appointment.GetValueOrDefault(DateTime.Now));
// Will display the default value provided (DateTime.Now), because appointment is null.

appointment = new DateTime(2022, 10, 20);
Console.WriteLine(appointment.GetValueOrDefault(DateTime.Now));
// Will display the appointment date, not the default

使用空合并运算符 ??(在 C# 中)或 If()(在 VB.NET 中)来提供一个默认值

遇到 null 时提供默认值的简写方式是使用空合并操作符(`??`)。

IService CreateService(ILogger log, Int32? frobPowerLevel)
{
   var serviceImpl = new MyService(log ?? NullLog.Instance);
 
   // Note that the above "GetValueOrDefault()" can also be rewritten to use
   // the coalesce operator:
   serviceImpl.FrobPowerLevel = frobPowerLevel ?? 5;
}

使用空值条件运算符:?.或?[x]用于数组(在C# 6和VB.NET 14中可用):

有时这也被称为安全导航或Elvis(根据其形状)运算符。如果运算符左侧的表达式为空,那么右侧将不会被计算,而是返回空值。这意味着像这样的情况:

var title = person.Title.ToUpper();

如果person没有title,这将抛出一个异常,因为它试图在一个空值属性上调用 ToUpper。

在C# 5及以下版本中,可以通过以下方式加以防范:

var title = person.Title == null ? null : person.Title.ToUpper();

现在,title变量将为空,而不会抛出异常。C# 6引入了一个更简洁的语法来实现这一点:

var title = person.Title?.ToUpper();

这将导致title变量为空,如果person.Title为空,就不会调用ToUpper。

当然,你仍然需要检查title是否为空,或者使用空值条件运算符与空值合并运算符(??)一起使用,以提供一个默认值:

// regular null check
int titleLength = 0;
if (title != null)
    titleLength = title.Length; // If title is null, this would throw NullReferenceException
    
// combining the `?` and the `??` operator
int titleLength = title?.Length ?? 0;

同样,对于数组,你可以使用?[i]来实现如下功能:

int[] myIntArray = null;
var i = 5;
int? elem = myIntArray?[i];
if (!elem.HasValue) Console.WriteLine("No value");

这将实现以下功能:如果myIntArray为空,表达式将返回null,你可以安全地进行检查。如果它包含一个数组,它将执行与 elem = myIntArray[i]; 相同的操作,并返回第i个元素。

使用空值上下文 (C# 8中可用):

在C# 8中引入了空值上下文和可空引用类型,它们对变量进行静态分析,并在值可能为空或已设置为null时提供编译器警告。可空引用类型允许明确允许类型为空。

可以使用csproj文件中的Nullable元素为项目设置可空注解上下文和可空警告上下文。此元素配置编译器如何解释类型的可空性以及生成哪些警告。有效的设置包括:

  • enable: 启用可空注解上下文。启用可空警告上下文。引用类型的变量,例如字符串,是非空的。所有可空性警告都已启用。
  • disable: 禁用可空注解上下文。禁用可空警告上下文。引用类型的变量是不可知的,就像之前的C#版本一样。所有可空性警告都已禁用。
  • safeonly: 启用可空注解上下文。启用安全可空警告上下文。引用类型的变量是非空的。所有安全的可空性警告都已启用。
  • warnings: 禁用可空注解上下文。启用可空警告上下文。引用类型的变量是不可知的。所有可空性警告都已启用。
  • safeonlywarnings: 禁用可空注解上下文。启用安全可空警告上下文。引用类型的变量是不可知的。所有安全的可空性警告都已启用。

可空引用类型的表示与可空值类型相同:在变量类型后追加?。

迭代器中调试和修复空指针引用的特殊技巧

C#支持“迭代器块”(在其他一些流行的语言中称为“生成器”)。NullReferenceException在迭代器块中可能特别难以调试,因为它具有延迟执行的特性。

public IEnumerable GetFrobs(FrobFactory f, int count)
{
    for (int i = 0; i < count; ++i)
    yield return f.MakeFrob();
}
...
FrobFactory factory = whatever;
IEnumerable frobs = GetFrobs();
...
foreach(Frob frob in frobs) { ... }

如果任何结果为空,那么MakeFrob就会抛出异常。现在,你可能会认为正确的做法是这样:

// DON'T DO THIS
public IEnumerable GetFrobs(FrobFactory f, int count)
{
   if (f == null) 
      throw new ArgumentNullException("f", "factory must not be null");
   for (int i = 0; i < count; ++i)
      yield return f.MakeFrob();
}

为什么这是错误的?因为迭代器块实际上直到foreach才运行!对GetFrobs的调用只是返回一个对象,当迭代时才运行迭代器块。

通过编写这样的空值检查,您可以避免NullReferenceException,但是将NullArgumentException移动到迭代点而不是调用点非常令人困惑。

正确的修复方法是:

// DO THIS
public IEnumerable GetFrobs(FrobFactory f, int count)
{
   // No yields in a public method that throws!
   if (f == null) 
       throw new ArgumentNullException("f", "factory must not be null");
   return GetFrobsForReal(f, count);
}
private IEnumerable GetFrobsForReal(FrobFactory f, int count)
{
   // Yields in a private method
   Debug.Assert(f != null);
   for (int i = 0; i < count; ++i)
        yield return f.MakeFrob();
}

这样,创建一个私有的辅助方法,其中包含迭代器块逻辑,以及一个公共的表面方法,用于进行空值检查并返回迭代器。现在,当调用GetFrobs时,空值检查会立即发生,然后在迭代序列时执行GetFrobsForReal。

如果你检查LINQ to Objects的参考源代码,你会发现这种技术被广泛使用。这样写起来可能有点笨拙,但可以更轻松地调试空值错误。优化你的代码以方便调用者,而不是以方便作者为首要考虑。

关于不安全代码中的空指针解引用说明

C#有一个“不安全”模式,顾名思义,非常危险,因为不强制执行提供内存安全性和类型安全性的正常安全机制。除非您深入了解内存工作原理,否则不应编写不安全的代码。

在不安全模式下,您应该注意两个重要事实:

解除引用空指针会产生与解除引用空引用相同的异常

在某些情况下,解除引用无效的非空指针也可能产生该异常

要理解其中的原因,了解.NET如何首先生成NullReferenceException会有所帮助。(这些细节适用于在Windows上运行的.NET;其他操作系统使用类似的机制。)

在Windows中,内存是虚拟化的;每个进程都获得许多“页面”的虚拟内存空间,这些页面由操作系统跟踪。每个内存页面都有设置的标志,确定它如何使用:读取、写入、执行等。最低页面标记为“如果以任何方式使用,则产生错误”。

在C#中,空指针和空引用都被内部表示为数字零,因此任何尝试将其解除引用为其对应的内存存储都会导致操作系统产生错误。然后,.NET运行时检测到此错误并将其转换为NullReferenceException。

这就是为什么解除引用空指针和空引用都会产生相同异常的原因。

第二点呢?解除引用任何无效指针,该指针位于虚拟内存的最低页面中,会导致相同的操作系统错误,从而导致相同的异常。

为什么这有意义呢?假设我们有一个包含两个int和一个等于null的非托管指针的结构体。如果我们尝试解除引用结构体中的第二个int,则CLR将不会尝试访问位置零处的存储;它将访问位置四处的存储。但从逻辑上讲,这是一个空解除引用,因为我们通过null到达了该地址。

如果您正在使用不安全的代码并且遇到NullReferenceException,请注意有问题的指针不一定为空。它可以是最低页面中的任何位置,并且将产生此异常。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论