Így használd a rekord típusokat a C# 9-től!

Üdvözöllek a modern C# világában! A fejlesztés során gyakran találkozunk olyan helyzetekkel, amikor egyszerű adatobjektumokat kell definiálnunk: felhasználói profilokat, termékinformációkat, konfigurációs beállításokat, vagy bármilyen adatcsomagot, amelynek elsődleges célja az adatok tárolása és továbbítása. Régebben ezeket általában osztályokkal vagy struktúrákkal oldottuk meg, de ezekkel a megközelítésekkel gyakran együtt járt a sok ismétlődő, „boilerplate” kód, különösen, ha az immutabilitás, az értékalapú egyenlőség és a rövidebb szintaxis volt a cél.

Itt jön a képbe a C# 9 egyik legizgalmasabb újdonsága: a rekord típusok. Ezek a típusok – ahogy a nevük is sugallja – kifejezetten az adatok tárolására és kezelésére lettek kitalálva, egy sor beépített funkcióval megspékelve, amelyekkel jelentősen csökkenthető a kódmennyiség, növelhető a biztonság és javítható az olvashatóság. De pontosan hogyan is illeszkednek ezek a rekordok a C# ökoszisztémába, és mikor érdemes használni őket? Merüljünk el benne!

Miért pont rekordok? A Boilerplate probléma és az értékalapú egyenlőség

Képzeld el, hogy van egy Person (Személy) osztályod, aminek van egy FirstName és egy LastName tulajdonsága. Ha ezt egy hagyományos osztályként definiálnád, és szeretnéd, hogy két Person objektum akkor legyen egyenlő, ha a nevük megegyezik (azaz értékalapú egyenlőség), nem pusztán akkor, ha ugyanarra a memóriacímre mutatnak (referenciális egyenlőség), akkor a következőket kellene implementálnod:

  • A GetHashCode() metódus felülírása.
  • Az Equals(object obj) metódus felülírása.
  • Az == és != operátorok felülterhelése.

Ez rengeteg kód, pusztán azért, hogy két objektum tartalmát összehasonlíthasd! Ráadásul, ha azt is szeretnéd, hogy az objektumok immutábilisak legyenek (azaz létrehozásuk után ne lehessen megváltoztatni az állapotukat), akkor csak a konstruktorban inicializálhatod a tulajdonságokat, és azok csak `init` vagy `readonly` settert kaphatnak.

A rekordok pontosan ezeket a problémákat oldják meg egy elegáns, beépített módon. A fordító (compiler) automatikusan generálja a szükséges metódusokat (Equals, GetHashCode, ToString, operátorok), és támogatja az immutabilitást, így te kevesebb kódot írhatsz, ami kevesebb hibalehetőséget rejt.

Első lépések: Rekord osztályok deklarálása

A rekordok alapértelmezetten referencia típusok (mint az osztályok), és a `record class` kulcsszóval deklarálhatók, vagy egyszerűen csak `record`-dal (ez az alapértelmezett). A legelterjedtebb forma a „pozícionális rekord” (positional record), ami hihetetlenül tömör szintaxist biztosít:

Pozícionális rekordok

public record Person(string FirstName, string LastName);

// Használat:
var person1 = new Person("John", "Doe");
var person2 = new Person("John", "Doe");
var person3 = person1 with { FirstName = "Jane" }; // with kifejezés

Console.WriteLine(person1 == person2); // True (értékalapú egyenlőség)
Console.WriteLine(person1);             // Person { FirstName = John, LastName = Doe } (automatikus ToString)
Console.WriteLine(person3);             // Person { FirstName = Jane, LastName = Doe }

Ez az egyetlen soros definíció több dolgot is tesz a háttérben:

  • Létrehoz egy `Person` osztályt.
  • Létrehoz két nyilvános, init-only property-t: `FirstName` és `LastName`. Ez biztosítja az immutabilitást.
  • Generál egy publikus konstruktort, ami paraméterként fogadja ezeket az értékeket.
  • Generál egy dekonstruktort, ami lehetővé teszi az objektum szétszedését komponenseire (pl. `var (first, last) = person1;`).
  • Felülírja az Equals(), GetHashCode() és ToString() metódusokat, valamint az == és != operátorokat, figyelembe véve az összes property értékét.

Nem pozícionális rekordok

Ha a hagyományos property definícióhoz szoktál, vagy szeretnél expliciten settert definiálni (bár ez általában ellentmond az immutabilitás céljának), használhatod a nem pozícionális szintaxist is:

public record Product
{
    public int Id { get; init; }
    public string Name { get; init; }
    public decimal Price { get; init; }
}

// Használat:
var product1 = new Product { Id = 1, Name = "Laptop", Price = 1200m };
var product2 = new Product { Id = 1, Name = "Laptop", Price = 1200m };
Console.WriteLine(product1 == product2); // True

Ebben az esetben is megkapjuk az értékalapú egyenlőség, a ToString() felülírás és az GetHashCode() előnyeit, de a konstruktort és a dekonstruktort manuálisan kellene megírnunk, ha szükségünk van rájuk.

Az immutabilitás ereje és a `with` kifejezés

Az immutabilitás, azaz a megváltoztathatatlanság, egy kulcsfontosságú fogalom a modern programozásban. Immutábilis objektumok esetén, miután egy példányt létrehoztunk, annak állapota nem módosítható. Ez számos előnnyel jár:

  • Szálbiztonság: Nincs szükség lock-okra, mutex-ekre, mert az objektumok nem változnak meg váratlanul más szálak által.
  • Prediktabilitás: Könnyebb megérteni a kód működését, mert tudjuk, hogy egy objektum állapota nem változik meg, miután átadtuk egy metódusnak.
  • Egyszerűbb debuggolás: Nehezebb akaratlanul módosítani az objektum állapotát.

A rekordok alapértelmezetten immutábilisak az `init-only` property-knek köszönhetően. De mi van akkor, ha egy meglévő rekord alapján szeretnénk létrehozni egy újat, csak egy vagy két tulajdonságát megváltoztatva? Erre szolgál a C# 9-ben bevezetett `with` kifejezés (non-destructive mutation):

public record Product(int Id, string Name, decimal Price);

var originalProduct = new Product(1, "Laptop", 1200m);
var updatedProduct = originalProduct with { Price = 1300m };

Console.WriteLine(originalProduct); // Product { Id = 1, Name = Laptop, Price = 1200 }
Console.WriteLine(updatedProduct);  // Product { Id = 1, Name = Laptop, Price = 1300 }
Console.WriteLine(ReferenceEquals(originalProduct, updatedProduct)); // False

A `with` kifejezés nem módosítja az `originalProduct` objektumot, hanem létrehoz egy *új* `Product` példányt, átmásolva az összes eredeti tulajdonság értékét, majd felülírva a megadott `Price` értéket. Ez egy rendkívül erőteljes és elegáns módja az adatok frissítésének immutábilis környezetben.

Öröklődés rekordokkal

A rekordok támogathatják az öröklődést, éppúgy, mint az osztályok, de néhány különbséggel. Az öröklődés során a leszármazott rekord típusnak is deklarálnia kell az összes szülő rekordban definiált pozícionális paramétert, és át kell adnia azokat a szülő konstruktorának.

public record Person(string FirstName, string LastName);

public record Student(string FirstName, string LastName, int StudentId) : Person(FirstName, LastName);

var student1 = new Student("Alice", "Smith", 12345);
var student2 = new Student("Alice", "Smith", 67890);
var person = new Person("Alice", "Smith");

Console.WriteLine(student1 == student2); // False (StudentId különböző)
Console.WriteLine(student1 == person); // False (eltérő típusok)

