Neues in C# 8.0

C# 8.0 fügt der Sprache C# die folgenden Features und Verbesserungen hinzu:

C# 8.0 wird unter .NET Core 3.x und .NET Standard 2.1 unterstützt. Weitere Informationen finden Sie unter C#-Sprachversionsverwaltung.

Der Rest dieses Artikels beschreibt diese Funktionen kurz. Wenn ausführliche Artikel verfügbar sind, werden Links zu diesen Tutorials und Übersichten bereitgestellt. Sie können sich diese Funktionen in unserer Umgebung mit dem globalen dotnet try-Tool näher ansehen:

  1. Installieren Sie das globale dotnet-try-Tool.
  2. Klonen Sie das dotnet/try-samples-Repository.
  3. Legen Sie das aktuelle Verzeichnis auf das Unterverzeichnis csharp8 für das try-samples-Repository fest.
  4. Führen Sie aus dotnet try.

Readonly-Member

Sie können den readonly-Modifizierer auf jeden Member einer Struktur anwenden. Damit wird angezeigt, dass der Member den Zustand nicht ändert. Dies ist granularer als das Anwenden des readonly-Modifikators auf eine struct-Deklaration. Betrachten Sie folgende veränderliche Struktur:

public struct Point
{
    public double X { get; set; }
    public double Y { get; set; }
    public double Distance => Math.Sqrt(X * X + Y * Y);

    public override string ToString() =>
        $"({X}, {Y}) is {Distance} from the origin";
}

Wie bei den meisten Strukturen verändert die ToString()-Methode den Zustand nicht. Sie könnten dies durch Hinzufügen des readonly-Modifikators zur Deklaration von ToString() angeben:

public readonly override string ToString() =>
    $"({X}, {Y}) is {Distance} from the origin";

Die vorhergehende Änderung generiert eine Compilerwarnung, weil ToString auf die Distance-Eigenschaft zugreift, die nicht als readonly markiert ist:

warning CS8656: Call to non-readonly member 'Point.Distance.get' from a 'readonly' member results in an implicit copy of 'this'

Der Compiler warnt Sie, wenn er eine Defensivkopie erstellen muss. Die Distance-Eigenschaft verändert nicht den Zustand, sodass Sie diese Warnung aufheben können, indem Sie der Deklaration den readonly-Modifizierer hinzufügen:

public readonly double Distance => Math.Sqrt(X * X + Y * Y);

Beachten Sie, dass der readonly-Modifizierer bei einer schreibgeschützten Eigenschaft erforderlich ist. Der Compiler geht nicht davon aus, dass get-Zugriffsmethoden den Zustand nicht ändern. Sie müssen readonly explizit deklarieren. Automatisch implementierte Eigenschaften sind eine Ausnahme; der Compiler behandelt alle automatisch implementierten Getter als readonly, sodass es hier nicht notwendig ist, den readonly-Modifizierer zu den X- und Y-Eigenschaften hinzuzufügen.

Der Compiler erzwingt die Regel, dass readonly-Member den Status nicht ändern. Die folgende Methode wird nicht kompiliert, es sei denn, Sie entfernen den readonly-Modifizierer:

public readonly void Translate(int xOffset, int yOffset)
{
    X += xOffset;
    Y += yOffset;
}

Mit diesem Feature können Sie Ihre Designabsicht angeben, damit der Compiler sie erzwingen und Optimierungen basierend auf dieser Absicht vornehmen kann.

Weitere Informationen finden Sie im Abschnitt readonly-Instanzmember des Artikels Strukturtypen.

Standardschnittstellenmethoden

Sie können nun Member zu Schnittstellen hinzufügen und eine Implementierung für diese Member bereitstellen. Dieses Sprachfeature ermöglicht es API-Autoren, in späteren Versionen Methoden zu einer Schnittstelle hinzuzufügen, ohne die Quell- oder Binärkompatibilität mit bestehenden Implementierungen dieser Schnittstelle zu beeinträchtigen. Bestehende Implementierungen erben die Standardimplementierung. Dieses Feature ermöglicht zudem die Interaktion zwischen C# und APIs, die auf Android oder Swift abzielen und ähnliche Funktionen unterstützen. Standardschnittstellenmethoden ermöglichen auch Szenarien, die einem „Traits“-Sprachfeature ähneln.

