Три ошибки в C#, которые лучше не допускать

от автора

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

Сегодня мы рассмотрим три самые коварные ошибки, которые регулярно просачиваются даже в продовые 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 байт движений памяти.

Лечим

  1. Отмечаем struct readonly — сразу отключаем защитные копии.

  2. Если нельзя сделать весь тип неизменяемым, помечаем сами методы readonly:

    public readonly double Len() => Math.Sqrt(X * X + Y * Y);
  3. При передаче больших 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(); }

Три уровня защиты

  1. IDisposable + using/await using

    public sealed class StatsWindow : Window, IDisposable {     // …     public void Dispose()     {         _timer.Elapsed -= OnTick;         _timer.Dispose();     } }
  2. Слабые события
    Для WPF:

    WeakEventManager<Timer, ElapsedEventArgs>     .AddHandler(_timer, nameof(_timer.Elapsed), OnTick);

    Сборщик сможет удалить StatsWindow, даже если таймер жив.

  3. Своя реализация 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 под нагрузкой до архитектурных подходов для стабильных микросервисов:

Откройте доступ ко всем открытым урокам, а заодно проверьте своей уровень знаний C# ASP.NET, пройдя вступительное тестирование.

Чтобы узнать больше о курсах по C# и получить доступ к записям открытых уроков, переходите в телеграм-бот.


ссылка на оригинал статьи https://habr.com/ru/articles/932888/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *