Привет, Хабр!
Сегодня мы рассмотрим три самые коварные ошибки, которые регулярно просачиваются даже в продовые 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 usingpublic 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# и получить доступ к записям открытых уроков, переходите в телеграм-бот.
ссылка на оригинал статьи https://habr.com/ru/articles/932888/
Добавить комментарий