assert(): Een moderne handleiding

Iets wat me altijd heeft gestoord, is de assert()-statement. Hoewel ik de eenvoudige assertion-statement beschouw als een van de fundamenten voor correcte, veilige en ondersteunbare software, heb ik het gevoel dat het op sommige vlakken tekortschiet.

Ten eerste zijn veel assert()-implementaties simpelweg beperkt; ze bieden weliswaar wat nuttige informatie, maar stoppen voordat ze diepere en relevantere context verschaffen. Ten tweede is het vaak verwarrend wanneer een assertion precies gebruikt moet worden. Is het een lichtgewicht debugging-statement? Kunnen assertions in productieomgevingen worden gebruikt? Waarop zou ik moeten asserten? Kan ik aanpassen hoe assert zich gedraagt? Deze verwarring wordt vergroot door het feit dat sommige implementaties zelfs toestaan dat alle assertions met een eenvoudige compile-time switch worden uitgeschakeld. Kortom: er moet nog veel werk worden verricht op het gebied van het juiste begrip en gebruik van assert().

De eerste en belangrijkste stap om assert() beter te benutten, is het definiëren van wanneer en waar deze statements gebruikt moeten worden. Ik onderscheid vier gebieden waar assertions effectief kunnen zijn:

  • Correctheid: Alle mogelijke input- en outputwaarden van functies die geen volledige waardedekking hebben, zouden gedekt moeten worden door assertions.
  • Veiligheid: Bij het uitvoeren van operaties die ongewenste, bekende of onbekende neveneffecten kunnen hebben, moeten assertions worden gebruikt om te voorkomen dat deze condities optreden.
  • Ontwikkeling: Gebruik een assertion om aannames over waarden en status af te dwingen. Deze assertions kunnen optioneel uit de code worden gecompileerd in combinatie met adequaat testen.
  • Documentatie: Gebruik assertions bij het schrijven van code als zelfdocumenterende vangrails rondom je logica. Gebruik ze om waarden en statussen af te dwingen die onduidelijk kunnen zijn vanuit de documentatie of moeilijk te ontcijferen zijn door het lezen van de code.

Een extra voordeel is dat wanneer assertions correct in je code worden gebruikt, statische analyse van je code effectiever wordt. Assertions beperken de reikwijdte voor datavalidatie en invariantanalyse aanzienlijk, wat leidt tot betere resultaten. Je vraagt je misschien af of al deze assertions mijn code traag en log maken. Het eenvoudige antwoord is nee; wanneer ze correct worden gebruikt, hebben assertions weinig tot geen overhead. We spreken hier over slechts een paar CPU-cycli om een lokale waarde te lezen en vervolgens een vertakking (branch) over te slaan, wat voor de meeste applicaties niet eens nauwkeurig gemeten kan worden. Afgewogen tegen de voordelen die assertions bieden, is het moeilijk om ertegen te argumenteren.

Correctheid en Veiligheid

Laten we praten over correctheid, want dit is waarschijnlijk de belangrijkste en meest onderbenutte rol van assertions. Bij het schrijven van code is het gemakkelijk om alle verschillende potentiële waarden die een parameter of retourwaarde kan hebben over het hoofd te zien. Een assertion kan dat gat dichten door simpelweg te bevestigen dat de waarde is wat je verwacht. Als je elke mogelijke waarde ondersteunt — wat meestal het geval is — is er geen assertion nodig. Tussen de logica en je assertion zouden nu alle potentiële waarden gedekt moeten zijn, waardoor we kunnen stellen dat de dekking voor deze waarde "correct" is.

Bijvoorbeeld, stel dat we deze logica hebben waarbij foutwaarden correct worden gedekt met een assertion:

var error = system_call(...);
assert(!error);

Stel nu dat er een fout wordt gegenereerd en geretourneerd. Je programma crasht. Overduidelijk is dit geen ideaal gedrag; veelvoorkomende waarden, inclusief fouten, zouden eleganter afgehandeld moeten worden. Echter, twee dingen zijn hier belangrijk:

  1. Je bent nu op de hoogte gebracht dat deze ge-assertede waarde bestaat. Je kunt nu besluiten actie te ondernemen, zoals een codeaanpassing om deze waarde daadwerkelijk af te handelen of tijd besteden aan het begrijpen van wat er is gebeurd.
  2. (En dit is het belangrijkste punt) je begon met 100% waardecorrectheid en behoudt die 100% waardecorrectheid. Een gefaalde assertion gedraagt zich precies zoals bedoeld: hij heeft correct een edge-case gevangen. Als die assertion niet had bestaan of was weggecompileerd, zou je applicatie zich in onbekend en onveilig gebied bevinden.