Standardschnittstellenmethoden wirken sich auf viele Szenarien und Sprachelemente aus. Unser erstes Tutorial behandelt die Aktualisierung einer Schnittstelle mit Standardimplementierungen.

Weitere Muster an mehr Orten

Musterabgleich bietet Tools zur Bereitstellung formabhängiger Funktionalität für verwandte, aber unterschiedliche Datentypen. C# 7.0 führte eine Syntax für Typmuster und konstante Muster ein, indem der Ausdruck is und die Anweisung switch verwendet wurden. Diese Funktionen stellten die ersten vorläufigen Schritte zur Unterstützung von Programmierparadigmen dar, bei denen Daten und Funktionalität getrennt voneinander sind. Da sich die Branche immer mehr auf Mikroservices und andere Cloud-basierte Architekturen konzentriert, werden andere Sprachtools benötigt.

C# 8.0 erweitert dieses Vokabular, sodass Sie mehr Musterausdrücke an mehreren Stellen in Ihrem Code verwenden können. Berücksichtigen Sie diese Funktionen, wenn Ihre Daten und Funktionen getrennt sind. Berücksichtigen Sie den Musterabgleich, wenn Ihre Algorithmen von einer anderen Tatsache als dem Runtimetyp eines Objekts abhängen. Diese Verfahren bieten eine weitere Möglichkeit, Entwürfe auszudrücken.

Zusätzlich zu neuen Mustern an neuen Orten fügt C# 8.0 rekursive Muster hinzu. Rekursive Muster sind Muster, die andere Muster enthalten können.

Switch-Ausdrücke

Häufig liefert eine switch-Anweisung in jedem ihrer case-Blöcke einen Wert. Mit switch-Ausdrücken können Sie eine präzisere Ausdruckssyntax verwenden. Es gibt weniger repetitive case- und break-Schlüsselwörter und weniger geschweifte Klammern. Betrachten Sie als Beispiel die folgende Enumeration, die die Farben des Regenbogens enumeriert:

public enum Rainbow
{
    Red,
    Orange,
    Yellow,
    Green,
    Blue,
    Indigo,
    Violet
}

Wenn Ihre Anwendung einen RGBColor-Typ definiert hat, der aus den Komponenten R, G und B aufgebaut ist, können Sie einen Rainbow-Wert in seine RGB-Werte konvertieren, indem Sie die folgende Methode verwenden, die einen Switch-Ausdruck enthält:

public static RGBColor FromRainbow(Rainbow colorBand) =>
    colorBand switch
    {
        Rainbow.Red    => new RGBColor(0xFF, 0x00, 0x00),
        Rainbow.Orange => new RGBColor(0xFF, 0x7F, 0x00),
        Rainbow.Yellow => new RGBColor(0xFF, 0xFF, 0x00),
        Rainbow.Green  => new RGBColor(0x00, 0xFF, 0x00),
        Rainbow.Blue   => new RGBColor(0x00, 0x00, 0xFF),
        Rainbow.Indigo => new RGBColor(0x4B, 0x00, 0x82),
        Rainbow.Violet => new RGBColor(0x94, 0x00, 0xD3),
        _              => throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)),
    };

Es gibt hier verschiedene Syntaxverbesserungen:

  • Die Variable steht vor dem switch-Schlüsselwort. Die unterschiedliche Reihenfolge macht es optisch einfach, den switch-Ausdruck von der switch-Anweisung zu unterscheiden.
  • Die case- und :-Elemente werden durch => ersetzt. Es ist präziser und intuitiver.
  • Der default-Fall wird durch eine _-Ausschlussvariable ersetzt.
  • Die Textkörper sind Ausdrücke, keine Anweisungen.

