Erstellen von Datensatztypen

Mit C# 9 wurden Datensätze eingeführt. Dabei handelt es sich um einen neuen Verweistyp, den Sie anstelle von Klassen oder Strukturen erstellen können. Datensätze unterscheiden sich insofern von Klassen, dass Datensatztypen wertbasierte Gleichheit verwenden. Zwei Variablen eines Datensatztyps sind gleich, wenn die Datensatztypdefinitionen identisch sind und wenn die Werte in beiden Datensätzen für alle Felder identisch sind. Zwei Variablen eines Klassentyps sind identisch, wenn die Objekte, auf die verwiesen wird, denselben Klassentyp aufweisen und die Variablen auf dasselbe Objekt verweisen. Die wertbasierte Gleichheit impliziert andere Funktionen, die Sie wahrscheinlich in Datensatztypen benötigen. Der Compiler generiert viele dieser Member, wenn Sie record anstelle von class deklarieren.

In diesem Tutorial lernen Sie Folgendes:

  • Wann Sie class oder record deklarieren sollten
  • Deklarieren von Datensatztypen und Datensatztypen mit fester Breite
  • Ersetzen Ihrer Methoden für vom Compiler generierte Methoden in Datensätzen

Voraussetzungen

Sie müssen Ihren Computer für die Ausführung von .NET 5 oder höher einrichten, einschließlich des Compilers von C# 9.0 oder höher. Der C# 9.0-Compiler steht ab Visual Studio 2019 Version 16.8 oder mit dem .NET 5.0 SDK zur Verfügung.

Charakteristiken von Datensätzen

Sie definieren einen Datensatz, indem Sie einen Typ mit dem Schlüsselwort record deklarieren, anstatt mit dem Schlüsselwort class oder struct. Ein Datensatz ist ein Verweistyp und unterliegt der Semantik der wertbasierten Gleichheit. Zum Erzwingen der Wertsemantik generiert der Compiler mehrere Methoden für Ihren Datensatztyp:

Darüber hinaus stellen Datensätze eine Überschreibung von Object.ToString() bereit. Der Compiler synthetisiert Methoden zum Anzeigen von Datensätzen mit Object.ToString(). Diese Member untersuchen Sie, während Sie Code für dieses Tutorial schreiben. Datensätze unterstützen with-Ausdrücke, um nicht destruktive Änderungen von Datensätzen zu ermöglichen.

Sie können auch Datensätze mit fester Breite mithilfe einer kürzeren Syntax deklarieren. Der Compiler synthetisiert mehr Methoden für Sie, wenn Sie Datensätze mit fester Breite deklarieren:

  • Ein primärer Konstruktor, dessen Parameter mit den Parametern mit fester Breite der Datensatzdeklaration übereinstimmen
  • Öffentliche init-only-Eigenschaften für jeden Parameter eines primären Konstruktors
  • Eine Deconstruct-Methode zum Extrahieren von Eigenschaften aus dem Datensatz

Erstellen von Temperaturdaten

Daten und Statistiken gehören zu den Szenarios, in denen Sie Datensätze verwenden sollten. Für dieses Tutorial erstellen Sie eine Anwendung, die Wärmesummen für verschiedene Verwendungszwecke berechnet. Wärmesummen sind ein Maß für Temperaturwerte über einen gewissen Zeitraum von Tagen, Wochen oder Monaten. Wärmesummen verfolgen den Energieverbrauch und prognostizieren diesen. Eine größere Anzahl von Tagen mit hohen Temperaturen führt zu erhöhter Nutzung von Klimaanlagen, während eine größere Anzahl von Tagen mit niedrigen Temperaturen zu erhöhter Nutzung von Heizkörpern führt. Wärmesummen helfen bei der Verwaltung von Pflanzenbeständen und korrelieren mit dem Pflanzenwachstum im Wechsel der Jahreszeiten. Wärmesummen werden außerdem zur Nachverfolgung von Tierwanderungen für Spezies verwendet, die sich dem Klima entsprechend bewegen.

