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

Сегодня мы рассмотрим 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.

Комментарии (0)