Hier is een ander voorbeeld dat waardecorrectheid illustreert:

var x = random();
assert(x > 0);

We stellen de assertie-conditie in dat we verwachten dat x groter is dan nul. Zolang de logica die x gebruikt alle mogelijke waarden van x groter dan nul ondersteunt, kunnen we zeggen dat we hier correct zijn voor x. Stel nu dat random() om welke reden dan ook een nul of een negatief getal geeft; de assertion vertelt ons dat onze aanname onjuist was en het programma stopt onmiddellijk. We hebben nu hopelijk de kans om deze fout te herstellen met betere afhandeling of in ieder geval onderzoek naar wat er misging. Opnieuw: als we deze code zonder de assertion hadden geschreven en we zouden een nul of negatief getal tegenkomen, dan hebben we onverwacht gedrag en, erger nog, we weten mogelijk niet eens dat het gebeurt.

Enkele veelvoorkomende voorbeelden van waarden die moeilijk te hanteren zijn, is wanneer een aanroep voor geheugenallocatie mislukt. Hoewel er valide manieren zijn om dit af te handelen, is het in de meeste gevallen een legitieme actie om een assertion te triggeren en af te sluiten. Wat zou je doen als een lock faalt of een thread niet kan worden gestart? Opnieuw: een assertion die deze gevallen dekt is redelijk, gezien de extreme zeldzaamheid van deze gebeurtenissen.

Een andere belangrijke reden om 100% waardecorrectheid te garanderen, is dat de omgeving waarop je test soms niet dezelfde is als de omgeving waarop je applicatie draait. Subtiele gedragsverschillen tussen versies, API's, bibliotheken en platforms kunnen een applicatie ontregelen en potentieel zeer moeilijk te debuggen gedrag introduceren. Volledige waardedekking is dus een manier voor je applicatie om dit soort stille defecten proactief en defensief onmiddellijk te bestrijden. En wederom: als je applicatie alle mogelijke input- en outputwaarden getrouw afhandelt, kun je dit advies veilig negeren.

Operationele veiligheid is een ander groot gebied waar assertions kunnen helpen. Het klassieke voorbeeld is directe toegang tot het geheugen: hoe zorgen we ervoor dat die toegangen zijn wat we verwachten en willen (d.w.z. veilig)? Dit komt neer op twee dingen. Ten eerste moeten we in staat zijn om te definiëren wat de grenzen van dat geheugen zijn. Zodra we die grenzen hebben gedefinieerd, asserten we simpelweg dat we binnen die grenzen blijven bij het benaderen van het bewuste geheugen. Vaak kunnen deze grenzen en controles automatisch worden gedaan bij het werken met goed gedefinieerde types. Bij minder gedefinieerde types, zoals een C-array of een raw memory pointer, moet er altijd een grens worden gedefinieerd en moeten assertions op die grens worden toegepast bij toegang.

Een ander klassiek voorbeeld van operationele veiligheid is iets simpels als:

var c = a + b;

Hoe weten we dat we c niet hebben laten overlopen (overflow) bij het optellen van a en b? Hoe zou je applicatie zich gedragen als c niet gelijk is aan a + b? Handel je de situatie af waarin c nul is? Als dit onbekenden zijn, kan een eenvoudige assertion dit oplossen (ervan uitgaande dat het om niet-negatieve waarden gaat):

assert(c >= a);

Als dit overdreven lijkt voor iets simpels als een optelsom, denk dan na over welke grenzen er in je applicatie aanwezig zijn die voorkomen dat a en b het punt van potentiële overflow bereiken. Zijn deze waarden gekoppeld aan logica, een resource of een andere codestructuur met een beperkende factor? Als er structurele limieten zijn, is een development- of documentatie-assertion wellicht op orde. Als er geen natuurlijke grenzen bestaan, kan het zinvol zijn om veiligheidsassertions aan je code toe te voegen of over te stappen op een overflow-safe bibliotheek, omdat het onbewust bereiken van overflow-limieten op variabelen meestal een teken is dat er onveilige zaken in je applicatie broeden.

Ontwikkeling en Documentatie

Vervolgens hebben we development asserts. Deze assertions dekken doorgaans logica- en statusfouten. In combinatie met adequaat testen kunnen deze assertions worden overgeslagen in productiecode, maar niet altijd. Een voorbeeld van een development assertion is het garanderen dat we geen "off by 1"-fout maken bij het gebruik van een index-gebaseerde API. Dit komt vaak voor bij het werken met strings. Stel dat we een bestandsnaam hebben en de naam en extensie willen scheiden:

