A modern funkcionális nyelvek alapja az immutable (nem változtatható) típus. Több előnye is van, ha egy osztály immutable. Az ilyen objektumok biztos, hogy probléma nélkül használhatóak egy több szálú környezetben, mivel az osztály létrehozása után nem módosítható. Ezért nem kell azon aggódnunk, hogy két párhuzamosan futó szál, ami használja az osztályunkat, inkonzisztens állapotot lát/eredményez.
Ennek következményeként a szinkronizáció és a lock költsége is megspórolható, mivel szimplán nincs rá szükség. Ha mégis az kéne, hogy módosuljon az adott érték, szimplán új példányt hozunk létre, ez pedig egy atomi műveletnek tekinthető.
Van egy hatalmas előnye a nem módosítható objektumoknak nem párhuzamos kódok esetében is, méghozzá az, hogy egyszerűsítheti a tervezést és a tesztelést, hisz be van határolva, hogy az adott értéket ki hozza létre, és nem kell attól tartani, hogy az életciklusa alatt megváltozik. Így nem kell több hívási láncot is végigkövetni, hogy melyik metódus állított el valamit a példányon, amelyik megkapta paraméterül.
Felmerülhet a kérdés, hogy akkor miért nem minden immutable?
Itt is a „valamit valamiért” érv érvényes. Egy csomó előnyért cserébe nehézkes implementálással kellett korábban fizetni. Mondhatnánk, hogy immutable típust egyszerű készíteni, de minél jobban beleássuk magunkat, annál jobban kiütközik, hogy csak gondok vannak.
De mielőtt nagyon belemegyünk, nézzünk egy immutable osztályt, ami X és Y Descartes koordinátát tárol.
class Point
{
public double X { get; }
public double Y { get; }
public Point(double x, double y)
{
X = x;
Y = y;
}
}
A fenti kódrészlet problémája, hogy az initializer szintaxist elveszítjük. Ez jelen példa esetén nem olyan nagy gond, mivel csak két adat van az osztályban, de ha mondjuk 10 értéket kell átadni konstruktorban és nem is biztos, hogy minden esetben kell a 10 paraméter, akkor kapunk legalább egy igazán csúnya konstruktort.
Mivel ez nem újkeletű probléma és más nyelvekben is előfordul, nem csoda, hogy egy egész design pattern család (creational design patterns) foglalkozik ennek a problémának az egyszerűsítésével. Viszont ez mind plusz kód, amit ugyanúgy karban kell valakinek tartania.
És akkor még nem beszéltünk a másolás kérdéséről. Tételezzük fel, hogy egy példányt le kell másolnunk. Ez is egy triviális problémának tűnik, viszont ha az osztályunk, amit másolni szeretnénk, tartalmaz referenciatípusokat is, akkor már nem annyira egyszerű a történet.
Ebben az esetben, ha csak simán egyenlőségjellel adunk értéket, akkor referencia másolás történik (Shallow copy), ami több, mint valószínű nem lesz nekünk jó. Éppen ezért minden tag referenciatípus esetén meg kell oldanunk a másolást (Deep copy), ami további karbantartásra szoruló kódot eredményez.
Ahhoz, hogy jó immutable-t tervezzen/fejlesszen az ember, ügyelni kell arra, hogy ha az adott típusunk más komplex típusokat tartalmaz, azok se változtathassák meg a belső állapotukat, hisz enélkül nem lesz valóban változatlan a teljes életciklusa alatt az adott példány értéke.
Amennyiben a tartalmazott objektumok engedik az állapotukat állítani kívülről, akkor ügyelni kell arra, hogy ez az osztály erős tartalmazott legyen, tehát másnak ne legyen rá referenciája, hogy senki se állíthassa az állapotát.
Ha ez még nem lenne elég probléma, akkor ott a helyes Equals() és GetHashCode() felülírás problémája, ami már önmagában megérne egy külön könyvet, ha még az öröklődés témakörét is belevesszük a képletbe.
Ennyi bevezető után jogosan tűnhet úgy, hogy az objektumorientáltság és az immutable osztályok nem kompatibilisek egymással. Nem erről van szó, csupán macerás volt és mint látható, jó néhány módon lábon lőhetjük magunkat a megvalósítás során.
De a C# készítői felismerték ezt a problémát. Ennek kapcsán született meg a record típus. A record egy referenciatípus, vagyis ugyanúgy a heap-en tárolódik, mint a class típusok. Azonban ha record típust alkalmazunk, akkor az Equals(), GetHashCode(), a másolás és tuple deconstruct helyes implementációjával nem kell foglalkoznunk, mert ezt a keretrendszer fogja nekünk biztosítani és garantáltan jól fog működni.
Itt persze kitétel a helyes működéshez az, hogy a record típusunk csak érték típusokat és további record típusokat tartalmazzon.
A fenti Point osztály record esetén így néz ki:
record Point
{
public double X { get; }
public double Y { get; }
public Point(double x, double y)
{
X = x;
Y = y;
}
}
Mint látható, eddig nincs sok változás, csak a típus létrehozó kulcsszó változott meg. Cserébe viszont kapunk jól működő Equals(), GetHashCode() és egyenlőségre vonatkozó operátorokat (== és !=) plusz munka nélkül. A = operátorral átadás itt szintén referencia átadást fog végezni. Másolni a with kulcsszóval tudunk.
Point a = new Point(1, 2);
//itt másolás történik, nem referencia átadás
Point b = a;
A record típusok ugyanúgy részt vehetnek öröklésben, mint az osztályok, de csak másik record típusoktól származhatnak, így a leszármazottjaik is csak record típusúak lehetnek, korlátozni az öröklésben részvételüket szintén a sealed kulcsszóval lehet.
A record típus szépsége, hogy akár egy sorban deklarálhatóak:
public record Konyv(string Szerzo, DateTime Kiadas);
Ebben az esetben a típus neve Konyv lesz, ami rendelkezni fog csak olvasható Szerzo és Kiadas tulajdonságokkal.
A rekordok és immutable típusok problémái
Ha immutable típust alkalmazunk ott, ahol gyakran változó adatokat kell reprezentálnunk, akkor az sebesség problémát tud okozni, mivel immutable esetén új példány jön létre mindenből, ami hosszú távon (vagy már rövid távon is, alkalmazásfüggő) heap töredezettséget eredményez, ami hosszú és erőforrás igényes GC hívásokat fog eredményezni.
Éppen ezért a record és az immutable nem egyfajta spanyol viasz, ami mindenre jó. Mivel a record öröklésben is részt vehet, ezért ha immutable adatok tárolására szeretnénk egy ilyen osztályt használni, akkor érdemes ellátni a sealed módosítóval is.
Illetve attól, hogy valami record, az még nem garantálja, hogy az a valami immutable lesz. Ezt egy példán keresztül egyszerű szemlélteni:
record Point
{
public double X { get; set; } //írható.
public double Y { get; }
public Point(double x, double y)
{
X = x;
Y = y;
}
}
Ezt a fordító nem ellenőrzi helyettünk. Ennek abban keresendő az oka, hogy a C# egy több paradigmát támogató nyelv. Lehet értelme olyan objektumoknak, amelyek ugyan nem immutable típusúak, de értékre összehasonlíthatóak. Ilyen objektumok tipikusan Domain Driven Designt1 alkalmazó programokban fordulnak elő.
C# 10 újdonságok
A record C# 9 esetén saját típusként van jelölve, azonban CLR szinten nem kapott új típuskodot, vagyis a record C# 9 esetén egy osztály, amihez a fordító generálja ki az érték szintű egyenlőséget és a klónozáshoz szükséges részeket.
Éppen ezért a record nem alkalmazható generikus constraint-nek.
//fordítási hibát eredményez
public record Generic<T> where T: record { }
C# 10 óta a record egy módosítószóként viselkedik, mint önálló típusként. Ez azt jelenti, hogy osztály (class) és struktúrával (struct) együtt is használható. C# 10 esetén lehetőség szerint a record class definíciót igyekezzünk használni, mivel egyértelműbbé teszi a kulcsszó jelentését, működését.
//C# 9 és 10 esetén is működik
record A { }
//C# 10 esetén elfogadott szintaxis
record class A { }
//C# 10 esetén elfogadott szintaxis
record struct B { }
A ToString() kérdése
Mint minden típus, a record is rendelkezik ToString() metódussal. Azonban ez felülírás nélkül is információt ad a típus adattagjairól, mivel ezt is generálja a fordító minden esetben. Itt a hangsúly a minden esetben részen van, mert nem feltétlen az történik, amire számítanánk. Nézzünk meg erre egy példát.
using System;
namespace recordString
{
record Pelda
{
public int Szam { get; init; }
public int Szam2 { get; init; }
public override string ToString()
{
return $"Szam1: {Szam}; Szam2: {Szam2}";
}
}
record PeldaGyerek : Pelda
{
}
public class Program
{
public static void Main(string[] args)
{
var p = new Pelda
{
Szam = 42,
Szam2 = 13,
};
var p2 = new PeldaGyerek
{
Szam = 42,
Szam2 = 13,
};
Console.WriteLine(p);
Console.WriteLine(p2);
Console.ReadKey();
}
}
}
Eddigi ismereteink alapján azt feltételezhetnénk, hogy a p2 kíírásakor a Pelda rekord ToString() metódusa fog lefutni, hiszen ott felülírtuk a működést, de nagy meglepetésünkre a program kimenete:
Szam1: 42; Szam2: 13
PeldaGyerek { Szam = 42, Szam2 = 13 }
Ez azért van, mert ugyan a PeldaGyerek öröklődik a Pelda típusból, de mivel rekord típusról beszélünk, a ToString() implementációt a fordító kigenerálja, így eltérő működést kapunk a két típus esetén. C# 10 óta lehetőségünk van a sealed kulcsszót alkalmazni a record módosított típusok ToString() metódusa előtt. Ez megakadályozza a fordítót abban, hogy a leszármaztatott rekordok esetén a ToString() implementáció kigenerálódjon:
record class Pelda
{
public int Szam { get; init; }
public int Szam2 { get; init; }
//megtiltjuk a ToString() generálását a leszármazott
//típusoknak
public sealed override string ToString()
{
return $"Szam1: {Szam}; Szam2: {Szam2}";
}
}
Ezzel a módosítással a fenti programunk mind a két esetben ugyanazt a kimenetet produkálja:
Szam1: 42; Szam2: 13
Szam1: 42; Szam2: 13
2024.09.06. @ 13:20
Van benne egy elírás: „gyenerálja”
2024.09.17. @ 15:36
Javítottam.