Informationen zu neuen Elementen in .NET 5 und C # 9.0

Guten Tag.



Wir haben .NET seit seiner Einführung verwendet. Wir haben Lösungen in allen Versionen des Frameworks in der Produktion geschrieben: von der ersten bis zur neuesten .NET Core 3.1.



Die Geschichte von .NET, die wir die ganze Zeit genau verfolgt haben, geschieht vor unseren Augen: Die Version von .NET 5, die voraussichtlich im November veröffentlicht wird, wurde gerade in Form von Release Candidate 2 veröffentlicht. Wir wurden lange gewarnt, dass die fünfte Version epochal sein wird: Sie wird damit enden .NET-Schizophrenie, als es zwei Zweige des Frameworks gab: klassisch und Kern. Jetzt werden sie in Ekstase verschmelzen und es wird ein kontinuierliches .NET geben.



Veröffentlicht RC2Sie können bereits mit der vollständigen Nutzung beginnen. Vor der Veröffentlichung werden keine neuen Änderungen erwartet. Es werden nur die gefundenen Fehler behoben. Außerdem: RC2 hat bereits eine offizielle Website für .NET.



Und wir präsentieren Ihnen einen Überblick über die Innovationen in .NET 5 und C # 9. Alle Informationen mit Codebeispielen wurden aus dem offiziellen Blog der Entwickler der .NET-Plattform (sowie aus vielen anderen Quellen) entnommen und persönlich überprüft.



Neue native und nur neue Typen



C # und .NET haben gleichzeitig native Typen hinzugefügt:



  • nint und nuint für C #
  • ihre entsprechenden System.IntPtr und System.UIntPtr in BCL


Der Punkt zum Hinzufügen dieser Typen sind Operationen mit APIs auf niedriger Ebene. Der Trick besteht darin, dass die tatsächliche Größe dieser Typen bereits zur Laufzeit bestimmt wird und von der Bitigkeit des Systems abhängt: Bei 32-Bit-Typen beträgt ihre Größe 4 Byte und bei 64-Bit-Typen 8 Byte.



Höchstwahrscheinlich werden Sie diese Typen in der realen Arbeit nicht antreffen. Wie jedoch bei einem anderen neuen Typ: Half. Dieser Typ existiert nur in BCL, es gibt noch kein Analogon in C #. Es ist ein 16-Bit-Typ für Gleitkommawerte. Dies kann nützlich sein, wenn keine höllische Genauigkeit erforderlich ist und Sie Speicher für das Speichern von Werten gewinnen können, da die Typen float und double 4 und 8 Byte belegen. Das Interessanteste ist, dass für diesen Typ im Allgemeinen bisherarithmetische Operationen sind nicht definiert, und Sie können nicht einmal zwei Variablen vom Typ Half hinzufügen, ohne sie explizit in float oder double umzuwandeln. Das heißt, der Zweck dieses Typs ist jetzt rein zweckmäßig - um Platz zu sparen. Sie planen jedoch, in der nächsten Version von .NET und C # eine Arithmetik hinzuzufügen. In einem Jahr.



Attribute für lokale Funktionen



Zuvor waren sie verboten, was zu Unannehmlichkeiten führte. Insbesondere war es unmöglich, Attribute der Parameter lokaler Funktionen aufzuhängen. Jetzt können Sie Attribute für sie festlegen, sowohl für die Funktion selbst als auch für ihre Parameter. Zum Beispiel so:



#nullable enable
private static void Process(string?[] lines, string mark)
{
    foreach (var line in lines)
    {
        if (IsValid(line))
        {
            // Processing logic...
        }
    }

    bool IsValid([NotNullWhen(true)] string? line)
    {
        return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
    }
}


Statische Lambda-Ausdrücke



Mit dieser Funktion soll sichergestellt werden, dass Lambda-Ausdrücke keinen Kontext und keine lokalen Variablen erfassen können, die außerhalb des Ausdrucks selbst vorhanden sind. Im Allgemeinen ist die Tatsache, dass sie den lokalen Kontext erfassen können , bei der Entwicklung häufig hilfreich. Aber manchmal kann dies die Ursache für schwer zu fassende Fehler sein.



Um solche Fehler zu vermeiden, können Lambda-Ausdrücke jetzt mit dem statischen Schlüsselwort markiert werden. In diesem Fall verlieren sie den Zugriff auf jeden lokalen Kontext: von lokalen Variablen auf diese und die Basis.



Hier ist ein ziemlich umfassendes Anwendungsbeispiel:



static void SomeFunc(Func<int, int> f)
{
    Console.WriteLine(f(5));
}

static void Main(string[] args)
{
    int y1 = 10;
    const int y2 = 10;
    SomeFunc(i => i + y1);          //  15
    SomeFunc(static i => i + y1);   //  : y1    
    SomeFunc(static i => i + y2);   //  15
}


Beachten Sie, dass Konstanten statische Lambdas gut erfassen.



GetEnumerator als Erweiterungsmethode



Jetzt kann die GetEnumerator-Methode eine Erweiterungsmethode sein, mit der Sie das foreach durchlaufen können, auch wenn es zuvor nicht aufgezählt werden konnte. Zum Beispiel - Tupel.



Hier ist ein Beispiel, in dem es möglich wird, ValueTuple über foreach mit der dafür geschriebenen Erweiterungsmethode zu iterieren:



