“I call it my billion-dollar mistake”. Dat zijn de woorden van Sir Antony Hoare. Hij zei deze woorden toen hij de basis legde voor de computertaal ALGOL W en de null reference had ingevoerd. Deze fouten, verwijzingen naar iets dat niet bestaat, horen bij de meest voorkomende fouten en irritaties. Deze null-references zijn vanaf het onstaan ervan onderwerp van discussie.
In object georiënteerde talen is null ook een overtreding van de Substitutieprincipe van Liskov, een van de vijf onderdelen van de SOLID principes. Ter herinnering: Barbara Liskov beweerde dat een “is” relatie tussen de klassen onvoldoende is: de juiste relatie is eigenlijk “is vervangbaar door”.
We kunnen bijvoorbeeld de klasse ”walvis”, de klasse ”zoogdier” laten overerven. Maar als daarna overal in de code uitzonderingregels if (dit Zoogdier is walvis) opduiken, dan is duidelijk dat we ons aan de Substitutieprincipe van Liskov niet hebben gehouden.
Dat is ook het geval met null in C#. In .NET bestaat de aanname dat alles, inclusief null, een object is. Alle objecten erven de System.Object klasse. Een null is dus een object, maar een null is niet uitwisselbaar met een non-nullable object. Deze onuitwisselbaarheid is een overtreding van het substitutieprincipe en de gevolgen kennen we: duizenden controles op null waarde die overal in de code staan om NullReferenceExceptions te voorkomen.
In dit artikel behandelen we het onderwerp null-references. In het artikel leg ik uit hoe C# versie 8+ een oplossing is voor dit probleem. Een probleem dat de software-industrie al decennia lang plaagt. Met name in een omgeving waarbij je heel vaak releaset is het erg fijn dat de compiler dergelijke fouten voorkomt. Vanaf C# versie 8 is dat een feit. Ook al is deze versie al vanaf 2019 beschikbaar, zie ik nog steeds dagelijks dat ontwikkelaars onvoldoende gebruik maken van deze mogelijkheden in C#.
De ontwikkelaars van de C# programmeertaal hadden de ambitie om de afhandeling van null-references beter te maken. Vanaf versie C# versie 8 zijn er Nullable Reference Types (NRT) beschikbaar. In deze, zogenaamde “null-bewuste context” zijn alle variabelen per default non-nullable.
Ontwikkelaars kunnen met speciale annotaties hun intenties over null verwijzingen specificeren. Met deze annotaties declareer je of een variabele altijd ‘niet-null’ is of null kan zijn. De compiler volgt deze aanwijzingen, voert extra flow-controles uit en waarschuwt voor risico’s dat een niet nulbare waarde tijdens de runtime toch naar null verwijst.
Deze wijzigingen zijn zo grondig dat ze bestaande code kunnen breken. In oudere versies van C# werden alle referenties als null-baar gezien. Vanaf C# 8 zijn ook de traditioneel null-bare datatypes zoals strings, per default niet meer null-baar. Nullable waarden moet je expliciet declareren.
Vanaf .NET versie 6.0 wordt null-bewuste context voor alle nieuw aangemaakte projecten automatisch aangezet. Het betekent dat een bestaand project waarschijnlijk talloze nieuwe waarschuwingen genereert. Bij het migreren van oude projecten naar .NET 6.0 blijft NRT uitgeschakeld. Je kan zelf kiezen of je de NRT’s wel, niet of gedeeltelijk invoert.
Het inschakelen van NRT heeft twee voordelen. Het eerste voordeel is de uitgebreide statische flow analyse. Tijdens het schrijven van de code krijg je waarschuwingen over mogelijke null-references, over ontbrekende (maar ook overbodige) controles op null en over de variabelen die wel of niet nullable moeten zijn. De compiler geeft je ook de suggesties voor de code aanpassingen. Dat vermindert de kansen op NullReferenceExceptions tijdens runtime.
Het tweede voordeel is dat je de compiler je aanspoort bewuster met null-waarden om te gaan. De bekende “gouden regel van het programmeren” luidt: “If it can be null, it will be null”. In het null-bewuste context zijn alle variabelen per default non-nullable, tenzij je een gefundeeerde reden hebt om ze expliciet nullable te maken.
De ‘nullability’ van een variabele geeft de compiler, zoals in eerdere versies van C#, met “?” weer. De compiler behandelt alle variabelen zonder een vraagteken als niet nullable. Dat geldt ook voor ‘reference variables’, die traditioneel altijd nulbaar waren.
string eersteNietNulbareString; string tweedeNietNulbareString = string.Empty;
In de bovenstaande twee voorbeelden veronderstelt de compiler dat de waarde van de strings niet nul kan zijn. Je krijgt waarschuwingen als de code een null waarde of een “maybe-null” waarde kan toewijzen aan een variabele.
string? nulbaarString;
Bovenstaande string wordt als een maybe-null variabele gezien en mag in de code een null-waarde of een maybe-null waarde toegewezen krijgen. De compiler waarschuwt indien de waarde van de variabele volgens de statische flow analyse null kan zijn. Je krijgt ook waarschuwingen als je probeert properties (bv: nulbaarString.Length) zonder null controle benadert. Er zijn geen waarschuwingen als je een maybe-null waarde aan een andere maybe-null waarde of expressie overdraagt.
Variabelen van de value type kun je op null controleren door de boolean property HasValue af te lezen. Null controle voor de referentie types gebeurt met vergelijking met null. Dit is hetzelfde als in de oude versies van C#.
_ = nulbaarInteger.HasValue; // value-type variabelen, zoals int _ = nulbaarString != null; // referentie-type variabelen
Alle impliciet (met var) gedeclareerde variabelen zijn altijd maybe-null.
De statische flow analyse van de compiler kent beperkingen. De compiler heeft maar beperkt inzicht in de afhankelijkheden tussen de methoden (lees: routines). Soms is het onmogelijk te bepalen hoe een methode met null waarde omgaat of wat voor waarde van die methode terugkomt. Dat kan onjuiste meldingen veroorzaken.
In sommige scenario’s wil je ook zelf de controle over de meldingen overnemen en uitzonderingen instellen omdat het beter bij je ontwerp past. NRT geeft je de mogelijkheid de compiler met tips te helpen of sommige controles voor bepaalde methodes, properties of klassen aan te passen. Dat doe je met annotaties. Er zijn vijf types NRT-annotaties:
1. Precondities
2. Postcondities
3. Conditionele Postcondities
4. Helpers
5. Signalen voor onbereikbare code
De twee precondities [AllowNull] en [DisallowNull] zijn toepasbaar op parameters, velden en property setters. In onderstaand voorbeeld geven we aan dat de parameter van de TestMethode nooit null mag zijn, ondanks dat de type van de parameter null-baar is:
public void TestMethode([DisallowNull] List<Person?>? persons)
Als we deze methode ergens in de code met een parameter aanroepen, dat volgens de compiler null kan zijn, dan geeft de compiler een waarschuwing. Een voorbeeld van deze waarschuwing staat onderaan in deze paragraaf weergegeven.
Zonder [DisallowNull] komt de waarschuwing niet voor. Een voorbeeld waarbij je [DisallowNull] expliciet aangeeft is als de variabelen in andere lagen van de applicatie of in een externe library als nullable zijn gedeclareerd en je wilt in jou methode hiervan afwijken.
[AllowNull] werkt andersom. Hiermee geef je aan dat parameters of properties wel null kunnen zijn, ondanks dat in de definitie het tegenovergestelde is gedeclareerd.
Postcondities [MaybeNull] en [NotNull] passen we toe op argumenten, bij het teruggegeven van waarden van methodes of op de waarde van properties. Met [MayBeNull] geven we aan dat een methode null terug kan sturen ook al is de retourwaarde niet null-baar.
[return: MaybeNull] public List<Person> FindPersons(Location location)
Toepassen van [NotNull] op een argument vertelt dat de waarde van dat argument niet null kan zijn nadat de methode is uitgevoerd. In het onderstaande voorbeeld is de parameter person gegarandeerd niet null als de methode ThrowAlsPersoonNul succesvol was.
public void ThrowAlsPersoonNul([NotNull] Person? person) { if (person == null) { throw new ArgumentNullException(nameof(person)); } }
Zonder controle geeft de compiler een waarschuwing. Als we deze controle op het resultaat van een zoekopdracht toepassen, verdwijnt de melding.
var person = personList.Find(p => p.Name == "Al"); ThrowAlsPersoonNul(person); [Hier ontstaat de foutmelding] var displayName = person.DisplayName;
Conditionele postcondities zijn [NotNullWhen] [MaybeNullWhen] en [NotNullifNotNull]. Het zijn postcondities als eerder genoemde [MaybeNull] en [NotNull]. Het verschil is dat deze condities voorwaardelijk werken.
NotNullWhen heeft een boolean parameter. In het onderstaand voorbeeld controleert de functie of de string DisplayNaam niet null is:
public bool IsDisplayNaamIngevuld([NotNullWhen(true)] string? displayNaam) { return (displayNaam != null && displayNaam.Length > 0); }
Zonder controle bestaat een kans dat de lengte van een null-reference gelezen wordt en ontstaat de foutmelding. Als de code eerst de null-reference controle uitvoert voordat de code de lengte van de string bepaalt verdwijnt de foutmelding.
if (IsDisplayNaamIngevuld(displayName)) { length = displayName.Length; }
Na de controle weet de compiler dat displayName onmogelijk nul kan zijn. Zonder onze hulp met de annotatie [NotNullWhen(true)] zou de compiler dat niet kunnen bepalen.
Met de helpende methodes [MemberNotNull] en [MemberNotNullWhen] geef je aan dat je zeker bent dat een property netjes wordt geïnitialiseerd:
[MemberNotNull(nameof(DisplayName))] private void CreateDisplayName() { DisplayName = $"{FirstName} {Name}"; }
Met de annotaties [DoesNotReturn] en [DoesNotReturnIf] markeer je methoden die nooit een waarde teruggeven, omdat ze alleen een exceptie creëren als iets niet goed gaat. Bijvoorbeeld een methode die controleert of een belangrijke waarde null is en in dat geval een exceptie creëert. Deze annotaties maken de compiler duidelijk dat in de code die volgt de variabele al op null is getest. In dat geval zijn waarschuwingen zijn niet meer nodig.
[DoesNotReturn] private void KritiekeFout() { throw new InvalidOperationException(); } public void VerwerkPersoonsGegevens(Person person) { if (person is null) { KritiekeFout(); } // person kan na de vorige methode niet null zijn. var naam = person.Name; // ... code }
Als de compiler verkeerde aannames maakt heb je naast de annotaties ook de null-forgiving operator “!” tot je beschikking. Dit geldt ook voor als je de meldingen van de compiler wilt overrulen. Je roept bijvoorbeeld een functie aan waarvan je zeker weet dat de waarde die de functie teruggeeft nooit null kan zijn, terwijl de compiler waarschuwingen blijft geven. Of je leest een property van een object en je bent zeker dat de property onmogelijk null kan zijn. In dat geval los je dat met de “!” operator op.
In het onderstaand voorbeeld is de variabele persoon niet nullable. We gebruiken het uitroepteken aan het einde van de Find opdracht, omdat we zeker zijn dat de persoon met de naam “Al” in de lijst staat.
Person person = personList.Find(p => p.Name == "Al")!;
In het volgende statement zijn we zeker dat én de persoon én de naam van de persoon een geldige waarde hebben. We geven dat met de uitroeptekens aan.
var length = person!.Name!.Length;
Null coalsecing operator “?” bestaat al lang. Het vervangt de if-else constructies en maakt de code leesbaarder. Twee nieuwe operatoren, ?? en ??= hebben een vergelijkbare rol. Met ?? operator controleren we of de waarde links van de operator null is. In dat geval geven we die variabele direct een waarde:
Person person = personList.Find(p => p.Name == "Al") ?? new Person();
Met nul toekenningsoperator initialiseren we een waarde:
List<Person?>? personList = null; (personList ??= new List<Person?>()).Add(new Person { Name = "Al" });
Het is ook mogelijk de operatoren achter elkaar te gebruiken:
var gender = person.Gender?.ToString() ?? nameof(Gender.Unknown);
De operatoren zijn van recht naar links associatief, dus:
a ?? b ?? c is eigenlijk a ?? (b ?? c)
a ??= b ??= c parset de compiler als a ??= (b ??= c)
Het aanzetten van de null-bewuste context in bestaande applicaties kan een ware lawine aan waarschuwingen veroorzaken. Er zijn vier mogelijke configuratie opties. De opties zijn als volgt:
Deze opties zijn nauwkeurig op drie verschillende niveaus aan te zetten:
Globaal instellen is eenvoudig door aan het element <Nullable> in het .csproj bestand op een van de vier mogelijke configuratieopties in te stellen:
<PropertyGroup> <TargetFramework>net6.0</TargetFramework> <Nullable>enable</Nullable> </PropertyGroup>
Instellen op bestandsniveau is mogelijk met de preprocessor directieve #nullable in het bestand. Dat heeft prioriteit op de globale instellingen.
#nullable enable
#nullable enable warnings
#nullable enable annotations
#nullable disable
#nullable disable warnings
#nullable disable annotations
Met de volgende directieven herstellen we het nullable context naar de globale instelling uit het project bestand:
#nullable restore
#nullable restore warnings
#nullable restore annotations
Het is mogelijk om de nullable context alleen voor de specifieke stukken code in een bestand ‘aan’ of ‘uit’ te zetten, zo vaak als nodig. Daarvoor gebruik je dezelfde directieven als voor het instellen op bestandsniveau. Indien je de null references zeer serieus neemt, met foutmeldingen in plaats van waarschuwingen, dan kan je voor “Nullable” de instelling “WarningsAsErrors” aanzetten. Waarschuwingen kun je namelijk negeren en foutmeldingen niet. Dit ziet er als volgt uit:
<PropertyGroup> <TargetFramework>net6.0</TargetFramework> <Nullable>enable</Nullable> <WarningsAsErrors>Nullable</WarningsAsErrors> </PropertyGroup>
Omgaan met ontbrekende of tijdelijk onbekende waarden is een belangrijk aspect van programmeren. In veel programmeertalen, waaronder in C#, is null gekozen als dé manier om met zulke waarden om te gaan. Programmeren zonder null zou in C# onmogelijk of zeer omslachtig zijn.
Null-waarden heeft een duidelijk doel. De problemen ontstaan als we door onoplettendheid of te weinig inzicht een null-waarde aan een property toewijzen die geen null mag zijn.
Het invoeren van de null bewuste omgeving in .NET vind ik een grote verbetering. Nieuwe taalconstructies maken de code leesbaarder en de flow-analyse maakt de kansen op Null-reference Exceptions aanzienlijk kleiner. Het is nu mogelijk om null-waarden te gebruiken waar het noodzakelijk is in het ontwerp van de flow en onderliggende data. Juist in een kort-cyclische context is het belangrijk dat de compiler je helpt met dergelijke automatische tests.