1. Po co w ogóle powstały record
W C# (i .NET ogólnie) przez długi czas głównymi budulcami były klasy (class) i struktury (struct). Ale każdy z nich jest niepełny do pewnych zadań. Klasy — referencyjne, mutowalne i porównywane po referencji (z bardzo rzadkimi wyjątkami). Struktury — typy wartościowe (kopiowane przy przekazaniu), domyślnie porównywane bajt po bajcie i najczęściej mutowalne (dopóki nie pojawiły się readonly struct).
A jeśli chciałeś prosty i naturalny zapis danych, który łatwo porównać po wartości, szybko sklonować i nie martwić się, że ktoś gdzieś zmienił twój obiekt — trzeba było kombinować na własną rękę albo korzystać z bibliotek typu ValueTuple czy nawet System.Tuple. Ale to wszystko nie jest tak fajne i czytelne, jak by się chciało.
Dlatego w C# 9 pojawił się record — typ danych, który łączy zwięzłość deklaracji, bezpieczeństwo niezmienności i zachowanie nastawione na porównywanie po wartości.
2. Wszystkie cztery typy na jednej stronie
| class | struct | record | record struct | |
|---|---|---|---|---|
| Kategoria | Typ referencyjny | Typ wartościowy | Typ referencyjny | Typ wartościowy |
| Mutowalność | Domyślnie — tak | Domyślnie — tak | Domyślnie — nie (init) | Domyślnie — nie (init) |
| Porównanie | Po referencji (==) | Po wartości (pola) | Po wartości (fields/properties) | Po wartości (fields) |
| Klonowanie | Tylko ręcznie | Tylko ręcznie | Jest wsparcie wbudowane (with) | Jest wsparcie wbudowane (with) |
| Dziedziczenie | Tak | Nie | Tak | Nie |
| Niezmienność | Trzeba zaimplementować | Trzeba zaimplementować | Bardzo łatwo zaimplementować | Bardzo łatwo zaimplementować |
| Składnia | Najdłuższa | Krótka | Najkrótsza (pozycyjna) | Dość krótka |
| Użycie w kolekcjach | Po referencji | Kopie | Po referencji | Kopie |
Schemat wizualny
+----------------+ +----------------+ +--------------------+
| class | | struct | | record |
+----------------+ +----------------+ +--------------------+
| Reference Type | | Value Type | | Reference Type |
| Mutable | | Mutable | | Immutable (init) |
| == : Reference | | == : By Fields | | == : By Value |
+----------------+ +----------------+ +--------------------+
3. Istota różnic między record, class i struct
Zachowanie w pamięci: referencja czy wartość?
- class i record — to typy referencyjne. Gdy przekazujesz je do funkcji, kopiowana jest referencja do obiektu.
- struct i record struct — typy wartościowe. Zawsze kopiowane bajt po bajcie (chyba że jawnie przekażesz je przez referencję).
class PointClass { public int X; public int Y; }
struct PointStruct { public int X; public int Y; }
record PointRecord(int X, int Y);
record struct PointRecordStruct(int X, int Y);
void Demo()
{
var pc = new PointClass { X = 1, Y = 2 };
var ps = new PointStruct { X = 1, Y = 2 };
var pr = new PointRecord(1, 2);
var prs = new PointRecordStruct(1, 2);
ChangeY(pc); // pc.Y się zmieni!
ChangeY(ps); // ps.Y się nie zmieni — kopia!
ChangeY(pr); // pr.Y się zmieni!
ChangeY(prs); // prs.Y się nie zmieni — kopia!
}
void ChangeY(dynamic p) { p.Y = 99; }
Jeśli wydaje ci się, że dynamic to jakaś magia — tak, to tylko dla przykładu, żebyś zobaczył, że przy pracy ze strukturami wartość się nie zmieni, a przy pracy z klasą lub record (referencyjnym) — zmieni się.
Porównanie: jak sprawdzić, że dwa obiekty są równe?
- class: porównywane po referencji (== — true tylko jeśli to ten sam obiekt w pamięci), jeśli nie nadpiszesz Equals.
- struct: porównywane po wartości wszystkich pól (domyślnie).
- record: porównywane po wartości wszystkich pól/właściwości zadanych w głównym konstruktorze.
class Foo { public int A; public int B; }
record Bar(int A, int B);
var foo1 = new Foo { A = 42, B = 1 };
var foo2 = new Foo { A = 42, B = 1 };
var bar1 = new Bar(42, 1);
var bar2 = new Bar(42, 1);
Console.WriteLine(foo1 == foo2); // False! Różne obiekty
Console.WriteLine(bar1 == bar2); // True! Wartości się zgadzają
Ciekawostka:
Z record struct jest jeszcze ciekawiej: mają porównanie "po wartości", jak zwykłe struct, ale ze składnią i możliwościami record.
4. Niezmienność: kto ile gwarantuje?
Porównujemy bezpieczeństwo obiektów:
- class: Domyślnie łatwo zmienialny, jeśli nie zrobisz wszystkich pól readonly.
- struct: To samo, ale możesz zadeklarować jako readonly struct, wtedy ani pola, ani właściwości nie można zmieniać.
- record: Zazwyczaj pola są z modyfikatorem init, czyli można je ustawić tylko w konstruktorze lub inicjalizatorze obiektu (with). To wygodne do bezpiecznego przekazywania danych.
- record struct: To samo, możesz zrobić readonly record struct i mieć niezmienność typów wartościowych z darmowymi bonusami record.
record Person(string Name, int Age);
var p1 = new Person("Aleksiej", 23);
// p1.Age = 24; // Błąd! Tylko init
var p2 = p1 with { Age = 24 }; // Działa! Tworzy kopię z nowymi danymi
Gdy masz duży projekt i dziesiątki entity-obiektów, record pozwolą uniknąć masy bugów związanych z "ktoś gdzieś zmienił pole — i wszystko się posypało".
5. Składnia: jak wyglądają deklaracje i jak się nie pogubić
// class
public class Product
{
public int Id { get; init; }
public string Name { get; init; }
}
// struct
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
// record
public record Product(int Id, string Name);
// record struct
public record struct Point(int X, int Y);
Jak widzisz, zapis jest najkrótszy dla typu record. Dostajesz konstruktor, destruktor, porównanie, ToString() i wiele więcej "z pudełka".
| Składnia | Dostajesz za darmo | Czy można dziedziczyć |
|---|---|---|
| class | nic (prawie) | Tak |
| struct | nic (prawie) | Nie |
| record | ToString, Equals, Deconstruct, with | Tak |
| record struct | ToString, Equals, Deconstruct, with | Nie |
6. Klonowanie i operator with
Tylko record i record struct mają specjalny operator with, który pozwala bez bólu kopiować obiekt ze zmianą wybranych właściwości.
record User(string Name, int Age);
var user1 = new User("Irina", 28);
var user2 = user1 with { Age = 29 }; // user2.Name == "Irina", user2.Age == 29
W przypadku klasy (class) trzeba zaimplementować metodę kopiowania ręcznie, a jeśli zapomnisz skopiować któreś pole — witaj, bugi.
7. Dziedziczenie: kto kogo
- class: Wspiera standardowe dziedziczenie (hierarchie klas, metody wirtualne, abstrakcje itd.).
- struct: Nie wspiera (może tylko implementować interfejsy).
- record: Wspiera dziedziczenie, ale z pewnymi ograniczeniami (np. dziedziczenie możliwe tylko między typami record, nie z class).
- record struct: Nie wspiera, jak zwykłe struct.
record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name); // OK!
class Vehicle { }
class Car : Vehicle { } // OK!
// struct nie może dziedziczyć po struct
Więcej o dziedziczeniu w kolejnym poziomie :P
8. Gdzie jakie typy stosować w praktyce
- class: Duże obiekty z bogatym zachowaniem, długim cyklem życia, zmiennym stanem, hierarchią (np. logika biznesowa, komponenty UI).
- struct: Małe obiekty-wartości, gdzie ważne jest szybkie kopiowanie, brak GC i minimalny narzut (np. współrzędne, kolory, sumy — coś, co łatwo i szybko sklonować).
- record: DTO (Data Transfer Object), obiekty-parametry, parametry konfiguracji, niezmienne stany, wyniki obliczeń, które wygodnie porównywać po zawartości.
- record struct: Typy wartościowe, gdzie potrzebna jest niezmienność i zachowanie jak w records, ale bez zbędnych alokacji na stercie.
9. Typowe błędy i pułapki
Czasem programiści myślą, że record to po prostu "zamiennik klasy". To nieprawda! Jeśli tworzysz obiekt, który ma zmieniać swój stan w trakcie życia — użyj class.
Jeśli chcesz porównywać obiekty po referencji (np. w wzorcu "singleton" albo gdy cykl życia obiektu jest krytyczny) — użyj class.
Jeśli robisz obiekt-wartość, który ma się zachowywać jak liczba lub punkt na siatce współrzędnych — struct albo record struct.
Jeśli pracujesz z niezmiennymi, łatwo porównywalnymi obiektami, które często przekazujesz między warstwami aplikacji, trzymasz w kolekcjach, logujesz i serializujesz — twój wybór to record.
Pamiętaj też: jeśli zadeklarowałeś record-klasę z tylko do odczytu właściwościami, ale zapomniałeś o zagnieżdżonych obiektach — zagnieżdżone pola nadal mogą być zmienione, jeśli same są mutowalne.
record Student(string Name, List<int> Grades);
var s1 = new Student("Anton", new List<int>() {5,5,5});
var s2 = s1 with { };
s2.Grades.Add(2); // Oba obiekty "przeklęte" dwójką! s1.Grades == s2.Grades
GO TO FULL VERSION