static class Program
{
    public static IEnumerator<T> GetEnumerator<T>(this ValueTuple<T, T, T, T, T> source)
    {
        yield return source.Item1;
        yield return source.Item2;
        yield return source.Item3;
        yield return source.Item4;
        yield return source.Item5;
    }

    static void Main(string[] args)
    {
        foreach(var item in (1,2,3,4,5))
        {
            System.Console.WriteLine(item);
        }
    }
}


Dieser Code druckt Zahlen von 1 bis 5 auf die Konsole.



Muster in Parametern von Lambda-Ausdrücken und anonymen Funktionen verwerfen



Mikroverbesserung. Falls Sie keine Parameter in einem Lambda-Ausdruck oder in einer anonymen Funktion benötigen, können Sie diese durch einen Unterstrich ersetzen und dabei Folgendes ignorieren:



Func<int, int, int> someFunc1 = (_, _) => {return 5;};
Func<int, int, int> someFunc2 = (int _, int _) => {return 5;};
Func<int, int, int> someFunc3 = delegate (int _, int _) {return 5;};


Anweisungen der obersten Ebene in C #



Dies ist eine vereinfachte C # -Code-Struktur. Das Schreiben des einfachsten Codes sieht jetzt wirklich einfach aus:



using System;

Console.WriteLine("Hello World!");


Und alles wird gut kompiliert. Das heißt, Sie müssen jetzt keine Methode erstellen, in der die Konsolenausgabeanweisung platziert werden soll, Sie müssen keine Klasse beschreiben, in der die Methode platziert werden soll, und es ist nicht erforderlich, einen Namespace zu definieren, in dem die Klasse erstellt werden soll.



Übrigens denken C # -Entwickler in Zukunft daran, ein Thema mit vereinfachter Syntax zu entwickeln und das verwendete System loszuwerden. in offensichtlichen Fällen. In der Zwischenzeit können Sie es loswerden, indem Sie einfach so schreiben:



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


Und es wird wirklich ein einzeiliges Arbeitsprogramm sein.



Komplexere Optionen können verwendet werden:



using System;
using System.Runtime.InteropServices;

Console.WriteLine("Hello World!");
FromWhom();
Show.Excitement("Top-level programs can be brief, and can grow as slowly or quickly in complexity as you'd like", 8);

void FromWhom()
{
    Console.WriteLine($"From {RuntimeInformation.FrameworkDescription}");
}

internal class Show
{
    internal static void Excitement(string message, int levelOf)
    {
        Console.Write(message);

        for (int i = 0; i < levelOf; i++)
        {
            Console.Write("!");
        }

        Console.WriteLine();
    }
}


In Wirklichkeit wird der Compiler selbst den gesamten Code in die erforderlichen Namespaces und Klassen einschließen, Sie werden es einfach nicht wissen.



Diese Funktion weist natürlich Einschränkungen auf. Das wichtigste ist, dass dies nur in einer Projektdatei möglich ist. In der Regel ist es sinnvoll, dies in der Datei zu tun, in der Sie zuvor den Einstiegspunkt in das Programm in Form der Funktion Main (string [] args) erstellt haben. Gleichzeitig kann dort die Hauptfunktion selbst nicht definiert werden - dies ist die zweite Einschränkung. Tatsächlich ist eine solche Datei selbst mit einer vereinfachten Syntax die Hauptfunktion und enthält sogar implizit die Variable args, bei der es sich um ein Array mit Parametern handelt. Das heißt, dieser Code kompiliert auch die Länge des Arrays und zeigt sie an:



System.Console.WriteLine(args.Length);


Im Allgemeinen ist die Funktion nicht die wichtigste, aber für Demonstrations- und Schulungszwecke ist sie für sich selbst gut geeignet. Details hier .



Mustervergleich in einer if-Anweisung



Stellen Sie sich vor, Sie müssen eine Objektvariable überprüfen, die nicht von einem bestimmten Typ ist. Bisher musste man so schreiben:



if (!(vehicle is Car)) { ... }


Aber mit C # 9.0 können Sie menschlich schreiben:



if (vehicle is not Car) { ... }


Es wurde auch möglich, einige Schecks kompakt aufzuzeichnen:



if (context is {IsReachable: true, Length: > 1 })
{
    Console.WriteLine(context.Name);
}


Diese neue Notation entspricht der guten alten wie folgt:



if (context is object && context.IsReachable && context.Length > 1 )
{
    Console.WriteLine(context.Name);
}


Oder Sie können dasselbe auch relativ neu schreiben (aber das ist schon gestern):



if (context?.IsReachable && context?.Length > 1 )
{
    Console.WriteLine(context.Name);
}


In der neuen Syntax können Sie auch die booleschen Operatoren und oder und nicht plus Klammern verwenden, um Prioritäten zu setzen:



if (context is {Length: > 0 and (< 10 or 25) })
{
    Console.WriteLine(context.Name);
}


Und dies sind nur Verbesserungen des Mustervergleichs in einem regulären if. Was wir zum Mustervergleich für den Schalterausdruck hinzugefügt haben - lesen Sie weiter.



Verbesserte Musterübereinstimmung im Schalterausdruck



Der switch-Ausdruck (nicht zu verwechseln mit der switch-Anweisung) wurde im Hinblick auf den Mustervergleich erheblich verbessert. Schauen wir uns Beispiele aus der offiziellen Dokumentation an . Beispiele sind der Berechnung des Fahrpreises eines bestimmten Transports zu einem bestimmten Zeitpunkt gewidmet. Hier ist das erste Beispiel:



