Привет, Хабр!
Сегодня мы рассмотрим три самые коварные ошибки, которые регулярно просачиваются даже в продовые C#-проекты. Разберёмся, как они рождаются, почему остаются незамеченными и что нужно сделать, чтобы больше никогда не ловить эти проблемы.
Ошибка №1
Игнорирование Nullable Reference Types и тихие NullReferenceException
С выхода C# 8 компилятор умеет предупреждать о потенциальном null
дереференсе, но только если вы включили контекст #nullable enable
и реагируете на предупреждения. Там, где проект собирают с #nullable disable
, поле или свойство может остаться со значением null
, и в рантайме прилетает NullReferenceException
. Это часто появляется в новых микросервисах — команда «включили, но потом пришлось временно выключить, чтобы не править сотни варнингов», и так живут месяцами.
Типичный анти-пример:
#nullable disable
public sealed class ReportService
{
private readonly IEmailClient _client;
public ReportService(IEmailClient client)
{
_client = client;
}
public void SendSummary(string recipient)
{
// _client может быть null, если подали неправильный DI-регистратор
_client.Send(recipient, BuildBody());
}
}
Как фиксить
Включаем nullable-анализ на уровне всего решения, не на уровне отдельных файлов.
Это делается через csproj
. Добавляем:
<PropertyGroup>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup>
Тем самым не просто активируем проверку на null
, но и делаем все варнинги обязательными к устранению. Т.е проект не соберётся, если где-то потенциально может прилететь NullReferenceException
.
Разбираем конкретику предупреждений
Каждое предупреждение — это не абстракция, а прямой сигнал, что либо значение может быть null
, либо вы не инициализировали поле как надо. Вот основные:
CS8600
,CS8602
— вы присваиваете или обращаетесь к потенциальноnull
-значению.CS8618
— полеstring Name { get; set; }
осталось без инициализации, хотяnullable
-анализ включён.CS8625
— в метод, не ожидающийnull
, передаётсяnull
, возможно неявно.
Учимся правильно общаться с компилятором
Иногда вы точно знаете, что значение не может быть null
, но компилятор не понимает этого. В таких случаях надо давать явные подсказки:
ArgumentNullException.ThrowIfNull(arg);
— ранняя проверка на входе, предотвращает весь остальной шум.MemberNotNull
иMemberNotNullWhen
— атрибуты, которые документируют: «после вызова этого метода поле точно не null».??
(null-coalescing operator) — краткая заменаif (x == null) x = default
.
Не забываем про сложные случаи — struct
и массивы
Внутри struct
поля со ссылочным типом часто обходятся вниманием анализатора. Например, если вы определили MyStruct { public string? Name; }
, компилятор не будет предупреждать, если Name
остаётся null
. Здесь помогает только ручной контроль и unit-тесты: конструкторы должны проверяться на корректность инициализации всех ссылочных полей. Массивы — то же самое: если new string[10]
, то все элементы по умолчанию null
.
Подводим черту
#nullable enable
— это не опциональная настройка, а такой же базовый слой безопасности, как try/catch
или async
. Включили и довели до нуля варнингов. Не оставляйте на потом. С каждой пропущенной проверкой вы даёте разрешение на NullReferenceException
в рантайме.
Ошибка №2
Скрытые копии значимых типов: struct + readonly = сюрприз
readonly struct Point
{
public int X { get; }
public int Y { get; }
public double Len() => Math.Sqrt(X * X + Y * Y);
}
readonly Point _origin = new(0, 0);
double GetLen() => _origin.Len();
Кажется, всё должно быть zero-alloc. Но нет: если Point
не помечен readonly
, компилятор сделает defensive copy перед каждым вызовом метода, потому что field in readonly context
может мутировать состояние.
Как проверить
public class MailNotifier
{
public async void SendAsync(string address, string body)
{
await _smtpClient.SendAsync(address, body);
}
}
В IL увидите ldfld
, потом stloc
, потом вызов — лишняя копия. На горячем пути, где метод зовётся миллионы раз, это потеря кеш-лайна и лишние 32/64 байт движений памяти.
Лечим
Отмечаем struct
readonly
— сразу отключаем защитные копии.-
Если нельзя сделать весь тип неизменяемым, помечаем сами методы
readonly
:public readonly double Len() => Math.Sqrt(X * X + Y * Y);
-
При передаче больших struct в методы используем
in
-параметры, но только для по-настоящему неизменяемых типов:double Calc(in Matrix4x4 m) { /* … */ }
иначе defensive copy вернётся.
Ошибка №3
Утечки памяти из-за забытых подписок на события
Когда объект A подписался на событие объекта B, последний держит на A сильную ссылку. Если мы забыли отписаться, A не собирается сборщиком даже после того, как вышел из использования. В долгоживущих приложениях это тает память гигабайтами.
public sealed class StatsWindow : Window
{
private readonly Timer _timer = new(TimeSpan.FromSeconds(1).TotalMilliseconds);
public StatsWindow()
{
_timer.Elapsed += OnTick; // забыли отписаться
_timer.Start();
}
private void OnTick(object? s, ElapsedEventArgs e)
=> CpuLabel.Content = CpuSensor.ReadCurrent();
}
Три уровня защиты
-
IDisposable
+using
/await using
public sealed class StatsWindow : Window, IDisposable { // … public void Dispose() { _timer.Elapsed -= OnTick; _timer.Dispose(); } }
-
Слабые события
Для WPF:WeakEventManager<Timer, ElapsedEventArgs> .AddHandler(_timer, nameof(_timer.Elapsed), OnTick);
Сборщик сможет удалить
StatsWindow
, даже если таймер жив. -
Своя реализация
WeakEvent<TEventArgs>
для кросс-платформенных библиотекpublic sealed class WeakEvent<T> where T : EventArgs { private readonly List<WeakReference<EventHandler<T>>> _handlers = new(); public void Add(EventHandler<T> handler) => _handlers.Add(new WeakReference<EventHandler<T>>(handler)); public void Raise(object sender, T args) { for (int i = _handlers.Count - 1; i >= 0; i--) { if (_handlers[i].TryGetTarget(out var h)) h(sender, args); else _handlers.RemoveAt(i); // чистим мёртвые ссылки } } }
Здесь ни один слушатель не помешает сборке мусора.
А отладку делаем так: в Visual Studio открываем Diagnostic Tools → Memory Usage → Take Snapshot, после чего фильтруемпо «Event Handler Leaks» — сразу видно, какие объекты остались в памяти из-за висящих подписок. Альтернатива для продвинутого анализа — PerfView, где команда !DumpHeap -type <TypeName>
позволяет быстро оценить количество живых экземпляров нужного типа и понять, кто держит ссылки.
Вот такие три ошибки — от банального null до потерь памяти и невидимых копий struct — продолжают портить жизнь даже в зрелых C#‑проектах. Если сталкивались с похожими ситуациями или у вас есть свои грабли из опыта — обязательно поделитесь в комментариях.
Если вы когда-нибудь писали тесты просто «на всякий случай» или ловили неожиданный тайм-аут после внедрения нового сервиса — значит, есть куда копать. Мы подготовили два открытых урока, где разберём реальные сценарии .NET‑разработки: от тестирования API под нагрузкой до архитектурных подходов для стабильных микросервисов:
5 августа в 20:00 — Тестирование API в ASP.NET Core: Интеграция и Нагрузка
19 августа в 20:00 — Оптимизация микросервисов с CQRS и Event Sourcing на .NET Aspire
Откройте доступ ко всем открытым урокам, а заодно проверьте своей уровень знаний C# ASP.NET, пройдя вступительное тестирование.
Чтобы узнать больше о курсах по C# и получить доступ к записям открытых уроков, переходите в телеграм-бот.
Wolfdp
По первому пункту видел немало "против" включения этой фичи. Некоторые сетовали мол "фиг доведёшь проект до нуля предупреждений (с чем я в принципе согласен, такая возможность не всегда есть) или что есть зависимость от либ, в которых нет этой проверки. Некоторые вообще утверждают что она только мешает и ничем не страхует (тут лично я уже не согласен, но слышал такое не от самых глупых людей в плане .net)
Heggi
"фиг доведёшь проект до нуля предупреждений" - легко и просто, если это новый проект, а не древнее легаси, портированное на современную версию net.
"зависимость от либ" - вот тут да, есть такое. Либа для работы с grpc до сих пор собирается без поддержки nullable и про это надо всегда помнить (и добавлять где надо проверки). Но это не мешает остальной проект писать нормально.
"Некоторые вообще утверждают что она только мешает и ничем не страхует" - чушь. IDE сразу подсказывает где потенциально может быть NullReferenceException. Добавляешь проверку и всё. Иначе надо самому помнить и ловить такие места, и вот тут как раз забыть добавить где-то проверку проще простого.
Kanut
Она "мешает" тем что создаёт иллюзию безопасности. После чего отдельные люди просто перестают думать что откуда-то может прилететь эта самая NullReferenceException.
А они вполне себе могут прилететь. Особенно если работать со сторонними библиотеками или какими-то фрэймворками. В том же Prism например это вполне себе ещё проблема. Как минимум до 8-й версии включительно.
В том же WPF есть целый ряд ситуаций когда не подсказывает. Даже без Prism. В MAUI и Blazor, когда я пару лет назад последний раз с ними сталкивался это тоже совсем не идеально работало.
В том то и дело что их всё равно надо помнить и ловить. Надо меньше помнить и ловить, это да. Но всё равно надо.
То есть в целом фича полезная, но как минимум на данный момент не на 100% рабочая.
bizonwar
Может, я что-то не до конца понимаю, и мне кто-нибудь подскажет верное решение.
Как это все выглядит - откуда-то прилетает переменная, которая содержит null, хотя не должна. Компилятор проверяет, что тут потенциально может быть NRE, и ругается. Мы делаем проверку на null и что дальше? Выкидывает Argument Null Exception (если в нашу либу прилетел null в качестве аргумента), или какое исключение, если чужая либа вернула null из вызова?
Ну так, таким образом, в методе - конечном потребителе переменной и так проверю ее на null и выкину соответствующее исключение.
Согласен с комментарием в этой ветке, что это иллюзия безопасности. Все равно нужно бдить по полной программе.
И ещё момент на счёт чужих либ. Разве нельзя в конкретном месте выставить #nullable disable, а потом включить обратно, если этот функционал включен для всего проекта? По-моему можно.
Heggi
Пусть у нас есть какой-то метод, который выбирает из БД какую-то запись по какому-то условию и возвращает нам модельку.
Вариант первый. Nullable не включен. Что может вернуть этот метод? Может вернуть SomeModel, а может вернуть null. Из объявления метода так сразу и не понятно. Дальше в коде, если мы вызовем этот метод и не проверим на null - ни компилятор, ни IDE ругаться не будут.
Вариант второй. Nullable включен. Теперь мы точно знаем, что метод возвращает строго SomeModel и без вариантов. Проверка на null не нужна. При этом и внутри метода GetSomeModel null вернуть уже не получится - компилятор будет ругаться.
Если же метод может вернуть null, то объявление нужно изменить:
Теперь мы знаем из одного только объявления метода, что может вернуться null. И если после вызова метода в коде не сделать проверку на null, компилятор (и IDE) будут ругаться.
А вот что делать, если вернулся null - зависит уже от логики приложения. Тупо бросать NRE - это самый отвратительный вариант, который абсолютно ничем не поможет пользователю. В нашем же случае, если это вызов API, мы, скорее всего, должны вернуть ошибку 404 - запись не найдена. Или, как вариант, создать новую запись в БД.