Перейти к основному содержимому

Обработка значения null и nullable-типы

Практические правила

  • Включайте nullable-анализ в новых проектах (<Nullable>enable</Nullable>).
  • На границе системы валидируйте входные DTO.
  • В доменной логике старайтесь работать с не-null значениями.
  • Для коллекций предпочитайте пустую коллекцию вместо null.

Мини-шаблон безопасной обработки

public string GetDisplayName(User? user)
{
if (user is null)
{
return "Аноним";
}

return string.IsNullOrWhiteSpace(user.Name) ? "Без имени" : user.Name;
}

Смежные статьи

Обработка значения null и nullable-типы

Разработчику Архитектору

Обработка значения null и nullable-типы

Что такое null?

null — это специальное значение, которое означает отсутствие ссылки на объект. Это не число, не пустая строка, не логическое значение, а именно отсутствие значения.

string name = null; // Переменная name не указывает ни на какой объект в памяти

Разбор:

  • string - ссылочный тип, поэтому переменная хранит ссылку на объект, а не символы "внутри себя".
  • name = null означает "ссылка отсутствует", а не "пустая строка".
  • Комментарий подчёркивает, что объект в памяти не создан и переменная никуда не указывает.
  • При попытке вызвать метод у name (например, name.Length) без проверки возникнет NullReferenceException.

Важно: null может существовать только для ссылочных типов (reference types), потому что они хранят указатель на объект в куче (heap). Значимые типы (value types), как правило, не могут быть null, так как всегда содержат какое-то значение.


Ссылочные типы и null

Ссылочные типы — это типы, экземпляры которых хранятся в куче (heap), а переменная содержит ссылку (указатель) на этот объект. Именно они могут быть null - string, object, классы, интерфейсы, делегаты, массивы.

Person person = new Person(); // ссылка на объект
Person another = null; // ссылка ни на что не указывает

Разбор:

  • new Person() создаёт экземпляр класса в куче и возвращает ссылку на него.
  • Переменная person содержит валидную ссылку, поэтому через неё можно безопасно обращаться к членам объекта.
  • another = null показывает противоположный сценарий: переменная есть, но объекта за ней нет.
  • Такой контраст полезен, чтобы понять разницу между "объект создан" и "переменная объявлена, но пустая".

Значимые типы и null

Значимые типы (value types) хранят сами данные, а не ссылку. Они размещаются на стеке (или в структуре). По умолчанию значимые типы не могут быть null, и это соответственно касается int, double, bool, char, DateTime, struct, enum.

int age = 25; // значение хранится напрямую
int number = null; // ОШИБКА компиляции!

Разбор:

  • int - значимый тип: значение хранится напрямую, а не через ссылку.
  • age = 25 корректно инициализирует переменную конкретным числом.
  • int number = null не компилируется, потому что обычный int не допускает null.
  • Чтобы хранить "число или отсутствие числа", нужен int? (Nullable<int>).

Чтобы сделать значимый тип "nullable" (допускающим значение null), C# предоставляет:

Nullable<T> — обобщённый шаблон:

Nullable<int> age = null;

Разбор:

  • Nullable<int> оборачивает тип int и добавляет возможность хранить null.
  • Здесь переменная age явно инициализируется как "значение отсутствует".
  • Это базовая форма, эквивалентная сокращённой записи int? age = null;.
  • Такой подход применяют для необязательных полей — возраст, дата завершения, рейтинг и т.д. Nullable<T> — это структура, которая может хранить либо значение типа T, либо null. Имеет два свойства:
    • HasValue — true, если значение задано.
    • Value — само значение (вызовет исключение, если HasValue == false).
Nullable<int> age = 30;
if (age.HasValue)
{
Console.WriteLine($"Возраст: {age.Value}");
}
else
{
Console.WriteLine("Возраст не указан");
}

Разбор:

  • HasValue безопасно проверяет, есть ли внутри Nullable<T> реальное значение.
  • В ветке if чтение age.Value допустимо, потому что уже подтверждено наличие значения.
  • Ветка else обрабатывает сценарий null, не допуская падения приложения.
  • Такой шаблон (HasValue -> Value) полезен при явной развилке бизнес-логики.

Сокращённый синтаксис — T?

Начиная с C# 2, можно использовать сокращение:

int? age = null; // эквивалентно Nullable<int>
bool? isActive = true;
DateTime? birthDate = null;

Разбор:

  • Суффикс ? у значимого типа включает nullable-режим (T? -> Nullable<T>).
  • int?, bool?, DateTime? позволяют хранить либо значение, либо null.
  • isActive = true показывает, что nullable-тип не ограничен только null: обычные значения тоже допустимы.
  • Такой синтаксис компактнее и читается лучше, чем полная запись Nullable<T>. Это синтаксический сахар, компилятор преобразует int?Nullable<int>.

Операции с Nullable-типами

С Nullable<T> можно выполнять соответствующие операции.

Арифметика: если один из операндов null, результат — null.

Сравнения: ==, != работают корректно с null.

int? a = 5;
int? b = null;
int? result = a + b; // result == null

bool isEqual = (a == b); // false
bool isNull = (b == null); // true

Разбор:

  • Арифметика с Nullable<T> "проталкивает" null: если один операнд null, результат тоже null.
  • Выражение a + b не падает, а возвращает null, что делает вычисления предсказуемыми.
  • == и != у nullable-типов корректно учитывают отсутствие значения.
  • isNull демонстрирует самый прямой способ проверки nullable-переменной на null. Value — опасен, если HasValue == false, выбросит InvalidOperationException.

GetValueOrDefault() — вернёт значение или default(T) (например, 0 для int).

GetValueOrDefault(defaultValue) — вернёт значение или указанное значение по умолчанию.

int? number = null;
int value1 = number.Value; // Исключение!
int value2 = number.GetValueOrDefault(); // 0
int value3 = number.GetValueOrDefault(42); // 42

Разбор:

  • number.Value опасен без проверки: при null выбрасывается InvalidOperationException.
  • GetValueOrDefault() возвращает default(int), то есть 0.
  • Перегрузка GetValueOrDefault(42) задаёт явное "бизнес-значение по умолчанию".
  • Пример показывает, как заменить рискованный доступ к Value на безопасные альтернативы. Условный оператор доступа: ?. (Null-conditional member access, Null-условный оператор) предоставляет безопасный доступ к свойствам и элементам, если они не null:
string name = person?.Name;

Разбор:

  • ?. (null-conditional) сначала проверяет person на null, и только затем читает Name.
  • Если person == null, выражение сразу возвращает null, а не вызывает исключение.
  • Это компактная замена шаблону person != null ? person.Name : null.
  • Оператор особенно полезен в цепочках обращения к вложенным объектам. Если person != null, то person.Name возвращается.

Если person == null, выражение возвращает null.

Тип результата: string? (ссылочный тип, допускающий null)

Person person = null;
string name = person?.Name; // null
int? length = person?.Name?.Length; // null (двойная проверка)
// Вызов метода
person?.PrintInfo();

// Вызов делегата
Action action = null;
action?.Invoke(); // безопасный вызов

Разбор:

  • person?.Name?.Length выполняется "ступенчато": если любой узел цепочки null, итог тоже null.
  • int? length выбран правильно, потому что результат длины может отсутствовать.
  • person?.PrintInfo() не делает вызов, если person == null, тем самым предотвращая падение.
  • action?.Invoke() - стандартный безопасный паттерн вызова делегатов/событий.

Для безопасного доступа к элементам массива, списка, словаря и т.п. используется условный индексный доступ: ?[]

int[] numbers = GetNumbers();
int? first = numbers?[0]; // null, если numbers == null
Dictionary<string, string> dict = null;
string value = dict?["key"]; // null

Разбор:

  • ?[] - null-условный индексатор: работает как безопасная версия array[index]/dict[key].
  • numbers?[0] вернёт null, если сам массив не создан, вместо исключения.
  • Для словаря dict?["key"] даёт тот же принцип безопасного доступа.
  • Важно понимать: оператор защищает от dict == null, но не от отсутствующего ключа в уже существующем словаре. Важно — ?[] возвращает T?, если T — значимый тип, иначе T (но с возможным null).

Оператор объединения с null — ?? (Null-coalescing operator) возвращает левый операнд, если он не null, иначе — правый.

string message = input ?? "Значение по умолчанию";

Разбор:

  • ?? возвращает левый операнд, если он не null, иначе берёт правый.
  • Здесь message всегда получает непустую ссылку: либо input, либо резервный текст.
  • Оператор полезен для "мягкого" задания дефолта без длинных if.
  • Конструкция часто применяется при нормализации входных данных. Если input != nullmessage = input