public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c           => 2.00m,
    Taxi t          => 3.50m,
    Bus b           => 5.00m,
    DeliveryTruck t => 10.00m,
    { }             => throw new ArgumentException("Unknown vehicle type", nameof(vehicle)),
    null            => throw new ArgumentNullException(nameof(vehicle))
};


Die letzten beiden Zeilen in der switch-Anweisung sind neu. Geschweifte Klammern stehen für jedes Objekt, das nicht null ist. Und Sie können jetzt das passende Schlüsselwort verwenden, um mit null abzugleichen.



Das ist nicht alles. Beachten Sie, dass Sie für jede Zuordnung zu einem Objekt eine Variable erstellen müssen: c für Auto, t für Taxi usw. Diese Variablen werden jedoch nicht verwendet. In solchen Fällen können Sie das Verwerfungsmuster bereits jetzt in C # 8.0 verwenden:



public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car _           => 2.00m,
    Taxi _          => 3.50m,
    Bus _           => 5.00m,
    DeliveryTruck _ => 10.00m,
    // ...
};


Aber ab der neunten Version von C # können Sie in solchen Fällen überhaupt nichts schreiben:



public decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car             => 2.00m,
    Taxi            => 3.50m,
    Bus             => 5.00m,
    DeliveryTruck   => 10.00m,
    // ...
};


Die Verbesserungen am Schalterausdruck enden hier nicht. Es ist jetzt einfacher, komplexere Ausdrücke zu schreiben. Beispielsweise muss das zurückgegebene Ergebnis häufig von den Eigenschaftswerten des übergebenen Objekts abhängen. Dies kann jetzt kürzer und bequemer geschrieben werden als eine Kombination von ifs:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car { Passengers: 0 } => 2.00m + 0.50m,
    Car { Passengers: 1 } => 2.0m,
    Car { Passengers: 2 } => 2.0m - 0.50m,
    Car => 2.00m - 1.0m,
    // ...
};


Beachten Sie die ersten drei Zeilen des Schalters: Tatsächlich wird der Wert der Eigenschaft Passengers überprüft, und bei Gleichheit wird das entsprechende Ergebnis zurückgegeben. Wenn keine Übereinstimmung vorliegt, wird der Wert für die allgemeine Variante zurückgegeben (die vierte Zeile innerhalb des Schalters). Eigenschaftswerte werden übrigens nur geprüft, wenn das übergebene Fahrzeugobjekt nicht null ist und eine Instanz der Car-Klasse ist. Das heißt, Sie sollten bei der Überprüfung keine Angst vor einer Nullreferenzausnahme haben.



Aber das ist nicht alles. Jetzt können Sie im Schalterausdruck sogar Ausdrücke schreiben, um den Abgleich zu vereinfachen:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
    DeliveryTruck => 8.00m,
    // ...
};


Und das ist nicht alles. Die Syntax für Schalterausdrücke wurde auf verschachtelte Schalterausdrücke erweitert, um die Beschreibung komplexer Bedingungen noch einfacher zu machen:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c => c.Passengers switch
    {
        0 => 2.00m + 0.5m,
        1 => 2.0m,
        2 => 2.0m - 0.5m,
        _ => 2.00m - 1.0m
    },
    // ...
};


Wenn Sie alle bereits angegebenen Codebeispiele vollständig zusammenkleben, erhalten Sie dieses Bild mit allen beschriebenen Neuerungen auf einmal:



public static decimal CalculateToll(object vehicle) =>
    vehicle switch
{
    Car c => c.Passengers switch
    {
        0 => 2.00m + 0.5m,
        1 => 2.0m,
        2 => 2.0m - 0.5m,
        _ => 2.00m - 1.0m
    },

    Taxi t => t.Fares switch
    {
        0 => 3.50m + 1.00m,
        1 => 3.50m,
        2 => 3.50m - 0.50m,
        _ => 3.50m - 1.00m
    },

    Bus b when ((double)b.Riders / (double)b.Capacity) < 0.50 => 5.00m + 2.00m,
    Bus b when ((double)b.Riders / (double)b.Capacity) > 0.90 => 5.00m - 1.00m,
    Bus => 5.00m,

    DeliveryTruck t when (t.GrossWeightClass >= 5000) => 10.00m + 5.00m,
    DeliveryTruck t when (t.GrossWeightClass >= 3000 && t.GrossWeightClass < 5000) => 10.00m,
    DeliveryTruck => 8.00m,

    null => throw new ArgumentNullException(nameof(vehicle)),
    _ => throw new ArgumentException(nameof(vehicle))
};


Aber das ist auch noch nicht alles. Hier ist ein weiteres Beispiel: Eine gewöhnliche Funktion, die den Schalterausdrucksmechanismus verwendet, um die Last basierend auf der verstrichenen Zeit zu bestimmen: Hauptverkehrszeit morgens / abends, Tag- und Nachtperioden:



private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
    {
        < 6 or > 19 => TimeBand.Overnight,
        < 10 => TimeBand.MorningRush,
        < 16 => TimeBand.Daytime,
        _ => TimeBand.EveningRush,
    };


Wie Sie sehen können, ist es in C # 9.0 auch möglich, die Vergleichsoperatoren <,>, <=,> = sowie die logischen Operatoren und oder und nicht beim Abgleich zu verwenden.