var ext_position = filename.indexOf(".");
var name = filename.substring(0, ext_position);
var extension = filename.substring(ext_position + 1);
assert_dev(name + "." + extension == filename);

In dit voorbeeld gebruiken we een development assertion om er zeker van te zijn dat we de bestandsnaam correct en op de juiste indexen hebben geparsed, door de delen weer samenvoegen en te controleren of dit overeenkomt met het origineel. Zolang dit is getest en de assertions niet triggeren, zijn we er vrij zeker van dat deze logica correct is en dat het veilig is om deze assertion in productiecode over te slaan. Je zou kunnen zeggen dat dit een goede kandidaat is voor een unit test, en dat heb je waarschijnlijk gelijk. Je zou ook kunnen zeggen dat dit enkele assert-statement de unit test is en dat er geen feitelijke unit test nodig is. Een goede vuistregel is dat development assertions meestal opereren op een niveau dat te granulair is voor een volledige unit test.

Voor een development assertion om echter waarde te hebben, moet deze regelmatig worden getest. Het is vermeldenswaard dat dit soort assertions meer execution overhead kunnen hebben dan eenvoudige waardecorrecties; wees er dus van bewust dat een hoge concentratie dure assertions de boel kan vertragen. Dit is acceptabel voor testen, maar minder geschikt voor productiecode.

Documentatie-asserts lijken op development asserts, behalve dat ze dienen als versterking om te documenteren welke aannames een bepaald stuk logica heeft gedaan. Ze kunnen redundant lijken of zelfs overkomen als "over-asserting", maar ze zijn er echt om de ontwikkeling te ondersteunen. Deze assertions vervullen een dubbelrol: ze helpen een lezer van de code te redeneren over (of zich herinneren) hoe een stuk logica werkt, en ze bewaken tegen onjuist gebruik. Grote en geëvolueerde codebases die door refactoringsrondes gaan en waarbij logica verspreid is over veel bronbestanden, kunnen echt profiteren van dit soort documenterende asserts.

Uitbreidend op het bovenstaande voorbeeld kunnen we een documentatie-assert toevoegen:

var ext_position = filename.indexOf(".");
assert_dev(ext_position > 0, "filename must have a name and extension, see validate_filename(): " + filename);

Als we nu deze assertion raken, vragen we onszelf af: wat is er gebeurd met onze validatie?

Bij het toevoegen van nieuwe logica aan een reeds complexe codebase is het soms goede praktijk om door de logica heen te werken met development assertions. Je hebt niet alleen een laag documentatie toegevoegd, maar ook vangrails geplaatst om toekomstig debuggen en ontwikkelingswerk te ondersteunen.

Als je het gevoel hebt dat een assertion redundant is, maak er dan een "development only" assertion van. Als het nog steeds redundant en overmatig uitgebreid voelt, heb je die assertion waarschijnlijk niet nodig. Onthoud dat development assertions als unit tests zijn. Hoewel ze onesthetisch kunnen zijn, kan ik me niet herinneren dat iemand heeft geklaagd dat een codebase te veel tests had. Wanneer je assertions effectief begint te gebruiken en de feedbackloop tussen correctheid en je ontwikkelproces opbouwt, krijg je een beter gevoel van waar assertions waarde bieden en hoe waardevol ze zijn. Natuurlijk zijn je assertions slechts zo goed als je vermogen om ze te testen. En te veel assertions die triggeren in productiecode is nooit een goed teken.

Blik op de toekomst

Hoe ziet een ideale assertion API eruit?

Ten eerste moet deze zowel een productie- als een development-API hebben. Productie-assertions mogen nooit worden uitgeschakeld en development-assertions moeten eenvoudig aan- en uitgeschakeld kunnen worden.

Ten tweede moet het dynamische berichten ondersteunen. Een assertion zoals deze vertelt ons slechts een deel van het verhaal: assert(state == CONNECTED, "invalid state");

Dit is een betere manier om de assertion over te brengen: assert(state == CONNECTED, "invalid state found: " + state);

Ten derde zou het een stack dump moeten ondersteunen samen met het assertion-bericht. Als je een assertion raakt diep in de code, geeft die stack dump de broodnodige context over hoe dat stuk code is bereikt.

Tot slot: bonuspunten als je in staat bent om belangrijke delen van je applicatiestatus, context en/of metrieken op te slaan en vervolgens te dumpen tijdens het post-assertion proces. Dit zal verder helpen bij het debuggen van wat er misging waardoor die assertion werd getriggerd.