A nullable reference types egy nagyon hasznos eszköz, de mint nyelvi eszköz, nem képes minden null esetet helyesen detektálni, illetve bizonyos esetekben a lehet null vagy nem lehet null nem biztosít kellő flexibilitást. Éppen ezért a nullable reference types szolgáltatáshoz kapcsolódóan pár attribútum is bevezetésre került, amikkel támpontot tudunk adni a fordítónak analízis tekintetében. Valamint ezek az attribútumok világosabban leírják az elvárásainkat, és jobb élményt nyújtanak az általunk fejlesztett API-kat felhasználó fejlesztők számára.
Előfeltételek: AllowNull és DisallowNull attribútumok
Tételezzük fel, hogy van egy tulajdonságunk egy osztályban, amely sosem lehet null, mert rendelkezik egy logikus alapértelmezett értékkel. Ebben az esetben logikus, hogy nem nullable értékként jelöljük meg a tulajdonságot. De mi van akkor, ha a null értéket is meg szeretnénk engedni beírásra? Tételezzük fel, hogy egy üzenetküldő rendszert fejlesztünk, amely becenevet kér a csevegőszobában. Ha nem adunk meg ilyet, akkor a rendszer egy véletlenszerű nevet generál:
private string _screenName;
public string ScreenName
{
get => _screenName;
set => _screenName = value ?? GenerateRandomScreenName();
}
A fenti példában ha a ScreenName tulajdonságnak null értéket szeretnénk adni, akkor bizony figyelmeztetés fog generálódni, annak ellenére, hogy a tulajdonság set ága helyesen lekezeli ezt az esetet is. Ebben az esetben az AllowNull attribútummal tudjuk jelezni, hogy a null érték megengedett bemeneti paraméternek, viszont a tulajdonság olvasásakor sosem fogunk null értéket kapni.
private string _screenName;
[AllowNull]
public string ScreenName
{
get => _screenName;
set => _screenName = value ?? GenerateRandomScreenName();
}
Ha ennek a viselkedésnek az ellentettjére lenne szükségünk, vagyis arra, hogy a tulajdonság null értékkel térhessen vissza, de explicit ne lehessen null értéket adni neki, akkor a DisallowNull attribútumot használhatjuk. Ez például olyan esetekben jöhet jól, ahol megjegyzést kell társítanunk valamihez. Alapértelmezetten, adatbázis helyspórolás szempontjából nem tárolunk semmit, tehát visszakaphatunk null értéket, de null érték beírását nem szeretnénk engedni. Ebben az esetben a kódunkat így annotálhatjuk:
string? _comment;
[DisallowNull]
public string? ReviewComment
{
get => _comment;
set => _comment = value ?? throw new ArgumentNullException(nameof(value), "Cannot set to null");
}
Utófeltételek: MaybeNull és NotNull
Ezen attribútumok akkor jönnek jól, ha a generikus kódunk esetén szeretnénk, ha az jól működne. Tételezzük fel, hogy van egy általános, generikus implementációnk keresésre, ami egy adott feltétel alapján elemet ad vissza egy IEnumerable<T> típusból:
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)
{
//implementáció
}
A fenti metódus szignatúra addig tökéletes, amíg el nem kezdjük implementálni a metódust. A probléma akkor jön elő, mikor menet közben rájövünk, hogy T akár null értéket is felvehet, ha éppen nem találunk a keresettnek megfelelő elemet. Ebben az esetben a MaybeNull attribútummal egészíthetjük ki a kódunkat:
[return: MaybeNull]
public T Find<T>(IEnumerable<T> sequence, Func<T, bool> predicate)
{
//implementáció
}
A NotNull attribútum meg akkor lesz hasznos, ha van egy ilyesmi kódrészletünk:
public static void ThrowWhenNull(object value, string valueExpression = "")
{
if (value is null) throw new ArgumentNullException(nameof(value), valueExpression);
}
public static void LogMessage(string? message)
{
ThrowWhenNull(message, $"{nameof(message)} must not be null");
Console.WriteLine(message.Length);
}
Ebben a példában a Console.WriteLine esetén a message objektum használatakor figyelmeztetést fogunk kapni, hogy az null értékű lehet. Azonban mint láthatjuk, a ThrowWhenNull metódus hívása után biztos, hogy nem lehet null, mert ha az lett volna, akkor kaptunk volna kivételt.
Ebben az esetben a ThrowWhenNull szignatúrájában a value argumentumot annotálva jelezhetjük a fordítónak, hogy a metódusból kilépve nem kapunk null értéket:
public static void ThrowWhenNull([NotNull] object value, string valueExpression = "")
{
if (value is null) throw new ArgumentNullException(nameof(value), valueExpression);
}
Feltételes Posztkondíciók: NotNullWhen, MaybeNullWhen, NotNullIfNotNull
A NotNullWhen attribútumra jó példa a string típus IsNullOrEmpty metódusa, ami ha false értéket ad vissza, akkor biztos, hogy a bemenet nem volt null érték. Ezt a fordító is tudja. Mégpedig abból, hogy a metódus bemenete el van látva a NotNullWhen annotációval:
bool IsNullOrEmpty([NotNullWhen(false)] string? value)
{
//implementáció
}
Ez különösen hasznos tud lenni, ha hasonló null biztos ellenőrzéseket szeretnénk implementálni a kódunkban. Ez az attribútum out paraméterek esetén is használható:
bool TryGetMessage(string key, [NotNullWhen(true)] out string? message)
{
//implementáció
}
A NotNullWhen ellentetje a MaybeNullWhen, ami leginkább generikusok és out értékek esetén jön jól:
public class MyQueue<T>
{
public bool TryDequeue([MaybeNullWhen(false)] out T result)
{
//implementáció
}
}
A fenti példában ez az annotáció jelzi a fordítónak, hogy ha a TryDequeue hívás értéke false, akkor a result értéke akár lehet null is, ezért használat előtt ellenőrizni kell.
A NotNullIfNotNull attribútum pedig olyan esetekben jó, ha a metódusunk visszatérési értéke lehet null és a bemenete is lehet null. Azonban ha a bemenet nem null értékű, akkor biztos, hogy értéket kapunk vissza:
[return: NotNullIfNotNull("url")]
string? GetTopLevelDomainFromFullUrl(string? url)
{
//implementáció
}
A fenti példában, ha az url paramétere nem null, akkor a metódus nem fog null értéket visszaadni, ezért a GetTopLevelDomainFromFullUrl hívás esetén csak akkor fogja megkövetelni a null ellenőrzést a fordító, ha a bemenet is null érték volt.
Segéd attribútumok: MemberNotNull és MemberNotNullWhen
Tételezzük fel, hogy a programunkban van egy ilyen kódrészletünk:
public class Optional
{
public string State { get; private set; }
public Optional()
{
Helper();
}
public void Helper()
{
State = "state";
}
}
A fenti kódrészletben a State tulajdonság értéket fog kapni, de nem közvetlenül a konstruktorban, hanem a Helper() metódusban. Ebben az esetben a fordító figyelmeztet bennünket. Ilyenkor használhatjuk a MemberNotNull attribútumot a Helper metóduson, amivel jelezhetjük a fordítónak, hogy ez a metódushívás inicializálja a State tulajdonságot:
public class Optional
{
public string State { get; private set; }
public Optional()
{
Helper();
}
[MemberNotNull(nameof(State))]
public void Helper()
{
State = "state";
}
}
Ha az inicializálást egy bool visszatérési értékű metódus végzi el, akkor a MemberNotNullWhen attribútummal tudjuk jelezni, hogy egy adott osztálytag nem lesz null értékű, ha a metódus igaz, vagy hamis értékkel tér vissza.
Null analízis leállítása
A kivételt dobó metódusok esetén a DoesNotReturn és a DoesNotReturnIf attribútumokkal jelezhetjük a fordítónak, hogy a metódus meghívása után a vezérlés nem fog visszatérni a hívás helyére:
public class Example
{
private string _field = "HelloWorld";
[DoesNotReturn]
private void Fail()
{
throw new InvalidOperationException("Error");
}
[DoesNotReturn]
private void Fail([DoesNotReturnIf(true)] bool isNull)
{
throw new InvalidOperationException("Error");
}
public void SomeMethod(string? parameter)
{
if (parameter == null)
Fail();
Fail(parameter == null);
_field = parameter;
}
}