Nullable-аннотации: MaybeNull и NotNullWhen в C#

от автора

Привет, Хабр!

Сегодня мы рассмотрим nullable‑аннотации в C#: как с помощью [MaybeNull] и [NotNullWhen] (плюс родственных атрибутов вроде [MaybeNullWhen], [NotNullIfNotNull], [DoesNotReturn]) формально описывать те самые «ну тут иногда null, а тут точно нет».

MaybeNull — когда null возвращается, но не всегда

Атрибут [MaybeNull] из System.Diagnostics.CodeAnalysis говорит статическому анализатору: «на выходе этого члена (return, out, поле, свойство, параметр по ref) может оказаться null — даже если сам тип объявлен как необнуляемый». Это важно для обратной совместимости и для API, где сигнатура по типу остаётся строгой (например, T, User, string), но фактический контракт допускает отсутствие значения. Компилятор учитывает эту подсказку в nullable flow analysis и, увидев [MaybeNull], будет предупреждать о потенциальном null при дальнейшем использовании результата без проверки.

Чем это отличается от T? / User?? Суффикс ? меняет сам тип в системе типов — объявляете значение как nullable reference (или nullable value wrapper, если речь о struct?). [MaybeNull] же не меняет тип, а расширяет контракт анализа: «смотри, даже если тип формально non‑nullable, относись к факту как к потенциально null». Это важно в дженериках и местах, где тип параметра не может (или не должен) быть помечен ?, например при открытых ограничениях T, библиотеках общего назначения, или когда вы хотите сохранить сигнатуру совместимой с до‑nullable кодом, но дать современным компиляторам правильный сигнал.

Простой пример. Допустим, есть метод, который возвращает объект, но null может вернуться, если что‑то пошло не так:

