Привет, Хабр!
Сегодня мы рассмотрим 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.
Сравнение вариантов
Сигнатура |
Что говорит тип |
Что говорит анализатор |
Когда применять |
|---|---|---|---|
|
Возврат может быть null. |
Анализатор видит напрямую. |
Если вы контролируете API и хотите явную nullable‑типизацию. |
|
Тип формально не допускает null. |
Анализатор предупреждает: null возможен. |
Совместимость со старым API; дженерики; вы не хотите менять публичный тип. |
|
Ничего не ясно по 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) |
|
Не null при успехе |
|
Самый читаемый вариант. |
Проверка на пустоту / null с отрицательной логикой |
|
Не null при возврате |
|
Как |
Обобщённый Try, не хотите |
|
Может быть default при |
|
Избегаете ложного обещания 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.