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

Сегодня мы рассмотрим самый — казалось бы — скромный модификатор, который способен сэкономить кучу времени в горячих участках кода. Речь, конечно, про 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);

Под всем этим:

  1. Передача по адресу. IL‑код содержит ldarga.s, не ldarg.0, а значит никаких копий.

  2. Защита от мутаций. Любая попытка изменить поле внутри метода — ошибка компиляции.

  3. Дефензивная копия при подозрении. Если 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

Характеристика

in (C# 7.2+)

ref (со времён 1.0)

out (со времён 1.0)

Как передаётся

По адресу (без копии)

По адресу

По адресу

Можно ли менять значение в теле метода

Нельзя — компилятор запретит

Можно

Обязательно задать перед выходом

Требуется ли инициализация до вызова

Да

Да

Нет

Риск защитной копии от компилятора

Есть, если struct не readonly

Нет

Нет

Подходит для ссылочных типов

Не даёт плюсов, передавать бессмысленно

Не даёт плюсов

Не даёт плюсов

Типичный кейс

Чтение «толстых» readonly struct без мутаций

Двусторонний обмен данными, быстрая мутация

Множественный «выход» из метода (Try‑API)

Потенциальные ловушки

Boxing с интерфейсами, async‑замыкания

Сложнее параллелить из‑за мутабельности

Лишняя связность, зачастую хуже, чем возвращаемое значение

in берите, когда методу нужно только читать крупный readonly struct (20 + байт) в горячем цикле: адрес передаётся без копии, JIT смело инлайнит, а запрет на мутации не даёт случайно расшатать данные. Для ссылочных типов, мелких структур или ситуаций, где всё равно придётся менять поля, профита нет — оставляйте обычную передачу по значению или переходите на ref.

ref — ваш выбор, если нужно передать и тут же изменить объект (будь то struct или класс) без лишних аллокаций, но помните о потокобезопасности: мутабельность усложняет жизнь в параллели. out остается рабочей лошадкой Try‑паттерна и многовыходных методов: запрашиваете ресурс, получаете bool ok плюс заполненные параметры. Во всех менее горячих сценариях выгоднее вернуть результат кортежем или record‑типом.


Спасибо, что дочитали. Если есть интересный опыт с in — делитесь в комментариях!

Если вы до сих пор не понимаете, почему одни алгоритмы работают быстрее других или как не допускать архитектурных ошибок в коде, возможно, вам стоит обратить внимание на эти темы. Не теряйте время на поиски решений на уровне "потому что так работает". Разберитесь, что стоит за оптимизацией и правильно строите приложение с самого начала.

Получить глубокие знания C# и практические навыки с поддержкой преподавателей можно с нуля на специализации "C# Developer".

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



  1. malstraem
    01.07.2025 06:12

    Разбирать модификатор из версии языка 7.2 в 25 году это сильно.

    Там, где нужна передача жирной структуры по ссылке и мутации нет, на замену in давно пришел ref readonly - ругается на передачу по значению в компайл тайме.