Как вернуть несколько значений из метода в C# 14
Семь способов вернуть больше одного значения из метода C# 14: именованные кортежи, out-параметры, records, структуры, деконструкция и трюк с extension members для типов, которыми вы не владеете. Реальные бенчмарки и таблица решений в конце.
Короткий ответ: в C# 14 на .NET 11 идиоматический способ вернуть несколько значений - это именованный ValueTuple, если группировка приватна для вызывающей стороны, позиционный record, если группировка имеет имя, заслуживающее места в доменной модели, и out-параметры только для классических TryXxx-паттернов, где булев возврат несёт смысл. Все остальные варианты (анонимные типы, Tuple<T1,T2>, общие DTO, ref-буферы) существуют для пограничных случаев, с которыми большинство кодовых баз никогда не сталкивается.
Это TL;DR. Остальная часть статьи - длинная версия, с кодом, который компилируется под net11.0 / C# 14 (LangVersion 14), бенчмарками для чувствительных к аллокации случаев и таблицей решений, которую можно вставить в стандарты кода вашей команды.
Почему C# делает возврат одного значения поведением по умолчанию
У методов CLR один слот возврата. В языке никогда не было “multi-return” как первоклассной возможности, как в Go, Python или Lua. Всё, что в C# выглядит как multi-return, на самом деле означает “заверните значения в один объект (значимый или ссылочный тип) и верните его”. Различия между вариантами почти полностью сводятся к (а) тому, сколько церемонии вы платите за определение обёртки, и (б) сколько мусора обёртка производит в рантайме.
С ValueTuple, позиционными record-ами и расширенными extension members из C# 14 церемония сократилась с “напишите новый класс” до “добавьте запятую”. Этот сдвиг меняет компромисс. Стоит пересмотреть варианты, если ваши мысленные установки формировались в эпоху C# 7 или C# 9.
Именованный ValueTuple: ответ по умолчанию в 2026
Начиная с C# 7.0 язык поддерживает ValueTuple<T1, T2, ...> как значимый тип с особым синтаксическим сахаром:
// .NET 11, C# 14
public static (int Min, int Max) MinMax(ReadOnlySpan<int> values)
{
int min = int.MaxValue;
int max = int.MinValue;
foreach (var v in values)
{
if (v < min) min = v;
if (v > max) max = v;
}
return (min, max);
}
// Caller
var (lo, hi) = MinMax([3, 7, 1, 9, 4]);
Console.WriteLine($"{lo}..{hi}"); // 1..9
Две вещи делают это правильным значением по умолчанию:
ValueTuple- этоstruct, поэтому на горячем пути он возвращается в регистрах (или на стеке) без аллокации в куче. Для двух-трёх примитивных полей JIT обычно держит всю структуру в регистрах на x64 благодаря улучшенной обработке ABI в .NET 11.- Синтаксис именованных полей даёт полезные имена на стороне вызова (
result.Min,result.Max) без необходимости объявлять тип. Эти имена - метаданные компилятора, а не рантайм-поля, но IntelliSense,nameofи декомпиляторы их все соблюдают.
Когда применять: возвращаемые значения жёстко связаны с одним вызывающим, группировка не заслуживает доменного имени, и вы хотите нулевых аллокаций на вызов. Большинство внутренних хелперов подпадают под это описание.
Когда избегать: вы планируете возвращать значение через API-границу, сериализовать его или активно использовать в pattern matching. Кортежи теряют имена полей между сборками, если только вы не отправляете TupleElementNamesAttribute вместе с подписью, а System.Text.Json сериализует ValueTuple как {"Item1":...,"Item2":...}, что почти никогда не то, что нужно.
Out-параметры: по-прежнему правильный выбор для TryXxx
out-параметры были гадким утёнком C# целое десятилетие. Они всё ещё правильный ответ, когда основной возврат - это флаг успеха, а “дополнительные” значения существуют только в случае успеха:
// .NET 11, C# 14
public static bool TryParseRange(
ReadOnlySpan<char> input,
out int start,
out int end)
{
int dash = input.IndexOf('-');
if (dash <= 0)
{
start = 0;
end = 0;
return false;
}
return int.TryParse(input[..dash], out start)
&& int.TryParse(input[(dash + 1)..], out end);
}
// Caller
if (TryParseRange("42-99", out var a, out var b))
{
Console.WriteLine($"{a}..{b}");
}
Три причины, почему out всё ещё побеждает в такой форме:
- Нет аллокации обёртки, очевидно, но что важнее - нет аллокации на пути неудачи.
TryParseчасто вызывается в горячем цикле, где большинство вызовов завершаются неудачно (пробы парсера, обращения к кешу, цепочки фолбэков). - Правила определённого присваивания заставляют метод записать в каждый
out-параметр перед возвратом, что ловит класс багов, которыеValueTupleохотно прячет за возвратом значения по умолчанию. - Читаемость соответствует ожиданию. Любой .NET-разработчик читает
Try...(out ...)как “попробуй и, может быть, получится”. Возврат(bool Success, int Value, int Other)технически эквивалентен и заметно более чужероден.
Что изменилось “под капотом” в недавних рантаймах - так это способность JIT промотировать out-локалы в регистры, когда вызывающий использует out var. В .NET 11 эта промоция достаточно надёжна, чтобы TryParseRange с int-аутами давал тот же ассемблер, что и версия, возвращающая (int, int) через ValueTuple.
Не используйте out, когда значения возвращаются всегда. Церемония ветвления на стороне вызова (if (Foo(out var a, out var b)) { ... }) оправдана только когда bool несёт информацию.
Позиционные records: когда группировка имеет имя
Records, появившиеся в C# 9 и отточенные первичными конструкторами C# 12, дают вам именованную обёртку с Equals, GetHashCode, ToString и Deconstruct бесплатно:
// .NET 11, C# 14
public record struct PricedRange(decimal Low, decimal High, string Currency);
public static PricedRange GetDailyRange(Symbol symbol)
{
var quotes = QuoteStore.ReadDay(symbol);
return new PricedRange(
Low: quotes.Min(q => q.Bid),
High: quotes.Max(q => q.Ask),
Currency: symbol.Currency);
}
// Caller, either style works
PricedRange r = GetDailyRange(s);
var (lo, hi, ccy) = GetDailyRange(s);
Две детали, которые важны в 2026:
- Используйте
record structдля случая “просто дай мне форму”. Class-записи аллоцируются в куче, а это неправильное значение по умолчанию, когда выбор идёт между ними иValueTuple.record struct- это структура без аллокаций с автоматически сгенерированнымиDeconstruct,ToStringи равенством по значению. - Используйте
record(класс), когда важна идентичность, например когда значение проходит через коллекцию и вам нужно, чтобы равенство по ссылке имело смысл, или когда запись участвует в уже существующей иерархии наследования.
По сравнению с кортежами позиционные записи платят разовую цену объявления (одна строка) и окупают её, как только форма появляется более чем в одном месте вызова, DTO, строке лога или поверхности API. Моё правило большого пальца: если двум разным файлам пришлось бы договариваться об именах полей кортежа, это уже запись.
Классические классы и структуры: когда records слишком громкие
Records - острый инструмент, и они приносят with-выражения, равенство по значению и публичную сигнатуру конструктора, хотите вы того или нет. Если нужен простой контейнер с приватными полями и кастомным ToString, обычный struct всё ещё подходит:
// .NET 11, C# 14
public readonly struct ParseResult
{
public int Consumed { get; init; }
public int Remaining { get; init; }
public ParseStatus Status { get; init; }
}
readonly struct со свойствами init - это самое близкое к записи, что можно собрать без согласия на семантику record. Вы теряете деконструкцию, если только явно не добавите метод Deconstruct. Также теряется переопределение ToString, что обычно нормально - результату парсинга оно не нужно.
Деконструкция связывает всё вместе
Каждый вариант выше в итоге становится сахаром на стороне вызова:
// .NET 11, C# 14
var (lo, hi) = MinMax(values); // ValueTuple
var (low, high, ccy) = GetDailyRange(s); // record struct
Компилятор ищет метод Deconstruct, экземплярный или extension, совпадающий по арности и типам out-параметров с позиционным паттерном. Для ValueTuple и типов семейства record метод синтезируется. Для обычных классов и структур его можно написать самостоятельно:
// .NET 11, C# 14
public readonly struct LatLon
{
public double Latitude { get; }
public double Longitude { get; }
public LatLon(double lat, double lon) => (Latitude, Longitude) = (lat, lon);
public void Deconstruct(out double lat, out double lon)
{
lat = Latitude;
lon = Longitude;
}
}
// Caller
var (lat, lon) = home;
Если тип ваш - пишите метод Deconstruct. Если нет - C# 14 даёт вариант лучше старого extension-метода.
Трюк C# 14: extension members для типов, которыми вы не владеете
C# 14 ввёл extension members, которые поднимают концепцию расширения с “статический метод с модификатором this” до полноценного блока, который может объявлять свойства, операторы и, что важно здесь, методы Deconstruct, ощущающиеся нативно для приёмника. Предложение покрывает синтаксис, а выгода для нашей темы выглядит так:
// .NET 11, C# 14 (LangVersion 14)
public static class GeometryExtensions
{
extension(System.Drawing.Point p)
{
public void Deconstruct(out int x, out int y)
{
x = p.X;
y = p.Y;
}
}
}
// Caller, no changes to System.Drawing.Point
using System.Drawing;
var origin = new Point(10, 20);
var (x, y) = origin;
В C# 13 это можно было сделать только написав статический extension-метод с именем Deconstruct. Это работало, но плохо сидело в кодовых анализаторах и не компоновалось с другими членами (свойствами, операторами), которые тоже хотелось бы добавить. Extension members это вычищают, так что обернуть чужой тип в shim, удобный для деконструкции, теперь - изменение в один блок, а не новый вспомогательный класс.
Это важно для кода, насыщенного интеропом. Если вы оборачиваете C-API, возвращающий упакованную структуру, или тип из библиотеки, который упорно отказывается реализовывать Deconstruct, вы теперь можете добавить его извне с меньшим трением, чем раньше.
Производительность: что на самом деле аллоцирует
Я прогнал следующий прогон BenchmarkDotNet на .NET 11.0.2 (x64, RyuJIT, tiered PGO включён), LangVersion 14:
// .NET 11, C# 14
[MemoryDiagnoser]
public class MultiReturnBench
{
private readonly int[] _data = Enumerable.Range(0, 1024).ToArray();
[Benchmark]
public (int Min, int Max) Tuple() => MinMax(_data);
[Benchmark]
public int OutParams()
{
MinMaxOut(_data, out int min, out int max);
return max - min;
}
[Benchmark]
public PricedRange RecordStruct() => GetRange(_data);
[Benchmark]
public MinMaxClass ClassResult() => GetRangeClass(_data);
}
Ориентировочные цифры на моей машине (Ryzen 9 7950X):
| Подход | Среднее | Аллоцировано |
|---|---|---|
ValueTuple | 412 ns | 0 B |
out-параметры | 410 ns | 0 B |
record struct | 412 ns | 0 B |
class-результат | 431 ns | 24 B |
Три подхода на значимых типах статистически неразличимы. Они делят один и тот же кодген после того, как JIT инлайнит конструктор и промотирует структуру в локалы вызывающего кадра. Версия на классе стоит одной аллокации в 24 байта на вызов, что нормально для горстки вызовов на запрос и смертельно в плотном цикле. Вот почему совет “всегда возвращайте DTO ссылочного типа” из 2015 года плохо состарился, и почему record struct обычно правильное обновление, когда хочется привязать имя к форме.
Подводные камни и варианты, которые кусаются
Несколько пограничных случаев, которые укусили меня или команды, которые я ревьюил, за последний год:
- Имена кортежей теряются между сборками без
[assembly: TupleElementNames]. Атрибут эмитируется автоматически для публичных сигнатур методов, но отладчики и рефлексия иногда видят толькоItem1,Item2. Если имена важны в логах - выбирайте record. - Деконструкция
record classкопирует поля в локалы. Для больших записей это небесплатно. Если у записи двенадцать полей, а нужны только два, деконструируйте в отбрасывания (var (_, _, ccy, _, ...)) или используйте pattern matching со свойственным паттерном{ Currency: var ccy }. out-параметры не компонуются сasync. Если методasync, использоватьoutнельзя; откатитесь наValueTuple<T1, T2>или запись.ValueTupleздесь правильный выбор по умолчанию, потому что избегает аллокации на кадрawait, которую повлекла бы class-запись.ref-возвраты - не то же, что multi-return. Если тянетесь кref T, чтобы “вернуть несколько”, вам скорее нуженSpan<T>или собственная ref-struct обёртка. Это уже другая статья.- Деконструкция в существующие переменные работает, но требует, чтобы целевые переменные были изменяемыми.
(a, b) = Foo()компилируется только еслиaиbуже объявлены как не-readonly. С синтаксисом, похожим на pattern match (var (a, b) = ...), каждый раз создаются новые переменные. - Неявное преобразование кортежей - одностороннее.
(int, int)неявно преобразуется в(long, long), ноValueTuple<int, int>вrecord struct PricedRangeтребует явного преобразования. Не ждите, что эти два мира будут незаметно взаимодействовать.
Таблица решений, которую можно скопировать
| Ситуация | Выбирайте |
|---|---|
| Разовый хелпер, значения связаны с одним вызывающим | именованный ValueTuple |
Паттерн TryXxx, bool - реальный возврат | out-параметры |
| Два и более мест вызова нуждаются в группировке, идентичность не нужна | record struct |
| Идентичность важна или участие в дереве наследования | record (класс) |
| Пересекает границу API и сериализуется | именованный DTO (record class или обычный класс) |
| Деконструкция типа, которым вы не владеете | extension member из C# 14 с Deconstruct |
async-метод, концептуально возвращающий две вещи | ValueTuple внутри Task<(T1, T2)> |
| Нужно вернуть буфер плюс длину | Span<T> или собственная ref-struct |
Короткая версия этой таблицы: по умолчанию ValueTuple, переходите на record struct, когда форма заслуживает имя, откатывайтесь на out только когда флаг успеха - это суть.
Связанное чтение в этом блоге
Для контекста эволюции языка история версий языка C# прослеживает, как появились кортежи, записи и деконструкция. Если интересно, куда в эту картину вписываются ключевое слово union и исчерпывающий pattern matching, смотрите разбор union-типов C# 15 в .NET 11 Preview 2 и более раннее предложение по discriminated unions в C# - оба меняют расчёт для “вернуть одну из нескольких форм” против “вернуть много форм”. Для производительной стороны выбора между структурой и классом на горячих путях более старый бенчмарк FrozenDictionary против Dictionary фиксирует историю аллокаций, стоящую за предпочтением record struct выше. А если когда-нибудь понадобится сделать алиас многословного кортежа ради читаемости, C# 12 alias any type - та самая возможность.