什么是空引用異常(NullReferenceException),我該怎么修復它?
在C#中,空引用異常(NullReferenceException)是最常見的異常之一。它發(fā)生在你嘗試訪問或操作一個空對象時。換句話說,當你沒有為變量分配一個有效的對象引用,卻嘗試使用它時,就會拋出空引用異常。
引發(fā)的原因是什么
您正在嘗試使用空值(或 VB.NET 中的“Nothing”)。這意味著您將其設置為 null,或者根本沒有設置任何值。
與其他任何東西一樣,空值會傳遞。如果在方法“A”中為 null,則可能是方法“B”將 null 傳遞給了方法“A”。
空值可能有不同的含義:
- 未初始化的對象變量,因此指向空值。在這種情況下,如果您訪問此類對象的成員,會導致 NullReferenceException。
- 開發(fā)人員故意使用null來表示沒有可用的有意義的值。請注意,C#有變量的可空數(shù)據(jù)類型的概念(就像數(shù)據(jù)庫表可以有可空字段一樣)-您可以將null分配給它們,以指示其中沒有存儲值,例如int? a = null;(這是Nullable<int> a = null;的快捷方式;)其中問號表示允許在變量a中存儲null。您可以使用if(a.HasValue){...}或if(a==null){...}來檢查它??煽兆兞浚热邕@個例子,允許通過a.Value顯式訪問值,或者像正常情況下一樣通過a訪問。
請注意,通過a.Value訪問它如果a為空會引發(fā)InvalidOperationException而不是NullReferenceException-您應該事先進行檢查,即如果您有另一個非空變量int b;然后您應該進行賦值,如if(a.HasValue){b = a.Value;}或更短的if(a!=null){b = a;}。
這篇文章的其余部分會更詳細地介紹,并展示許多程序員經常犯的錯誤,這些錯誤可能導致空引用異常。
更具體地
運行時拋出 NullReferenceException 總是意味著同樣的事情:你試圖使用一個引用,但該引用沒有被初始化(或者曾經被初始化過,但現(xiàn)在已經不再被初始化)。
這意味著該引用是空的,你無法通過空引用訪問成員(比如方法)。這是最簡單的情況:
string foo = null;
foo.ToUpper();
這將在第二行拋出NullReferenceException,因為你不能在指向null的字符串引用上調用實例方法ToUpper()。
調試 Debugging
你如何找到 NullReferenceException 的源頭?除了查看異常本身會在發(fā)生異常的位置拋出之外,Visual Studio 調試的一般規(guī)則也適用:設置策略性的斷點并檢查你的變量,可以通過將鼠標懸停在它們的名稱上、打開(快速)監(jiān)視窗口或者使用諸如本地變量和自動變量等各種調試面板來進行。
如果你想找出引用是在哪里設置或未設置,右鍵單擊它的名稱并選擇“查找所有引用”。然后你可以在每個找到的位置設置斷點,并使用附加了調試器的程序運行。每當調試器在這樣的斷點上中斷時,你需要確定你是否期望引用是非空的,檢查變量,并驗證它在你期望的時候指向一個實例。
通過這種方式跟蹤程序流程,你可以找到實例不應為 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)的構造函數(shù)中對其進行初始化。
嵌套對象初始化
同樣的規(guī)則也適用于嵌套對象初始化器:
Book b1 = new Book
{
Author = { Age = 45 }
};
轉換為:
Book b1 = new Book();
b1.Author.Age = 45;
雖然使用了新關鍵字,它只創(chuàng)建了 Book 的一個新實例,而不是 Person 的新實例,所以 Author 屬性仍然為空。
嵌套集合初始化器
public class Person
{
public ICollection<Book> 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 只會創(chuàng)建一個 Person 的實例,但 Books 集合仍然為空。集合初始化器語法不會為 p1.Books 創(chuàng)建一個集合,它只是將其轉換為 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<string, int> 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<Person>();
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。)
糟糕的命名規(guī)范:
如果你將字段命名與局部變量不同,你可能會意識到你從未初始化該字段。
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 的屬性時發(fā)生異常,你需要明白 Model 是在你的操作方法中設置的,當你返回一個視圖時。當你從控制器返回一個空模型(或模型屬性)時,視圖在訪問它時會發(fā)生異常。
// 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.
{
}
<p>@Model.somePropertyName</p> <!-- Also throws -->
WPF Control Creation Order and Events
WPF 控件在調用 InitializeComponent 時按照它們在可視樹中出現(xiàn)的順序創(chuàng)建。如果在 InitializeComponent 中引用了在后續(xù)創(chuàng)建階段的控件的早期創(chuàng)建控件中的事件處理程序等,就會引發(fā) NullReferenceException。
例如:
<Grid>
<!-- Combobox declared first -->
<ComboBox Name="comboBox1"
Margin="10"
SelectedIndex="0"
SelectionChanged="comboBox1_SelectionChanged">
<ComboBoxItem Content="Item 1" />
<ComboBoxItem Content="Item 2" />
<ComboBoxItem Content="Item 3" />
</ComboBox>
<!-- Label declared later -->
<Label Name="label1"
Content="Label"
Margin="10" />
</Grid>
在這里,comboBox1 在 label1 之前創(chuàng)建。如果 comboBox1_SelectionChanged 嘗試引用 `label1,它可能尚未被創(chuàng)建。
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<int> list = null;
foreach(var v in list) { } // NullReferenceException here
更現(xiàn)實的例子是從 XML 文檔中選擇節(jié)點。如果未找到節(jié)點,會拋出異常,但初始調試顯示所有屬性都是有效的。
foreach (var node in myData.MyXml.DocumentNode.SelectNodes("http://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,以便在異常發(fā)生之前更早地捕獲問題
當你在開發(fā)過程中知道一個方法可能會返回 null,但實際上不應該返回 null 時,你可以使用 Debug.Assert(),以便在出現(xiàn)這種情況時盡快中斷程序。
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.
}
盡管這個檢查不會出現(xiàn)在發(fā)布版本中,但在運行時(在發(fā)布模式下)當 `book == null` 時,它會再次引發(fā) 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]用于數(shù)組(在C# 6和VB.NET 14中可用):
有時這也被稱為安全導航或Elvis(根據(jù)其形狀)運算符。如果運算符左側的表達式為空,那么右側將不會被計算,而是返回空值。這意味著像這樣的情況:
var title = person.Title.ToUpper();
如果person沒有title,這將拋出一個異常,因為它試圖在一個空值屬性上調用 ToUpper。
在C# 5及以下版本中,可以通過以下方式加以防范:
var title = person.Title == null ? null : person.Title.ToUpper();
現(xiàn)在,title變量將為空,而不會拋出異常。C# 6引入了一個更簡潔的語法來實現(xiàn)這一點:
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;
同樣,對于數(shù)組,你可以使用?[i]來實現(xiàn)如下功能:
int[] myIntArray = null;
var i = 5;
int? elem = myIntArray?[i];
if (!elem.HasValue) Console.WriteLine("No value");
這將實現(xiàn)以下功能:如果myIntArray為空,表達式將返回null,你可以安全地進行檢查。如果它包含一個數(shù)組,它將執(zhí)行與 elem = myIntArray[i]; 相同的操作,并返回第i個元素。
使用空值上下文 (C# 8中可用):
在C# 8中引入了空值上下文和可空引用類型,它們對變量進行靜態(tài)分析,并在值可能為空或已設置為null時提供編譯器警告??煽找妙愋驮试S明確允許類型為空。
可以使用csproj文件中的Nullable元素為項目設置可空注解上下文和可空警告上下文。此元素配置編譯器如何解釋類型的可空性以及生成哪些警告。有效的設置包括:
- enable: 啟用可空注解上下文。啟用可空警告上下文。引用類型的變量,例如字符串,是非空的。所有可空性警告都已啟用。
- disable: 禁用可空注解上下文。禁用可空警告上下文。引用類型的變量是不可知的,就像之前的C#版本一樣。所有可空性警告都已禁用。
- safeonly: 啟用可空注解上下文。啟用安全可空警告上下文。引用類型的變量是非空的。所有安全的可空性警告都已啟用。
- warnings: 禁用可空注解上下文。啟用可空警告上下文。引用類型的變量是不可知的。所有可空性警告都已啟用。
- safeonlywarnings: 禁用可空注解上下文。啟用安全可空警告上下文。引用類型的變量是不可知的。所有安全的可空性警告都已啟用。
可空引用類型的表示與可空值類型相同:在變量類型后追加?。
迭代器中調試和修復空指針引用的特殊技巧
C#支持“迭代器塊”(在其他一些流行的語言中稱為“生成器”)。NullReferenceException在迭代器塊中可能特別難以調試,因為它具有延遲執(zhí)行的特性。
public IEnumerable<Frob> GetFrobs(FrobFactory f, int count)
{
for (int i = 0; i < count; ++i)
yield return f.MakeFrob();
}
...
FrobFactory factory = whatever;
IEnumerable<Frobs> frobs = GetFrobs();
...
foreach(Frob frob in frobs) { ... }
如果任何結果為空,那么MakeFrob就會拋出異?!,F(xiàn)在,你可能會認為正確的做法是這樣:
// DON'T DO THIS
public IEnumerable<Frob> 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的調用只是返回一個對象,當?shù)鷷r才運行迭代器塊。
通過編寫這樣的空值檢查,您可以避免NullReferenceException,但是將NullArgumentException移動到迭代點而不是調用點非常令人困惑。
正確的修復方法是:
// DO THIS
public IEnumerable<Frob> 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<Frob> 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();
}
這樣,創(chuàng)建一個私有的輔助方法,其中包含迭代器塊邏輯,以及一個公共的表面方法,用于進行空值檢查并返回迭代器?,F(xiàn)在,當調用GetFrobs時,空值檢查會立即發(fā)生,然后在迭代序列時執(zhí)行GetFrobsForReal。
如果你檢查LINQ to Objects的參考源代碼,你會發(fā)現(xiàn)這種技術被廣泛使用。這樣寫起來可能有點笨拙,但可以更輕松地調試空值錯誤。優(yōu)化你的代碼以方便調用者,而不是以方便作者為首要考慮。
關于不安全代碼中的空指針解引用說明
C#有一個“不安全”模式,顧名思義,非常危險,因為不強制執(zhí)行提供內存安全性和類型安全性的正常安全機制。除非您深入了解內存工作原理,否則不應編寫不安全的代碼。
在不安全模式下,您應該注意兩個重要事實:
解除引用空指針會產生與解除引用空引用相同的異常
在某些情況下,解除引用無效的非空指針也可能產生該異常
要理解其中的原因,了解.NET如何首先生成NullReferenceException會有所幫助。(這些細節(jié)適用于在Windows上運行的.NET;其他操作系統(tǒng)使用類似的機制。)
在Windows中,內存是虛擬化的;每個進程都獲得許多“頁面”的虛擬內存空間,這些頁面由操作系統(tǒng)跟蹤。每個內存頁面都有設置的標志,確定它如何使用:讀取、寫入、執(zhí)行等。最低頁面標記為“如果以任何方式使用,則產生錯誤”。
在C#中,空指針和空引用都被內部表示為數(shù)字零,因此任何嘗試將其解除引用為其對應的內存存儲都會導致操作系統(tǒng)產生錯誤。然后,.NET運行時檢測到此錯誤并將其轉換為NullReferenceException。
這就是為什么解除引用空指針和空引用都會產生相同異常的原因。
第二點呢?解除引用任何無效指針,該指針位于虛擬內存的最低頁面中,會導致相同的操作系統(tǒng)錯誤,從而導致相同的異常。
為什么這有意義呢?假設我們有一個包含兩個int和一個等于null的非托管指針的結構體。如果我們嘗試解除引用結構體中的第二個int,則CLR將不會嘗試訪問位置零處的存儲;它將訪問位置四處的存儲。但從邏輯上講,這是一個空解除引用,因為我們通過null到達了該地址。
如果您正在使用不安全的代碼并且遇到NullReferenceException,請注意有問題的指針不一定為空。它可以是最低頁面中的任何位置,并且將產生此異常。