Привет, Хабр!
Сегодня мы рассмотрим самый — казалось бы — скромный модификатор, который способен сэкономить кучу времени в горячих участках кода. Речь, конечно, про in‑аргументы. Рассмотрим, чем они отличаются от ref и out, где ими действительно стоит пользоваться, а где лучше пройти мимо.
Ещё со времён C# 1.0 мы имели два способа передать значение «по ссылке»:
-
ref— вызывающий обязан инициализировать переменную; метод может менять её содержимое. -
out— вызывающий может передать мусор; метод гарантированно задаёт значение, иначе компилятор ругнётся.
Обе опции выполняют одно и то же по своей сути: метод получает адрес переменной, а не копию. Отличие лишь в том, кто отвечает за инициализацию и что разрешено менять внутри метода.
Проблема выплыла, когда мы начали перебирать массивы больших значений. Каждое копирование 128-байтного struct на каждый вызов — это лишние мегабайты памяти, cache miss и потеря драгоценных наносекунд. Поэтому в C# 7.2 подбросили третий вариант — in — в одном пакете с readonly struct.
in
in говорит компилятору: «передай по ссылке, но не дай ничего менять». Синтаксис тривиален:
public static double Length(in BigVector v) => Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);
Под всем этим:
-
Передача по адресу. IL‑код содержит
ldarga.s, неldarg.0, а значит никаких копий. -
Защита от мутаций. Любая попытка изменить поле внутри метода — ошибка компиляции.
-
Дефензивная копия при подозрении. Если
BigVectorне помеченreadonly, компилятор может скопировать значение, если счёт покажется сомнительным.
Чтобы копий не было вообще — делаем структуру readonly:
public readonly struct BigVector { public readonly double X; public readonly double Y; public readonly double Z; public BigVector(double x, double y, double z) => (X, Y, Z) = (x, y, z); }
Теперь Length(in BigVector) пройдет без лишних аллокаций и копирований даже в Release‑сборке с агрессивной inlining‑оптимизацией.
Что генерирует компилятор
Возьмём BenchmarkDotNet и проверим скорость трёх вариантов:
[StructLayout(LayoutKind.Sequential)] public readonly struct Huge { public readonly long A, B, C, D, E, F, G, H; } public class Bench { private readonly Huge _value = new(1,2,3,4,5,6,7,8); [Benchmark] public long ByValue() => Sum(_value); [Benchmark] public long ByRef() => SumRef(ref _value); [Benchmark] public long ByIn() => SumIn(in _value); static long Sum(Huge h) => h.A + h.B + h.C + h.D + h.E + h.F + h.G + h.H; static long SumRef(ref Huge h) => h.A + h.B + h.C + h.D + h.E + h.F + h.G + h.H; static long SumIn(in Huge h) => h.A + h.B + h.C + h.D + h.E + h.F + h.G + h.H; }
ByValue: компилятор делает копию Huge (64 байта) на стеке вызова. ByRef: передаём адрес, но открываем ворота для мутаций — JIT не даёт проворачивать такие штуки в многопотоке без блокировок. ByIn: передаём адрес и обещаем неизменность, поэтому JIT смело инлайнит и читает данные напрямую из исходного адреса.
Результаты на.NET 9 Preview (Release, x64):
Method Mean Ratio Gen0 Allocated ByValue 18.34 ns 2.01 - - ByRef 9.12 ns 1.00 - - ByIn 9я.18 ns 1.01 - -
Копия съела ровно половину производительности. in даёт тот же выигрыш, что ref, но без риска нечаянно мутировать значение.
Примеры применения
Тайм-слот в календарном сервисе
Задача. Для корпоративного календаря нужно быстро выбирать свободные окна. Интервал описывается TimeSlot — пара DateTime плюс флаги (64 байта). Функция Overlaps вызывается тысячами раз при поиске общего слота для митинга.
public readonly struct TimeSlot { public readonly DateTime Start; public readonly DateTime End; public readonly byte Flags; // recurrence, PTO и т. д. public TimeSlot(DateTime start, DateTime end, byte flags = 0) => (Start, End, Flags) = (start, end, flags); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool Overlaps(in TimeSlot a, in TimeSlot b) => a.Start < b.End && b.Start < a.End;
64-байтный объект не копируется при каждом сравнении, а календарный алгоритм оперирует десятками тысяч ячеек за один запрос. Код остаётся thread‑safe — метод не может случайно поменять интервал.
Контекст логирования в микросервисах
Во внутренних API‑гейтвеях мы логируем каждый проход HTTP‑запроса. Контекст включает trace‑id, span‑id, user‑id и восемь флагов: struct LogScope (48 байт). Его нужно передавать в цепочку LogDebug, LogInfo, LogError.
public readonly struct LogScope { public readonly Guid TraceId; public readonly Guid SpanId; public readonly int UserId; public readonly byte Flags; } public static void LogInfo( in LogScope scope, string message, [CallerMemberName] string? member = null) { // быстрый StringBuilder-роут без аллокаций }
Контекст создаётся один раз на входе и «едет» по всему графу вызовов без лишних дубликатов. Любая попытка мутировать LogScope внутри лог‑метода ловится компилятором → нельзя случайно изменить trace‑id. Передача ссылки вместо копии заметно важна под нагрузкой 50–100 k RPS.
ETL-парсер CSV
Задача. Каждый вечер бухгалтерия грузит многогигабайтный CSV с операциями. Строка распарсена в TransactionRow — 9 decimal, 2 DateTime, пара bool (около 104 байт). После парсинга десяток функций вычисляют налоги, валидации и агрегаты.
public readonly struct TransactionRow { public readonly decimal Amount; public readonly decimal Tax; public readonly decimal Fee; // …ещё поля public readonly DateTime Created; public readonly DateTime Booked; } static decimal CalcVat(in TransactionRow row) => row.Amount * 0.20m; static bool IsSuspicious(in TransactionRow row) => row.Fee > 100m && (row.Created - row.Booked).TotalDays > 3;
Парсинг и валидация ходят по тем же данным 3–4 раза; без in каждая функция копирует 100+ байт. Структура readonly, значит JIT оптимизирует доступы напрямую.
Сравнение с in, ref и out
|
Характеристика |
|
|
|
|---|---|---|---|
|
Как передаётся |
По адресу (без копии) |
По адресу |
По адресу |
|
Можно ли менять значение в теле метода |
Нельзя — компилятор запретит |
Можно |
Обязательно задать перед выходом |
|
Требуется ли инициализация до вызова |
Да |
Да |
Нет |
|
Риск защитной копии от компилятора |
Есть, если |
Нет |
Нет |
|
Подходит для ссылочных типов |
Не даёт плюсов, передавать бессмысленно |
Не даёт плюсов |
Не даёт плюсов |
|
Типичный кейс |
Чтение «толстых» |
Двусторонний обмен данными, быстрая мутация |
Множественный «выход» из метода (Try‑API) |
|
Потенциальные ловушки |
Boxing с интерфейсами, async‑замыкания |
Сложнее параллелить из‑за мутабельности |
Лишняя связность, зачастую хуже, чем возвращаемое значение |
in берите, когда методу нужно только читать крупный readonly struct (20 + байт) в горячем цикле: адрес передаётся без копии, JIT смело инлайнит, а запрет на мутации не даёт случайно расшатать данные. Для ссылочных типов, мелких структур или ситуаций, где всё равно придётся менять поля, профита нет — оставляйте обычную передачу по значению или переходите на ref.
ref — ваш выбор, если нужно передать и тут же изменить объект (будь то struct или класс) без лишних аллокаций, но помните о потокобезопасности: мутабельность усложняет жизнь в параллели. out остается рабочей лошадкой Try‑паттерна и многовыходных методов: запрашиваете ресурс, получаете bool ok плюс заполненные параметры. Во всех менее горячих сценариях выгоднее вернуть результат кортежем или record‑типом.
Спасибо, что дочитали. Если есть интересный опыт с in — делитесь в комментариях!
Если вы до сих пор не понимаете, почему одни алгоритмы работают быстрее других или как не допускать архитектурных ошибок в коде, возможно, вам стоит обратить внимание на эти темы. Не теряйте время на поиски решений на уровне «потому что так работает». Разберитесь, что стоит за оптимизацией и правильно строите приложение с самого начала.
-
3 июля в 20:00 — Анализ сложности алгоритмов и сортировка на C#
Поговорим о том, что такое алгоритмическая сложность и как она влияет на производительность кода. -
15 июля в 20:00 — Переиспользуемый код на C#: архитектурный подход
Обсудим принципы архитектуры приложения и применение SOLID, DRY, KISS, YAGNI.
Получить глубокие знания C# и практические навыки с поддержкой преподавателей можно с нуля на специализации «C# Developer».
ссылка на оригинал статьи https://habr.com/ru/articles/922898/
Добавить комментарий