Stellen Sie dies mit dem entsprechenden Code unter Verwendung der klassischen switch-Anweisung gegenüber:

public static RGBColor FromRainbowClassic(Rainbow colorBand)
{
    switch (colorBand)
    {
        case Rainbow.Red:
            return new RGBColor(0xFF, 0x00, 0x00);
        case Rainbow.Orange:
            return new RGBColor(0xFF, 0x7F, 0x00);
        case Rainbow.Yellow:
            return new RGBColor(0xFF, 0xFF, 0x00);
        case Rainbow.Green:
            return new RGBColor(0x00, 0xFF, 0x00);
        case Rainbow.Blue:
            return new RGBColor(0x00, 0x00, 0xFF);
        case Rainbow.Indigo:
            return new RGBColor(0x4B, 0x00, 0x82);
        case Rainbow.Violet:
            return new RGBColor(0x94, 0x00, 0xD3);
        default:
            throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand));
    };
}

Weitere Informationen finden Sie unter switch-Ausdruck.

Eigenschaftsmuster

Mit dem Eigenschaftsmuster können Sie die Eigenschaften des untersuchten Objekts anpassen. Denken Sie an eine eCommerce-Website, die die Umsatzsteuer auf der Grundlage der Adresse des Käufers berechnen muss. Diese Berechnung ist keine Kernaufgabe einer Address-Klasse. Sie wird sich im Laufe der Zeit ändern, wahrscheinlich häufiger als Adressformatänderungen. Die Höhe der Umsatzsteuer hängt von der State-Eigenschaft der Adresse ab. Die folgende Methode verwendet das Eigenschaftsmuster, um die Umsatzsteuer aus der Adresse und dem Preis zu berechnen:

public static decimal ComputeSalesTax(Address location, decimal salePrice) =>
    location switch
    {
        { State: "WA" } => salePrice * 0.06M,
        { State: "MN" } => salePrice * 0.075M,
        { State: "MI" } => salePrice * 0.05M,
        // other cases removed for brevity...
        _ => 0M
    };

Ein Musterabgleich erstellt eine präzise Syntax,. um diesen Algorithmus auszudrücken.

Weitere Informationen finden Sie im Abschnitt Eigenschaftsmuster des Artikels Muster.

Tupelmuster

Einige Algorithmen sind von mehreren Eingaben abhängig. Tupelmuster erlauben Ihnen, auf Grundlage mehrerer Werte, ausgedrückt als ein Tupel, zu wechseln („switch“). Der folgende Code zeigt einen switch-Ausdruck für das Spiel Stein, Schere, Papier (Schnick, Schnack, Schnuck):

public static string RockPaperScissors(string first, string second)
    => (first, second) switch
    {
        ("rock", "paper") => "rock is covered by paper. Paper wins.",
        ("rock", "scissors") => "rock breaks scissors. Rock wins.",
        ("paper", "rock") => "paper covers rock. Paper wins.",
        ("paper", "scissors") => "paper is cut by scissors. Scissors wins.",
        ("scissors", "rock") => "scissors is broken by rock. Rock wins.",
        ("scissors", "paper") => "scissors cuts paper. Scissors wins.",
        (_, _) => "tie"
    };

Die Meldungen zeigen den Gewinner an. Der Fall „Verwerfen“ („case discard“) stellt die drei Kombinationen für Unentschieden oder andere Texteingaben dar.

Positionsmuster

Einige Typen umfassen eine Deconstruct-Methode, die ihre Eigenschaften in diskrete Variablen dekonstruiert. Wenn auf eine Deconstruct-Methode zugegriffen werden kann, können Sie Positionsmuster verwenden, um Eigenschaften des Objekts zu untersuchen, und diese Eigenschaften für ein Muster verwenden. Beachten Sie die folgende Point-Klasse, die eine Deconstruct-Methode umfasst, um diskrete Variablen für X und Y zu erstellen:

public class Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y) => (X, Y) = (x, y);

    public void Deconstruct(out int x, out int y) =>
        (x, y) = (X, Y);
}