Fontos megjegyezni, hogy az öröklődési hierarchia befolyásolja az értékalapú egyenlőséget. Két rekord akkor egyenlő, ha az azonos típusúak és minden tulajdonságuk (beleértve az öröklött tulajdonságokat is) megegyezik. A `PrintMembers` metódus, ami a `ToString` mögött áll, szintén rekurzívan hívódik meg a szülő típusokon is.

A rekordok alapértelmezetten nem `sealed` (lezártak), ami azt jelenti, hogy tovább lehet belőlük örökölni. Azonban az értékalapú egyenlőség implementációja miatt nem feltétlenül javasolt széles körben használni az öröklődést rekordok esetében, ha az egyenlőség összehasonlításakor nem szeretnénk, hogy a származtatott típus speciális tulajdonságait is figyelembe vegye. Ha mégis, akkor a fordító által generált kód kezeli ezt.

Rekord osztályok vs. Rekord struktúrák (C# 10-től)

A C# 9-ben bevezetett rekord típusok alapértelmezetten referencia típusok voltak, azaz `record class` viselkedéssel rendelkeztek. A C# 10 már bevezette a rekord struktúrákat is (`record struct`), amelyekkel érték típusú rekordokat hozhatunk létre. Ez a megkülönböztetés nagyon fontos a teljesítmény és a memóriakezelés szempontjából.

public record struct Point(int X, int Y);

public record class PersonRef(string FirstName, string LastName); // explicit record class

// Használat:
var p1 = new Point(10, 20); // Érték típusú rekord
var p2 = new Point(10, 20);
Console.WriteLine(p1 == p2); // True

var pr1 = new PersonRef("Adam", "Smith"); // Referencia típusú rekord
var pr2 = new PersonRef("Adam", "Smith");
Console.WriteLine(pr1 == pr2); // True

Fő különbségek:

  • Referencia típus (record class): A heap-en foglal memóriát, garbage collection kezeli. Átadáskor referenciát másol. Ajánlott nagyobb adatstruktúrákhoz vagy ha null érték is lehet.
  • Érték típus (record struct): A stack-en vagy a heap-en (ha egy referencia típus tartalmazza) foglal memóriát, nem garbage collection kezeli. Átadáskor másolódik az *egész* objektum. Ajánlott kisebb, néhány tulajdonságból álló, gyakran használt objektumokhoz, ahol a másolás költsége alacsonyabb, mint a heap allokáció és GC költsége.

A választás attól függ, hogy mi a célod. Ha az objektum identitása számít, vagy nagy méretű az adat, `record class` a jó választás. Ha kis, érték alapú adatokról van szó, és a teljesítmény kritikus, a `record struct` lehet a jobb megoldás.

Mikor használjunk rekordokat? A választás dilemmája

Bár a rekordok rendkívül hasznosak, nem mindenhol jelentenek optimális megoldást. Íme, néhány szempont, ami segíthet a döntésben:

Mikor válassz rekordokat?

  • Adatátviteli objektumok (DTO-k): Ha adatszállításra használsz objektumokat API-k között, vagy rétegek között az alkalmazásodon belül, a rekordok minimalizálják a boilerplate kódot és biztosítják az értékalapú egyenlőséget.
  • Immutábilis értékobjektumok: Pl. egy Money típus, ami valutát és összeget tárol, vagy egy Coordinates objektum. Itt az immutabilitás és az értékalapú egyenlőség kulcsfontosságú.
  • Konfigurációs adatok: Alkalmazásbeállítások vagy egyéb konfigurációs adatok, amelyek létrehozásuk után nem változnak.
  • Funkcionális programozási minták: Az immutabilitás jól illeszkedik a funkcionális programozás elveihez, ahol az adatok módosítása helyett új adatok generálása a preferált.
  • Rövidebb kód, tisztább szándék: Amikor az objektum lényege az általa képviselt adatok, és nem a viselkedése vagy az identitása.

Mikor érdemes hagyományos osztályokat használni?

  • Módosítható objektumok: Ha az objektum állapota gyakran változik, és az identitása (nem az értéke) a fontos. Például egy ORM által betöltött adatbázis entitás, ahol a változások nyomon követése és mentése a cél.
  • Viselkedésközpontú objektumok: Ha az objektum fő célja metódusok, műveletek végrehajtása, és nem pusztán adatok tárolása.
  • Komplex identitás: Ha az objektum egyenlőségét egyedi logika alapján kell meghatározni, ami túlmutat a property-k egyszerű összehasonlításán.
  • Polimorfizmus: Bár a rekordok támogatják az öröklődést, az osztályok rugalmasabbak lehetnek komplex polimorfikus hierarchiák építésénél.

Gyakorlati példák és felhasználási területek

Nézzünk néhány konkrét példát, ahol a rekordok ragyognak:

1. API válasz modellek:

public record UserResponse(int Id, string Username, string Email, DateTime RegistrationDate);

// Egy API endpointból érkező adat feldolgozása
public UserResponse GetUserById(int id)
{
    // ... adatbázis lekérdezés ...
    return new UserResponse(id, "janedoe", "[email protected]", DateTime.UtcNow);
}

2. Konfigurációs beállítások:

public record AppSettings(string DatabaseConnectionString, int MaxConnections, bool EnableCaching);

// Konfiguráció betöltése
var settings = new AppSettings("Data Source=mydb.db", 10, true);
var updatedSettings = settings with { MaxConnections = 20 };

3. Domeniális értékobjektumok:

public record Money(decimal Amount, string Currency)
{
    // Hozzáadhatunk viselkedést is, pl. összeadás
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Currencies must match for addition.");
        return this with { Amount = Amount + other.Amount };
    }
}

var price1 = new Money(100m, "HUF");
var price2 = new Money(50m, "HUF");
var totalPrice = price1.Add(price2); // totalPrice: Money { Amount = 150, Currency = HUF }

Teljesítmény és egyéb szempontok

A rekordok fordítási időben nagyrészt „szétnyílnak” hagyományos osztályokká vagy struktúrákká, ahogy azt a fordító generálja. Ez azt jelenti, hogy a futásidejű teljesítményük nagyban hasonlít a kézzel írt osztályokéhoz vagy struktúrákéhoz, de a fejlesztési idő és a hibalehetőségek jelentősen csökkennek. Némi „overhead” (többletmunka) jelentkezhet az automatikusan generált metódusok (pl. `Equals`, `GetHashCode`, `ToString`) miatt, de ez a legtöbb esetben elhanyagolható, és cserébe óriási előnyöket nyújt a kód tisztasága és biztonsága terén.

Fontos megjegyezni, hogy a rekordok a referenciális egyenlőséget is támogatják (az `ReferenceEquals` metódussal), de az `==` operátor alapértelmezetten az értékalapú egyenlőséget használja. Ez a viselkedés az, ami a rekordokat kiemeli a hagyományos osztályok közül az adatok összehasonlítása terén.

Összefoglalás

A C# 9-től bevezetett rekord típusok egy rendkívül hasznos és elegáns kiegészítői a C# nyelvnek, amelyekkel sokkal tisztább, tömörebb és biztonságosabb kódot írhatunk adatkezelésre. Az immutabilitás, az értékalapú egyenlőség és a `with` kifejezés beépített támogatásával a rekordok ideális választást jelentenek DTO-k, konfigurációs objektumok és értéktípusok definiálására.

Mint minden eszköznél, itt is kulcsfontosságú a körültekintő alkalmazás. Értsd meg, mikor előnyösek a rekordok (adatautó, immutabilitás, értékalapú egyenlőség), és mikor maradj a hagyományos osztályoknál (viselkedés, módosítható állapot, komplex identitás). Ha okosan használod őket, a rekordok jelentősen javíthatják a C# kódod minőségét és karbantarthatóságát. Ne habozz, próbáld ki őket a következő projektjeidben!

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük