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