Beachten Sie zudem die folgende Enumeration, die verschiedene Positionen eines Quadranten darstellt:

public enum Quadrant
{
    Unknown,
    Origin,
    One,
    Two,
    Three,
    Four,
    OnBorder
}

Die folgende Methode verwendet das Positionsmuster, um die Werte von x und y zu extrahieren. Dann wird mit einer when-Klausel der Quadrant des Punktes bestimmt:

static Quadrant GetQuadrant(Point point) => point switch
{
    (0, 0) => Quadrant.Origin,
    var (x, y) when x > 0 && y > 0 => Quadrant.One,
    var (x, y) when x < 0 && y > 0 => Quadrant.Two,
    var (x, y) when x < 0 && y < 0 => Quadrant.Three,
    var (x, y) when x > 0 && y < 0 => Quadrant.Four,
    var (_, _) => Quadrant.OnBorder,
    _ => Quadrant.Unknown
};

Das Ausschussmuster im vorherigen Switch stimmt überein, wenn entweder x oder y 0 ist, jedoch nicht beide. Ein switch-Ausdruck muss entweder einen Wert erzeugen oder eine Ausnahme auslösen. Wenn keiner der Fälle übereinstimmt, löst der switch-Ausdruck eine Ausnahme aus. Der Compiler erzeugt für Sie eine Warnung, wenn Sie nicht alle möglichen Fälle in Ihrem Switch-Ausdruck abdecken.

In diesem erweiterten Tutorial zum Musterabgleich erhalten Sie weitere Informationen zu Musterabgleichverfahren. Weitere Informationen zu einem Positionsmuster finden Sie im Abschnitt Positionsmuster des Artikels Muster.

Using-Deklarationen

Eine using-Deklaration ist eine Variablendeklaration, der das Schlüsselwort using vorangestellt ist. Es teilt dem Compiler mit, dass die zu deklarierende Variable am Ende des umschließenden Bereichs angeordnet werden soll. Sehen Sie sich beispielsweise den folgenden Code an, der eine Textdatei schreibt:

static int WriteLinesToFile(IEnumerable<string> lines)
{
    using var file = new System.IO.StreamWriter("WriteLines2.txt");
    int skippedLines = 0;
    foreach (string line in lines)
    {
        if (!line.Contains("Second"))
        {
            file.WriteLine(line);
        }
        else
        {
            skippedLines++;
        }
    }
    // Notice how skippedLines is in scope here.
    return skippedLines;
    // file is disposed here
}

Im vorhergehenden Beispiel wird die Datei angeordnet, wenn die schließende Klammer für die Methode erreicht ist. Dies ist das Ende des Bereichs, in dem file deklariert wird. Der obige Code entspricht dem folgenden Code mit klassischer using-Anweisung:

static int WriteLinesToFile(IEnumerable<string> lines)
{
    using (var file = new System.IO.StreamWriter("WriteLines2.txt"))
    {
        int skippedLines = 0;
        foreach (string line in lines)
        {
            if (!line.Contains("Second"))
            {
                file.WriteLine(line);
            }
            else
            {
                skippedLines++;
            }
        }
        return skippedLines;
    } // file is disposed here
}

Im vorhergehenden Beispiel wird die Datei angeordnet, wenn die der using-Anweisung zugeordnete schließende Klammer erreicht ist.

In beiden Fällen generiert der Compiler den Aufruf von Dispose(). Der Compiler erzeugt einen Fehler, wenn der Ausdruck in der using-Anweisung nicht verwerfbar ist.

Statische lokale Funktionen

Sie können nun den static-Modifikator zu lokalen Funktionen hinzufügen, um sicherzustellen, dass die lokale Funktion keine Variablen aus dem umschließenden Bereich erfasst (referenziert). Dadurch wird CS8421 erzeugt, „Eine statische lokale Funktion kann keine Referenz auf <variable> enthalten.“