Если input == nullmessage = "Значение по умолчанию"

int count = value ?? 0; // если value == null, используем 0

string displayName = user?.Name ?? "Аноним";

DateTime creationDate = record?.Created ?? DateTime.Now;

Разбор:

  • value ?? 0 закрывает сценарий отсутствующего числа и даёт безопасное значение для дальнейшей арифметики.
  • user?.Name ?? "Аноним" комбинирует два оператора: безопасное чтение + fallback.
  • record?.Created ?? DateTime.Now полезен для дефолтных дат, когда источник может быть пустым.
  • Такой стиль заметно снижает количество ветвлений if/else в коде. Это используется для подстановки значений по умолчанию.

Оператор присваивания объединения

Оператор присваивания объединения с null: ??= (Null-coalescing assignment) появился в C# 8.0. Присваивает правую часть только если левая часть равна null.

List<string> names = null;
names ??= new List<string>(); // инициализируем, если null

Разбор:

  • ??= присваивает значение только когда левая часть null.
  • В примере список создаётся лениво: лишний объект не создаётся, если список уже есть.
  • Это удобный паттерн инициализации коллекций перед добавлением элементов.
  • По смыслу эквивалентно if (names == null) names = new List<string>();. Работает только с переменными и полями, нельзя использовать с выражениями вроде person?.Name ??= "Unknown".
private string _cache;
public string Data => _cache ??= LoadFromDatabase();

// Ленивая инициализация
public List<int> Items { get; set; }
// где-то в коде:
Items ??= new List<int>();

Разбор:

  • Data => _cache ??= LoadFromDatabase(); реализует ленивую загрузку: чтение из БД произойдёт только один раз.
  • После первой инициализации _cache хранит значение, и повторные вызовы возвращают уже готовые данные.
  • Items ??= new List<int>() делает то же для коллекции: инициализация по требованию.
  • Такой подход уменьшает накладные расходы и упрощает код инициализации.

Статическая проверка null

До C# 8.0 компилятор не различал, может ли ссылочный тип быть null. Это приводило к частым ошибкам в рантайме. Начиная с C# 8.0, появилась возможность включить статическую проверку null для ссылочных типов.

В .csproj файле можно включить режим Nullable Reference Types, это Null-безопасность на уровне компилятора:

<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>

Разбор:

  • Секция <PropertyGroup> задаёт параметры сборки проекта.
  • <Nullable>enable</Nullable> включает nullable-анализ для ссылочных типов на уровне компилятора.
  • После включения компилятор начинает предупреждать о потенциально опасных местах с null.
  • Это профилактика ошибок на ранней стадии, до запуска приложения. или на уровне файла:
#nullable enable

Разбор:

  • Директива #nullable enable включает nullable-контекст локально, на уровне файла.
  • Удобно для поэтапной миграции старого проекта: можно включать анализ постепенно.
  • В таком режиме string трактуется как non-nullable, а string? - как nullable.
  • Компилятор начинает проверять контракты null-безопасности прямо в этом файле. После этого:
    • string name — не должен быть null (non-nullable reference type)
    • string? name — может быть null (nullable reference type)

Пример:

#nullable enable

string name = null; // ⚠️ Предупреждение компилятора!
string? optional = null; // OK

void PrintName(string name) // параметр не может быть null
{
Console.WriteLine(name.Length); // компилятор уверен: name != null
}

PrintName(null); // ⚠️ Предупреждение!

Разбор:

  • string name = null в nullable-контексте помечается предупреждением: нарушен non-nullable-контракт.
  • string? optional = null корректен, потому что тип явно допускает отсутствие значения.
  • Сигнатура PrintName(string name) сообщает, что метод ожидает не-null аргумент.
  • Вызов PrintName(null) помечается предупреждением на этапе компиляции, что помогает заранее исправить баг.
  • name.Length внутри метода безопасен с точки зрения анализа потока, потому что параметр non-nullable. Компилятор отслеживает инициализацию, проверки на null, присваивания. Поэтому включайте nullable enable в новых проектах, это помогает избежать NullReferenceException ещё на этапе компиляции. И используйте ?., ??, ??= для безопасной работы с null.

Избегайте null там, где можно использовать другие значения - вместо null строки — string.Empty или "", вместо null коллекций — Array.Empty<T>() или new List<T>().