Aber das ist verdammt noch mal nicht das Ende. Sie können jetzt ... Tupel im Schalterausdruck verwenden. Hier ist ein vollständiges Beispiel für einen Code, der einen bestimmten Koeffizienten für den Tarif berechnet, abhängig vom Wochentag, der Tageszeit und der Fahrtrichtung (von / nach der Stadt):



private enum TimeBand
{
    MorningRush,
    Daytime,
    EveningRush,
    Overnight
}

private static bool IsWeekDay(DateTime timeOfToll) =>
    timeOfToll.DayOfWeek switch
{
    DayOfWeek.Saturday => false,
    DayOfWeek.Sunday => false,
    _ => true
};

private static TimeBand GetTimeBand(DateTime timeOfToll) =>
    timeOfToll.Hour switch
{
    < 6 or > 19 => TimeBand.Overnight,
    < 10 => TimeBand.MorningRush,
    < 16 => TimeBand.Daytime,
    _ => TimeBand.EveningRush,
};

public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.MorningRush, true) => 2.00m,
    (true, TimeBand.MorningRush, false) => 1.00m,
    (true, TimeBand.Daytime, true) => 1.50m,
    (true, TimeBand.Daytime, false) => 1.50m,
    (true, TimeBand.EveningRush, true) => 1.00m,
    (true, TimeBand.EveningRush, false) => 2.00m,
    (true, TimeBand.Overnight, true) => 0.75m,
    (true, TimeBand.Overnight, false) => 0.75m,
    (false, TimeBand.MorningRush, true) => 1.00m,
    (false, TimeBand.MorningRush, false) => 1.00m,
    (false, TimeBand.Daytime, true) => 1.00m,
    (false, TimeBand.Daytime, false) => 1.00m,
    (false, TimeBand.EveningRush, true) => 1.00m,
    (false, TimeBand.EveningRush, false) => 1.00m,
    (false, TimeBand.Overnight, true) => 1.00m,
    (false, TimeBand.Overnight, false) => 1.00m,
};


Die PeakTimePremiumFull-Methode verwendet Tupel für den Abgleich. Dies wurde in der neuen Version von C # 9.0 möglich. Wenn Sie sich den Code genau ansehen, bieten sich übrigens zwei Optimierungen an:



  • Die letzten acht Zeilen geben denselben Wert zurück.
  • Tag- und Nachtverkehr haben den gleichen Koeffizienten.


Infolgedessen kann der Methodencode mithilfe des Verwerfungsmusters erheblich reduziert werden:



public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.MorningRush, true)  => 2.00m,
    (true, TimeBand.MorningRush, false) => 1.00m,
    (true, TimeBand.Daytime,     _)     => 1.50m,
    (true, TimeBand.EveningRush, true)  => 1.00m,
    (true, TimeBand.EveningRush, false) => 2.00m,
    (true, TimeBand.Overnight,   _)     => 0.75m,
    (false, _,                   _)     => 1.00m,
};


Wenn Sie noch genauer hinschauen, können Sie diese Option reduzieren, indem Sie im allgemeinen Fall den Koeffizienten 1,0 herausnehmen:



public static decimal PeakTimePremiumFull(DateTime timeOfToll, bool inbound) =>
    (IsWeekDay(timeOfToll), GetTimeBand(timeOfToll), inbound) switch
{
    (true, TimeBand.Overnight, _) => 0.75m,
    (true, TimeBand.Daytime, _) => 1.5m,
    (true, TimeBand.MorningRush, true) => 2.0m,
    (true, TimeBand.EveningRush, false) => 2.0m,
    _ => 1.0m,
};


Lassen Sie mich für alle Fälle klarstellen: Die Vergleiche werden in der Reihenfolge durchgeführt, in der sie aufgelistet sind. Bei der ersten Übereinstimmung wird der entsprechende Wert zurückgegeben und es werden keine weiteren Vergleiche durchgeführt.

Update-



Tupel im Schalterausdruck können auch in C # 8.0 verwendet werden. Der wertlose Entwickler, der diesen Artikel geschrieben hat, ist jetzt ein bisschen schlauer.





Und zum Schluss noch ein verrücktes Beispiel, das eine neue Syntax für den Abgleich mit Tupeln und Objekteigenschaften demonstriert:



public static bool IsAccessOkOfficial(Person user, Content content, int season) => 
    (user, content, season) switch 
{
    ({Type: Child}, {Type: ChildsPlay}, _)          => true,
    ({Type: Child}, _, _)                           => false,
    (_ , {Type: Public}, _)                         => true,
    ({Type: Monarch}, {Type: ForHerEyesOnly}, _)    => true,
    (OpenCaseFile f, {Type: ChildsPlay}, 4) when f.Name == "Sherlock Holmes"  => true,
    {Item1: OpenCaseFile {Type: var type}, Item2: {Name: var name}} 
        when type == PoorlyDefined && name.Contains("Sherrinford") && season >= 3 => true,
    (OpenCaseFile, var c, 4) when c.Name.Contains("Sherrinford")              => true,
    (OpenCaseFile {RiskLevel: >50 and <100 }, {Type: StateSecret}, 3) => true,
    _                                               => false,
};


Das sieht alles ziemlich ungewöhnlich aus. Zum vollständigen Verständnis empfehle ich, dass Sie sich die Quelle ansehen . Es gibt ein vollständiges Beispiel für den Code.



Neue neue sowie grundlegend verbesserte Zieltypisierung



