Основы C#

Ref, out и in в C#: передача по ссылке

Обновлено: · 7 мин чтения

По умолчанию параметры методов в C# передаются по значению: метод получает копию, и изменение внутри не затрагивает исходную переменную. Ключевые слова ref, out и in меняют это правило и передают в метод саму переменную, точнее, ссылку на неё.

Ниже разобрано, чем они отличаются, где применяются, как устроен TryParse с параметром out и какие подводные камни есть у каждого модификатора.

Ref, out и in в C#: передача по ссылке
Содержание
  1. Передача по значению и по ссылке
  2. Ключевое слово ref
  3. Ключевое слово out
  4. Шаблон Try и метод TryParse
  5. Ключевое слово in
  6. Сравнительная таблица ref, out и in
  7. Ограничения и особенности
  8. Когда что выбирать
  9. Частые вопросы

Передача по значению и по ссылке

Чтобы понять назначение модификаторов, вспомните, как работает обычная передача. Для типов-значений (int, double, struct) в метод уходит копия данных. Для ссылочных типов (class, string, массивы) копируется ссылка, то есть адрес объекта. Через такую копию можно изменить содержимое объекта, но нельзя подменить сам объект в вызывающем коде.

static void AddOne(int x) { x++; }

int a = 5;
AddOne(a);
Console.WriteLine(a); // 5, изменилась только копия

Если же метод должен изменить саму переменную вызывающей стороны, нужен один из трёх модификаторов. Они ставятся и в объявлении параметра, и при вызове (кроме in, где при вызове слово можно опустить). Различие между ними в том, кто и когда должен присвоить значение и можно ли его менять.

Разница между копированием значения и копированием ссылки связана с устройством типов, о котором подробнее в статье про класс и структуру.

Ключевое слово ref

Модификатор ref передаёт переменную по ссылке. Переменная обязана быть инициализирована до вызова, а метод вправе прочитать её и изменить. После возврата из метода вызывающий код видит новое значение.

static void AddOne(ref int x) { x++; }

int a = 5;
AddOne(ref a);
Console.WriteLine(a); // 6

static void Swap(ref int x, ref int y)
{
    int t = x;
    x = y;
    y = t;
}

int p = 1, q = 2;
Swap(ref p, ref q);
Console.WriteLine($"{p} {q}"); // 2 1

Для ссылочных типов ref тоже имеет смысл: он позволяет заменить сам объект. Без ref присвоение параметру нового объекта затрагивает только локальную копию ссылки.

static void Reset(List<int> list) { list = new List<int>(); }
static void ResetRef(ref List<int> list) { list = new List<int>(); }

var data = new List<int> { 1, 2, 3 };
Reset(data);
Console.WriteLine(data.Count); // 3
ResetRef(ref data);
Console.WriteLine(data.Count); // 0

Аргументом для ref может быть только переменная, поле или элемент массива. Литерал, результат выражения и свойство передать нельзя: у них нет адреса, который можно было бы изменить. Свойство нужно сначала записать в локальную переменную.

Ключевое слово out

Модификатор out предназначен для возврата результатов. Переменная перед вызовом может быть не инициализирована, зато метод обязан присвоить значение параметру out во всех путях выполнения до возврата управления. Читать out-параметр до присвоения нельзя, компилятор это запретит.

static void MinMax(int[] arr, out int min, out int max)
{
    min = arr[0];
    max = arr[0];
    foreach (int v in arr)
    {
        if (v < min) min = v;
        if (v > max) max = v;
    }
}

MinMax(new[] { 4, 9, 1, 7 }, out int lo, out int hi);
Console.WriteLine($"{lo}..{hi}"); // 1..9

Начиная с C# 7 переменную можно объявить прямо в месте вызова: out int lo. Если значение не нужно, используйте discard: out _. Так делают, когда важен только признак успеха.

Несколько out-параметров были популярным способом вернуть сразу несколько значений. Сейчас для этого чаще берут кортежи, они читаются легче. Сравнение подходов есть в статье про возврат нескольких значений из метода. Однако у out есть своя ниша, которая не заменяется кортежами: шаблон Try.

Шаблон Try и метод TryParse

Самый знакомый пример out - метод int.TryParse. Он пытается разобрать строку и возвращает bool: получилось или нет. Само число отдаётся через out-параметр. Такой дизайн не бросает исключение при неверном вводе, а исключения дороги и для пользовательского ввода неуместны.

string input = Console.ReadLine();

if (int.TryParse(input, out int number))
{
    Console.WriteLine($"Число: {number}");
}
else
{
    Console.WriteLine("Это не число");
}

// число видно и дальше, объявление было в условии
Console.WriteLine(number); // 0 при неудаче

При неудаче TryParse записывает в out-параметр значение по умолчанию, то есть 0 для int. Поэтому нельзя использовать число без проверки результата: ноль может оказаться как введённым значением, так и признаком ошибки. Всегда смотрите на возвращаемый bool.

По тому же шаблону устроены Dictionary.TryGetValue, Enum.TryParse, DateTime.TryParse, Path.TryJoin и десятки других методов. Если вы пишете собственный метод, который может не найти или не разобрать значение, делайте так же: возвращайте bool, а результат отдавайте через out.

static bool TryFindUser(List<User> users, string name, out User found)
{
    foreach (var u in users)
    {
        if (u.Name == name)
        {
            found = u;
            return true;
        }
    }
    found = null;
    return false;
}