Betrachten Sie folgenden Code. Die lokale Funktion LocalFunction greift auf die Variable y zu, die im umschließenden Bereich (die Methode M) deklariert ist. Daher kann LocalFunction nicht mit dem static-Modifikator deklariert werden:

int M()
{
    int y;
    LocalFunction();
    return y;

    void LocalFunction() => y = 0;
}

Der folgende Code enthält eine statische lokale Funktion. Er kann statisch sein, da er nicht auf Variablen im umschließenden Bereich zugreift:

int M()
{
    int y = 5;
    int x = 7;
    return Add(x, y);

    static int Add(int left, int right) => left + right;
}

Verwerfbare Referenzstrukturen

Ein mit dem ref-Modifizierer deklarierter struct darf keine Schnittstellen und damit auch keine IDisposable implementieren. Aus diesem Grund Aktivieren einer ref struct um verworfen werden, muss eine zugängliche void Dispose() Methode. Dieses Feature gilt auch für readonly ref struct-Deklarationen.

Nullwerte zulassende Verweistypen

In einem Nullwerte zulassenden Anmerkungskontext, wird jede Variable eines Referenztyps als keine Nullwerte zulassender Referenztyp betrachtet. Wenn Sie angeben möchten, dass eine Variable Null sein kann, müssen Sie den Typnamen mit ? ergänzen, um die Variable als keine Nullwerte zulassenden Verweistyp zu deklarieren.

Wenn der Verweistyp keine Nullwerte zulässt, verwendet der Compiler die Flussanalyse, um sicherzustellen, dass lokale Variablen bei der Deklaration auf einen Nicht-Null-Wert initialisiert werden. Felder müssen während der Erstellung initialisiert werden. Der Compiler erzeugt eine Warnung, wenn die Variable nicht durch einen Aufruf eines der verfügbaren Konstruktoren oder durch einen Initialisierer festgelegt wird. Darüber hinaus kann Verweistypen, die keine Nullwerte zulassen, kein Wert zugewiesen werden, der Null sein könnte.

Verweistypen, die keine Nullwerte zulassen werden nicht überprüft, um sicherzustellen, dass sie nicht mit Null belegt oder initialisiert werden. Der Compiler verwendet jedoch die Flussanalyse, um sicherzustellen, dass jede Variable eines Verweistypen, der Nullwerte zulässt, gegen null geprüft wird, bevor auf sie zugegriffen oder einem nicht Verweistypen zugewiesen wird, der Nullwerte zulässt.

Mehr über die Funktion erfahren Sie in der Übersicht der Verweistypen, die Nullwerte zulassen. Probieren Sie es selbst in einer neuen Anwendung in diesem Tutorial für Verweistypen, die Nullwerte zulassen aus. Weitere Informationen über die Schritte zur Migration einer bestehenden Codebasis, um Verweistypen zu nutzen, die Nullwerte zulassen, finden Sie im Tutorial Migration einer Anwendung zur Verwendung Nullwerte zulassenden Verweistypen.

Asynchrone Streams

Ab C# 8.0 können Sie Streams asynchron erstellen und nutzen. Eine Methode, die einen asynchronen Stream zurückgibt, hat drei Eigenschaften:

  1. Die Deklaration erfolgte mit dem async-Modifikator.
  2. Es wird IAsyncEnumerable<T> zurückgegeben.
  3. Das Verfahren enthält yield return-Anweisungen, um aufeinanderfolgende Elemente im asynchronen Stream zurückzugeben.

Die Verwendung eines asynchronen Streams erfordert, dass Sie das Schlüsselwort await vor dem Schlüsselwort foreach hinzufügen, wenn Sie die Elemente des Streams auflisten. Das Hinzufügen des Schlüsselwortes await erfordert, dass die Methode, die den asynchronen Strom enumiert, mit dem Modifikator async deklariert wird und einen für eine async-Methode zulässigen Typ zurückgibt. In der Regel ist dies die Rückgabe von Task oder Task<TResult>. Es kann auch ValueTask oder ValueTask<TResult> sein. Eine Methode kann einen asynchronen Stream sowohl verwenden als auch erzeugen, was bedeutet, dass sie IAsyncEnumerable<T> zurückgeben würde. Der folgende Code erzeugt eine Sequenz von 0 bis 19 und wartet 100 ms zwischen der Generierung jeder Zahl:

