Start Debugging

Как вернуть несколько значений из метода в 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

Две вещи делают это правильным значением по умолчанию:

  1. ValueTuple - это struct, поэтому на горячем пути он возвращается в регистрах (или на стеке) без аллокации в куче. Для двух-трёх примитивных полей JIT обычно держит всю структуру в регистрах на x64 благодаря улучшенной обработке ABI в .NET 11.
  2. Синтаксис именованных полей даёт полезные имена на стороне вызова (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 всё ещё побеждает в такой форме:

Что изменилось “под капотом” в недавних рантаймах - так это способность 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:

По сравнению с кортежами позиционные записи платят разовую цену объявления (одна строка) и окупают её, как только форма появляется более чем в одном месте вызова, 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):

ПодходСреднееАллоцировано
ValueTuple412 ns0 B
out-параметры410 ns0 B
record struct412 ns0 B
class-результат431 ns24 B

Три подхода на значимых типах статистически неразличимы. Они делят один и тот же кодген после того, как JIT инлайнит конструктор и промотирует структуру в локалы вызывающего кадра. Версия на классе стоит одной аллокации в 24 байта на вызов, что нормально для горстки вызовов на запрос и смертельно в плотном цикле. Вот почему совет “всегда возвращайте DTO ссылочного типа” из 2015 года плохо состарился, и почему record struct обычно правильное обновление, когда хочется привязать имя к форме.

Подводные камни и варианты, которые кусаются

Несколько пограничных случаев, которые укусили меня или команды, которые я ревьюил, за последний год:

Таблица решений, которую можно скопировать

СитуацияВыбирайте
Разовый хелпер, значения связаны с одним вызывающимименованный 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 - та самая возможность.

Источники

< Назад