Для словарей TryGetValue экономит один поиск по сравнению с парой ContainsKey и индексатором: ключ ищется один раз. В горячих участках кода это заметно.

var prices = new Dictionary<string, decimal> { ["хлеб"] = 45m };
if (prices.TryGetValue("хлеб", out decimal price))
    Console.WriteLine(price);

Ключевое слово in

Модификатор in появился в C# 7.2. Он передаёт аргумент по ссылке, но только для чтения: метод не может присвоить параметру значение. Его цель - избежать копирования больших структур. Если структура занимает десятки байт, передача по значению при каждом вызове дорога, а in передаёт лишь адрес.

readonly struct Matrix4
{
    public readonly double M11, M12, M13, M14;
    public readonly double M21, M22, M23, M24;
    public readonly double M31, M32, M33, M34;
    public readonly double M41, M42, M43, M44;
    public double Trace => M11 + M22 + M33 + M44;
}

static double Trace(in Matrix4 m) => m.Trace;

var matrix = new Matrix4();
Console.WriteLine(Trace(in matrix));
Console.WriteLine(Trace(matrix)); // слово in при вызове необязательно

Здесь важно слово readonly у структуры. Если структура изменяема, компилятор при обращении к её членам через in-параметр создаёт защитную копию, чтобы метод не мог изменить оригинал. В итоге выигрыш пропадает, а иногда становится хуже. Поэтому in применяют к неизменяемым структурам размером больше 16 байт. Для int, double и маленьких структур in не даёт выгоды, а иногда замедляет код из-за дополнительного разыменования.

Для ссылочных типов in бессмыслен: ссылка и так занимает 8 байт, а лишний уровень косвенности только мешает. Ещё одна особенность - в in можно передать литерал или выражение, тогда компилятор создаст временную переменную.

Сравнительная таблица ref, out и in

Свойствоrefoutin
Инициализация до вызоваобязательнане требуетсяобязательна
Присвоение внутри методаразрешенообязательнозапрещено
Чтение внутри методадатолько после присвоенияда
Слово при вызовеобязательнообязательнонеобязательно
Основная цельизменить переменнуювернуть результатдешёвая передача структуры

Правило запоминания такое: ref работает в обе стороны, out только наружу, in только внутрь.

Ограничения и особенности

У методов с этими модификаторами есть ограничения, которые полезно знать заранее. Они не работают с async-методами и итераторами (yield return): нельзя объявить async-метод с параметрами ref, out или in, потому что состояние метода переносится в объект и ссылка на локальную переменную стека потеряет смысл. Лямбда-выражения могут иметь такие параметры, если указать тип явно, но использовать их внутри замыканий нельзя.

  • Перегрузки могут различаться по ref, out и in, но не могут различаться только по ref и out между собой.
  • Параметр ref или out нельзя захватить в лямбде или локальной функции.
  • Для свойств, индексаторов и readonly-полей (вне конструктора) ref использовать нельзя.
  • Тип параметров в вызове должен точно совпадать с объявленным: неявные преобразования для ref и out не выполняются.

Отдельно стоит упомянуть ref locals и ref returns. Метод может вернуть ссылку на элемент, а вызывающий код сохранит её в ref-переменную и сможет изменять элемент массива напрямую.

static ref int Find(int[] arr, int target)
{
    for (int i = 0; i < arr.Length; i++)
        if (arr[i] == target)
            return ref arr[i];
    throw new InvalidOperationException("Не найдено");
}

int[] numbers = { 10, 20, 30 };
ref int slot = ref Find(numbers, 20);
slot = 99;
Console.WriteLine(string.Join(",", numbers)); // 10,99,30

Это низкоуровневая возможность, которая нужна в высокопроизводительном коде: Span, коллекциях и обработке больших массивов. В прикладных программах она встречается редко.

Когда что выбирать

Если метод возвращает один результат, используйте обычный return. Для двух-трёх связанных значений удобнее кортеж или небольшой record. Модификатор out подходит для шаблона Try, где результат необязателен. Ref нужен для методов, которые должны изменять переданную переменную, вроде Swap, или для работы с большими структурами на месте. In оправдан только для крупных readonly-структур в местах, где профилирование показало расходы на копирование.

Если вы часто пишете методы с ref или out ради побочных эффектов, пересмотрите дизайн. Метод, который изменяет сразу несколько аргументов, трудно тестировать и читать. Чаще лучше вернуть новое значение, а не менять входное.

Частые вопросы

В чём главная разница между ref и out?

Ref требует, чтобы переменная была инициализирована до вызова, и метод может её читать. Out не требует инициализации, но метод обязан присвоить ей значение до выхода.

Нужно ли использовать ref для ссылочных типов?

Только если метод должен заменить сам объект, на который указывает переменная вызывающего кода. Для изменения свойств и содержимого объекта ref не нужен.

Что вернёт TryParse при ошибке?

Метод вернёт false, а out-параметр получит значение по умолчанию для типа: 0 для чисел, false для bool, null для ссылочных типов.

Можно ли использовать in для классов?

Технически можно, но смысла нет. Ссылка на объект уже мала, а in добавляет ещё один уровень косвенности. Модификатор создан для больших структур.

Можно ли использовать out в async-методах?

Нет, async-методы не допускают параметров ref, out и in. Возвращайте кортеж или объект результата через Task.

Чем out лучше кортежа?

Он нужен там, где метод возвращает bool как признак успеха, а данные идут отдельно, как в TryParse. Для обычного возврата нескольких значений кортеж читается лучше.