public static async System.Collections.Generic.IAsyncEnumerable<int> GenerateSequence()
{
    for (int i = 0; i < 20; i++)
    {
        await Task.Delay(100);
        yield return i;
    }
}

Die Enumeration der Sequenz erfolgt mit der await foreach-Anweisung:

await foreach (var number in GenerateSequence())
{
    Console.WriteLine(number);
}

Sie können asynchrone Streams selbst in unserem Tutorial zum Erstellen und Verwenden von asynchronen Streams ausprobieren. Standardmäßig werden Streamelemente im erfassten Kontext verarbeitet. Wenn Sie die Erfassung des Kontexts deaktivieren möchten, verwenden Sie die Erweiterungsmethode TaskAsyncEnumerableExtensions.ConfigureAwait. Weitere Informationen über Synchronisierungskontexte und die Erfassung des aktuellen Kontexts finden Sie im Artikel über das Verwenden des aufgabenbasierten asynchronen Musters.

Asynchrone verwerfbare Typen

Ab C# 8.0 unterstützt die Sprache asynchrone verwerfbare Typen, die die System.IAsyncDisposable-Schnittstelle implementieren. Verwenden Sie die Anweisung await using, um mit einem asynchron verwerfbaren Objekt zu arbeiten. Weitere Informationen finden Sie im Artikel Implementieren einer DisposeAsync-Methode.

Indizes und Bereiche

Indizes und Bereiche bieten eine prägnante Syntax für den Zugriff auf einzelne Elemente oder Bereiche in einer Sequenz.

Diese Sprachunterstützung basiert auf zwei neuen Typen und zwei neuen Operatoren:

  • System.Index: Stellt einen Index in einer Sequenz dar.
  • Der Index vom Endeoperator ^, der angibt, dass ein Index relativ zum Ende der Sequenz ist.
  • System.Range: Stellt einen Unterbereich einer Sequenz dar.
  • Der Bereichsoperator .., der den Beginn und das Ende eines Bereichs als seine Operanden angibt.

Beginnen wir mit den Regeln für Indizes. Betrachten Sie einen Array sequence. Der 0-Index entspricht sequence[0]. Der ^0-Index entspricht sequence[sequence.Length]. Beachten Sie, dass sequence[^0] genau wie sequence[sequence.Length] eine Ausnahme auslöst. Für eine beliebige Zahl n ist der Index ^n identisch mit sequence.Length - n.

Ein Bereich gibt den Beginn und das Ende eines Bereichs an. Der Beginn des Bereichs ist inklusiv, das Ende des Bereichs ist jedoch exklusiv. Das bedeutet, dass der Beginn im Bereich enthalten ist, das Ende aber nicht. Der Bereich [0..^0] stellt ebenso wie [0..sequence.Length] den gesamten Bereich dar.

Schauen wir uns einige Beispiele an. Betrachten Sie das folgende Array, kommentiert mit seinem Index „from the start“ und „from the end“:

var words = new string[]
{
                // index from start    index from end
    "The",      // 0                   ^9
    "quick",    // 1                   ^8
    "brown",    // 2                   ^7
    "fox",      // 3                   ^6
    "jumped",   // 4                   ^5
    "over",     // 5                   ^4
    "the",      // 6                   ^3
    "lazy",     // 7                   ^2
    "dog"       // 8                   ^1
};              // 9 (or words.Length) ^0

Sie können das letzte Wort mit dem ^1-Index abrufen:

Console.WriteLine($"The last word is {words[^1]}");
// writes "dog"

Der folgende Code erzeugt einen Teilbereich mit den Worten „quick“, „brown“ und „fox“. Er enthält words[1] bis words[3]. Das Element words[4] befindet sich nicht im Bereich.

