Привет, Хабр!
Сегодня мы рассмотрим 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.