Menu

Record w C#: równość wartościowa, wyrażenia with i record struct

Czym są rekordy w C# (C# 9 i nowsze): składnia pozycyjna, składowe generowane przez kompilator, równość oparta na wartościach, niedestrukcyjne kopie z with, wbudowany ToString, record struct z C# 10, dziedziczenie między rekordami i odpowiednik w postaci ręcznie napisanej klasy.

Na tej stronie są działające edytory: edytuj, uruchamiaj i od razu zobacz wynik.

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 with wykona głęboką kopię. Zagnieżdżone obiekty i kolekcje są wspólne dla oryginału i kopii.
  • Przypadkowo modyfikowalny pozycyjny record struct. Dodaj readonly, 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.

Ilustracja języków programowania w Coddy

Ucz się programowania z Coddy

ZACZNIJ