Gdy model danych ma głównie opisywać stan, a nie zmieniać go krok po kroku, zwykła klasa często wymaga zbyt dużej ilości kodu. W C# typ record upraszcza taki scenariusz, oferując niemutowalne właściwości, porównywanie po wartościach i wygodne tworzenie kopii. Pokażę, jak definiować rekordy, czym różnią się od klas i struktur oraz gdzie ich użycie naprawdę ma sens.
Rekordy upraszczają pracę z niezmiennymi modelami danych
-
recorddomyślnie oznacza referencyjnyrecord class. - Porównanie wartości sprawia, że dwa rekordy z takimi samymi danymi są równe.
-
Wyrażenie
withtworzy zmodyfikowaną kopię bez zmiany oryginału. -
record structjest typem wartościowym, areadonly record structlepiej wspiera niemutowalność. - Niemutowalność jest płytka, więc obiekt wskazywany przez właściwość nadal może być zmieniany.
Co daje record w C# i kiedy ma sens
Rekord to specjalny wariant klasy albo struktury przeznaczony przede wszystkim do przechowywania danych. Kompilator generuje za nas między innymi obsługę porównywania, GetHashCode(), ToString() oraz wygodny konstruktor. Dzięki temu możemy skupić się na modelu, a nie na kodzie technicznym.
Najważniejsza różnica względem zwykłej klasy dotyczy równości. Dwie instancje klasy są domyślnie różne, nawet jeśli przechowują identyczne wartości. Dwa rekordy tego samego typu porównują swoje dane, więc poniższy warunek zwróci true.
var first = new CustomerId("PL-100");
var second = new CustomerId("PL-100");
Console.WriteLine(first == second); // True
public record CustomerId(string Value);To dobrze pasuje do obiektów takich jak identyfikatory, adresy, wartości pieniężne, komendy, zdarzenia domenowe i modele odpowiedzi API. Nie oznacza jednak, że każdy obiekt w aplikacji powinien być rekordem. Encja mająca własny cykl życia, tożsamość i wiele operacji biznesowych nadal częściej będzie czytelniejsza jako klasa.
Jak zdefiniować rekord bez zbędnego kodu
Najkrótsza składnia wykorzystuje parametry pozycyjne. Każdy parametr staje się publiczną właściwością, a konstruktor jest generowany automatycznie.
public record Product(string Name, decimal Price);
var product = new Product("Klawiatura", 299.99m);
Console.WriteLine(product.Name);
Console.WriteLine(product);W przypadku record class właściwości utworzone z parametrów mają setter init. Można je ustawić podczas tworzenia obiektu, ale nie można przypisać nowej wartości po zakończeniu inicjalizacji.
var product = new Product("Klawiatura", 299.99m);
// product.Price = 249.99m; // błąd kompilacji
public record Product(string Name, decimal Price);Gdy model wymaga dodatkowej logiki albo bardziej opisowej deklaracji, używam składni blokowej. Pozwala ona dodać walidację, właściwości obliczane i metody.
public record Order
{
public required int Id { get; init; }
public required decimal Total { get; init; }
public bool IsLarge => Total >= 1000m;
}
var order = new Order
{
Id = 42,
Total = 1250m
};W praktyce nie przywiązuję się do jednej formy. Składnia pozycyjna świetnie działa dla małych, oczywistych modeli. W przypadku większego kontraktu blokowa deklaracja zwykle lepiej pokazuje, które pola są wymagane, a które opcjonalne.
W przykładach często stosuję także var, gdy typ wynika bezpośrednio z konstruktora. Jeśli chcesz uporządkować zasady takiego zapisu, pomocny będzie artykuł o użyciu var w C#.
Record class, record struct i readonly record struct
Słowo record bez dodatkowego modyfikatora jest skrótem dla record class. Taki typ jest referencyjny, podobnie jak zwykła klasa, ale korzysta z semantyki wartościowej i wygenerowanych mechanizmów rekordów.
record struct jest typem wartościowym. Zwykle stosuję go dla małych danych, które mają być lekkie i nie wymagają tożsamości obiektu na stercie. Trzeba jednak uważać, ponieważ pozycyjne właściwości zwykłego record struct są domyślnie zapisywalne.
public record struct Point(int X, int Y);
var point = new Point(10, 20);
point.X = 15; // dozwoloneJeśli zależy mi na niemutowalnym typie wartościowym, wybieram readonly record struct.
public readonly record struct Temperature(double Celsius);
var temperature = new Temperature(21.5);
// temperature.Celsius = 22.0; // błąd kompilacji| Typ | Kategoria | Domyślna mutowalność właściwości pozycyjnych | Typowe zastosowanie |
|---|---|---|---|
record class |
Referencyjny | init |
DTO, komendy, zdarzenia, modele domenowe oparte na danych |
record struct |
Wartościowy | set |
Małe wartości, gdy mutowalność jest akceptowalna |
readonly record struct |
Wartościowy | init |
Niewielkie, niemutowalne wartości, na przykład współrzędne |
Moja praktyczna zasada jest prosta. Zaczynam od record class, a po record struct sięgam dopiero wtedy, gdy rozmiar, sposób przekazywania i semantyka typu wartościowego są naprawdę uzasadnione. Sam fakt, że struktura może być szybsza, nie wystarcza do podjęcia tej decyzji.
Równość, with i granice niemutowalności
Rekordy porównują wartości swoich właściwości, a nie adresy obiektów. Kompilator generuje odpowiednie metody oraz operatory == i !=. Dzięki temu model danych zachowuje się tak, jak większość osób intuicyjnie oczekuje.
public record User(string Login, string Role);
var admin = new User("anna", "admin");
var copy = new User("anna", "admin");
Console.WriteLine(admin == copy); // True
Console.WriteLine(ReferenceEquals(admin, copy)); // FalseDo zmiany pojedynczej wartości służy wyrażenie with. Tworzy ono nową instancję na podstawie istniejącej, pozostawiając oryginał bez zmian.
var regularUser = new User("anna", "user");
var promotedUser = regularUser with { Role = "admin" };
Console.WriteLine(regularUser.Role); // user
Console.WriteLine(promotedUser.Role); // adminTo bardzo wygodny model pracy w aplikacjach webowych i systemach opartych na zdarzeniach. Mogę przekazać dane przez kilka warstw bez obawy, że jedna z nich przypadkiem zmieni stan współdzielonego obiektu. Nie jest to jednak głęboka niemutowalność.
public record Report(string Title, List Tags);
var first = new Report("Raport", new List { "C#" });
var second = first with { Title = "Nowy raport" };
second.Tags.Add(".NET");
Console.WriteLine(first.Tags.Count); // 2 Lista nadal jest tym samym obiektem, więc zmiana wykonana przez second jest widoczna także przez first. Przy danych zagnieżdżonych używam niemutowalnych kolekcji albo tworzę kopie kolekcji podczas budowania rekordu. Sam zapis get i init nie rozwiązuje tego problemu.
Gdzie rekord sprawdza się najlepiej
Najbardziej naturalnym miejscem dla rekordów są kontrakty przesyłane między warstwami. Przykładowy model żądania nie potrzebuje własnej tożsamości ani metod zmieniających stan, dlatego krótka deklaracja jest tu czytelniejsza niż rozbudowana klasa.
public record CreateInvoiceCommand(
int CustomerId,
DateOnly IssueDate,
decimal Amount);Podobnie wyglądają zdarzenia domenowe i odpowiedzi z API. Ich sens polega na tym, jakie dane opisują, a nie na tym, że są konkretnym obiektem przechowywanym w pamięci.
public record InvoiceCreated(
Guid InvoiceId,
DateTimeOffset CreatedAt,
decimal Amount);Ostrożniej podchodzę do rekordów jako encji mapowanych przez ORM. Encja w bazie często ma tożsamość, śledzenie zmian i operacje aktualizujące stan. W takim przypadku rekord może utrudnić konfigurację albo zasugerować niemutowalność, której model persystencji wcale nie potrzebuje.
Rekordy dobrze pasują także do wartościowych elementów większych modeli, na przykład adresu, przedziału dat czy parametrów paginacji. Sam wybór typu nie zastępuje jednak dobrego projektu kolekcji. Przy takich decyzjach przydaje się świadomy dobór kolekcji C#, szczególnie gdy rekord zawiera listy, słowniki albo zbiory.
Najczęstsze błędy przy wyborze rekordów
Traktowanie rekordu jak gwarancji pełnej niezmienności
Właściwość może być niezmienna, ale wskazywany przez nią obiekt już nie. Dotyczy to list, tablic, słowników i własnych klas. Przy modelach współdzielonych między wątkami trzeba zadbać również o niemutowalność ich zawartości.
Używanie record struct bez sprawdzenia kosztu kopiowania
Struktura jest kopiowana przy przypisaniu i przekazywaniu według wartości. Dla małego punktu to świetny wybór, ale dla dużego modelu może oznaczać niepotrzebne kopiowanie danych. Rozmiar typu i częstotliwość przekazywania mają tu większe znaczenie niż sama moda na typy wartościowe.
Wkładanie do rekordu logiki zarządzającej cyklem życia
Rekord może mieć metody i dziedziczenie, ale jego mocną stroną pozostaje opis danych. Jeżeli obiekt posiada wiele reguł, zmienia stan etapami i pilnuje własnej spójności, zwykła klasa często komunikuje zamiar lepiej.
Przeczytaj również: Jak definiować elementy w C#? Klasy, metody i #define
Zapominanie o zgodności serializacji
Rekordy dobrze współpracują z popularnymi serializerami, ale warto sprawdzić wymagania konkretnego frameworka. Konstruktor pozycyjny, właściwości init, nazwy parametrów i ustawienia deserializacji mogą mieć znaczenie przy starszym kodzie albo nietypowej konfiguracji.
Record najlepiej działa tam, gdzie liczy się stan danych
Wybieram rekord wtedy, gdy obiekt jest przede wszystkim wartością opisującą dane, a nie właścicielem zmiennego procesu. W takich miejscach dostaję mniej kodu, bezpieczniejsze przekazywanie informacji i naturalne porównywanie instancji.
Najczęściej zaczynam od record class, pamiętam o płytkiej niemutowalności i używam with do tworzenia kolejnych wersji modelu. Po record struct sięgam dopiero wtedy, gdy świadomie potrzebuję typu wartościowego. Taki wybór jest prosty, przewidywalny i dobrze skaluje się w aplikacjach .NET.