Vor langer Zeit wurde es in C # möglich, var anstelle eines Typnamens zu schreiben, da der Typ selbst aus dem Kontext bestimmt werden konnte (tatsächlich wird dies als Zieltypisierung bezeichnet). Das heißt, anstelle des folgenden Eintrags:



SomeLongNamedType variable = new SomeLongNamedType();


es wurde möglich, kompakter zu schreiben:



var variable = new SomeLongNamedType()


Und der Compiler wird den Variablentyp selbst erraten. Im Laufe der Jahre wurde die umgekehrte Syntax implementiert:



SomeLongNamedType variable = new ();


Besonderer Dank dafür, dass diese Syntax nicht nur beim Deklarieren einer Variablen funktioniert, sondern auch in vielen anderen Fällen, in denen der Compiler den Typ sofort erraten kann. Wenn Sie beispielsweise Parameter an eine Methode übergeben und einen Wert von der Methode zurückgeben:



var result = SomeMethod(new (2020,10,01));

//...

public Car SomeMethod(DateTime p)
{
    //...

    return new() { Passengers = 2 };
}


In diesem Beispiel wird beim Aufrufen von SomeMethod der Parameter des DateTime-Typs durch die Kurzschrift-Syntax erstellt. Der von der Methode zurückgegebene Wert wird auf die gleiche Weise erstellt.



Wenn diese Syntax wirklich von Nutzen ist, können Sie Sammlungen definieren:



List<DateTime> datesList = new()
{
    new(2020, 10, 01),
    new(2020, 10, 02),
    new(2020, 10, 03),
    new(2020, 10, 04),
    new(2020, 10, 05)
};

Car[] cars = 
{
    new() {Passengers = 2},
    new() {Passengers = 3},
    new() {Passengers = 4}
};


Das Fehlen der Notwendigkeit, den vollständigen Typnamen zu schreiben, wenn die Elemente der Sammlung aufgelistet werden, macht den Code ein wenig sauberer.



Ziel typisierte Operatoren? und?:



Der ternäre Operator?: Wurde in C # 9.0 verbessert. Früher war die vollständige Einhaltung der Rückgabetypen erforderlich, jetzt ist es jedoch intelligenter. Hier ist ein Beispiel für einen Ausdruck, der in früheren Versionen der Sprache ungültig, im neunten jedoch rechtmäßig war:



int? result = b ? 0 : null; // nullable value type


Früher war es erforderlich, explizit von Null in int umzuwandeln. Jetzt ist es nicht mehr erforderlich.



In der neuen Version der Sprache ist es außerdem zulässig, die folgende Konstruktion zu verwenden:



Person person = student ?? customer; // Shared base type


Die Kunden- und Schülertypen sind technisch unterschiedlich, obwohl sie von Person abgeleitet sind. In der vorherigen Version der Sprache konnten Sie ein solches Konstrukt nicht ohne explizite Typumwandlung verwenden. Jetzt versteht der Compiler genau, was gemeint ist.



Überschreiben des Rückgabetyps von Methoden



In C # 9.0 durfte der Rückgabetyp überschriebener Methoden überschrieben werden. Es gibt nur eine Anforderung: Der neue Typ muss vom Original geerbt werden (Kovariante). Hier ist ein Beispiel:



abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}


In der Tiger-Klasse wurde der Rückgabewert der GetFood-Methode von Food to Meat neu definiert. Es ist jetzt in Ordnung, wenn Fleisch aus Lebensmitteln gewonnen wird.



init-Eigenschaften sind nicht wirklich schreibgeschützte Mitglieder



In der neuen Version der Sprache ist eine interessante Funktion erschienen: init-properties. Dies sind Eigenschaften, die nur während der Erstinitialisierung des Objekts festgelegt werden können. Es scheint, dass nur schreibgeschützte Klassenmitglieder dafür existieren, aber tatsächlich sind es verschiedene Dinge, mit denen Sie verschiedene Probleme lösen können. Um den Unterschied und die Schönheit der Init-Eigenschaften zu verstehen, hier ein Beispiel:



Person employee = new () {
    Name = "Paul McCartney",
    Company = "High Technologies Center",
    CompanyAddress = new () {
        Country = "Russia",
        City = "Izhevsk",
        Line1 = "246, Karl Marx St."
    }
}


Diese Syntax zum Deklarieren einer Instanz einer Klasse ist sehr praktisch, insbesondere wenn sich mehr Objekte in den Eigenschaften der Klasse befinden. Diese Syntax weist jedoch Einschränkungen auf: Die entsprechenden Klasseneigenschaften müssen veränderbar sein . Dies liegt daran, dass die Initialisierung dieser Eigenschaften nach dem Aufruf des Konstruktors erfolgt. Das heißt, die Person-Klasse aus dem Beispiel sollte wie folgt deklariert werden:



class Person {
    //...
    public string Name {get; set;}
    public string Company {get; set;}
    public Address CompanyAddress {get; set;}
    //...
}


Tatsächlich ist die Name-Eigenschaft jedoch unveränderlich. Derzeit besteht die einzige Möglichkeit, diese schreibgeschützte Eigenschaft zu erstellen, darin, einen privaten Setter zu deklarieren:



class Person {
    //...
    public string Name {get; private set;}
    //...
}