var quickBrownFox = words[1..4];

Der folgende Code erzeugt einen Teilbereich mit „lazy“ und „dog“. Dazu gehören words[^2] und words[^1]. Der Endindex words[^0] ist nicht enthalten:

var lazyDog = words[^2..^0];

Die folgenden Beispiele erstellen Bereiche, die am Anfang, am Ende und auf beiden Seiten offen sind:

var allWords = words[..]; // contains "The" through "dog".
var firstPhrase = words[..4]; // contains "The" through "fox"
var lastPhrase = words[6..]; // contains "the", "lazy" and "dog"

Sie können Bereiche auch als Variablen deklarieren:

Range phrase = 1..4;

Der Bereich kann dann innerhalb der Zeichen [ und ] verwendet werden:

var text = words[phrase];

Indizes und Bereiche werden nicht nur von Arrays unterstützt. Indizes und Bereiche können auch mit string, Span<T> oder ReadOnlySpan<T> verwendet werden. Weitere Informationen finden Sie unter Typunterstützung für Indizes und Bereiche.

Weitere Informationen zu Indizes und Bereichen finden Sie im Tutorial zu Indizes und Bereichen.

NULL-Coalescing-Zuweisung

In C# 8.0 wird der NULL-Coalescing-Zuweisungsoperator ??= eingeführt. Sie können den ??=-Operator verwenden, um den Wert des rechten Operanden dem linken Operanden nur dann zuzuweisen, wenn die Auswertung des linken Operanden null ergibt.

List<int> numbers = null;
int? i = null;

numbers ??= new List<int>();
numbers.Add(i ??= 17);
numbers.Add(i ??= 20);

Console.WriteLine(string.Join(" ", numbers));  // output: 17 17
Console.WriteLine(i);  // output: 17

Weitere Informationen finden Sie im Artikel zu den Operatoren ?? und ??=.

Nicht verwaltete konstruierte Typen

In C# 7.3 und früher darf ein konstruierter Typ (also ein Typ, der mindestens ein Typargument enthält) kein nicht verwalteter Typ sein. Ab C# 8.0 ist ein konstruierter Werttyp nicht verwaltet, wenn er nur Felder von nicht verwalteten Typen enthält.

Ein Beispiel: Ihr Code enthält die folgende Definition des generischen Coords<T>-Typs:

public struct Coords<T>
{
    public T X;
    public T Y;
}

Dann ist der Coords<int>-Typ in C# 8.0 und höher ein nicht verwalteter Typ. Wie bei allen nicht verwalteten Typen können Sie einen Zeiger auf eine Variable dieses Typs erstellen oder Instanzen dieses Typs einen Arbeitsspeicherblock im Stapel zuordnen:

Span<Coords<int>> coordinates = stackalloc[]
{
    new Coords<int> { X = 0, Y = 0 },
    new Coords<int> { X = 0, Y = 3 },
    new Coords<int> { X = 4, Y = 0 }
};

Weitere Informationen finden Sie unter Nicht verwaltete Typen.

Stackalloc in geschachtelten Ausdrücken

Ab C# 8.0 können Sie, wenn das Ergebnis eines stackalloc-Ausdrucks vom Typ System.Span<T> oder System.ReadOnlySpan<T> ist, den stackalloc-Ausdruck in anderen Ausdrücken verwenden:

Span<int> numbers = stackalloc[] { 1, 2, 3, 4, 5, 6 };
var ind = numbers.IndexOfAny(stackalloc[] { 2, 4, 6, 8 });
Console.WriteLine(ind);  // output: 1

Erweiterung von interpolierten ausführlichen Zeichenfolgen

$- und @-Token in interpolierten ausführlichen Zeichenfolgen können in beliebiger Reihenfolge vorliegen: sowohl $@"..." als auch @$"..." sind gültige interpolierte ausführliche Zeichenfolgen. In früheren C#-Versionen musste das Token $ vor dem Token @ vorhanden sein.