Zum Hauptinhalt springen
Neue C♯ Features die man tatsächlich benutzt
← Zurück zum Blog

Neue C♯ Features die man tatsächlich benutzt

12 min Lesezeit Daniel Fahsig

Die meisten neuen Sprachfeatures sind nicht für den Alltag gedacht, sondern decken nur sehr spezielle Problemstellungen ab. Einige der neuen Features in den Versionen 8 bis 14 sind aber tatsächlich im Alltag hilfreich. In diesem Post möchten wir ein paar teilen, die wir hilfreich finden.

Neue C♯ Features die man tatsächlich benutzt

Der switch Ausdruck (8.0)

Wenn man eine switch-Anweisung nutzt um einer einzelnen Variablen abhängig von den Bedingungen einen neuen Wert zuzuweisen, oder wenn man sie nur nutzt um einen Wert zurückzugeben, kann man jetzt den neuen switch Ausdruck nutzen.

Also statt diesem Code hier:

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

Könnte man das jetzt so schreiben:

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)),
    };

Wenn man die beiden Implementierungen vergleicht, fallen folgende Änderungen auf:

  • Die Variable, die zur Entscheidung genutzt wird, wird vor das switch gesetzt
  • case wird ersetzt durch =>
  • default wird ersetzt durch _ (discard-Muster)
  • es können lediglich Ausdrücke in den einzelnen Zweigen benutzt werden

Enhancements to pattern matching

Mit C♯ 7 wurde der Musterabgleich eingeführt, womit dann das folgende möglich wurde:

if (arg1 is ComplexObject complex)
{
    internal = complex.SophisticatedAction();
}

Statt diesem Code:

var complex = arg1 as ComplexObject;
if (complex != null) {
    internal = complex.SophisticatedAction();
}

Eigenschaftsmuster (8.0)

Eigenschaftsmuster ermöglichen den Zugriff auf Eigenschaften des Objekts auf dem die Entscheidung basieren soll, z.B. bei Verwendung eines switch Ausdrucks.

Statt also das hier zu schreiben:

var state =  location.State;
state switch
{
    "WA" => salePrice * 0.08M;
    "MN" => salePrice * 0.06M;
    "MI" => salePrice * 0.09M;
    _ => 0M;
}

Ist es möglich das hier zu schreiben:

location switch
{
    { State: "WA" } => salePrice * 0.08M;
    { State: "MN" } => salePrice * 0.06M;
    { State: "WI" } => salePrice * 0.09M;
}

Sogar etwas komplexeres wie das hier ist damit möglich:

public static bool IsReleaseDate(DateTime date)  => date is { Year: 2022, Month: 10, Day: 1 };

Ausbau mit C♯ 10

Miz C♯ 10 wurden die Eigenschaftsmuster noch einmal vereinfacht. Wenn man bis dahin auf innere Eigenschaften zugreifen wollte, musste man das so tun:

if (e is MethodCallExpression { Method: { Name: "MethodName" } })

Jetzt geht das ein wenig kürzer und eingängiger:

if (e is MethodCallExpression { Method.Name: "MethodName" })

Tupelmuster (8.0)

Manchmal muss man zwei Variablen für eine Entscheidung prüfen. Dazu könnte man eine verschachtelte if-Anweisung nutzen, um alle Fälle abzudecken. Aber mit einem Tupelmuster ist das nicht mehr nötig:

public static bool CanEditDocument(DocumentState state, UserRole role)
    => (state, role) switch
    {
        (DocumentState.Approval, UserRole.Approver) => true,
        (DocumentState.Open, UserRole.Editor) => true,
        (DocumentState.Open, UserRole.Manager) => true,
        (DocumentState.Closed, UserRole.Reviewer) => true,
        (_,_) => false,
    }

Die Rollen, die ein Dokument bearbeiten dürfen sind hier klar ersichtlich und mit das discard-Muster deckt hier alle Fälle ab, in denen das Bearbeiten nicht erlaubt ist.

Typmuster (9.0)

Typprüfungen können jetzt auch in einem switch-Ausdruck gemacht werden, wenn man Typmuster nutzt:

public abstract class Vehicle {}
public class Car : Vehicle {}
public class Truck : Vehicle {}

public static class TollCalculator
{
    public static decimal CalculateToll(this Vehicle vehicle) => vehicle switch
    {
        Car _ => 2.00m,
        Truck _ => 7.50m,
        null => throw new ArgumentNullException(nameof(vehicle)),
        _ => throw new ArgumentException("Unknown type of a vehicle", nameof(vehicle)),
    };
}

Logische und relationale Muster (9.0)

Statt langer if-Anweisungen für jede Bedingung, kann man auch komplexe Bedingungen in einem switch-Ausdruck mithilfe logischer und relationaler Muster abprüfen:

Console.WriteLine(GetCalendarSeason(new DateTime(2021, 3, 14)));  // output: spring
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 7, 19)));  // output: summer
Console.WriteLine(GetCalendarSeason(new DateTime(2021, 2, 17)));  // output: winter

static string GetCalendarSeason(DateTime date) => date.Month switch
{
    >= 3 and < 6 => "spring",
    >= 6 and < 9 => "summer",
    >= 9 and < 12 => "autumn",
    12 or (>= 1 and < 3) => "winter",
    _ => throw new ArgumentOutOfRangeException(nameof(date), $"Date with unexpected month: {date.Month}."),
};

Hier wird mit relationalen Mustern geprüft, ob der Monat in dem festgelegten Bereich ist und mit den logischen and und or-Mustern werden die Bedingungen verknüpft.

Und mit dem logischen not-Muster ist es möglich eine besser lesbare null-Prüfung zu schreiben:

if (input is not null)
{
    // ...
}

Listenmuster (11.0)

Mit Listenmustern kann man Arrays oder Listen gegen eine Sequenz von Mustern abgleichen. Das ist besonders nützlich, wenn man die Struktur einer Eingabe prüfen möchte:

int[] numbers = { 1, 2, 3, 4, 5 };

var result = numbers switch
{
    [1, 2, ..]      => "Beginnt mit 1, 2",
    [.., 4, 5]      => "Endet mit 4, 5",
    [_, _, _, ..]   => "Hat mindestens 3 Elemente",
    []              => "Ist leer",
    _               => "Sonstiges"
};

Das Slice-Muster .. passt auf beliebig viele Elemente (auch null). Man kann es auch mit einer Variablen kombinieren:

if (args is [var program, var filename, .. var rest])
{
    Console.WriteLine($"Programm: {program}, Datei: {filename}, Rest: {rest.Length} Argumente");
}

Das ist besonders praktisch beim Parsen von Kommandozeilenargumenten oder CSV-Daten.

Raw String Literals (11.0)

Wenn man Strings mit vielen Anführungszeichen, Backslashes oder Zeilenumbrüchen schreiben muss (z.B. JSON, XML, SQL, Regex), war das bisher umständlich:

var json = @"{
    ""name"": ""Max Mustermann"",
    ""email"": ""max@example.com""
}";

Mit Raw String Literals entfällt das Escaping komplett. Sie beginnen und enden mit mindestens drei Anführungszeichen:

var json = """
    {
        "name": "Max Mustermann",
        "email": "max@example.com"
    }
    """;