Die Formel basiert auf der Durchschnittstemperatur eines jeweiligen Tages und einer Baselinetemperatur. Zum Berechnen von Wärmesummen über Zeit benötigen Sie die Höchst- und Mindesttemperaturen für jeden Tag eines Zeitraums. Im Folgenden beginnen Sie mit der Erstellung einer neuen Anwendung. Erstellen Sie eine neue Konsolenanwendung. Erstellen Sie einen neuen Datensatztyp in einer neuen Datei namens „DailyTemperature.cs“:

public record DailyTemperature(double HighTemp, double LowTemp);

Mit dem obigen Code wird ein Datensatz mit fester Breite definiert. Sie haben einen Verweistyp erstellt, der zwei Eigenschaften umfasst: HighTemp und LowTemp. Bei diesen Eigenschaften handelt es sich um init-only-Eigenschaften, d. h., sie können im Konstruktor oder mithilfe eines Eigenschafteninitialisierers festgelegt werden. Der Typ DailyTemperature verfügt ebenfalls über einen primären Konstruktor, der über zwei Parameter verfügt, die den zwei Eigenschaften entsprechen. Sie verwenden den primären Konstruktor zum Initialisieren eines DailyTemperature-Datensatzes:

private static DailyTemperature[] data = new DailyTemperature[]
{
    new DailyTemperature(HighTemp: 57, LowTemp: 30), 
    new DailyTemperature(60, 35),
    new DailyTemperature(63, 33),
    new DailyTemperature(68, 29),
    new DailyTemperature(72, 47),
    new DailyTemperature(75, 55),
    new DailyTemperature(77, 55),
    new DailyTemperature(72, 58),
    new DailyTemperature(70, 47),
    new DailyTemperature(77, 59),
    new DailyTemperature(85, 65),
    new DailyTemperature(87, 65),
    new DailyTemperature(85, 72),
    new DailyTemperature(83, 68),
    new DailyTemperature(77, 65),
    new DailyTemperature(72, 58),
    new DailyTemperature(77, 55),
    new DailyTemperature(76, 53),
    new DailyTemperature(80, 60),
    new DailyTemperature(85, 66) 
};

Sie können Ihre eigenen Eigenschaften oder Methoden zu Datensätzen hinzufügen, dazu zählen auch Datensätze mit fester Breite. Sie müssen die Durchschnittstemperatur für jeden Tag berechnen. Diese Eigenschaft können Sie zum Datensatz DailyTemperature hinzufügen:

public record DailyTemperature(double HighTemp, double LowTemp)
{
    public double Mean => (HighTemp + LowTemp) / 2.0;
}

Als Nächstes stellen Sie sicher, dass Sie diese Daten verwenden können. Fügen Sie der Main-Methode den folgenden Code hinzu:

foreach (var item in data)
    Console.WriteLine(item);

Führen Sie Ihre Anwendung aus. Daraufhin sollte Ihnen eine Ausgabe angezeigt werden, die der folgenden Anzeige ähnelt (einige Zeilen wurden aus Platzgründen entfernt):

DailyTemperature { HighTemp = 57, LowTemp = 30, Mean = 43.5 }
DailyTemperature { HighTemp = 60, LowTemp = 35, Mean = 47.5 }


DailyTemperature { HighTemp = 80, LowTemp = 60, Mean = 70 }
DailyTemperature { HighTemp = 85, LowTemp = 66, Mean = 75.5 }

Der obige Code zeigt die Ausgabe der Überschreibung von ToString an, die vom Compiler synthetisiert wurde. Wenn Sie einen anderen Text bevorzugen, können Sie Ihre eigene Version von ToString schreiben, die den Compiler daran hindert, eine Version für Sie zu synthetisieren.

Berechnen von Wärmesummen

