Ein Record ist ein Typ, dessen Hauptaufgabe es ist, Daten zu halten, und dessen Gleichheit durch diese Daten bestimmt wird. Records kamen mit C# 9. Du schreibst eine Zeile, und der Compiler erzeugt die Member, die eine Datenklasse braucht: Properties, einen Konstruktor, wertbasiertes Equals und ==, GetHashCode, ein lesbares ToString, Deconstruct und Unterstützung für Kopien mit with.
Records brauchen C# 9 oder neuer (.NET 5+), deshalb wird der Record-Code auf dieser Seite als einfaches C# gezeigt, mit der Ausgabe in Kommentaren. Der letzte Abschnitt schreibt dieselben Member von Hand in C# 7, das du ausführen kannst.
Positionelle Records
Die kürzeste Form listet die Properties in Klammern nach dem Namen auf. Jeder Parameter wird zu einer öffentlichen init-only-Property mit demselben Namen:
public record Product(string Sku, string Name, decimal Price);
var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name); // Mug
Console.WriteLine(mug); // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m; // error CS8852: init-only property
var (sku, name, price) = mug; // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50
Aus dieser einen Zeile erzeugt der Compiler:
- einen Konstruktor, der
(string Sku, string Name, decimal Price)nimmt; - drei Properties
public ... { get; init; }; Equals(object),Equals(Product),GetHashCode()und die Operatoren==und!=, die alle die drei Properties vergleichen;ToString(), das den Typnamen und jede öffentliche Property ausgibt;Deconstruct(out string Sku, out string Name, out decimal Price);- einen Kopierkonstruktor (protected, oder private in einem versiegelten Record) und eine versteckte Clone-Methode, die
withverwendet.
Ein Record lässt sich auch mit einem normalen Rumpf schreiben, was praktisch ist, wenn Properties Standardwerte oder Validierung brauchen:
public record Customer
{
public required string Email { get; init; } // required: C# 11
public string Name { get; init; } = "";
}
var c = new Customer { Email = "ana@example.com" };
Und beide Formen lassen sich kombinieren: positionelle Parameter plus zusätzliche Member in geschweiften Klammern.
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
Wertgleichheit
Bei einer normalen Klasse fragt == „sind das dieselben Objekte?“. Bei einem Record fragt es „haben diese dieselben Werte?“:
var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False: still two objects
GetHashCode wird passend erzeugt, Records funktionieren also korrekt als Dictionary-Schlüssel und in einem HashSet<T>: Ein zweiter Record mit denselben Werten findet den Eintrag des ersten.
Die Gleichheit vergleicht jedes Feld (bei positionellen Records das Feld hinter jeder Property) mit EqualityComparer<T>.Default, das das eigene Equals des Typs aufruft. Bei einer Collection-Property ist das Referenzgleichheit, was Leute überrascht:
public record Basket(string Owner, List<string> Items);
var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y); // False: two different List objects
Wenn ein Record eine Collection hält und nach deren Inhalt verglichen werden soll, überschreibe Equals(Basket other) und GetHashCode() oder nimm eine unveränderliche Collection mit eigener Wertsemantik.
with-Ausdrücke: nicht destruktive Änderungen
Records sind meist unveränderlich, du „änderst“ einen also, indem du eine geänderte Kopie erzeugst. with kopiert jede Property und wendet dann die Zuweisungen in den geschweiften Klammern an:
var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };
Console.WriteLine(sale); // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price); // 8.50: the original is untouched
Die Kopie ist flach. Eine Property eines Referenztyps wird als Referenz kopiert, beide Records teilen sich also das Objekt:
public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }
var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");
Console.WriteLine(string.Join(",", c1.Tags)); // vip,newsletter
Halte Record-Properties entweder bis ganz nach unten unveränderlich (IReadOnlyList<T>, einmal gefüllt, oder ImmutableList<T>), oder erzeuge im with eine neue Liste: c1 with { Tags = new List<string>(c1.Tags) }.
ToString
Das erzeugte ToString gibt den Typnamen und jede öffentliche Property aus, was Records in Logs und im Debugger angenehm macht:
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
Collections werden als ihr Typname ausgegeben (System.Collections.Generic.List`1[System.String]), und verschachtelte Records rekursiv. Du kannst die ganze Ausgabe ersetzen, indem du ToString überschreibst:
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct (C# 10)
record allein bedeutet record class: ein Referenztyp. C# 10 hat record struct eingeführt, einen Werttyp mit denselben erzeugten Membern:
public readonly record struct Point(int X, int Y);
var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4)); // True
Console.WriteLine(q); // Point { X = 3, Y = 10 }
Den Unterschied bei den Standards solltest du dir merken: Ein positionelles record struct hat veränderliche Properties ({ get; set; }), passend zum üblichen Verhalten von Structs, während readonly record struct und record class init-only-Properties haben. Wähle zwischen ihnen so, wie du zwischen Struct und Klasse wählen würdest: Kleine Werte, die frei kopiert werden, passen zu readonly record struct; alles andere zu record.
Vererbung
Ein Record kann von einem anderen Record erben (nicht von einer Klasse, und eine Klasse kann nicht von einem Record erben). Positionelle Parameter werden wie Konstruktorargumente an die Basis weitergegeben:
public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);
Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);
Console.WriteLine(a == b); // True
Console.WriteLine(a == c); // False: different runtime types are never equal
Console.WriteLine(a); // Circle { Color = red, Radius = 2 }
Die Gleichheit berücksichtigt den Laufzeittyp, über eine erzeugte Property EqualityContract. Deshalb ist ein Circle nie gleich einem Square mit derselben Color, obwohl beide über Shape verglichen werden, und deshalb funktionieren ToString und with mit dem abgeleiteten Typ, auch wenn die Variable den Basistyp hat.
Dasselbe in C# 7: eine Klasse mit Wertgleichheit
Records erzeugen Code, den du selbst schreiben kannst, und wenn man ihn sieht, versteht man ihr Verhalten. Hier ist eine Klasse in C# 7, die public record Point(int X, int Y); entspricht: schreibgeschützte Properties, ein Konstruktor, Deconstruct, Wertgleichheit, ein passender Hashcode, ==, ToString und eine Methode With als Ersatz für den with-Ausdruck.
Ausgabe:
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
Etwa 30 Zeilen für zwei Properties, und jede neue Property bedeutet, den Konstruktor, Deconstruct, Equals, GetHashCode und ToString erneut anzufassen. Einen davon zu vergessen ist ein klassischer Bug (zwei Punkte, die per == gleich sind, aber unterschiedliche Hashes haben, sodass ein HashSet sie aus den Augen verliert). Genau diese Wartung nehmen Records dir ab.
Die Klasse ist absichtlich sealed: Wertgleichheit zusammen mit Vererbung braucht die zusätzliche Typprüfung, die Records über EqualityContract erzeugen, und das Versiegeln umgeht das Problem.
Wann ein Record passt
Records passen zu Daten, die durch ihre Werte definiert sind und sich nach der Erzeugung nicht ändern:
- Request- und Response-Modelle für Web-APIs;
- Nachrichten, Befehle und Events, die zwischen Teilen eines Systems weitergegeben werden;
- Konfigurations- und Options-Objekte;
- zusammengesetzte Dictionary-Schlüssel (
record CacheKey(string Region, int Year)); - Ergebnisse einer Berechnung (
record PriceQuote(decimal Net, decimal Tax)).
Schlecht passen sie dort, wo die Identität wichtiger ist als die Werte: Eine Entity-Framework-Entität ist „Kunde 42“, auch nachdem sich sein Name geändert hat, und das Change Tracking von EF Core stützt sich auf Referenzidentität. Nimm dort eine Klasse.
Häufige Fehler
- Tiefe Gleichheit bei Collections erwarten. Eine
List<T>-Property wird per Referenz verglichen. Zwei Records mit gleich aussehenden Listen sind nicht gleich. - Erwarten, dass
withtief kopiert. Verschachtelte Objekte und Collections werden zwischen Original und Kopie geteilt. - Versehentlich ein veränderliches positionelles
record struct. Ergänzereadonly, außer du willst setzbare Properties. - Records als EF-Core-Entitäten verwenden. Wertgleichheit und Kopieren kollidieren mit dem Change Tracking.
- Einem Projekt mit C# 8 einen Record hinzufügen. Records brauchen C# 9 (den Standard ab .NET 5). Bei älteren Zielen schreibe die Klasse von Hand, wie oben gezeigt.
Häufig gestellte Fragen
Was ist ein Record in C#?
Ein Record (C# 9) ist eine Klasse, oder mit record struct (C# 10) ein Struct, für die der Compiler wertbasierte Gleichheit, ein lesbares ToString(), eine Methode Deconstruct und Unterstützung für Kopien mit with erzeugt. public record Product(string Sku, decimal Price); ist ein vollständiger Typ mit zwei init-only-Properties. Zwei Records mit gleichen Property-Werten sind gleich, obwohl sie verschiedene Objekte sind.
Was ist der Unterschied zwischen einem Record und einer Klasse in C#?
Ein record ist darunter eine Klasse, also ein Referenztyp, und kann von anderen Records erben. Die Unterschiede sind die erzeugten Member: Records vergleichen nach Wert (== und Equals prüfen jedes Feld), geben ihre Properties aus ToString() aus und unterstützen with. Eine normale Klasse vergleicht per Referenz und gibt ihren Typnamen aus, außer du schreibst diese Member selbst.
Was macht der with-Ausdruck in C#?
var sale = product with { Price = 6.00m }; erzeugt einen neuen Record, der jede Property von product kopiert und dann die aufgeführten setzt. Das Original bleibt unverändert. Die Kopie ist flach: Eine List<T>-Property wird von beiden Records geteilt, ein Hinzufügen über einen ist also über den anderen sichtbar.
Was ist ein record struct in C#?
record struct (C# 10) ist ein Werttyp mit denselben erzeugten Membern wie eine Record-Klasse: Wertgleichheit, ToString, Deconstruct und with. Anders als bei einer Record-Klasse sind seine positionellen Properties standardmäßig veränderlich; deklariere ihn als readonly record struct, um sie init-only zu machen. Nimm ihn für kleine Werte wie Koordinaten oder Geldbeträge.
Wann sollte ich in C# einen Record verwenden?
Für Daten, deren Identität ihre Werte sind: DTOs, Request- und Response-Modelle von APIs, Nachrichten und Events, Konfiguration und Schlüssel für Dictionaries. Vermeide Records für Entitäten, die sich über die Zeit ändern und über eine ID identifiziert werden, etwa Entity-Framework-Entitäten, weil Wertgleichheit und Kopien mit with gegen das Change Tracking arbeiten.