In diesem Fall verlieren wir jedoch sofort die Möglichkeit, die bequeme Syntax zum Deklarieren einer Klasseninstanz zu verwenden, indem wir Eigenschaften in geschweiften Klammern Werte zuweisen. Und wir können den Wert der Name-Eigenschaft nur festlegen, indem wir ihn in Parametern an den Klassenkonstruktor übergeben. Stellen Sie sich nun vor, dass die CompanyAddress-Eigenschaft auch eine unveränderliche Bedeutung hat. Im Allgemeinen befand ich mich oft in einer solchen Situation und musste mich immer zwischen zwei Übeln entscheiden:



  • ausgefallene Konstruktoren mit einer Reihe von Parametern, aber allen Eigenschaften der schreibgeschützten Klasse;
  • Praktische Syntax zum Erstellen eines Objekts, aber alle Eigenschaften der Lese- / Schreibklasse, und ich muss mich daran erinnern und sie nicht versehentlich irgendwo ändern.


An diesem Punkt könnte sich jemand an die schreibgeschützten Klassenmitglieder erinnern und vorschlagen, die Personenklasse wie folgt zu gestalten:



class Person {
    //...
    public readonly string Name;
    public readonly string Company;
    public readonly string CompanyAddress;
    //...
}


Worauf ich antworte, dass diese Methode nicht nur nicht Feng Shui entspricht, sondern auch das Problem der bequemen Initialisierung nicht löst: Readonly-Member können auch nur im Konstruktor festgelegt werden, wie Eigenschaften mit einem privaten Setter.



In C # 9.0 ist dieses Problem jedoch gelöst: Wenn Sie eine Eigenschaft als init-Eigenschaft definieren, erhalten Sie sowohl eine praktische Syntax zum Erstellen eines Objekts als auch eine Eigenschaft, die in Zukunft tatsächlich unveränderlich ist:



class Person {
    public string Name { get; init; }
    public string Company { get; init; }
    public Address CompanyAddress { get; init; }
}


Übrigens können Sie in init-Eigenschaften wie im Konstruktor schreibgeschützte Klassenmitglieder initialisieren und wie folgt schreiben:



public class Person
{
    private readonly string name;
       
    public string Name
    { 
        get => name; 
        init => name = (value ?? throw new ArgumentNullException(nameof(Name)));
    }
}


Rekord ist ein legalisierter DTO



Wenn wir das Thema unveränderliche Eigenschaften fortsetzen, kommen wir meiner Meinung nach zur Hauptinnovation der Sprache: dem Datensatztyp. Dieser Typ wurde entwickelt, um bequem ganze unveränderliche Strukturen zu erstellen, nicht nur Eigenschaften. Der Grund für die Entstehung eines separaten Typs ist einfach: Wir arbeiten nach allen Kanonen und erstellen ständig DTOs, um verschiedene Ebenen der Anwendung zu isolieren. DTOs sind normalerweise nur eine Sammlung von Feldern ohne Geschäftslogik. In der Regel ändern sich die Werte dieser Felder während der Lebensdauer dieser DTOs nicht.



.



DTO – Data Transfer Object. (DAL, BL, PL) - . «». -DTO' DAL BL, , DTO-, , DTO-, - ( HTML- JSON-).



— DTO-, - -, .



DTO- - . DTO-, AutoMapper - .



, DTO- .



Nach vielen, vielen Jahren kamen die C # -Entwickler endlich zu der wirklich notwendigen Verbesserung: Sie legalisierten DTO-Modelle als separaten Datensatztyp.



Bisher waren alle von uns erstellten DTO-Modelle (und wir haben sie in großen Mengen erstellt) gewöhnliche Klassen. Für den Compiler und zur Laufzeit unterschieden sie sich nicht von allen anderen Klassen, obwohl dies im klassischen Sinne nicht der Fall war. Nur wenige Leute haben Strukturen für DTO-Modelle verwendet - dies war aus verschiedenen Gründen nicht immer akzeptabel.