using System.Diagnostics.CodeAnalysis;  public sealed class UserRepository {     [return: MaybeNull]     public User GetUserOrNull(int id)     {         // если не нашли — возвращаем null,         // хотя тип сигнатуры формально User (не User?)         return id > 0 ? new User(id) : null;     } }

Намеренно убрали User? из сигнатуры. Тип остаётся non‑nullable, но атрибут сообщает анализатору о возможности null. Если можно изменить тип — естественно, запишите User? и не усложняйте. [MaybeNull] нужен там, где не можете (совместимость, дженерики, API‑контракты).

Где атрибут спасает

Классика — универсальный метод поиска:

using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.Linq;  public static class SeqExtensions {     [return: MaybeNull]     public static T FindOrDefault<T>(this IEnumerable<T> source, Func<T, bool> predicate)     {         // Возвращает первый подходящий элемент или null / default(T)         // По контракту: может отсутствовать.         foreach (var item in source)         {             if (predicate(item))                 return item;         }         return default;     } }

Почему не T?? Потому что T не ограничен: сюда может прийти и value‑type (int), и reference‑type (string), и nullable типы. Записать T? легально только в тех местах, где это корректно по ограничениям. [MaybeNull] позволит статическому анализу предупредить вызвавший код о том, что возвращаемое может быть пустым значением (default) — что для ссылочных типов значит null.

Сравнение вариантов

Сигнатура

Что говорит тип

Что говорит анализатор

Когда применять

User? Get(...)

Возврат может быть null.

Анализатор видит напрямую.

Если вы контролируете API и хотите явную nullable‑типизацию.

[return: MaybeNull] User Get(...)

Тип формально не допускает null.

Анализатор предупреждает: null возможен.

Совместимость со старым API; дженерики; вы не хотите менять публичный тип.

[return: MaybeNull] T Get<T>(...)

Ничего не ясно по T.

Анализатор трактует «может быть default(T)».

Универсальные утилиты; обобщённые контейнеры.

Помните, что default для value‑типа не null, а нулевая структура. Поэтому [MaybeNull] на T фактически означает: «вызвавший код обязан обработать значение по умолчанию», а если T — ссылочный тип, то это будет null. Это заметно в generic‑коллекциях и при работе с API уровня сериализации / кэшей, где вы не знаете заранее, что за T придёт.

Быстрая проверка в потребителе

var user = repo.GetUserOrNull(id); if (user is null) {     // handle miss     return NotFound(); } // здесь анализатор знает: user not null (после проверки) return Ok(user);

Если убрать [MaybeNull] (и не использовать User?), компилятор может считать user гарантированно non‑null и подсветить проверку как лишнюю, или — наоборот — пропустить предупреждение там, где оно нужно, в зависимости от контекста и nullable режима, в котором компилировался исходный код библиотеки.

NotNullWhen — про условные гарантии

Атрибут [NotNullWhen(bool)] из System.Diagnostics.CodeAnalysis — это условный постконтракт на параметр (обычно out или ref), объявленный как nullable в сигнатуре. Он сообщает анализатору: «если метод вернул указанное булево значение, то этот параметр гарантированно не null». Тип при этом не меняется: он остаётся, например, User?, но после проверки if (TryX(...)) компилятор переключает null‑state переменной в «not‑null» и перестаёт требовать защитные проверки.

Чем полезен по сравнению с ручными if (x == null)? Во‑первых, информация «параметр точно не null при возврате X» становится частью публичного API, а не рассыпана по комментариям. Во‑вторых, статический анализ начинает правильно вести поток null‑состояний, сокращая ложные предупреждения и подсвечивая реальные нарушения (например, если внутри метода вы когда‑то забудете проинициализировать out при «успехе», анализатор может поймать это).

Try-паттерн

public bool TryGetUser(int id, [NotNullWhen(true)] out User? user) {     if (id > 0)     {         user = new User(id);         return true;     }      user = null;     return false; }

Использование:

if (repo.TryGetUser(id, out var user)) {     // Здесь компилятор знает: user не null     Console.WriteLine(user.Name); } else {     // Здесь user может быть null }

Благодаря [NotNullWhen(true)] после проверки if (TryGetUser(...)) IDE не требует user! или отдельного null‑чека. Это ровно то, что делается внутри платформенных TryParse, TryGetValue и похожих API.

Вариант с отрицательным условием: [NotNullWhen(false)]

Необязательно привязываться к true. Классический пример — методы вида IsNullOrEmpty / IsNullOrWhiteSpace: когда они возвращают false, значит значение не пустое и не null. Сигнатура выглядит так:

public static bool IsNullOrWhiteSpace([NotNullWhen(false)] string? value);

После вызова:

if (!string.IsNullOrWhiteSpace(s)) {     // тут s гарантированно не null и не пустая     Use(s); }

Компилятор корректно подхватывает эту гарантию благодаря атрибуту. Используйте false там, где «успешный» для вас сценарий — отрицательный результат проверки.

NotNullWhen(true) vs MaybeNullWhen(false)?

Иногда кажется, что «не null при true» и «может быть null при false» — одно и то же. На практике разница проявляется в нулл‑аннотации параметра и в исторических ограничениях языка. NotNullWhen применяется к nullable параметру, чтобы сузить состояние до non‑null в ветке. MaybeNullWhen применяется к non‑nullable параметру/типу, чтобы расширить состояние до «может быть null» в ветке. В ранних версиях языка (до возможности T? на универсальных аргументах) [MaybeNullWhen(false)] использовали, чтобы выразить Try‑паттерн для обобщённых API; сейчас выбор — дело читаемости и точности контракта: если параметр в сигнатуре уже nullable, ставьте [NotNullWhen(success)]; если тип строгий, но при ошибке вы вынуждены писать default/null, используйте [MaybeNullWhen(failure)].

Дженерики и подводные камни

В обобщённом Try‑методе:

public static bool TryGet<T>(int id, [NotNullWhen(true)] out T? value) {     // ... }

Работает, но требует C# с поддержкой nullable‑суффикса на T?T должен попадать под nullable‑аннотацию контекста). Если вы держите код совместимым со старой сигнатурой out T value или у вас ограничения на T, вместо этого нередко используют:

public static bool TryGet<T>(int id, [MaybeNullWhen(false)] out T value) {     // ... }

Так вы даёте понять: при false вызывающий код не должен рассчитывать на корректность объекта (может быть default). Еще это уместно, когда T может оказаться value‑типом или уже nullable‑типом, и обещать «всегда non‑null при true» было бы слишком смело.

Мини-guard-утилита в стиле IsNotNull

Часто хочется утилиту «верни true, если объект не null, и заодно подскажи анализатору». Без атрибута в месте вызова всё равно придётся писать проверку; с атрибутом — нет:

private static bool IsNotNull<T>([NotNullWhen(true)] T? value) => value is not null;

Использование:

if (IsNotNull(someString)) {     // здесь someString не null     Console.WriteLine(someString.Length); }

В MS Learn прямо написано о том, что такой подход лучше вместо спама оператором подавления !, потому что контракт становится декларативным и повторно используемым.

Несколько out-параметров

Атрибут ставится на каждый параметр отдельно — анализ ведётся независимо:

public bool TryLoadPair(     string key,     [NotNullWhen(true)] out User? user,     [NotNullWhen(true)] out Profile? profile) {     if (_store.TryGet(key, out user, out profile)) return true;     user = null;     profile = null;     return false; }

В успешной ветке оба считаются not‑null; в неуспешной — оба nullable. Дальше можно разнести логику по месту использования без дополнительного if (user != null && profile != null) после проверки результата. (Разумеется, если внутри метода вы нарушите обещание и оставите что‑то null при возврате true, статический анализатор это не остановит на рантайме — контракт на вашей совести.)

Соединяем с проверками аргументов

Иногда удобно комбинировать NotNullWhen(false) и автоматическую проверку в одном helper’е:

public static bool IsNullOrEmpty([NotNullWhen(false)] string? value)     => string.IsNullOrEmpty(value);

Компилятор читает: если вернули false, то value точно не null — а дальше уже на вызывающем месте вы можете либо работать со строкой, либо быстро выйти. Удобно в guard‑методах валидации DTO или параметров контроллеров.

Когда ставить какой атрибут в Try-методах

Ситуация

Параметр в сигнатуре

Контракт

Атрибут

Комментарий

Классический TryParse / TryGet* (ref type)

T?

Не null при успехе

[NotNullWhen(true)]

Самый читаемый вариант.

Проверка на пустоту / null с отрицательной логикой

T?

Не null при возврате false

[NotNullWhen(false)]

Как string.IsNullOrWhiteSpace.

Обобщённый Try, не хотите T? / поддержка старых версий

T

Может быть default при false

[MaybeNullWhen(false)]

Избегаете ложного обещания not‑null.

NotNullIfNotNull — когда возвращаемое зависит от входного

Ещё один классный контракт, который читается как: «если входной аргумент not null, то и возвращаемое значение not null».

[return: NotNullIfNotNull("input")] public string? Normalize(string? input) {     return input?.Trim(); }

Так можно дать компилятору понять, что Normalize(something) не может вернуть null, если something был не null.

DoesNotReturn — говорим компилятору: “дальше код не пойдёт”

Бывают такие методы, после которых никакой код не должен выполняться. Например, throw‑методы, типа FailFast.

[DoesNotReturn] public static void ThrowInvalidOperation() {     throw new InvalidOperationException("Это конец."); }

Если вы используете ThrowInvalidOperation() в условии:

if (someConditionIsBad) {     ThrowInvalidOperation(); } Console.WriteLine("Я не unreachable — компилятор знает об этом.");

Без DoesNotReturn компилятор мог бы жаловаться, что после if возможно выполнение дальше, хотя на деле — нет.


Итоги

Nullable‑аннотации — это способ вшить в код реальные договорённости о значениях: MaybeNull расширяет контракт там, где тип формально не допускает отсутствия данных; NotNullWhen (и компания вроде NotNullIfNotNull, MaybeNullWhen) сужают поток до гарантированно инициализированных объектов в нужных ветках; DoesNotReturn поясняет анализатору контроль потока. Итог — меньше ложных предупреждений, чище публичные API, меньше неявных ! и ловля «пустых» сценариев до рантайма. Делитесь опытом в комментариях: где эти атрибуты реально помогли, какие грабли встретили при миграции на #nullable, и какие внутренние практики договорились использовать в команде.

Если вы работаете с C# и хотите глубже разобраться в возможностях языка — включая тонкости работы с nullable‑аннотациями, контрактами типов и поведением компилятора — обратите внимание на курс «C# углублённый». Он позволит системно подойти к изучению языка и точнее формулировать поведение кода на уровне сигнатур и API.

Также рекомендуем заглянуть в каталог курсов по программированию — там собраны программы для тех, кто работает с кодом в самых разных направлениях.

Чтобы оставаться в курсе самых актуальных технологий и трендов, подписывайтесь на Telegram-канал OTUS.


ссылка на оригинал статьи https://habr.com/ru/articles/928286/


Комментарии

Добавить комментарий

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