Die Einrückung wird automatisch entfernt – die Position der schließenden """ bestimmt, wie viel Whitespace links abgeschnitten wird.

Für Interpolation nutzt man mehrere $-Zeichen, um festzulegen, wie viele geschweifte Klammern eine Interpolation einleiten:

var json = $$"""
    {
        "name": "{{person.Name}}",
        "count": {{items.Count}}
    }
    """;

Mit $$ wird {{...}} zur Interpolation, sodass einzelne { und } im JSON nicht escaped werden müssen.

Pflichtmitglieder mit required (11.0)

Mit init-only Settern kann man Eigenschaften nur bei der Initialisierung setzen. Aber es gab keine Möglichkeit sicherzustellen, dass sie auch tatsächlich gesetzt werden. Mit dem required-Modifikator wird das erzwungen:

public class Person
{
    public required string Name { get; init; }
    public required string Email { get; init; }
    public int? Age { get; init; }  // optional
}

var person = new Person { Name = "Max" };  // Fehler! Email fehlt
var person = new Person { Name = "Max", Email = "max@example.com" };  // OK

Das ist besonders nützlich für DTOs und Konfigurationsklassen, bei denen bestimmte Eigenschaften immer gesetzt sein müssen.

Primäre Konstruktoren (12.0)

Klassen und Structs benötigten oft viel Boilerplate-Code, um Abhängigkeiten zu injizieren:

public class UserService
{
    private readonly ILogger _logger;
    private readonly IUserRepository _repository;

    public UserService(ILogger logger, IUserRepository repository)
    {
        _logger = logger;
        _repository = repository;
    }

    public void CreateUser(string name) => _logger.Log($"Creating {name}");
}

Mit primären Konstruktoren wird das deutlich kürzer:

public class UserService(ILogger logger, IUserRepository repository)
{
    public void CreateUser(string name) => logger.Log($"Creating {name}");
}

Die Parameter sind im gesamten Klassenkörper verfügbar. Wenn sie nur zur Initialisierung verwendet werden, erzeugt der Compiler kein Feld dafür.

Collection Expressions (12.0)

Das Initialisieren von Collections war bisher je nach Typ unterschiedlich:

int[] array = new int[] { 1, 2, 3 };
List<int> list = new List<int> { 1, 2, 3 };
Span<int> span = stackalloc int[] { 1, 2, 3 };

Jetzt gibt es eine einheitliche Syntax mit eckigen Klammern:

int[] array = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3];

Besonders praktisch ist der Spread-Operator .., um Collections zu kombinieren:

int[] first = [1, 2, 3];
int[] second = [4, 5, 6];
int[] combined = [..first, ..second];  // [1, 2, 3, 4, 5, 6]

// Auch mit zusätzlichen Elementen
int[] withExtra = [0, ..first, 99];    // [0, 1, 2, 3, 99]

Typ-Aliase für beliebige Typen (12.0)

Die using-Direktive für Typ-Aliase war bisher auf benannte Typen beschränkt. Jetzt kann man auch Tupel, Arrays und andere Typen aliasieren:

using Point = (int X, int Y);
using Koordinaten = (double Breitengrad, double Längengrad);
using Matrix = int[,];

Point ursprung = (0, 0);
Koordinaten berlin = (52.52, 13.405);

Das macht Code lesbarer, wenn man häufig mit Tupeln arbeitet.

Null-bedingte Zuweisung (14.0)

Bisher musste man für eine bedingte Zuweisung eine if-Anweisung schreiben:

if (customer is not null)
{
    customer.LastOrder = GetCurrentOrder();
}

Jetzt kann man den ?.-Operator auch auf der linken Seite einer Zuweisung verwenden:

customer?.LastOrder = GetCurrentOrder();

Die rechte Seite wird nur ausgewertet, wenn customer nicht null ist. Das funktioniert auch mit zusammengesetzten Zuweisungen:

customer?.Balance += 100;
customer?.Orders?.Add(newOrder);

Das field-Schlüsselwort (14.0)

Wenn man bei einer Auto-Property Validierung oder Logik im Setter benötigt, musste man bisher ein explizites Backing-Field deklarieren:

private string _message;
public string Message
{
    get => _message;
    set => _message = value ?? throw new ArgumentNullException(nameof(value));
}

Mit dem neuen field-Schlüsselwort kann man auf das vom Compiler generierte Backing-Field zugreifen:

public string Message
{
    get;
    set => field = value ?? throw new ArgumentNullException(nameof(value));
}

Das reduziert den Boilerplate-Code erheblich, wenn man nur einen der Accessoren anpassen möchte.

Extension Members (14.0)

Extension Methods gibt es seit C# 3.0 – aber Extension Properties waren lange Zeit nicht möglich. Mit C# 14 ändert sich das durch eine neue Syntax für Extension Members:

public static class StringExtensions
{
    extension(string s)
    {
        // Extension Property (NEU!)
        public bool IsNullOrEmpty => string.IsNullOrEmpty(s);

        // Extension Method (neue Syntax)
        public string Truncate(int maxLength) 
            => s.Length <= maxLength ? s : s[..maxLength] + "...";
    }
}

Die Verwendung ist so intuitiv wie bei Extension Methods:

string name = "Max Mustermann";

if (!name.IsNullOrEmpty)  // Extension Property
{
    Console.WriteLine(name.Truncate(10));  // "Max Muster..."
}

Auch statische Extension Members und Operatoren sind möglich:

public static class ListExtensions
{
    extension<T>(IEnumerable<T>)  // ohne Variablenname = statisch
    {
        // Statischer Extension-Operator
        public static IEnumerable<T> operator +(IEnumerable<T> left, IEnumerable<T> right) 
            => left.Concat(right);
    }
}

// Verwendung
var combined = list1 + list2;  // statt list1.Concat(list2)

Das eröffnet neue Möglichkeiten für flüssigere APIs und expressiveren Code.

using Deklarationen (8.0)

Es kommt oft vor, dass man eine Methode oder einen Zweig einer if-Anweisung mit einer using-Anweisung beginnt, wodurch ein weiteres Klammerpaar und eine weitere Einrückungsebene dazukommt:

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
}

Wenn man eine using Deklaration benutzt, sieht das so aus:

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
}

Dadurch verringert man die Anzahl der Klammern, die Einrückungen des Codes und die Anzahl der Zeilen in einer Methode und einer Datei.

Indizes und Bereiche (8.0)

Um bestimmte Elemente in einer Sequenz zu erreichen gibt es eine verbesserte Syntax, die es ermöglicht statt

var wordBeforeLast = words[words.Length - 2];

das hier zu schreiben:

var wordBeforeLast = words[^2];

Der Operator Index vom Ende verhält sich genau wie zuvor, d.h. ^0 würde eine Exception auslösen (genau wie word[words.Length]).

Und wenn man einen Unterbereich aus einer Sequenz schneiden will kann man jetzt den Bereichsoperator nutzen:

var relevantPart = phoneNumber[4..9];

Das wird die folgenden Elemente einschließen: phoneNumber[4] bis phonenumber[8] - d.h. der Anfang ist eingschlossen, das Ende ist ausgeschlossen. Beide Zahlen sind optional und werden, falls sie fehlen, als Anfang oder Ende der Sequenz angenommen. Der Index vom Ende-Operator kann hier auch benutzt werden.

Null-Coalescing Zuweisung (8.0)

Bei Methoden, die einen Parameter haben, der als Standardwert null hat, wurde oft der folgende Weg gewählt, um einen gültigen Wert dafür sicherzustellen:

void Method(ComplexObject complex = null)
{
    if (complex is null)
    {
        complex = new ComplexObject(...);
    }

    ...
}

Mit der null-Coalescing Zuweisung geht das viel kürzer:

void Method(ComplexObject complex = null)
{
    complex ??= new ComplexObject(...);

    ...
}

init-only-Setter (9.0)

Wenn man das Setzen einer Eigenschaft eines Obekts mit dem Eigenschafteninitialisierer möglich machen wollte, dann musst man den Setter jeder betroffenen Eigenschaft auf public setzen. Das ermöglichte natürlich auch die nachträgliche Veränderung dieser Eigenschaft, was manchmal unerwünscht sein konnte.

Mit den neuen init-only Settern kann das verhindert werden, weil damit eine Eigenschaft nur bei der Initialisierung durch den Eigenschafteninitialisierer gesetzt und danach nicht mehr direkt verändert werden kann.

public class ComplexObject
{
    public int ChildrenCount { get; init; }

    public ComplexObject()
    {
        ...
    }
}

var complex = new ComplexObject { ChildrenCount = 20 };
complex.ChildrenCount = 25; // Error! CS8852 - this is not allowed

Top-Level-Anweisungen (9.0)

Üblicherweise hat ein Programm eine Hauptdatei, z.B. die Program.cs bei ASP.Net Core Applikationen und diese Datei enthielt dann viel Boilerplate-Code. Jetzt darf eine Datei im Projekt allen Boilerplate-Code weglassen. Sie muss dann lediglich den auszuführenden Code und die nötige using-Ausdrücke enthalten.

Also wird aus dem hier:

namespace Company.Application
{
    using System;
    ...

    public static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
        ...
    }
}

das hier:

using System;
...

Console.WriteLine("Hello World!");
...

Weglassen des Typs beim new-Ausdruck (9.0)

Wenn der Typ des zu erzeugenden Objekts bereits bekannt ist, kann der Typ beim new weggelassen werden:

private List<Chemical> chemicals = new();

Das geht auch beim Aufruf einer Methode:

public TimeSpan GetReactionTime(Chemical secondChemical, ReactionOptions options) {...}

var duration = chemical.GetReactionTime(catalysator, new());

Und in Kombination mit dem Eigenschafteninitialisierer:

ReactionOptions options = new() { Technician = "Heinz Müller" };

Dateibezogenen Namespacedeklaration (10)

Um die Einrückungen in den Codedateien weiter zu minimieren, kann man jetzt dateibezogene Namespacedeklarationen nutzen. So wird aus:

namespace Company.Application
{
    using System;

    using Company.Business;

    public class SpecialHandler
    {
        ...
    }
}

Das hier:

namespace Company.Application;
using System;

using Company.Business;

public class SpecialHandler
{
    ...
}

Damit spart man Platz in zwei Richtungen: Einrückung und Zeilenzahl

Zuweisung und Deklaration in derselben Dekonstruktion (10)

Mit C♯ 7 kann man ein benutzerdefiniertes Objekt mit Hilfe der Deconstruct Method dekonstruieren, allerdings nur alle Werte neuen Variablen oder alle Werte zu bereits existierenden Variablen zuweisen. Diese Einschränkung wurde aufgehoben:

public class Point
{
    ...
    public void Deconstruct(out int x, out int y) {...}
}

var point = new Point(...);

int x = 0;
(x, int y) = point;

Diese Features helfen dabei, Code lesbarer, kürzer und wartbarer zu machen. Welche dieser Features verwenden Sie bereits in Ihren Projekten?

Artikel teilen