Jetzt können wir einen Datensatz definieren (im Folgenden als Datensatz bezeichnet) - eine spezielle Struktur, mit der unveränderliche DTO-Modelle erstellt werden sollen. Die Aufnahme nimmt im üblichen Sinne einen Zwischenplatz zwischen Strukturen und Klassen ein. Es ist sowohl Unterklasse als auch Überbau. Ein Datensatz ist immer noch ein Referenztyp mit allen sich daraus ergebenden Konsequenzen. Datensätze verhalten sich fast immer wie eine reguläre Klasse, sie können Methoden enthalten, sie erlauben die Vererbung (aber nur von anderen Datensätzen, nicht von Objekten. Wenn der Datensatz jedoch nicht explizit von irgendetwas erbt, erbt er so implizit von Objekten wie alles in C # ) kann Schnittstellen implementieren. Außerdem müssen Sie Aufzeichnungen überhaupt nicht unveränderlich machen. Und wo ist dann die Bedeutung und was ist der Unterschied?



Erstellen wir einfach einen Eintrag:



public record Person 
{
    public string LastName { get; }
    public string FirstName { get; }

    public Person(string first, string last) => (FirstName, LastName) = (first, last);
}


Und jetzt hier ein Anwendungsbeispiel:



Person p1 = new ("Paul", "McCartney");
Person p2 = new ("Paul", "McCartney");

System.Console.WriteLine(p1 == p2);


In diesem Beispiel wird true auf der Konsole gedruckt. Wenn Person eine Klasse wäre, würde false auf der Konsole ausgegeben, da Objekte durch Referenz verglichen werden: Zwei Referenzvariablen sind nur dann gleich, wenn sie sich auf dasselbe Objekt beziehen. Bei Aufnahmen ist das aber nicht der Fall. Datensätze werden mit dem Wert aller ihrer Felder verglichen, einschließlich der privaten.



Fahren wir mit dem vorherigen Beispiel fort und schauen wir uns diesen Code an:



System.Console.WriteLine(p1);


Im Fall einer Klasse erhalten wir den vollständigen Namen der Klasse in der Konsole. Bei Einträgen wird dies jedoch in der Konsole angezeigt:



Person { LastName = McCartney, FirstName = Paul}


Tatsache ist, dass für Datensätze die ToString () -Methode implizit überschrieben wird und nicht den Typnamen, sondern eine vollständige Liste der öffentlichen Felder mit Werten anzeigt . In ähnlicher Weise werden für Datensätze die Operatoren == und! = Implizit neu definiert, wodurch die Vergleichslogik geändert werden kann.



Spielen wir mit der Vererbung von Datensätzen:



public record Teacher : Person
{
    public string Subject { get; }

    public Teacher(string first, string last, string sub)
        : base(first, last) => Subject = sub;
}


Erstellen wir nun zwei Posts unterschiedlichen Typs und vergleichen sie:



Person p = new("Paul", "McCartney");
Teacher t = new("Paul", "McCartney", "Programming");

System.Console.WriteLine(p == t);


Obwohl der Lehrerdatensatz von Person geerbt wird, sind die Variablen p und t nicht gleich, und false wird auf der Konsole gedruckt. Dies liegt daran, dass der Vergleich nicht nur für alle Datensatzfelder, sondern auch für Typen durchgeführt wird und die Typen hier deutlich unterschiedlich sind.



Und obwohl das Vergleichen geerbter Datensatztypen zulässig (aber sinnlos) ist, ist das Vergleichen verschiedener Datensatztypen im Allgemeinen grundsätzlich nicht zulässig:



public record Person
{
    public string LastName { get; }
    public string FirstName { get; }

    public Person(string first, string last) => (FirstName, LastName) = (first, last);
}

public record Person2
{
    public string LastName { get; }
    public string FirstName { get; }

    public Person2(string first, string last) => (FirstName, LastName) = (first, last);
}

// ...

Person p = new("Paul", "McCartney");
Person2 p2 = new("Paul", "McCartney");
System.Console.WriteLine(p == p2);    //  


Die Einträge scheinen gleich zu sein, aber in der letzten Zeile wird ein Kompilierungsfehler angezeigt. Sie können nur Datensätze vergleichen, die vom selben Typ oder vom geerbten Typ sind.



Eine weitere nette Funktion von Datensätzen ist das Schlüsselwort with, mit dem Sie problemlos Änderungen an Ihren DTO-Modellen vornehmen können. Schauen Sie sich ein Beispiel an:



Person me = new("Steve", "Brown");
Person brother = me with { FirstName = "Paul" };


In diesem Beispiel werden für den Bruderdatensatz die Werte aller Felder aus dem me-Datensatz ausgefüllt, mit Ausnahme des Felds FirstName - es wird in Paul geändert.



Bisher haben Sie die klassische Methode zum Erstellen von Datensätzen kennengelernt - mit vollständigen Definitionen von Konstruktoren, Eigenschaften usw. Aber jetzt gibt es auch einen lakonischen Weg:



public record Person(string FirstName, string LastName);

public record Teacher(string FirstName, string LastName,
    string Subject)
    : Person(FirstName, LastName);

public sealed record Student(string FirstName,
    string LastName, int Level)
    : Person(FirstName, LastName);


Sie können die Einträge in Kurzform definieren, und der Compiler erstellt die Eigenschaften und den Konstruktor für Sie. Diese Funktion verfügt jedoch über eine zusätzliche Funktion: Sie können nicht nur eine Kurzschreibweise zum Definieren von Eigenschaften und eines Konstruktors verwenden, sondern gleichzeitig dem Eintrag eine eigene Methode hinzufügen:



public record Pet(string Name)
{
    public void ShredTheFurniture() =>
        Console.WriteLine("Shredding furniture");
}

public record Dog(string Name) : Pet(Name)
{
    public void WagTail() =>
        Console.WriteLine("It's tail wagging time");

    public override string ToString()
    {
        StringBuilder s = new();
        base.PrintMembers(s);
        return $"{s.ToString()} is a dog";
    }
}


In diesem Fall werden auch die Eigenschaften und der Konstruktor von Datensätzen automatisch erstellt. Immer weniger Boilerplate-Code, aber nur für Posts. Dies funktioniert nicht für Klassen und Strukturen.



Zusätzlich zu allem, was bereits gesagt wurde, kann der Compiler automatisch einen Dekonstruktor für Datensätze erstellen:



var person = new Person("Bill", "Wagner");

var (first, last) = person; //    
Console.WriteLine(first);
Console.WriteLine(last);


Auf IL-Ebene sind Datensätze jedoch immer noch eine Klasse. Es gibt jedoch einen Verdacht, für den noch keine Bestätigung gefunden wurde: Auf Laufzeitebene werden Datensätze sicher irgendwo wild optimiert. Höchstwahrscheinlich aufgrund der Tatsache, dass im Voraus bekannt ist, dass ein bestimmter Datensatz unveränderlich ist. Dies eröffnet Optimierungsmöglichkeiten, zumindest in einer Multithread-Umgebung, und der Entwickler muss hierfür nicht einmal besondere Anstrengungen unternehmen.



In der Zwischenzeit schreiben wir alle DTO-Modelle von Klassen in Datensätze um.



.NET-Quellgeneratoren



Der Quellgenerator (im Folgenden einfach als Generator bezeichnet) ist eine ziemlich interessante Funktion. Ein Generator ist ein Code, der in der Kompilierungsphase ausgeführt wird, den bereits kompilierten Code analysieren kann und zusätzlichen Code generieren kann, der ebenfalls kompiliert wird. Wenn es nicht ganz klar ist, dann ist hier ein ziemlich relevantes Beispiel, wenn ein Generator gefragt sein kann.



Stellen Sie sich eine C # /. NET-Webanwendung vor, die Sie in ASP.NET Core schreiben. Wenn Sie diese Anwendung starten, gibt es eine Menge Hintergrundinformationen zur Initialisierung, um zu analysieren, woraus diese Anwendung besteht und was sie überhaupt tun sollte. Reflexion wird hektisch eingesetzt. Infolgedessen kann die Zeit vom Starten der Anwendung bis zum Beginn der Verarbeitung der ersten Anforderung übermäßig lang sein, was bei hoch ausgelasteten Diensten nicht akzeptabel ist. Der Generator kann dazu beitragen, diese Zeit zu verkürzen: Bereits in der Kompilierungsphase kann er Ihre bereits kompilierte Anwendung analysieren und zusätzlich den erforderlichen Code generieren, der sie beim Start viel schneller initialisiert.



Es gibt auch eine ziemlich große Anzahl von Bibliotheken, die mithilfe von Reflection zur Laufzeit die verwendeten Objekttypen bestimmen (darunter gibt es viele Top-Nuget-Pakete). Dies eröffnet einen enormen Spielraum für die Optimierung mithilfe von Generatoren, und die Autoren dieser Funktion erwarten von den Bibliotheksentwicklern entsprechende Verbesserungen.



Codegeneratoren sind ein neues Thema und zu ungewöhnlich, um in den Umfang dieses Beitrags zu passen. Zusätzlich sehen Sie ein Beispiel für das einfachste "Hallo Welt!" Generator in dieser Bewertung .



Codegeneratoren sind mit zwei neuen Funktionen verbunden, die im Folgenden beschrieben werden.



Teilmethoden



Teilklassen in C # gibt es schon seit langer Zeit. Ihr ursprünglicher Zweck besteht darin, den von einem bestimmten Designer generierten Code von dem vom Programmierer geschriebenen Code zu trennen. Teilmethoden wurden in C # 9.0 angepasst. Sie sehen ungefähr so ​​aus:



public partial class MyClass
{
    public partial int DoSomeWork(out string p);
}
public partial class MyClass
{
    public partial int DoSomeWork(out string p)
    {
        p = "test";
        System.Console.WriteLine("Partial method");
        return 5;
    }
}


Dieses Ersatzbeispiel zeigt, dass sich Teilmethoden im Wesentlichen nicht von gewöhnlichen unterscheiden: Sie können Werte zurückgeben, Out-Variablen akzeptieren und Zugriffsmodifikatoren haben.



Aus den verfügbaren Informationen geht hervor, dass Teilmethoden eng mit Codegeneratoren verwandt sind, in denen sie verwendet werden sollen.



Modulinitialisierer



Es gibt drei Gründe für die Einführung dieser Funktionalität:



  • Ermöglichen Sie Bibliotheken eine einmalige Initialisierung beim Booten mit minimalem Overhead und ohne explizite Notwendigkeit, dass der Benutzer etwas aufruft.
  • Die vorhandene Funktionalität statischer Konstruktoren ist für diese Rolle nicht sehr geeignet, da die Raintime zunächst herausfinden muss, ob überhaupt eine Klasse mit einem statischen Konstruktor verwendet wird (dies sind die Regeln), und dies führt zu messbaren Verzögerungen.
  • Codegeneratoren müssen über eine Art Initialisierungslogik verfügen, die nicht explizit aufgerufen werden muss.


Tatsächlich scheint der letzte Punkt entscheidend für die Aufnahme des Features in die Version geworden zu sein. Als Ergebnis haben wir ein neues Attribut entwickelt, mit dem wir die Methode der Initialisierung beschichten können:



using System.Runtime.CompilerServices;
class C
{
    [ModuleInitializer]
    internal static void M1()
    {
        // ...
    }
}


Es gibt einige Einschränkungen für die Methode:



  • es muss statisch sein;
  • es darf keine Parameter haben;
  • es sollte nichts zurückgeben;
  • es sollte nicht mit Generika funktionieren;
  • Es muss über das enthaltende Modul zugänglich sein, dh:

    • es muss intern oder öffentlich sein
    • Es muss keine lokale Methode sein


Und das funktioniert so: Sobald der Compiler alle mit dem ModuleInitializer-Attribut gekennzeichneten Methoden findet, generiert er speziellen Code, der sie alle aufruft. Die Reihenfolge des Aufrufs der Initialisierungsmethoden kann nicht angegeben werden, ist jedoch bei jeder Kompilierung gleich.



Fazit



Nachdem wir den Beitrag bereits veröffentlicht haben, haben wir festgestellt, dass er sich mehr den Nachrichten in der Sprache C # 9.0 als den Nachrichten von .NET selbst widmet. Aber es ist gut geworden.



All Articles