Korábban láttuk, miért lehet a null
érték idegesítő problémaforrás. Bizonyos esetekben viszont kifejezetten szeretnénk, hogy egy változó, amely különben nem lehetne null
, mégis null
értéket vehessen fel.
Sorozatunkban az üresség különböző megjelenési formáit vizsgáljuk a C# nyelvben: a null
referenciát, a Nullable
és a void
típusokat, illetve módszereket a null
al kapcsolatos problémák kivédésére.
Képzeljünk el például egy weboldalt, amely minden felhasználóhoz tárolja az utolsó belépés idejét.
public class User { public string FullName { get; set; } public string Email { get; set; } public string PasswordHash { get; set; } public DateTime LastLoginTime { get; set; } }
Ha egy felhasználó épp most regisztrált, de még nem lépett be, azt szeretnénk, hogy a LastLoginTime
tulajdonság valahogyan üres legyen.
var user = new User(); user.LastLoginTime = null; // fordítási idejű hiba
A tulajdonságnak hiába is próbálnánk null
értéket adni. A DateTime
értéktípus, ezért a fenti kód le sem fordul.
Kitüntetett értékek
Első ötletünk a probléma megodására, hogy az üresség kifejezésére a DateTime
típus valamely, egyébként érvényes értékét jelöljük ki. Használhatjuk például a DateTime.MinValue
értéket.
var user = new User(); user.LastLoginTime = DateTime.MinValue;
Ez így működik, de magából a kódból egyáltalán nem látszik, mit jelent ez az érték. Ilyenkor mindig jó ötlet egy konstanst bevezetni, amely megmagyarázza a literális érték jelentését, ezért mi is így fogunk tenni.
private readonly DateTime Never = DateTime.MinValue; // később, egy metódusban var user = new User(); user.LastLoginTime = Never;
Ez már egész jól érthető. De csupán a User
osztályt megnézve fogalmunk sincs, hogy a LastLoginTime
tulajdonság esetén a DateTime.MinValue
érték különleges jelentéssel bír. Ezt persze dokumentálhatjuk egy XML-kommentben, de ha igazán őszinték vagyunk magunkhoz, beláthatjuk, hogy ezt senki sem fogja elolvasni. Ezen kívül, bár olyan biztosan nem fog előfordulni, hogy valaki Kr. u. 1-ben belépett a rendszerünkbe, és azóta soha nem tért vissza, ez csak egy szerencsés véletlen. A megoldásunk távolról sem általános. Sok olyan eset lehetséges, amikor a kérdéses értéktípus teljes értékkészletére szükségünk van, és az üresség kifejezésére egy érték sem marad.
Logikai mezők
Ez elvezet a második ötletünkhöz. Kitüntetett érték használata helyett az “üres” állapotot egy különálló bool
tulajdonságba helyezzük ki.
public DateTime LastLoginTime { get; set; } public bool HasLastLoginTime { get; set; }
Ezután már explicit módon jelezhetjük, ha a felhasználónak nincs utolsó belépési ideje; ez a lehetőség a User
osztály nyilvános felületén is látszik.
var user = new User(); user.HasLastLoginTime = false;
Ez a közvetlen problémát megoldja, de egy másik továbbra is jelen van. A User
osztály kliensének emlékeznie kell, hogy ne nyúljon a LastLoginTime
-hoz, amíg a HasLastLoginTime
értékét meg nem vizsgálta. Ezt az ellenőrzést valahogy ki kell kényszerítenünk.
Burkoló referenciatípus
Harmadik ötletünk pontosan ezt oldja meg. Ha tényleg azt szeretnénk, hogy a tulajdonság referenciatípusként viselkedjen, miért nem csinálunk belőle referenciatípust? Így ha a kliens egy null
érték használatát kísérelné meg, azonnal NullReferenceException
t kapna, amely bár kellemetlen, pontosan kijelöli a probléma helyét, és nem hagy lehetőséget hibás működésre.
public object LastLoginTime { get; set; }
Használatjuk akár az object
típust is, de így a típusbiztosságról teljesen lemondunk. Ha már statikusan típusos nyelvet használunk, ne adjuk fel a típusrendszer használatát az első kis nehézség láttán.
Sokkal szebb megoldás, ha egy burkolóosztályt – egy referenciatípust – definiálunk, mely egy DateTime
értéket tárol.
public class NullableDateTime { public DateTime Value { get; } public NullableDateTime(DateTime value) { Value = value; } public static explicit operator DateTime(NullableDateTime ndt) { if (ndt == null) throw new NullReferenceException( "Cannot convert null to DateTime"); return ndt.Value; } public static implicit operator NullableDateTime(DateTime dt) => new NullableDateTime(dt); }
A konverziós operátorok miatt nagyon kellemes lesz a típus használata. Ha módosítjuk a User
osztályt, hogy az új NullableDateTime
osztályt használja a LastLoginTime
tulajdonság típusaként,
public NullableDateTime LastLoginTime { get; set; }
akkor DateTime
típusú értékre minden további nélkül beállíthatjuk, mert ebben az irányban létezik implicit konverzió. Ha a tulajdonság értékét adnánk egy DateTime
típusú változónak, ezt csak explicit átalakítás (cast) segítségével tehetjük meg. Így figyelmeztetjük a hívót arra, hogy ez nem egy egyszerű DateTime
példány, és így sugalljuk, hogy érdemes előbb a null
értéket ellenőriznie.
var user = new User(); user.LastLoginTime = DateTime.Now; DateTime lastLoginTime; if (user.LastLoginTime != null) { lastLoginTime = (DateTime)user.LastLoginTime; }
Ez már egészen jó elgondolás, az eddigi ötleteinknél mindenképp jobb. Mégis van egy kellemetlen hiányossága. Ha nullértékű egészekre (int
), lebegőpontos számokra (float
, double
) és más hasonló értéktípusokra van szükségünk a kódban, mindegyikükhöz definiálnunk kell ugyanezt a burkoló osztályt.
Generikus burkoló osztály
Erre egyszerű megoldást biztosít a generikus típusosság.
public class Nullable<T> where T : struct { public T Value { get; } public Nullable(T value) { Value = value; } public static explicit operator T(Nullable<T> nullable) { if (nullable == null) throw new NullReferenceException( "Cannot convert null to " + typeof(T).Name); return nullable.Value; } public static implicit operator Nullable<T>(T value) => new Nullable<T>(value); }
Ha mindenhova, ahol korábban a DateTime
-ot használtuk, a T
generikus típusparamétert helyettesítjük, a fenti, meglepően jól működő megoldást kapjuk. Vegyük észre, hogy T
-re a struct
, vagyis értéktípus megszorítást alkalmaztuk. Ez azt is megelőzi, hogy a Nullable<Nullable<int>>
-hez hasonló értelmetlen típusdeklarációkat írjunk, mert a Nullable
referenciatípus.
Ha a LastLoginTime
tulajdonság típusát a generikus típusra cseréljük,
public Nullable<DateTime> LastLoginTime { get; set; }
változatlanul használhatjuk tovább, viszont végtelen számú további nullértékű típust is deklarálhatunk. Az Equals
és a GetHashCode
metódusok megvalósításával igazán tökéletesre csiszolhatjuk az osztályt, és valóban, ez a megoldás már éles feladatokhoz is sikeresen felhasználható lenne.
Generikus burkoló struktúra
Ha sok helyen használjuk a Nullable
osztályt, ahol korábban értéktípusokkal dolgoztunk, kiderülhet, hogy az új objektumpéldányok okozta többletterhelés teljesítményproblémákat okoz programunkban. Ezt valahogy meg kell előznünk. Ha kombináljuk korábbi kudarcos próbálkozásunkat a bool
típusú tulajdonságok bevezetésére a burkoló osztály ötletével, a Nullable
típust értéktípussá alakíthatjuk, melynek sokkal kedvezőbb a teljesítménye.
public struct Nullable<T> where T : struct { private T value; public Nullable(T value) { this.value = value; HasValue = true; } public bool HasValue { get; private set; } public T Value { get { if (!HasValue) throw new NullReferenceException(); return value; } } public override bool Equals(object other) { if (!HasValue) return other == null; if (other == null) return false; return value.Equals(other); } public override int GetHashCode() => HasValue ? value.GetHashCode() : 0; public override string ToString() => HasValue ? value.ToString() : string.Empty; public static implicit operator Nullable<T>(T value) => new Nullable<T>(value); public static explicit operator T(Nullable<T> value) => value.Value; }
Beláthatjuk, hogy e struktúra alapértékében a hasValue
mező értéke false
, épp ahogy szeretnénk. Ez majdnem tökéletes, egyetlen korábban említett esetben van még probléma. Ha ez egy értéktípus, a struct
megszorítás többé nem véd meg minket az egymásba ágyazott nullértékű deklarációktól (pl. Nullable<Nullable<int>>
).
Támogatás a C# nyelvben
Eljött az ideje, hogy beismerjem, egészen idáig megtévesztettem az Olvasót. A .NET keretrendszerben már létezik egy, a fentihez kísértetiesen hasonlóan megvalósított Nullable<T>
típus, amelyből nem lehet egymásba ágyazott példányokat létrehozni. Érdemes megnézni a forráskódját!
Ezt a C# nyelv tervezői némi csalással érték el: a where T: struct
megszorítást az alábbi módon definiálták:
A típusargumentum értéktípus kell, hogy legyen. Bármely nem nullértékű típus megadható. (The type argument must be a value type. Any value except Nullable can be specified.)
A nyelv egy kényelmes rövidítést is biztosít, ha nullértékű típusokkal szeretnénk dolgozni. Nullable<T>
helyett T?
-et írhatunk.
public DateTime? LastLoginTime { get; set; }
A C# nyelv a Nullable típust további képességekkel is felruházza, amely így inkább egy nullobjektumhoz hasonlít, mint a null
referenciához. Ezt az úgynevezett operátor-beemelés (operator lifting) segítségével éri el. Bármely, a mögöttes típusra definiált operátor a nullértékű típuson is használható, azzal a megkötéssel, hogy ha bármely operandus értéke null
, az eredmény is null
lesz.
int? a = 2; int? b = 3; int? c = null; int? d = a + b; // d == 5 int? e = b * c; // e == null
További hasznos eszköz a ??
operátor, mely az első operandusát adja vissza, ha az nem null, egyébként pedig a másodikat.
int? a = null; int? b = 5; int c = a ?? 10; // c == 10 int d = b ?? 10; // d == 5
Az operátor érdekes tulajdonsága, hogy láncolható.
int? x = null; int? y = null; int z = x ?? y ?? 10; // z == 10
Fontos kiegészítés, hogy a ??
operátor referenciatípusokkal is használható.
Az utolsó nyelvi szintű funkció a bool?
típus kivételes viselkedése. Íme a logikai és és vagy műveletek igazságtáblája, ha azokat nullértékű típusokra alkalmazzuk:
x |
y |
x && y |
x || y |
---|---|---|---|
true |
true |
true |
true |
true |
false |
false |
true |
true |
null |
null |
true |
false |
true |
false |
true |
false |
false |
false |
false |
false |
null |
false |
null |
null |
true |
null |
true |
null |
false |
false |
null |
null |
null |
null |
null |
Támogatás a futtatókörnyezetben
A Nullable<T>
típus összes eddig bemutatott funkcióját vagy a keretrendszer, vagy a fordító biztosította. Ezek a futtatókörnyezettől teljesen függetlenek. Van azonban egy sajátos működés, nevezetesen a nullértékű típusok be- és kidobozolása (boxing, unboxing), amelyhez már a CLR támogatása is szükséges. Ennek bemutatása túlmutat a cikk keretein, de akit érdekel, ebben a Stack Overflow válaszban egy kiváló magyarázatot olvashat.
Befejezés
Eredeti problémánkat tehát annyival elintézhetjük, hogy egy kérdőjelet biggyesztünk a DateTime
típus után az alábbi sorban:
public DateTime? LastLoginTime { get; set; }
Ám közben tettünk egy hatalmas utazást, és láttuk, mennyi ötlet és alkotó mérnöki munka áll egyetlen karakter mögött.
Források
A cikk elkészítése során rendkívül sokat segítettek az alábbi könyvek. Mindenkinek szívből ajánlom őket.
- C# in Depth , 3rd Edition — Skeet, Jon. Manning, 2013
- C# 6.0 in a Nutshell: The Definitive Reference — Albahari, Joseph & Albahari, Ben. O’Reilly, 2015
- Pro C# 4.5 and the .NET Framework — Troelsen, Andrew. Apress, 2012