Zum Berechnen der Wärmesummen verwenden Sie die Differenz zwischen einer Baselinetemperatur und einer Durchschnittstemperatur für einen Tag. Zum Berechnen der Wärmesumme über Zeit entfernen Sie alle Tage, an denen die Durchschnittstemperatur unterhalb der Baselinetemperatur liegt. Zum Berechnen der Kältesumme über Zeit entfernen Sie alle Tage, an denen die Durchschnittstemperatur über der Baselinetemperatur liegt. Beispielsweise verwenden die USA 65 F als Baselinetemperatur für Wärme- und Kältesummen. Bei dieser Temperatur ist weder Heizung noch Kühlung erforderlich. Ein Tag mit einer Durchschnittstemperatur von 70 F weist eine Kältesumme von „5“und eine Wärmesumme von „0“ auf. Wenn die Durchschnittstemperatur also 55 F entspricht, weist der Tag dementsprechend eine Wärmesumme von „10“ und eine Kältesumme von „0“ auf.

Sie können diese Formeln in Form einer kleinen Hierarchie aus Datensatztypen ausdrücken: ein abstrakter Temperatursummentyp und zwei konkrete Typen für Wärmesummen und Kältesummen. Bei diesen Typen kann es sich außerdem um Datensätze mit fester Breite handeln. Sie verwenden eine Baselinetemperatur und eine Reihe täglicher Temperaturdatensätze als Argumente für den primären Konstruktor:

public abstract record DegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords);

public record HeatingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean < BaseTemperature).Sum(s => BaseTemperature - s.Mean);
}

public sealed record CoolingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean > BaseTemperature).Sum(s => s.Mean - BaseTemperature);
}

Der abstrakte Datensatz DegreeDays ist die gemeinsame Basisklasse für die beiden Datensätze HeatingDegreeDays und CoolingDegreeDays. Die primären Konstruktordeklarationen der abgeleiteten Datensätze zeigen, wie die Initialisierung des Basisdatensatzes verwaltet wird. Ihr abgeleiteter Datensatz deklariert Parameter für alle Parameter im primären Konstruktor des Basisdatensatzes. Der Basisdatensatz deklariert und initialisiert diese Eigenschaften. Der abgeleitete Datensatz blendet diese nicht aus. Stattdessen erstellt und initialisiert er nur Eigenschaften für Parameter, die nicht in seinem Basisdatensatz deklariert sind. In diesem Beispiel fügen die abgeleiteten Datensätze keine neuen primären Konstruktorparameter hinzu. Testen Sie Ihren Code, indem Sie den folgenden Code zu Ihrer Main-Methode hinzufügen:

var heatingDegreeDays = new HeatingDegreeDays(65, data);
Console.WriteLine(heatingDegreeDays);

var coolingDegreeDays = new CoolingDegreeDays(65, data);
Console.WriteLine(coolingDegreeDays);

Daraufhin sollte eine Ausgabe ähnlich der folgenden angezeigt werden:

HeatingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 85 }
CoolingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 71.5 }

Definieren von durch den Compiler synthetisierten Methoden

Ihr Code berechnet die korrekte Anzahl von Wärme- und Kältesummen über den Zeitraum. Jedoch veranschaulicht dieses Beispiel, wieso Sie einige der synthetisierten Methoden durch Datensätze ersetzen sollten. Mit Ausnahme der Clone-Methode können Sie Ihre eigene Version von beliebigen durch den Compiler synthetisierten Methoden in einem Datensatztyp deklarieren. Die Clone-Methode verfügt über einen vom Compiler generierten Namen, und Sie können keine andere Implementierung bereitstellen. Die synthetisierten Methoden umfassen einen Kopierkonstruktor, die Member der System.IEquatable<T>-Schnittstelle, Gleichheits- und Ungleichheitstests sowie GetHashCode(). Zu diesem Zweck synthetisieren Sie PrintMembers. Sie könnten auch Ihre eigene Version von ToString deklarieren, jedoch stellt PrintMembers eine bessere Option für Vererbungsszenarios dar. Die Signatur muss mit der synthetisierten Methode übereinstimmen, wenn Sie Ihre eigene Version einer synthetisierten Methode verwenden möchten.

