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

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

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


  1. Wolfdp
    01.08.2025 12:43

    По первому пункту видел немало "против" включения этой фичи. Некоторые сетовали мол "фиг доведёшь проект до нуля предупреждений (с чем я в принципе согласен, такая возможность не всегда есть) или что есть зависимость от либ, в которых нет этой проверки. Некоторые вообще утверждают что она только мешает и ничем не страхует (тут лично я уже не согласен, но слышал такое не от самых глупых людей в плане .net)


    1. Heggi
      01.08.2025 12:43

      "фиг доведёшь проект до нуля предупреждений" - легко и просто, если это новый проект, а не древнее легаси, портированное на современную версию net.

      "зависимость от либ" - вот тут да, есть такое. Либа для работы с grpc до сих пор собирается без поддержки nullable и про это надо всегда помнить (и добавлять где надо проверки). Но это не мешает остальной проект писать нормально.

      "Некоторые вообще утверждают что она только мешает и ничем не страхует" - чушь. IDE сразу подсказывает где потенциально может быть NullReferenceException. Добавляешь проверку и всё. Иначе надо самому помнить и ловить такие места, и вот тут как раз забыть добавить где-то проверку проще простого.