Нулевые ссылочные типы в C# — Переход на нулевые ссылочные типы — часть 1

Функции нулевого типа в C#, представленные в C#8, помогут вам минимизировать вероятность столкнуться с ужасным исключением System.NullReferenceException. Синтаксис и аннотации Nullability дают подсказки о том, может ли тип быть nullable или нет. Улучшенный статический анализ позволяет выявлять необработанные нули во время разработки кода. Что не нравится?

Внедрение явной nullability в существующие кодовые базы — это довольно сложная задача. Это гораздо сложнее, чем просто рассыпать некоторые ? и ! по всему коду. Это также не серебряная пуля: вам все равно придется проверять переменные, не являющиеся нулевыми, на null.

В этой серии статей блога мы узнаем больше о нулевых типах в C#, а также рассмотрим некоторые методы и подходы, которые помогли мне при переводе существующей базы кода на использование нулевых ссылочных типов.

Давайте начнем с самого начала.

Ссылочные типы, типы значений и null

Возможно, вы слышали о том, что null — это «ошибка на миллиард долларов». В 2009 году Тони Хоар, создатель языка программирования ALGOL W, извинился за свою ошибку на миллиард долларов: нулевые ссылки. Его первоначальной целью было гарантировать, что использование любой ссылки будет абсолютно безопасным, подтвержденным проверками компилятора. Но поддерживать нулевые ссылки было легко, поэтому он так и сделал — что привело к бесчисленным ошибкам, уязвимостям и системным сбоям во многих системах. Другие языки скопировали эту идею, в результате чего нулевые ссылки с тех пор причиняют боль и ущерб на миллиарды долларов.

Нулевые ссылочные типы всегда были частью C#: ссылочный тип может быть либо ссылкой, либо нулем. Рассмотрим следующий пример:

string s = GetValue();
Console.WriteLine($"Length of '{s}': {s.Length}");

Вход в полноэкранный режим Выход из полноэкранного режима

Когда s не является нулевым, в консоль будет записано сообщение. Но что происходит, когда s равно null? Именно: при обращении к свойству Lengthnull возникает исключение NullReferenceException.

Мы можем добавить проверку на null перед обращением к этому свойству:

string s = GetValue();
Console.WriteLine(s != null
    ? $"Length of '{s}': {s.Length}"
    : "String is null.");

Вход в полноэкранный режим Выйти из полноэкранного режима

Как узнать, нужна ли вам эта проверка? Если GetValue() никогда не возвращает null, как вы узнаете, нужно ли проверять s при обращении к его свойствам?

Для типов значений, таких как int, bool, decimal, struct, таких как DateTime или пользовательские реализации и т.д., вы можете использовать Nullable<T>, чтобы сделать эти типы значений «nullable», и иметь безопасный способ доступа к ним.

Примечание: типы значений на самом деле не могут быть нулевыми — это значения, а не ссылки. С помощью Nullable<T> вы оборачиваете значение, чтобы иметь возможность отслеживать это дополнительное состояние null.

Существует некоторая магия компилятора, которая преобразует int? в Nullable<int>. При обращении к нулевому типу значений вы знаете, что он будет либо null, либо содержать значение, и что необходимо сделать проверку. Имея nullable DateTime, мы могли бы написать приведенный выше пример следующим образом:

DateTime? s = GetValue();
Console.WriteLine(s.HasValue
    ? $"The date is: {s.Value:O}"
    : "No date was given.");

Войти в полноэкранный режим Выйти из полноэкранного режима

Ключевым отличием здесь является намерение. В случае с нашей string вы понятия не имеете, следует ли ожидать null. Это никогда не произойдет, это может произойти — вы узнаете об этом, только заглянув в функцию GetValue() (или воспользуйтесь безопасным подходом и всегда добавляйте проверки null). С типами значений намерение более ясно. Тип DateTime никогда не может быть null, тогда как Nullable<DateTime> говорит вам, что требуется проверка на null (или .HasValue).

Что такое нулевые ссылочные типы?

В C#8 были введены нулевые ссылочные типы (NRT), которые помогают передать намерение, что ссылка может быть null или никогда не будет null.

Начнем с того, что ссылочные типы всегда были nullable, как мы видели во введении. Так было с первой версии C# — string s = null совершенно нормально, и каждый ссылочный тип действительно всегда был нулевым ссылочным типом.

Почему я указываю на это? Ссылочные типы всегда были nullable, а теперь мы перевернули эту идею, считая их по умолчанию non-nullable, и добавили синтаксис для аннотации их как nullable. Аннотации помогают при написании и компиляции кода, но не обеспечивают безопасность во время выполнения. Давайте посмотрим, что это значит.

При включенной функции нулевых ссылочных типов предыдущий пример сообщает компилятору, что мы ожидаем, что s не будет null (иначе мы бы объявили string? s), и мы можем спокойно убрать проверку на нуль…

string s = GetValue();
Console.WriteLine($"Length of '{s}': {s.Length}");

string? GetValue() => null;

Вход в полноэкранный режим Выход из полноэкранного режима

…или можем? Удивительно, но вы можете скомпилировать и запустить приведенный выше код, даже если GetValue() возвращает строку, которая является null. Вы просто увидите, что NullReferenceException выбрасывается при обращении к свойству Lengthnull.

Так что же такое нулевые ссылочные типы в C#8? Мне нравится называть их nullable annotations — NRT аннотируют ваш код, указывая, какой может быть их nullability.

Когда вы скомпилируете приведенный выше код или посмотрите на него в IDE, вы увидите несколько предупреждений, в которых говорится, что вы делаете что-то, что может преследовать вас, когда вы запустите приложение:

  • CS8600 — Преобразование нулевого литерала или возможного нулевого значения в не нулевой тип.
  • CS8602 — Разыменование возможно нулевой ссылки.

Спасибо, IDE! Спасибо, компилятор C#! Хотя все это может взорваться во время выполнения, по крайней мере, я получаю достаточно предупреждений, чтобы пойти и исправить этот код. И это исправление может быть сделано почти автоматически, с помощью быстрого исправления.

После применения этих исправлений мы, по сути, возвращаемся к нашему исходному примеру, но на этот раз с аннотацией того, что мы ожидаем. Опять же, IDE и компилятор помогут нам, и если мы изменим функцию GetValue(), чтобы она возвращала не nullable string, инструментарий скажет нам, что мы делаем лишние null проверки, и предложит их убрать.

string? s = GetValue();
Console.WriteLine($"Length of '{s}': {s.Length}");

string GetValue() => "";

Вход в полноэкранный режим Выход из полноэкранного режима

Анализ потока

Есть несколько интересных моментов, когда речь заходит о нулевости и анализе потока.

Например, var всегда считается nullable. Команда разработчиков языка C# решила всегда рассматривать var как nullable, и полагаться на анализ потока, чтобы определить, когда IDE или компилятор должны предупредить.

В нашем примере нуллируемость s определяется аннотациями функции GetValue().

var s = GetValue();
Console.WriteLine($"Length of '{s}': {s.Length}");

string GetValue() => "";

Вход в полноэкранный режим Выход из полноэкранного режима

Многие IDE отображают подсказки типа инкрустации, чтобы помочь вам определить, чем может быть var, основываясь на анализе потока. Вот быстрый пример. Обратите внимание, что подсказка на инлее var s изменяется, когда изменяется нулевая возможность функции GetValue(). Также обратите внимание на загогулину на s.Length, где анализ потока определяет, может ли разыменование Length потенциально привести к неприятностям.

Круто, да? Все становится еще круче, когда вы выполняете проверку null. Вот два метода:

void MethodA(string? value) {
    Console.WriteLine(value.Length); // CS8602 - Dereference of a possibly null reference.
}

void MethodB(string? value) {
    if (value == null) return;

    Console.WriteLine(value.Length); // No warning, null check happened before
}

Войти в полноэкранный режим Выйти из полноэкранного режима

В MethodA() мы не делаем проверку null для параметра value, и анализ потока предупредит о разыменовании возможно нулевой ссылки. В MethodB() анализ потока определил, что мы выполняем проверку null, и не возражает против обращения к .Length, поскольку нет шансов, что в этой строке кода появится ссылка null.

Оператор, прощающий нули

Прежде чем закончить, я хочу затронуть еще одну тему. Оператор прощения нуля (также известный как оператор подавления нуля или оператор «черт побери»), !.

При использовании нулевых ссылочных типов выражение может быть постфиксировано !, чтобы указать IDE и статическому анализу потока компилятора «игнорировать» нулевое состояние выражения.

Вот два примера, в которых ! сообщает анализу потока, что нулевое состояние можно игнорировать:

string? a = null;
Console.WriteLine(a!.Length);

string? b = null!;
Console.WriteLine(b.Length);

Войти в полноэкранный режим Выйти из полноэкранного режима

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

Тем не менее, существуют обоснованные случаи использования. Анализ потока может не обнаружить, что значение с нулевым значением на самом деле является ненулевым. Вы можете подавить эти случаи, если знаете, что ссылка не будет нулевой. В следующем примере IsValidUser() проверяет наличие null, поэтому мы можем подавить предупреждение о null, которое мы получили бы в противном случае в вызове Console.WriteLine:

var user = _userManager.GetUserById(id);
if (IsValidUser(user)) {
    Console.WriteLine($"Username: {user!.Username}");
}

public static bool IsValidUser(User? user)
    => !string.IsNullOrEmpty(user?.Username);

Вход в полноэкранный режим Выход из полноэкранного режима

Примечание: существуют более эффективные способы решения некоторых из этих проблем с подавлением, используя специальные атрибуты. Я расскажу о них в следующем посте.

Другой случай — перенос кодовой базы, которая еще не использует нулевые ссылочные типы. Вы можете захотеть (временно) подавить предупреждения на определенном пути кода и пока не заботиться о нулевых типах, пока вы работаете над другой частью кодовой базы.

Еще один случай может быть связан с модульными тестами. Возможно, вы хотите проверить, что произойдет, если кто-то передаст null в функцию, которая ожидает не нулевую ссылку. В таких ситуациях вы можете передать null! и посмотреть, что произойдет.

Помните, что оператор null-forgiving фактически отключает анализ потока и может легко скрыть ошибки нулевого доступа в вашей кодовой базе. Используйте его с осторожностью, а использование оператора подавления нуля считайте запахом кода.

Заключение

При включении нулевых ссылочных типов вы получаете улучшенный статический анализ кода, который помогает определить, может ли переменная быть нулевой до ее разыменования. Аннотации переменных могут быть использованы для явного объявления предполагаемого состояния null для этой переменной — можно добавить ?, чтобы передать ссылку null.

Нулевые ссылочные типы не дают вам безопасности во время выполнения, когда речь идет о нулевом состоянии, но помощь во время проектирования и компиляции очень полезна!

В следующем посте мы рассмотрим некоторые внутренние аспекты и контекст аннотации nullable.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *