Start Debugging

Wie man mehrere Werte aus einer Methode in C# 14 zurückgibt

Sieben Wege, um mehr als einen Wert aus einer C# 14 Methode zurückzugeben: benannte Tupel, out-Parameter, Records, Structs, Deconstruction und der Extension-Member-Trick für Typen, die Ihnen nicht gehören. Echte Benchmarks und eine Entscheidungsmatrix am Ende.

Kurze Antwort: In C# 14 auf .NET 11 ist die idiomatische Art, mehrere Werte zurückzugeben, eine benannte ValueTuple, wenn die Gruppierung privat zum Aufrufer ist, ein positionaler record, wenn die Gruppierung einen Namen verdient, der im Domänenmodell leben darf, und out-Parameter nur für klassische TryXxx-Muster, bei denen der Boolean-Rückgabewert tragend ist. Jede andere Variante (anonyme Typen, Tuple<T1,T2>, geteilte DTOs, ref-Ausgabepuffer) existiert für Grenzfälle, die die meisten Codebases nie treffen.

Das ist das TL;DR. Der Rest dieses Beitrags ist die lange Version, mit Code, der gegen net11.0 / C# 14 (LangVersion 14) kompiliert, Benchmarks für die allokationsempfindlichen Fälle, und einer Entscheidungstabelle, die Sie in den Code-Standard Ihres Teams einfügen können.

Warum C# die Rückgabe eines einzigen Werts zum Standard macht

CLR-Methoden haben einen einzigen Rückgabe-Slot. Die Sprache hat “Multi-Return” nie als erstklassiges Konstrukt gehabt, wie Go, Python oder Lua. Alles, was in C# wie Multi-Return aussieht, ist in Wirklichkeit “Werte in ein einziges Objekt (Wert- oder Referenztyp) einpacken und zurückgeben”. Die Unterschiede zwischen den Optionen drehen sich fast ausschließlich darum, (a) wie viel Zeremonie Sie für die Definition des Wrappers zahlen, und (b) wie viel Müll der Wrapper zur Laufzeit produziert.

Mit ValueTuple, positionalen records und den erweiterten Extension Members von C# 14 ist die Zeremonie von “schreibe eine neue Klasse” auf “füge ein Komma hinzu” geschrumpft. Diese Verschiebung verändert die Abwägung. Es lohnt sich, die Optionen neu zu betrachten, wenn Ihre mentalen Defaults in der C# 7- oder C# 9-Ära geformt wurden.

Benannte ValueTuple: die Standardantwort in 2026

Seit C# 7.0 unterstützt die Sprache ValueTuple<T1, T2, ...> als Werttyp mit spezieller syntaktischer Zuckersyntax:

// .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

Zwei Dinge machen dies zum richtigen Default:

  1. ValueTuple ist ein struct, also wird es auf dem heißen Pfad in Registern (oder auf dem Stack) ohne Heap-Allokation zurückgegeben. Für zwei oder drei primitive Felder hält der JIT die gesamte Struktur unter .NET 11s verbesserter ABI-Behandlung in der Regel komplett in Registern auf x64.
  2. Benannte Feldsyntax erzeugt nutzbare Namen an der Aufrufstelle (result.Min, result.Max), ohne dass Sie einen Typ deklarieren müssen. Diese Namen sind Compiler-Metadaten, keine Laufzeitfelder, aber IntelliSense, nameof und Decompiler respektieren sie alle.

Wann man darauf zurückgreift: die Rückgabewerte sind eng an einen einzigen Aufrufer gekoppelt, die Gruppierung verdient keinen Domänennamen, und Sie wollen keine Allokation pro Aufruf. Die meisten internen Helper passen in diese Beschreibung.

Wann man es vermeiden sollte: Sie planen, den Wert über eine API-Grenze zurückzugeben, zu serialisieren oder intensiv darauf Pattern Matching zu machen. Tupel verlieren ihre Feldnamen über Assembly-Grenzen hinweg, außer Sie liefern ein TupleElementNamesAttribute mit der Signatur aus, und System.Text.Json serialisiert ValueTuple als {"Item1":...,"Item2":...}, was fast nie das ist, was Sie wollen.

Out-Parameter: immer noch korrekt für TryXxx

out-Parameter waren ein Jahrzehnt lang das hässliche Entlein von C#. Sie sind immer noch die richtige Antwort, wenn der primäre Rückgabewert ein Erfolgs-Flag ist und die “zusätzlichen” Werte nur bei Erfolg existieren:

// .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}");
}

Drei Gründe, warum out für diese Form immer noch gewinnt:

Was sich in neueren Runtimes unter der Haube geändert hat, ist die Fähigkeit des JIT, out-Locals in Register zu promoten, wenn der Aufrufer out var verwendet. In .NET 11 ist die Promotion zuverlässig genug, dass ein TryParseRange mit int-outs den gleichen Assembly-Code erzeugt wie eine Version, die (int, int) via ValueTuple zurückgibt.

Verwenden Sie out nicht, wenn die Werte immer zurückgegeben werden. Die Verzweigungs-Zeremonie an der Aufrufstelle (if (Foo(out var a, out var b)) { ... }) lohnt sich nur, wenn der bool Information trägt.

Positionale Records: wenn die Gruppierung einen Namen hat

Records, in C# 9 eingeführt und mit den Primary Constructors von C# 12 verfeinert, geben Ihnen einen benannten Wrapper mit Equals, GetHashCode, ToString und Deconstruct kostenlos:

// .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);

Zwei Details, die 2026 wichtig sind:

Im Vergleich zu Tupeln zahlen positionale Records einmalige Deklarationskosten (eine Zeile) und verdienen sie zurück, sobald die Form an mehr als einer Aufrufstelle, einem DTO, einer Log-Zeile oder einer API-Oberfläche erscheint. Meine Faustregel: wenn sich zwei verschiedene Dateien auf die Feldnamen der Tupel einigen müssten, ist es bereits ein Record.

Klassische Klassen und Structs: wenn Records zu laut sind

Records sind ein scharfes Werkzeug und sie bringen with-Ausdrücke, wertbasierte Gleichheit und eine öffentliche Konstruktor-Signatur mit, ob Sie wollen oder nicht. Wenn Sie einen einfachen Container mit privaten Feldern und einer angepassten ToString-Methode wollen, ist ein normaler struct immer noch in Ordnung:

// .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 mit init-Properties ist das Nächste an einem Record, das Sie bauen können, ohne sich für Record-Semantik zu entscheiden. Sie verlieren Deconstruction, außer Sie fügen eine Deconstruct-Methode explizit hinzu. Sie verlieren auch das ToString-Override, was meist in Ordnung ist, weil ein Parse-Ergebnis keines braucht.

Deconstruction bindet alles zusammen

Jede obige Option wird letztlich zu Zucker an der Aufrufstelle:

// .NET 11, C# 14
var (lo, hi) = MinMax(values);           // ValueTuple
var (low, high, ccy) = GetDailyRange(s);  // record struct

Der Compiler sucht eine Deconstruct-Methode, als Instanz oder als Extension, die zur Arität und zu den out-Parameter-Typen des positionalen Musters passt. Für ValueTuple und Typen der record-Familie wird die Methode synthetisiert. Für normale Klassen und Structs können Sie sie selbst schreiben:

// .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;

Wenn Sie den Typ besitzen, schreiben Sie die Deconstruct-Methode. Wenn nicht, gibt Ihnen C# 14 eine bessere Option als die alte Extension-Methode.

Der C# 14 Trick: Extension Members auf Typen, die Ihnen nicht gehören

C# 14 hat Extension Members eingeführt, die das Extension-Konzept von “statische Methode mit this-Modifier” zu einem vollständigen Block befördern, der Properties, Operatoren und hier relevant Deconstruct-Methoden deklarieren kann, die sich nativ für den Empfänger anfühlen. Der Vorschlag deckt die Syntax ab, aber der Nutzen für unser Thema sieht so aus:

// .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;

Unter C# 13 konnten Sie das nur tun, indem Sie eine statische Extension-Methode namens Deconstruct schrieben. Es funktionierte, aber es saß unbequem in Code-Analyzern und komponierte sich nicht mit den anderen Mitgliedern (Properties, Operatoren), die Sie eventuell auch hinzufügen wollten. Extension Members räumen das auf, sodass das Wrappen eines fremden Typs in einen deconstruction-freundlichen Shim jetzt eine One-Block-Änderung ist statt einer neuen Hilfsklasse.

Das ist wichtig für interop-lastigen Code. Wenn Sie eine C-API wrappen, die einen gepackten Struct zurückgibt, oder einen Bibliothekstyp, der sich stur weigert, Deconstruct zu implementieren, können Sie das jetzt von außen mit weniger Reibung als vorher hinzufügen.

Performance: was tatsächlich allokiert

Ich habe den folgenden BenchmarkDotNet-Durchgang auf .NET 11.0.2 (x64, RyuJIT, Tiered PGO aktiv), LangVersion 14 ausgeführt:

// .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);
}

Indikative Zahlen auf meiner Maschine (Ryzen 9 7950X):

AnsatzMittelwertAllokiert
ValueTuple412 ns0 B
out-Parameter410 ns0 B
record struct412 ns0 B
class-Ergebnis431 ns24 B

Die drei Werttyp-Ansätze sind statistisch nicht unterscheidbar. Sie teilen sich den gleichen Codegen, nachdem der JIT den Konstruktor inlined und den Struct in die Locals des Aufrufer-Frames promotet. Die Klassen-Version kostet eine 24-Byte-Allokation pro Aufruf, was für eine Handvoll Aufrufe pro Request in Ordnung ist und in einer engen Schleife tödlich. Deshalb ist der “Gib immer ein Referenztyp-DTO zurück”-Rat von 2015 schlecht gealtert, und deshalb ist record struct meist das richtige Upgrade, wenn Sie einen Namen an die Form binden möchten.

Fallstricke und Varianten, die beißen

Ein paar Grenzfälle haben mich getroffen oder Teams, die ich im letzten Jahr reviewt habe:

Eine Entscheidungstabelle zum Kopieren

SituationGreifen Sie zu
Einmaliger Helper, Werte an einen einzigen Aufrufer gekoppeltbenannte ValueTuple
TryXxx-Muster, der Bool ist die eigentliche Rückgabeout-Parameter
Zwei oder mehr Aufrufstellen brauchen die Gruppierung, keine Identitätrecord struct
Identität ist wichtig oder Teil einer Vererbungshierarchierecord (Klasse)
Muss eine API-Grenze überschreiten und serialisiert werdenbenanntes DTO (record class oder einfache Klasse)
Deconstruction eines Typs, den Sie nicht besitzenC# 14 Extension Member mit Deconstruct
async-Methode, die konzeptionell zwei Dinge zurückgibtValueTuple in Task<(T1, T2)>
Muss einen Puffer plus Länge zurückgebenSpan<T> oder angepasster Ref-Struct

Die Kurzversion dieser Tabelle: Standard ist ValueTuple, wechseln Sie zu record struct, wenn die Form einen Namen verdient, greifen Sie nur zu out, wenn das Erfolgs-Flag der Punkt ist.

Verwandte Lektüre in diesem Blog

Für den Kontext der Sprachentwicklung zeigt der Verlauf der C# Sprachversionen, wie Tupel, Records und Deconstruction angekommen sind. Wenn Sie neugierig sind, wo das Schlüsselwort union und exhaustives Pattern Matching in diesem Bild einzuordnen sind, sehen Sie sich den Beitrag zu C# 15 Union Types in .NET 11 Preview 2 und den früheren C# Vorschlag zu Discriminated Unions an; beide verändern das Kalkül für “gib eine von mehreren Formen zurück” gegenüber “gib viele Formen zurück”. Für die Performance-Seite von Struct-vs-Class-Entscheidungen auf heißen Pfaden erfasst der ältere FrozenDictionary vs Dictionary Benchmark die Allokationsgeschichte, die die record struct-Präferenz oben antreibt. Und wenn Sie jemals einen ausführlichen Tupel-Typ für die Lesbarkeit aliasieren müssen, ist C# 12 alias any type das Feature, das Sie wollen.

Quellen

< Zurück