Rekord (record) to typ, którego głównym zadaniem jest przechowywanie danych i którego równość jest zdefiniowana przez te dane. Rekordy pojawiły się w C# 9. Piszesz jedną linię, a kompilator generuje składowe, których potrzebuje klasa danych: właściwości, konstruktor, oparte na wartościach Equals i ==, GetHashCode, czytelny ToString, Deconstruct i obsługę kopii przez with.
Rekordy wymagają C# 9 lub nowszego (.NET 5+), więc kod z rekordami na tej stronie jest pokazany jako zwykły C#, z wynikiem w komentarzach. Ostatnia sekcja pisze te same składowe ręcznie w C# 7 i ten kod możesz uruchomić.
Rekordy pozycyjne
Najkrótsza forma wymienia właściwości w nawiasach po nazwie. Każdy parametr staje się publiczną właściwością init-only o tej samej nazwie:
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
Z tej jednej linii kompilator generuje:
- konstruktor przyjmujący
(string Sku, string Name, decimal Price); - trzy właściwości
public ... { get; init; }; Equals(object),Equals(Product),GetHashCode()oraz operatory==i!=, wszystkie porównujące trzy właściwości;ToString(), który wypisuje nazwę typu i każdą publiczną właściwość;Deconstruct(out string Sku, out string Name, out decimal Price);- konstruktor kopiujący (protected, a w rekordzie sealed private) i ukrytą metodę klonującą używaną przez
with.
Rekord można też zapisać ze zwykłym ciałem, co przydaje się, gdy właściwości potrzebują wartości domyślnych albo walidacji:
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" };
Obie formy można też łączyć: parametry pozycyjne plus dodatkowe składowe w nawiasach klamrowych.
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
Równość wartościowa
Dla zwykłej klasy == pyta "czy to ten sam obiekt?". Dla rekordu pyta "czy mają te same wartości?":
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 jest generowany zgodnie z tym, więc rekordy działają poprawnie jako klucze słownika i w HashSet<T>: drugi rekord o tych samych wartościach znajduje wpis pierwszego.
Równość porównuje każde pole (w rekordach pozycyjnych pole za każdą właściwością) za pomocą EqualityComparer<T>.Default, który wywołuje własne Equals danego typu. Dla właściwości będącej kolekcją oznacza to równość referencji, co zaskakuje wiele osób:
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
Jeśli rekord przechowuje kolekcję i ma porównywać się według jej zawartości, nadpisz Equals(Basket other) i GetHashCode() albo użyj niemodyfikowalnej kolekcji z własną semantyką wartości.
Wyrażenia with: niedestrukcyjne zmiany
Rekordy są zwykle niemodyfikowalne, więc "zmieniasz" je, tworząc zmodyfikowaną kopię. with kopiuje każdą właściwość, a potem stosuje przypisania z nawiasów klamrowych:
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
Kopia jest płytka. Właściwość typu referencyjnego jest kopiowana jako referencja, więc oba rekordy współdzielą obiekt:
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
Albo trzymaj właściwości rekordu niemodyfikowalne do samego dołu (IReadOnlyList<T> wypełniona raz albo ImmutableList<T>), albo twórz nową listę w with: c1 with { Tags = new List<string>(c1.Tags) }.
ToString
Generowany ToString wypisuje nazwę typu i każdą publiczną właściwość, co sprawia, że rekordy dobrze wyglądają w logach i w debuggerze:
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
Kolekcje wypisują się jako nazwa ich typu (System.Collections.Generic.List`1[System.String]), a zagnieżdżone rekordy wypisują się rekurencyjnie. Całe wyjście możesz zastąpić, nadpisując ToString:
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct (C# 10)
Samo record oznacza record class: typ referencyjny. C# 10 dodał record struct, typ wartościowy z tymi samymi generowanymi składowymi:
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 }
Warto zapamiętać różnicę w ustawieniach domyślnych: pozycyjny record struct ma modyfikowalne właściwości ({ get; set; }), zgodnie z typowym zachowaniem struktur, a readonly record struct i record class mają właściwości init-only. Wybieraj między nimi tak, jak wybierasz między strukturą a klasą: małe wartości swobodnie kopiowane pasują do readonly record struct, wszystko inne do record.
Dziedziczenie
Rekord może dziedziczyć po innym rekordzie (nie po klasie, a klasa nie może dziedziczyć po rekordzie). Parametry pozycyjne przekazuje się do bazy jak argumenty konstruktora:
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 }
Równość uwzględnia typ w czasie działania, dzięki generowanej właściwości EqualityContract. Dlatego Circle nigdy nie jest równy Square o tym samym Color, choć oba są porównywane przez Shape, i dlatego ToString oraz with działają na typie pochodnym nawet wtedy, gdy zmienna ma typ bazowy.
To samo w C# 7: klasa z równością wartościową
Rekordy generują kod, który możesz napisać sam, a zobaczenie go wyjaśnia ich zachowanie. Oto klasa w C# 7 równoważna public record Point(int X, int Y);: właściwości tylko z getterem, konstruktor, Deconstruct, równość wartościowa, pasujący kod skrótu, ==, ToString i metoda With zastępująca wyrażenie with.
Wynik:
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
Około 30 linii dla dwóch właściwości, a każda nowa właściwość oznacza ponowne zmiany w konstruktorze, Deconstruct, Equals, GetHashCode i ToString. Zapomnienie o którejś z nich to klasyczny błąd (dwa punkty, które są ==, ale mają różne hasze, więc HashSet gubi je z oczu). Rekordy usuwają właśnie tę pracę utrzymaniową.
Klasa jest celowo sealed: równość wartościowa połączona z dziedziczeniem wymaga dodatkowego sprawdzenia typu, które rekordy generują przez EqualityContract, a zapieczętowanie omija ten problem.
Kiedy używać rekordu
Rekordy pasują do danych, które są zdefiniowane przez swoje wartości i nie zmieniają się po utworzeniu:
- modele żądań i odpowiedzi dla webowych API;
- komunikaty, polecenia i zdarzenia przekazywane między częściami systemu;
- obiekty konfiguracji i opcji;
- złożone klucze słowników (
record CacheKey(string Region, int Year)); - wyniki obliczeń (
record PriceQuote(decimal Net, decimal Tax)).
Słabo pasują tam, gdzie tożsamość liczy się bardziej niż wartości: encja Entity Framework to "klient 42" nawet po zmianie nazwiska, a śledzenie zmian w EF Core opiera się na tożsamości referencji. Tam użyj klasy.
Typowe błędy
- Oczekiwanie głębokiej równości kolekcji. Właściwość
List<T>porównuje się według referencji. Dwa rekordy z wyglądającymi tak samo listami nie są równe. - Oczekiwanie, że
withwykona głęboką kopię. Zagnieżdżone obiekty i kolekcje są wspólne dla oryginału i kopii. - Przypadkowo modyfikowalny pozycyjny
record struct. Dodajreadonly, chyba że chcesz właściwości z setterem. - Używanie rekordów jako encji EF Core. Równość wartościowa i kopiowanie kłócą się ze śledzeniem zmian.
- Dodanie rekordu do projektu w C# 8. Rekordy wymagają C# 9 (domyślnego dla .NET 5 i nowszych). W starszych projektach napisz klasę ręcznie, jak pokazano wyżej.
Najczęściej zadawane pytania
Czym jest record w C#?
Rekord (C# 9) to klasa, a w wersji record struct (C# 10) struktura, dla której kompilator generuje równość opartą na wartościach, czytelny ToString(), metodę Deconstruct i obsługę kopii przez with. public record Product(string Sku, decimal Price); to kompletny typ z dwiema właściwościami init-only. Dwa rekordy o równych wartościach właściwości są równe, choć to różne obiekty.
Jaka jest różnica między record a class w C#?
record to pod spodem klasa, więc jest typem referencyjnym i może dziedziczyć po innych rekordach. Różnica leży w generowanych składowych: rekordy porównują się według wartości (== i Equals sprawdzają każde pole), wypisują swoje właściwości w ToString() i obsługują with. Zwykła klasa porównuje się według referencji i wypisuje nazwę swojego typu, chyba że sam napiszesz te składowe.
Co robi wyrażenie with w C#?
var sale = product with { Price = 6.00m }; tworzy nowy rekord, który kopiuje każdą właściwość product, a potem ustawia te wymienione. Oryginał pozostaje bez zmian. Kopia jest płytka: właściwość typu List<T> jest wspólna dla obu rekordów, więc dodanie elementu przez jeden jest widoczne przez drugi.
Czym jest record struct w C#?
record struct (C# 10) to typ wartościowy z tymi samymi generowanymi składowymi co klasa rekordu: równością wartościową, ToString, Deconstruct i with. W odróżnieniu od klasy rekordu jego właściwości pozycyjne są domyślnie modyfikowalne; zadeklaruj go jako readonly record struct, żeby były init-only. Używaj go dla małych wartości, takich jak współrzędne albo kwoty pieniędzy.
Kiedy używać record w C#?
Dla danych, których tożsamością są ich wartości: DTO, modeli żądań i odpowiedzi API, komunikatów i zdarzeń, konfiguracji oraz kluczy słowników. Unikaj rekordów dla encji, które zmieniają się w czasie i są identyfikowane przez id, na przykład encji Entity Framework, bo równość wartościowa i kopie przez with utrudniają śledzenie zmian.