NullReferenceException (NRE) — тип исключения платформы .NET, возникающий при попытке обращения по нулевой ссылке. В заметке рассмотрим причины, из-за которых возникают исключения этого типа, а также способы их предотвращения и исправления.
Примечание. Эта заметка рассчитана на начинающих программистов. Разработчикам с опытом предлагаю 2 активности:
Переменные ссылочных типов в C# хранят ссылки на объекты. Чтобы обозначить, что ссылка не указывает на объект, используют значение null. Стоит также отметить, что null — значение выражений ссылочных типов по умолчанию.
Исключение типа NullReferenceException возникает при попытке обращения по нулевой ссылке. Операции, при которых может возникнуть исключение, мы перечислим ниже.
Рассмотрим пример:
Object notNullRef = new Object();
Object nullRef = default;
int hash;
hash = notNullRef.GetHashCode();
hash = nullRef.GetHashCode(); // NullReferenceException (NRE)
В коде объявляются две переменные ссылочного типа Object — notNullRef и nullRef:
Вызов метода GetHashCode через ссылку в notNullRef отработает нормально, так как ссылка указывает на объект. При попытке вызова того же метода для nullRef средой CLR будет выброшено исключение типа NullReferenceException.
Ниже мы рассмотрим, откуда могут прийти null-значения и какие операции могут привести к исключению NullReferenceException.
Рассмотрим примеры того, как в переменную может попасть значение null.
1. Явная запись значения null или default.
String name = null;
var len = name.Length; // NRE
Результатом выражения default и default(T) для ссылочных типов также будет null.
Object obj = default; // or default(Object)
var hash = obj.GetHashCode(); // NRE
2. Инициализация поля ссылочного типа по умолчанию.
class A
{
private String _name;
public void Foo()
{
var len = _name.Length; // NRE
}
}
var obj = new A();
obj.Foo();
В этом примере поле _name инициализируется значением по умолчанию. На момент вызова Foo поле _name равно null, поэтому при обращении к свойству Length будет выброшено исключение.
3. Результат работы null-conditional оператора (?.).
String name = user?.Name;
var len = name.Length; // Potential NRE
Если значение user или user.Name будет равно null, в переменную name также будет записано значение null. В таком случае при обращении к свойству Length без проверки на null возникнет исключение.
4. Результат приведения с использованием оператора as.
Object obj = new Object();
String name = obj as String; // unsuccessful cast, name is null
var len = name.Length; // NRE
Результатом преобразования с помощью оператора as будет значение null, если преобразование выполнить не удалось.
В примере выше переменная obj хранит ссылку на экземпляр типа Object. Попытка приведения obj к типу String закончится неудачей, в результате чего в name будет записано значение null.
5. Результат работы *OrDefault метода.
Методы вида *OrDefault (FirstOrDefault, LastOrDefault и т. п.) из стандартной библиотеки возвращают значение по умолчанию, если значение предиката не подходит ни для одного элемента или коллекция пустая.
String[] strArr = ....;
String firstStr = strArr.FirstOrDefault();
var len = firstStr.Length; // Potential NRE
Если в массиве strArr нет элементов, метод FirstOrDefault вернёт значение default(String) — null. При разыменовании нулевой ссылки возникнет исключение.
6. Упаковка default значения типа Nullable<T>.
Результатом упаковки экземпляров Nullable<T> с default-значением будет null.
long? nullableLong1 = default;
long? nullableLong2 = null;
Nullable<long> nullableLong3 = default;
Nullable<long> nullableLong4 = null;
Nullable<long> nullableLong5 = new Nullable<long>();
var nullableToBox = ....; // nullableLong1 — nullableLong5
object boxedValue = (Object)nullableToBox; // null
_ = boxedValue.GetHashCode(); // NRE
При записи в переменную nullableToBox любого из значений nullableLong1 – nullableLong5 и последующей упаковки результатом будет null. При использовании такого значения без проверки на null будет выброшено исключение.
Подробности упаковки значений типа Nullable<T> описаны в статье "Хорошо ли вы помните nullable value types?".
В этом разделе перечислены операции, выполнение которых с null-значением приведёт к исключению NullReferenceException.
1. Явное обращение к члену объекта.
class A
{
public String _name;
public String Name => _name;
public String GetName() { return _name; }
}
A aObj = null;
_ = aObj._name; // NRE
_ = aObj.Name; // NRE
_ = aObj.GetName(); // NRE
То же самое — при разыменовании внутри метода:
void Foo(A obj)
{
_ = obj.Name;
}
A aObj = null;
Foo(aObj); // NRE inside method
2. Обращение по индексу.
int[] arr = null;
int val = arr[0]; // NRE
3. Вызов делегата.
Action fooAct = null;
fooAct(); // NRE
4. Итерирование в foreach.
List<long> list = null;
foreach (var item in list) // NRE
{ .... }
Обратите внимание, что оператор '?.' здесь не поможет:
foreach (var item in wrapper?.List) // Potential NRE
{ .... }
Если wrapper или wrapper.List равны null, всё так же будет выброшено исключение. Подробнее ситуацию разобрали в статье "Использование оператора ?. в foreach: защита от NullReferenceException, которая не работает".
5. Использование null-значения в качестве операнда для await.
Task GetPotentialNull()
{
return _condition ? .... : null;
}
await GetPotentialNull(); // Potential NRE
6. Распаковка null-значения.
object obj = null;
int intVal = (int)obj; // NRE
7. Выброс исключения с null-значением.
InvalidOperationException invalidOpException
= flag ? new InvalidOperationException()
: null;
throw invalidOpException; // Potential NRE
В переменную invalidOpException может быть записано значение null. В этом случае оператор throw выбросит исключение типа NullReferenceException.
8. Разыменование значения свойства Target у экземпляра типа WeakReference.
void ProcessIfNecessary(WeakReference weakRef)
{
if (weakRef.IsAlive)
(weakRef.Target as DataProcessor).Process(); // Potential NRE
}
Ссылка в WeakReference указывает на объект, при этом не защищая его от сборки мусора. Если объект попадёт под сборку мусора после проверки weakRef.IsAlive, но до вызова метода Process, то:
9. Использование значения поля ссылочного типа до явной инициализации.
class A
{
private String _name;
public A()
{
var len = _name.Length; // NRE
}
}
На момент обращения к свойству Length поле _name проинициализировано значением по умолчанию (null). Результат обращения — исключение.
10. Небезопасный вызов обработчиков события в многопоточном коде.
public event EventHandler MyEvent;
void OnMyEvent(EventArgs e)
{
if (MyEvent != null)
MyEvent(this, e); // Potential NRE
}
Если между проверкой MyEvent != null и вызовом обработчиков события MyEvent у него не останется подписчиков, при вызове будет выброшено исключение типа NullRefernceException.
Чтобы избежать исключений типа NullReferenceException, исключите ситуацию разыменования нулевых ссылок. Для этого:
Рассмотрим пример:
foreach (var item in potentialNullCollection?.Where(....))
{ .... }
Если значением potentialNullCollection будет null, оператор '?.' также вернёт значение null. При попытке обхода коллекции в цикле foreach возникнет исключение.
Если potentialNullCollection в данном фрагменте кода никогда не равен null, стоит убрать оператор '?.', чтобы не запутать разработчиков и инструменты анализа кода:
foreach (var item in potentialNullCollection.Where(....))
{ .... }
Если potentialNullCollection может принимать значение null, стоит добавить явную проверку или использовать оператор '??'.
// 1
if (potentialNullCollection != null)
{
foreach (var item in potentialNullCollection.Where(....))
{ .... }
}
// 2
foreach (var item in potentialNullCollection?.Where(....)
?? Enumerable.Empty<T>)
{ .... }
Примечание. Добавить проверку на неравенство null — самый простой способ защититься от NullReferenceException. Однако иногда такая правка будет не решать исходную проблему, а только маскировать её. Поэтому при исправлении кода полезно думать о том, достаточно ли будет добавить проверку или нужно исправить в коде что-то ещё.
Кроме достаточно очевидного совета "не разыменовывать нулевые ссылки" есть несколько практик, которые помогут избежать возникновения исключений NRE.
Без использования nullable-контекста значение null считается допустимым для ссылочных типов:
String str = null; // No warnings
Начиная с C# 8, в языке появилась возможность использовать nullable-контекст. Он вводит понятие nullable reference types. В nullable-контексте ссылочные типы считаются не допускающими значения null. Например, при использовании nullable-контекста на код, который мы только что рассмотрели, компилятор выдаст предупреждение:
String str = null; // CS8600
Предупреждение: CS8600 Converting null literal or possible null value to non-nullable type.
Аналогичная ситуация при вызове методов:
void ProcessUserName(String userName)
{
var len = userName.Length;
....
}
....
ProcessUserName(null); // CS8625
Предупреждение компилятора: CS8625 Cannot convert null literal to non-nullable reference type.
Чтобы указать компилятору, что переменная ссылочного типа может принимать значение null, используется символ '?':
String firstName = null; // CS8600
String? lastName = null; // No warning
При попытке разыменовать nullable-переменную без проверки на null компилятор также выдаст предупреждение:
void ProcessUserName(String? userName)
{
var len = userName.Length; // CS8602
}
Предупреждение компилятора: CS8602 - Dereference of a possibly null reference.
Если нужно указать компилятору, что в конкретном месте кода выражение точно не имеет значения null, можно использовать null-forgiving оператор — '!'. Пример:
void ProcessUserName(String? userName)
{
int len = default;
if (_flag)
len = userName.Length; // CS8602
else
len = userName!.Length; // No warnings
}
Таким образом, nullable-контекст помогает писать код так, чтобы минимизировать возможность разыменования нулевых ссылок.
Включить nullable-контекст можно несколькими способами:
У nullable-контекста куда больше возможностей для настройки. Подробнее о них мы писали в отдельной статье.
Примечание. Обратите внимание, что nullable-context влияет на выдачу предупреждений компилятором, но не на логику исполнения приложения.
String? str = null;
var len = str!.Length;
Компилятор не выдаст предупреждения на этот код, так как в нём используется null-forgiving оператор. Однако на этапе исполнения в коде возникнет исключение типа NullReferenceException.
Статические анализаторы помогают находить дефекты безопасности и ошибки в коде. В том числе анализаторы помогают находить места возникновения исключений типа NullReferenceException.
Пример такого статического анализатора — PVS-Studio.
Рассмотрим пример C# кода, в котором может возникнуть NullReferenceException.
private ImmutableArray<char>
GetExcludedCommitCharacters(ImmutableArray<CompletionItem> items)
{
var hashSet = new HashSet<char>();
foreach (var item in items)
{
foreach (var rule in item.Rules?.FilterCharacterRules)
{
if (rule.Kind == CharacterSetModificationKind.Add)
{
foreach (var c in rule.Characters)
{
hashSet.Add(c);
}
}
}
}
return hashSet.ToImmutableArray();
}
Во втором цикле foreach разработчики выполняют обход коллекции FilterCharacterRules, для получения которой используют выражение roslynItem.Rules?.FilterCharacterRules. Оператор '?.' предполагает, что свойство Rules может иметь значение null. Однако если результатом выражения будет null, при попытке перебора null-значения в foreach всё равно возникнет NullReferenceException.
PVS-Studio находит эту проблему и выдаёт предупреждение V3153.
Если items.Rules действительно может иметь значение null, защититься от NullReferenceException можно дополнительной проверкой:
foreach (var item in items)
{
if (item.Rules == null)
continue;
foreach (var rule in item.Rules.FilterCharacterRules)
{
....
}
}
Анализатор не будет выдавать предупреждение на такой код.
PVS-Studio ищет и другие ситуации в коде, при которых может возникнуть исключение NullReferenceException:
Чтобы проверить код с помощью PVS-Studio, нужно:
Документация по работе с PVS-Studio в разном окружении:
0