1. Problem przekazywania danych
Załóżmy, że mamy apkę dla szkoły i musimy przekazywać info o uczniu między różnymi modułami: imię, rok urodzenia, klasa. Jak zwykle to robimy?
Tworzymy klasę typu:
public class Student
{
public string Name { get; set; }
public int YearOfBirth { get; set; }
public string Class { get; set; }
}
Wygląda znajomo. Ale taki sposób ma kilka problemów:
- Żeby porównać dwóch uczniów pod kątem równości, domyślnie porównywane jest tylko referencja do obiektu. Czyli dwóch uczniów z tymi samymi polami, ale innymi obiektami — nie są równe!
- Klasę można zmienić po utworzeniu, co czasem prowadzi do bugów (szczególnie jeśli obiekt już gdzieś jest używany);
- Dużo „szablonowego” kodu: konstruktory, metody porównania, kopiowania (klony), ToString.
I jak już pewnie się domyślasz: C# może nas od tego uwolnić! Poznaj — record.
2. Czym jest record?
record — to specjalny typ w C#, zaprojektowany specjalnie do przechowywania danych. Record ma dwie główne cechy:
- Niezmienność (immutability): obiekty typu record są domyślnie niezmienne, czyli wartości ich właściwości ustalasz raz przy tworzeniu i już nie zmieniasz (dokładniej: set-settery są prywatne). Można zadeklarować też zmienne recordy, ale domyślnie — niezmienność.
- Porównywanie po wartości: jeśli dwa obiekty record mają takie same wartości we wszystkich polach, są uznawane za równe (== i .Equals() działają inaczej!).
Tak naprawdę, record — to idealna otoczka do przekazywania danych między warstwami aplikacji (np. z bazy do kontrolera, z kontrolera do widoku itd.).
3. Składnia record
Najprościej — składnia pozycyjna
Kiedy musimy po prostu przekazać zestaw wartości, deklarujemy typ jedną krótką linijką:
public record Student(string Name, int YearOfBirth, string Class);
Co się dzieje pod spodem? Kompilator sam wygeneruje dla nas:
- Automatyczne właściwości tylko do odczytu (z prywatnym setterem);
- Konstruktor przyjmujący wszystkie parametry;
- Metody porównania i kopiowania;
- Fajny ToString, który ładnie formatuje wyjście!
Użycie pozycyjnego recorda
Spróbujmy użyć tego nowego typu w naszej szkolnej apce:
var student1 = new Student("Ivan", 2008, "8A");
var student2 = new Student("Maria", 2008, "8B");
Dostęp do właściwości jest jak zwykle (tylko nie możesz ich zmieniać):
Console.WriteLine($"{student1.Name}, {student1.YearOfBirth}, {student1.Class}");
Próba zmiany właściwości po utworzeniu
student1.Name = "Piotr"; // Błąd! Właściwość tylko do odczytu.
Jeśli odkomentujesz taką linijkę — kompilator od razu się obrazi: nie można ustawić wartości dla właściwości tylko do odczytu.
Tak wygląda automatycznie wygenerowany ToString
Console.WriteLine(student1); // Wypisze: Student { Name = Ivan, YearOfBirth = 2008, Class = 8A }
Wszystko czytelne i jasne nawet bez ręcznego formatowania!
4. Porównywanie obiektów record
Przypomnę: jeśli utworzysz dwa różne obiekty klasycznej klasy z tymi samymi danymi, i tak nie będą sobie równe:
var a = new Student("Ivan", 2008, "8A");
var b = new Student("Ivan", 2008, "8A");
Console.WriteLine(a == b); // Dla klasy: false
Jeśli jednak Student to record, równość działa tak, jak byś chciał:
public record Student(string Name, int YearOfBirth, string Class);
var a = new Student("Ivan", 2008, "8A");
var b = new Student("Ivan", 2008, "8A");
Console.WriteLine(a == b); // Dla record: true!
Czyli dwa recordy z tymi samymi polami są sobie równe nawet jeśli to dwa różne obiekty w pamięci.
5. Jak to wygląda w środku
Studenci często się dziwią, ile kompilator robi za nas w przypadku recorda. Porównajmy dla jasności, jaki kod trzeba by pisać ręcznie dla klasy i co robi record.
Stara dobra klasa napisana ręcznie
public class Student
{
public string Name { get; }
public int YearOfBirth { get; }
public string Class { get; }
public Student(string name, int yearOfBirth, string @class)
{
Name = name;
YearOfBirth = yearOfBirth;
Class = @class; //referencja do własnej klasy
}
public override bool Equals(object? obj)
{
if (obj is not Student other) return false;
return Name == other.Name && YearOfBirth == other.YearOfBirth && Class == other.Class;
}
public override int GetHashCode()
{
return HashCode.Combine(Name, YearOfBirth, Class);
}
public override string ToString()
{
return $"Student {{ Name = {Name}, YearOfBirth = {YearOfBirth}, Class = {Class} }}";
}
}
Nic dziwnego, że programiści stają się paranoikami — przecież piszemy to samo po 10 razy!
Record — jedna linijka
public record Student(string Name, int YearOfBirth, string Class);
6. Record i niezmienność: co wolno, a czego nie
Recordy domyślnie mają właściwości tylko do odczytu i to świetnie zapobiega masie bugów. Ale jeśli bardzo chcesz (np. piszesz pod bardzo stare API), możesz zadeklarować też zmienne recordy:
public record MutableStudent
{
public string Name { get; set; }
public int YearOfBirth { get; set; }
public string Class { get; set; }
}
Teraz te pola można zmieniać, ale tracisz część zalet (np. bezpieczeństwo).
7. Destrukturyzacja recorda
Ponieważ składnia pozycyjna bardzo przypomina tuple, można łatwo destrukturyzować record:
var student = new Student("Ivan", 2008, "8A");
var (name, year, className) = student;
Console.WriteLine($"{name} - {year}, {className}"); // Ivan - 2008, 8A
Kompilator generuje metodę Deconstruct dla każdego pozycyjnego recorda i to ułatwia pracę z LINQ, switch-patternami i ogólnie uprzyjemnia życie.
8. Record — to klasa czy struct?
Domyślnie record — to typ referencyjny, jak klasa. Czyli wszystkie cechy zachowania typów referencyjnych (przechowywanie na stercie, kopiowanie referencji itd.) działają normalnie.
Jeśli chcesz typ wartościowy (value type), to w C# też jest opcja — zawsze możesz napisać:
public record struct Point(int X, int Y);
Ale do przekazywania danych prawie zawsze używa się klasycznej formy recordów, czyli typ referencyjny. Więcej o record struct — w kolejnych wykładach :P
Porównanie class, struct, record
| Typ | Niezmienność domyślnie | Porównanie po wartości | Łatwa destrukturyzacja | Auto ToString |
|---|---|---|---|---|
| class | Nie | Nie (po referencji) | Nie | Nie |
| struct | Nie | Tak | Nie | Nie |
| record | Tak | Tak | Tak | Tak |
9. Cechy i typowe błędy
Wielu początkujących gubi się w niuansach używania recordów. Na przykład czasem oczekują, że zmiana pola w jednym obiekcie record zmieni drugi (jak w klasach z kopiowaniem referencji). Nie! Poza tym, gdy piszesz with, pamiętaj, że zawsze tworzona jest kopia, a nie zmieniany oryginał. Recordy są idealne do logiki, gdzie twoja apka dba o czystość danych i przewidywalność.
Tak, jeśli zadeklarujesz pola z init zamiast set, też możesz je ustawić tylko przy tworzeniu lub przez with, ale nie później.
GO TO FULL VERSION