Das Element TempRecords in der Konsolenausgabe ist nicht nützlich. Es zeigt den Typ an, erfüllt aber sonst keinen Zweck. Sie können dieses Verhalten ändern, indem Sie Ihre eigene Implementierung der synthetisierten PrintMembers-Methode angeben. Die Signatur hängt von den Modifizierern ab, die auf die record-Deklaration angewendet werden:

  • Wenn ein Datensatztyp sealed ist, lautet die Signatur private bool PrintMembers(StringBuilder builder);.
  • Wenn ein Datensatztyp nicht sealed ist und von object abgeleitet wird (d. h., er deklariert keinen Basisdatensatz), lautet die Signatur protected virtual bool PrintMembers(StringBuilder builder);.
  • Wenn ein Datensatztyp nicht sealed ist und von einem anderen Datensatz abgeleitet wird, lautet die Signatur protected override bool PrintMembers(StringBuilder builder);.

Diese Regeln sind am einfachsten zu verstehen, wenn Sie den Zweck von PrintMembers verstehen. PrintMembers fügt Informationen zu jeder Eigenschaft in einem Datensatztyp zu einer Zeichenfolge hinzu. Der Vertrag erfordert, dass Basisdatensätze ihre Member zur Anzeige hinzufügen, und geht davon aus, dass abgeleitete Member ihre Member hinzufügen. Jeder Datensatztyp synthetisiert eine ToString-Überschreibung, die dem folgenden Beispiel für HeatingDegreeDays ähnelt:

public override string ToString()
{
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.Append("HeatingDegreeDays");
    stringBuilder.Append(" { ");
    if (PrintMembers(stringBuilder))
    {
        stringBuilder.Append(" ");
    }
    stringBuilder.Append("}");
    return stringBuilder.ToString();
}

Sie deklarieren eine PrintMembers-Methode im Datensatz DegreeDays, der den Typ der Sammlung nicht ausgibt:

protected virtual bool PrintMembers(StringBuilder stringBuilder)
{
    stringBuilder.Append($"BaseTemperature = {BaseTemperature}");
    return true;
}

Die Signatur deklariert eine virtual protected-Methode entsprechend der Version des Compilers. Sie müssen sich keine Sorgen darüber machen, dass Sie die falschen Zugriffsmethoden verwenden, da die Sprache die richtige Signatur erzwingt. Wenn Sie die richtigen Modifizierer für synthetisierte Methoden vergessen, gibt der Compiler Warnungen oder Fehler aus, die Sie dabei unterstützen, die richtige Signatur zu verwenden.

Nicht destruktive Änderungen

Die synthetisierten Member in einem Datensatz mit fester Breite ändern den Zustand des Datensatzes nicht. Das Ziel besteht darin, dass Sie unveränderliche Datensätze einfacher erstellen können. Sehen Sie sich noch einmal die vorherigen Deklarationen für HeatingDegreeDays und CoolingDegreeDays an. Die hinzugefügten Member führen Berechnungen der Werte für den Datensatz durch, ändern aber nicht den Zustand. Datensätze mit fester Breite vereinfachen das Erstellen unveränderlicher Verweistypen.

Das Erstellen unveränderlicher Verweistypen impliziert, dass Sie nicht destruktive Änderungen verwenden sollten. Sie erstellen neue Datensatzinstanzen mit with-Ausdrücken, die den vorhandenen Datensatzinstanzen ähneln. Diese Ausdrücke sind eine Kopierkonstruktion mit zusätzlichen Zuweisungen, die die Kopie ändern. Das Ergebnis ist eine neue Datensatzinstanz, bei der alle Eigenschaften aus dem vorhandenen Datensatz kopiert und optional geändert wurden. Der ursprüngliche Datensatz bleibt unverändert.

Als Nächstes fügen Sie zur Veranschaulichung von with-Ausdrücken einige Features zu Ihrem Programm hinzu. Zuerst erstellen Sie einen neuen Datensatz zum Berechnen steigender Wärmesummen mithilfe derselben Daten. Für steigende Wärmesummen werden in der Regel 41 F als Baselinetemperatur verwendet und sie messen Temperaturen über der Baseline. Sie können einen neuen Datensatz erstellen, der coolingDegreeDays ähnelt, aber eine andere Baselinetemperatur verwendet, um dieselben Daten zu verwenden:

// Growing degree days measure warming to determine plant growing rates
var growingDegreeDays = coolingDegreeDays with { BaseTemperature = 41 };
Console.WriteLine(growingDegreeDays);

Sie können die Anzahl der berechneten Temperaturen mit den generierten Zahlen mit einer höheren Baselinetemperatur vergleichen. Denken Sie daran, dass Datensätze Verweistypen sind und dass es sich bei diesen Kopien um flache Kopien handelt. Das Array für die Daten wird nicht kopiert, aber beide Datensätze beziehen sich auf dieselben Daten. Dies ist in einem anderen Szenario von Vorteil. Bei steigenden Wärmesummen ist es nützlich, die Gesamtsumme der letzten fünf Tage zu überwachen. Mithilfe von with-Ausdrücken können Sie neue Datensätze mit anderen Quelldaten erstellen. Mit dem folgenden Code wird eine Sammlung dieser Akkumulationen erstellt, und anschließend werden die Werte angezeigt:

// showing moving accumulation of 5 days using range syntax
List<CoolingDegreeDays> movingAccumulation = new();
int rangeSize = (data.Length > 5) ? 5 : data.Length;
for (int start = 0; start < data.Length - rangeSize; start++)
{
    var fiveDayTotal = growingDegreeDays with { TempRecords = data[start..(start + rangeSize)] };
    movingAccumulation.Add(fiveDayTotal);
}
Console.WriteLine();
Console.WriteLine("Total degree days in the last five days");
foreach(var item in movingAccumulation)
{
    Console.WriteLine(item);
}

Sie können auch with-Ausdrücke verwenden, um Kopien von Datensätzen zu erstellen. Geben Sie keine Eigenschaften zwischen den geschweiften Klammern für den with-Ausdruck an. Das bedeutet, eine Kopie wird erstellt und es werden keine Eigenschaften geändert:

var growingDegreeDaysCopy = growingDegreeDays with { };

Führen Sie die fertiggestellte Anwendung aus, um die Ergebnisse anzuzeigen.

Zusammenfassung

In diesem Tutorial wurden verschiedene Aspekte von Datensätzen vorgestellt. Datensätze bieten eine präzise Syntax für Verweistypen, bei dem der grundlegende Zweck das Speichern von Daten ist. Bei objektorientierten Klassen ist der grundlegende Zweck die Definition von Zuständigkeiten. Im Mittelpunkt dieses Tutorials standen Datensätze mit fester Breite, für die Sie eine präzise Syntax zum Deklarieren der init-only-Eigenschaften für einen Datensatz verwenden können. Der Compiler synthetisiert einige Member des Datensatzes zum Kopieren und Vergleichen der Datensätze. Sie können beliebige andere Member hinzufügen, die Sie für Ihre Datensatztypen benötigen. Sie können unveränderliche Datensatztypen erstellen und sich gewiss sein, das kein vom Compiler generierter Member den Zustand ändern würde. with-Ausdrücke vereinfachen zudem die Unterstützung nicht destruktiver Änderungen.

Datensätze bieten eine weitere Möglichkeit zum Definieren von Typen. Sie können class-Definitionen zum Erstellen von objektorientierten Hierarchien verwenden, die sich auf die Zuständigkeiten und das Verhalten von Objekten konzentrieren. Sie können struct-Typen für Datenstrukturen erstellen, die Daten speichern und klein genug für effiziente Kopiervorgänge sind. Sie erstellen record-Typen, wenn Sie wertbasierte Gleichheit und Vergleiche wünschen, keine Werte kopieren und Verweisvariablen verwenden möchten.

Eine vollständige Beschreibung der Datensätze finden Sie im Artikel der C#-Sprachreferenz für den Datensatztyp und in der vorgeschlagenen Datensatztyp-Spezifikation.