Модификатор out в C# и другие: ref, in, ref readonly, params

Модификатор out в C# и другие: ref, in, ref readonly, params Полезное

out в C# — это модификатор параметра, который передает аргумент по ссылке и обязывает метод записать в него значение до выхода. Так метод возвращает дополнительный результат через параметр: классический пример — int.TryParse(text, out int n), где bool сообщает об успехе, а само число приходит через out.

У out есть родственники: ref (передать по ссылке с правом читать и менять), in и ref readonly (передать по ссылке только для чтения) и params (передать переменное число аргументов). Ниже — таблица отличий, рабочие примеры и ошибки компиляции, на которых спотыкаются чаще всего. Все примеры проверены на .NET SDK 10.0.401 (C# 14).

Термины: что именно называют «модификаторами»

В заголовке старой версии статьи стояло «модификаторы методов», но точнее — модификаторы параметров: они ставятся перед отдельным параметром, а не перед методом. Модификаторы доступа (public, private и т.д.) — другая тема, здесь их не разбираем.

  • Передача по значению — метод получает копию значения аргумента. Для ссылочного типа копируется сама ссылка, а не объект.
  • Передача по ссылке — параметр становится псевдонимом переменной вызывающего кода: запись в параметр меняет эту переменную.
  • Модификатор на стороне вызова — слово ref/out/in перед аргументом при вызове. Для ref и out оно обязательно, для in — нет.

Сводная таблица отличий

Модификатор Нужно ли инициализировать аргумент до вызова Метод может читать Метод обязан/может записать Слово при вызове Типичный сценарий
нет (по значению) да да меняет только свою копию не пишется обычный случай
out нет только после своей записи обязан записать на каждом пути выхода out обязательно вернуть второй результат, Try-методы
ref да да может ref обязательно изменить переменную вызывающего
in да (или выражение — тогда будет временная копия) да запрещено in необязательно большая структура без копирования
ref readonly (C# 12+) да, ожидается переменная да запрещено ref или in, иначе предупреждение API, где важно, что передается именно переменная
params — да — не пишется, аргументы через запятую переменное число аргументов

Правило выбора коротко. Если метод должен вернуть еще одно значение — out (или кортеж, см. ниже). Если должен поменять существующее значение — ref. Если хочет только прочитать большую структуру — in. Если число аргументов заранее неизвестно — params.

Минимальный пример: все модификаторы в одной программе

int a = 5;
ByValue(a);
Console.WriteLine($"по значению: {a}");     // 5

int b = 5;
ByRef(ref b);
Console.WriteLine($"ref: {b}");             // 6

int q, r;                                    // присваивать не нужно
DivRem(17, 5, out q, out r);
Console.WriteLine($"out: {q} и {r}");        // 3 и 2

var big = new BigStruct(1, 2, 3, 4);
Console.WriteLine($"in: {Sum(in big)}");     // 10

Console.WriteLine($"params: {Total(1, 2, 3)} {Total()}"); // 6 0

static void ByValue(int n) => n++;
static void ByRef(ref int n) => n++;
static void DivRem(int x, int y, out int quotient, out int remainder)
{
    quotient = x / y;
    remainder = x % y;
}
static long Sum(in BigStruct s) => s.A + s.B + s.C + s.D;
static int Total(params int[] items) => items.Sum();

readonly record struct BigStruct(long A, long B, long C, long D);

Вывод в консоли:

по значению: 5
ref: 6
out: 3 и 2
in: 10
params: 6 0

Разбор по строкам. ByValue увеличил свою копию, поэтому a осталось 5. ByRef работал с самой переменной b — она стала 6. В DivRem переменные q и r объявлены без значения: для out это нормально, метод обязан их заполнить. Sum читает структуру через in без копирования ее четырех полей. Total() без аргументов получил пустой массив и вернул 0.

out и TryParse: главный практический сценарий

Методы вида TryXxx — основное место, где новичок встречает out. Они не бросают исключение при плохом вводе, а возвращают false.

using System.Globalization;

string[] inputs = { "42", "  7 ", "12abc", "", "99999999999" };

foreach (var s in inputs)
{
    if (int.TryParse(s, out int n))
        Console.WriteLine($"'{s}' -> {n}");
    else
        Console.WriteLine($"'{s}' -> не число");
}

// Если нужен только факт успеха - отбрасываем результат
bool ok = int.TryParse("15", out _);
Console.WriteLine(ok);                                   // True

// Дробные числа: культура решает, что считать разделителем
bool parsed = double.TryParse("3.14", NumberStyles.Float,
                              CultureInfo.InvariantCulture, out double pi);
Console.WriteLine($"{parsed} {pi.ToString(CultureInfo.InvariantCulture)}"); // True 3.14

// Свой Try-метод по тому же образцу
if (TryParsePoint("10;-3", out var x, out var y))
    Console.WriteLine($"x={x}, y={y}");                  // x=10, y=-3

static bool TryParsePoint(string text, out int x, out int y)
{
    x = 0; y = 0;                        // out обязан получить значение на любом пути
    var parts = text.Split(';');
    return parts.Length == 2
        && int.TryParse(parts[0], out x)
        && int.TryParse(parts[1], out y);
}
'42' -> 42
'  7 ' -> 7
'12abc' -> не число
'' -> не число
'99999999999' -> не число
True
True 3.14
x=10, y=-3

Что здесь важно. Пробелы по краям int.TryParse по умолчанию допускает, а число вне диапазона int дает false, а не исключение. Запись out int n объявляет переменную прямо в вызове (с C# 7.0), out _ — отбрасывание, когда значение не нужно. При неудаче int.TryParse записывает в out-параметр 0, поэтому смотреть на число без проверки bool бессмысленно.

Для дробных чисел культура важна: без CultureInfo.InvariantCulture на машине с русской локалью, где десятичный разделитель — запятая, double.TryParse("3.14", out var d) вернет false, а d будет 0. Если входные данные машинные (файлы, API) — передавайте культуру явно.

У своего TryParsePoint есть граница: при частичной неудаче ("10;abc") в x останется 10, а в y — 0. Вызывающий код должен доверять значениям только при true, как и у встроенных Try-методов.

ref: когда метод меняет переменную вызывающего

С типами-значениями (int, структуры) эффект ref очевиден. Со ссылочными типами путаница частая: массив и так «передается по ссылке»? Нет — по значению передается ссылка. Разница видна на примере:

int[] data = { 1, 2, 3 };

Replace(data);
Console.WriteLine(string.Join(",", data));   // 1,2,3 - замена копии ссылки не видна

ReplaceRef(ref data);
Console.WriteLine(string.Join(",", data));   // 9,9 - переменная вызывающего перенаправлена

Mutate(data);
Console.WriteLine(string.Join(",", data));   // 0,9 - содержимое массива меняется и без ref

static void Replace(int[] arr) => arr = new[] { 9, 9 };
static void ReplaceRef(ref int[] arr) => arr = new[] { 9, 9 };
static void Mutate(int[] arr) => arr[0] = 0;

Вывод: 1,2,3, затем 9,9, затем 0,9. Итог для выбора: менять содержимое объекта можно и без ref; ref нужен, только если метод должен заменить сам объект в переменной вызывающего (как Array.Resize(ref arr, n)).

in и ref readonly: передача без копирования и без записи

in полезен для крупных структур (условно, десятки байт и больше): метод получает ссылку и не может записать в параметр. Для int или небольших структур выигрыша обычно нет — ссылка стоит столько же, сколько копия. Оценка «крупная» — ориентир, реальную пользу подтверждает только замер.

Граница, о которой стоит знать: если структура не readonly, вызов ее метода через in-параметр может заставить компилятор сделать защитную копию, и экономия пропадет. Поэтому in лучше сочетать с readonly struct, как BigStruct в первом примере.

ref readonly (C# 12) похож на in, но сигнализирует, что ожидается именно переменная. Компилятор проверяет вызов строже:

int v = 3;
M.Peek(v);           // предупреждение CS9192
M.Peek(5);           // предупреждение CS9193
M.Peek(in v);        // ок
M.Peek(ref v);       // ок

static class M
{
    public static void Peek(ref readonly int n) => Console.WriteLine(n);
}
Program.cs(2,8): warning CS9192: Argument 1 should be passed with 'ref' or 'in' keyword
Program.cs(3,8): warning CS9193: Argument 1 should be a variable because it is passed to a 'ref readonly' parameter

В прикладном коде ref readonly встречается редко — его чаще видишь в библиотечных API, которые раньше принимали ref, а теперь хотят запретить запись, не ломая существующие вызовы.

params: переменное число аргументов

params позволяет вызывать метод как Max(3, 8, 1) вместо Max(new[] { 3, 8, 1 }). Параметр с params должен быть последним в списке, и такой параметр у метода может быть только один. До C# 13 он мог быть только одномерным массивом; с C# 13 допускаются и коллекции, например ReadOnlySpan<T> или List<T>.

Console.WriteLine(Max(3, 8, 1));        // 8
int[] arr = { 4, 5 };
Console.WriteLine(Max(arr));            // 5 - массив передается как есть
Console.WriteLine(Log("id={0}, user={1}", 7, "ann")); // [INFO] id=7, user=ann

static int Max(params ReadOnlySpan<int> items)   // C# 13+
{
    if (items.IsEmpty) throw new ArgumentException("нужен хотя бы один аргумент");
    int m = items[0];
    foreach (var i in items) if (i > m) m = i;
    return m;
}
static string Log(string format, params object[] args) => "[INFO] " + string.Format(format, args);

Вызов без аргументов у params законен, поэтому пустой набор нужно обрабатывать явно, как в Max. params — не передача по ссылке, а синтаксическое удобство: он не сочетается с ref/out/in на том же параметре.

Ошибки компиляции: неверно -> сообщение -> исправление

Самая частая ошибка с out — забыть присвоить значение на одном из путей выхода:

static bool TryGet(string s, out int value)
{
    if (s.Length > 0) { value = s.Length; return true; }
    return false;                    // value не присвоено
}
error CS0177: The out parameter 'value' must be assigned to before control leaves the current method

Исправление: присвоить значение по умолчанию в начале метода (value = 0;) или в каждой ветке перед return. Остальные типовые ошибки — таблицей; тексты сообщений взяты из прогона на .NET SDK 10.0.401.

Что написано Ошибка компилятора Как исправить
int x; Inc(ref x); CS0165: Use of unassigned local variable ‘x’ для ref инициализировать до вызова или заменить на out
Inc(y); при Inc(ref int n) CS1620: Argument 1 must be passed with the ‘ref’ keyword дописать ref при вызове
чтение out-параметра до записи CS0269: Use of unassigned out parameter ‘n’ сначала записать, потом читать
void Show(in int n) { n = 10; } CS8331: Cannot assign to variable ‘n’ … because it is a readonly variable убрать запись или сменить модификатор на ref
async Task LoadAsync(out int n) CS1988: Async methods cannot have ref, in or out parameters вернуть Task<(bool, int)> или объект результата
лямбда внутри метода использует ref-параметр CS1628: Cannot use ref, out, or in parameter ‘n’ inside an anonymous method, lambda expression… скопировать значение в локальную переменную
перегрузки A(ref int) и A(out int) (так же для пар ref/in и in/out) CS0663: cannot define an overloaded method that differs only on parameter modifiers ‘out’ and ‘ref’ переименовать один из методов
P(params int[] a, string s) CS0231: A params parameter must be the last parameter in a parameter list перенести params в конец

Перегрузка «по значению» и «с ref» (A(int) и A(ref int)) при этом допустима — запрещено различать методы только модификаторами ref, out и in между собой.

out или кортеж

Для нового кода, где метод просто возвращает несколько значений, часто читаемее кортеж: (int q, int r) DivRem(int x, int y). Он работает и в async-методах, где out запрещен. out остается естественным выбором для паттерна Try (результат bool удобно ставить прямо в if) и там, где этого требует существующий API.

Выводы

  • out передает аргумент по ссылке и обязывает метод записать значение на каждом пути выхода; инициализировать переменную до вызова не нужно.
  • ref требует инициализированную переменную и дает методу право читать и менять ее; для ссылочных типов он нужен, только чтобы заменить сам объект.
  • in и ref readonly передают по ссылке только для чтения; выигрыш заметен на крупных readonly-структурах, а ref readonly строже проверяет, что передана переменная.
  • params — синтаксис для переменного числа аргументов, с C# 13 не только массивы; параметр должен быть последним.
  • Типовые ошибки ловит компилятор: CS0177, CS0165, CS1620, CS8331, CS1988.

Где применяется / связь с практикой

Освойте тему на практике

Try-методы, работа с числами и структурами, API без лишних копий — повседневная часть кода на C#: от разбора пользовательского ввода в консольной утилите до игровой логики в Unity и сервисов на ASP.NET Core. Разобраться с передачей параметров, типами-значениями и ссылочными типами последовательно, с практикой и ревью кода, можно на курсе «C#-разработчик. Базовый уровень». Чтобы посмотреть формат обучения, подойдут бесплатные открытые уроки Otus.

FAQ

Можно ли использовать out в свойствах и индексаторах?
Нет. Модификаторы ref и out применимы к параметрам методов, конструкторов, делегатов и локальных функций; у параметров индексатора они запрещены (CS0631), допустим только in. Свойство нельзя передать как ref- или out-аргумент (CS0206), его значение сначала копируют в локальную переменную.

Нужно ли писать out при вызове, если метод объявлен с out?
Да, для out и ref слово при вызове обязательно — это видимый сигнал, что переменная может измениться. Для in его можно опустить.

Что быстрее — out или возврат кортежа?
Для нескольких int или ссылок разница обычно несущественна; выбирать стоит по читаемости и ограничениям (async, лямбды). Если производительность критична — решает замер на своем коде.

OTUS Журнал