Null и ссылочные типы


Кроме стандартных значений типа чисел, строк, язык C# имеет специальное значение - null, которое фактически указывает на отсутствие значения как такового, отсутствие данных. До сих пор значение null выступает как значение по умолчанию для ссылочных типов.

До версии C# 8.0 всем ссылочным типам спокойно можно было присваивать значение null:

string name = null;
Console.WriteLine(name);
Но начиная с версии C# 8.0 в язык была введена концепция ссылочных nullable-типов (nullable reference types) и nullable aware context - nullable-контекст, в котором можно использовать ссылочные nullable-типы.

Чтобы определить переменную/параметр ссылочного типа, как переменную/параметр, которым можно присваивать значение null, после названия типа указывается знак вопроса ?

string? name = null;
Console.WriteLine(name); // ничего не выведет
К примеру встроенный метод Console.ReadLine(). который считывает с консоли строку, возвращает именно значение string?, а не просто string:

string? name = Console.ReadLine();
Зачем нужно это значение null? В различных ситуациях бывает удобно, чтобы объекты могли принимать значение null, то есть были бы не определены. Стандартный пример - работа с базой данных, которая может содержать значения null. И мы можем заранее не знать, что мы получим из базы данных - какое-то определенное значение или же null.

При этом подобные ссылочные типы, которые допускают присвоение значения null, доступно только в nullable-контексте. Для nullable-контекста характерны следующие особенности:

Переменную ссылочного типа следует инициализировать конкретным значением, ей не следует присваивать значение null

Переменной ссылочного nullable-типа можно присвоить значение null, но перед использование необходимо проверять ее на значение null.

Начиная с .NET 6 и C# 10 nullable-контекст по умолчанию распространяется на все файлы кода в проекта. Например, если мы наберем в Visual Studio 2022 для проекта .NET 6 предыдущий пример, то мы столкнемся с предупреждением:

nullable reference types in C# и .NET
Хотя nullable-контекст - это опция, которой мы можем управлять. Так, откроем файл проекта. Для этого либо двойным кликом левой кнопкой мыши нажмем на проект, либо нажмем на проект правой кнопкой мыши и в появившемся меню выберем пункт Edit Project File

nullable enable в C# в Visual Studio
После этого Visual Studio откроет нам файл проекта, который будет выглядеть примерно следующим образом:

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
Здесь строка

<Nullable>enable</Nullable>
точнее элемент <Nullable> со значением enable указывает, что эта nullable-контекст будет распространяться на весь проект.

Чем так плох null? Дело в том, что это значение означает, отсутствие данных. Но, допустим, у нас есть ситуация, когда мы получаем извне некоторую строку и пытаемся обратиться к ее функциональности. Например, в примере ниже у строки вызывается метод ToUpper(), который переводит все символы строки в верхний регистр:

string name = null;
PrintUpper(name); // ! NullReferenceException

void PrintUpper(string text)
{
Console.WriteLine(text.ToUpper());
}
Здесь при выполнении вызова PrintUpper(name) мы столкнемся с исключением NullReferenceException, и программа аварийно завершит свою работу. Кто-то может сказать, что ситуация искуственная - мы же явно знаем, что в функцию передается null. Однако в реальности данные могут приходить извне, например, из базы данных, откуда-то из сети и т.д. И мы можем явно не знать, есть ли в реальности данные или нет. И использование ссылочных nullable-типов позволяет частично решить эту ситуацию. Частично - поскольку предупреждения все равно не мешают нам скомпилировать и запустить программу выше. Однако nullable-контекст позволяет воспользоваться возможностями статического анализа, благодаря которому можно увидеть потентиально опасные куски кода, где мы можем столкнуться с NullReferenceException.

Кроме того, есть вероятность, что Microsoft изменит отношение в отношении null и NullReferenceException, и подобные предупреждения превратятся в будущих версиях в ошибки, поэтому лучше уже сейчас быть к этому готовым

Например, изменим предыдущий пример следующим образом:

string? name = null;
PrintUpper(name); //

void PrintUpper(string? text)
{
Console.WriteLine(text.ToUpper());
}
Здесь статический анализ подскажет, что в методе PrintUpper потенциально опасная ситуация, поскольку параметр text может быть равен null.

nullable static analyse в C# в Visual Studio
Отключение nullable-контекста
Для отключения nullable-контекста в файле конфигурации проекта достаточно изменить значение опции Nullable, например, на "disable":

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
</PropertyGroup>

</Project>
Отключив nullable-контекст, мы больше не сможем использовать в файлах кода в проекте ссылочные nullable-типы и соответственно воспользоваться встроенным статическим анализом потенциально опасных ситуаций, где можно столкнуться с NullReferenceException.

nullable-контекст на уровне участка кода
Мы также можем включить nullable-контекст на урове отдельных участков кода с помощью директивы #nullable enable. Допустим, глобально у нас отключен nullable-контекст:

<Nullable>disable</Nullable>
Определим в файле Program.cs следующий код:

#nullable enable // включаем nullable-контекст на уровне файла

string? name = null;

PrintUpper(name);

void PrintUpper(string? text)
{
Console.WriteLine(text.ToUpper());
}
Первая строка позволяет включить на уровне всего файла nullable-контекст.

#nullable enable в C# и .NET
Оператор ! (null-forgiving operator)
Оператор ! (null-forgiving operator) позволяет указать, что переменная ссылочного типа не равна null:

string? name = null;

PrintUpper(name!);

void PrintUpper(string text)
{
if(text == null) Console.WriteLine("null");
else Console.WriteLine(text.ToUpper());
}
Здесь если бы мы не использовали оператор !, а написали бы PrintUpper(name), то компилятор высветил бы нам предупреждение. Но в самом методе мы итак проверяем на null, поэтому даже если в метод передается null, то мы не столкнемся ни с какими проблемами. И чтобы убрать ненужное предупреждение, применяется данный оператор. То есть данный оператор не оказывает никакого влияния во время выполнения кода и предназначен только для статического анализа компилятора. Во время выполнения выражение name! будет аналогично значению name

Исключение кода из nullable-контекста
С помощью специальной директивы #nullable disable можно исключить какой-то определенный кусок кода из nullable-контекста. Например:

#nullable disable
string text = null; // здесь nullable-контекст не действует
#nullable restore

string? name = null; // здесь nullable-контекст снова действует
Любой код между директивами #nullable disable и #nullable restore будет исключен из nullable-контекста и тем самым не будет подлежать статическому анализу.