Geheugen- en codebeheer
Dit hoofdstuk gaat een beetje overal over. In de eerste, en belangrijkste, plaats gaan we eens kijken wat er allemaal achter de schermen gebeurt wanneer we met objecten programmeren. Je zal namelijk ontdekken dat er een fundamenteel verschil is in het werken met bijvoorbeeld een object van het type Student
tegenover werken met een eenvoudige variabele van het type int
.
Vervolgens gaan we kort de keywords using
en namespace
bekijken. Die tweede heb je al bij iedere project bovenaan je code zien staan, nu wordt het tijd om toe te lichten waarom dat is.
Finaal lijkt het ons een goed moment om je robuustere, minder crashende, code te leren schrijven. Exception handling, de naam zegt het al, gaat ons helpen om die typische uitzonderingen (zoals deling door 0) in algoritmes op een elegante manier op te vangen (en dus niet door een nest van if
structuren te schrijven in de hoop dat je iedere mogelijke uitzondering kunt opvangen).
Geheugenbeheer in C#
In hoofdstuk 8 deed ik reeds uit de doeken dat variabelen op 2 manieren in het geheugen kunnen leven:
- Value types: waren variabelen wiens waarde rechtstreeks op de geheugenplek stonden waar de variabele naar verwees. Dit gold voor alle bestaande, ingebakken datatypes zoals
int
,bool
,char
enz. alsook voorenum
types. - Reference types: deze variabelen bevatten als inhoud een geheugenadres naar een andere plek in het geheugen waar de effectieve waarde van deze variabele stond. We zagen dat dit voorlopig enkel bij arrays gebeurde.
Ook objecten zijn reference types. Hoofdstuk 8 liet uitschijnen dat vooral value type variabelen veelvuldig in programma's voorkwamen. Wel je zal nu ontdekken dat reference types véél meer voorkomen: simpelweg omdat alles in C# een object is (en dus ook arrays van objecten én zelfs valuetypes, enz.).
Om goed te begrijpen waarom reference types zo belangrijk zijn, zullen we nu eerst eens inzoomen op hoe het geheugen van een C# applicatie werkt.
Stack en heap
In hoofdstuk 8 toonde ik ook hoe alle variabelen in één grote "wolk geheugen" zitten, ongeacht of ze nu value types of reference types zijn. Dat klopt niet helemaal. Eigenlijk zijn er 2 soorten geheugens die een C# applicatie tot z'n beschikking heeft.
Wanneer een C# applicatie wordt uitgevoerd krijgt het twee soorten geheugen toegewezen dat het 'naar hartelust' kan gebruiken, namelijk:
- Het kleine, maar snelle stack geheugen.
- Het grote, maar tragere heap geheugen.
Afhankelijk van het soort variabele wordt ofwel de stack, ofwel de heap gebruikt. Het is uitermate belangrijk dat je weet in welk geheugen de variabele zal bewaard worden! Je hebt hier geen controle over, maar het beïnvloedt wel de manier waarop je code zal werken.
Volgende tabel vat samen welke type in welk geheugen wordt bewaard:
Value types | Reference types | |
---|---|---|
Inhoud | Eigenlijke data | Referentie naar de eigenlijke data |
Locatie | Stack | Heap |
Beginwaarde | 0 ,0.0 , "" ,false , enz. | null |
Effect = operator | Kopieert actuele waarde | Kopieert adres naar actuele waarde |
Waarom twee geheugens?
Waarom plaatsen we niet alles in de stack? De reden hiervoor is dat bij het compileren van je applicatie er reeds zal berekend worden hoeveel geheugen de stack zal nodig hebben. Wanneer je programma dus later wordt uitgevoerd weet het OS perfect hoeveel geheugen het minstens moet reserveren bij het besturingssysteem.
Er is echter een probleem: de compiler kan niet alles perfect berekenen of voorspellen. Van een variabele van het type int
is perfect geweten hoe groot die zal zijn (32 bit). Maar wat met een string
die je aan de gebruiker vraagt? Of wat met een array waarvan we pas tijdens de uitvoer de lengte gaan berekenen gebaseerd op runtime informatie?
Het zou nutteloos (en zonde) zijn om reeds bij aanvang een bepaalde hoeveelheid stackgeheugen voor een array te reserveren als we niet weten hoe groot die zal worden. Beeld je in dat alle applicaties op je computer voor alle zekerheid een halve gigabyte aan geheugen zouden vragen. Je computer zou enkele terabyte aan geheugen nodig hebben. Het is dus veel realistischer om enkel het geheugen te reserveren waar de compiler 100% zeker van is dat deze zal nodig zijn.
De heap laat ons toe om geheugen op een wat minder gestructureerde manier in te palmen. Tijdens de uitvoer van het programma fungeert de heap als een grote speelplaats waar je overal dingen kunt neerzetten, zolang er maar ruimte vrij is. De stack daarentegen is het kleine bankje naast de zandbak: handig, snel, en met een precies bekende grootte.
Value types in de stack
Value type variabelen worden in de stack bewaard. De effectieve waarde van de variabele wordt in de stack bewaard. Dit zijn alle gekende, 'eenvoudige' datatypes die we totnogtoe gezien hebben:
sbyte
,byte
,short
,ushort
,int
,uint
,long
,ulong
,char
,float
,double
,decimal
,bool
.struct
(niet besproken in dit boek, maar wel kort toegelicht in de appendix).- enums (zie hoofdstuk 5).
= operator bij value types
Wanneer we een value-type willen kopiëren gebruiken we de =-operator die de waarde van de rechtse operand zal uitlezen en zal kopiëren naar de linkse operand:
int getal = 3;
int anderGetal = getal;
Vanaf nu zal anderGetal
de waarde 3
hebben. Als we nu één van beide variabelen aanpassen dan zal dit geen effect hebben op de andere variabelen.
We zien hetzelfde effect wanneer we een methode maken die een parameter van het value type aanvaardt:
void VerhoogParameter(int a)
{
a++;
Console.WriteLine($"In methode {a}");
}
Bij de aanroep geven we een kopie van de variabele mee:
int getal = 5;
VerhoogParameter(getal);
Console.WriteLine($"Na methode {getal}");
De parameter a
zal de waarde 5
gekopieerd krijgen. Maar wanneer we nu zaken aanpassen in a
zal dit geen effect hebben op de waarde van getal
.
De output van bovenstaand programma zal zijn:
In methode 6
Na methode 5
Reference types
Reference types worden in de heap bewaard. De effectieve waarde wordt in de heap bewaard, en in de stack zal enkel een referentie of pointer naar de data in de heap bewaard worden. Een referentie is niet meer dan het geheugenadres naar waar verwezen wordt (bv. 0xA3B3163
).
Concreet zijn dit alle zaken die vaak redelijk groot zullen zijn of waarvan op voorhand niet kan voorspeld worden hoe groot ze at runtime zullen zijn (denk maar aan arrays, instanties van complexe klassen, enz.).
= operator bij reference types
Wanneer we de = operator gebruiken bij een reference type dan kopiëren we de referentie naar de waarde van de rechtse operand, niet de waarde zelf.
We bekijken nu de impact van de = operator bij:
- objecten: en zullen zien dat we dus de referentie, niet het object zelf, kopiëren.
- arrays: en zullen ook hier zien dat we niet de array zelf kopiëren, maar enkel de referentie naar de array in de heap.
= operator bij objecten
We zien dit gedrag bij alle reference types, zoals objecten:
Student stud = new Student();
Wat gebeurt er hier?
new Student()
:new
roept de constructor vanStudent
aan. Deze zal met behulp van een constructor een object in de heap aanmaken en vervolgens de geheugenlocatie ervan teruggeven.- Een variabele
stud
wordt in de stack aangemaakt en mag enkel een referentie naar een object van het typeStudent
bewaren. - De geheugenlocatie uit de eerste stap wordt vervolgens in
stud
opgeslagen in de stack.
Laten we eens inzoomen op voorgaande door de code even in 2 delen op te splitsen:
Student stud;
stud = new Student();
Het geheugen na lijn 1 ziet er zo uit:
Merk op dat de variabele stud
eigenlijk de waarde null
heeft. We leggen later uit wat dit juist wil zeggen.
Lijn 2 gaan we nog trager bekijken: Eerst zal het gedeelte rechts van de =
-operator uitgevoerd worden. Er wordt dus in de heap een nieuw Student
-object aangemaakt:
Vervolgens wordt de toekenning toegepast en wordt het geheugenadres van het object in de variabele stud
geplaatst:
Ik ga nogal licht over het new
-keyword en de constructor. Maar zoals je merkt is dit een ongelooflijk belangrijk mechanisme in de wereld van de objecten. Het brengt letterlijk objecten tot leven (in de heap) en zal als resultaat laten weten op welke plek in het geheugen het object staat.
= operator bij arrays
Zoals we in hoofdstuk 8 hebben gezien, zien we het zelfde gedrag bij arrays:
int[] nummers = {4,5,10};
int[] andereNummers = nummers;
In dit voorbeeld zal andereNummers
nu dus ook verwijzen naar de array in de heap waar de actuele waarden staan.
Als we dus volgende code uitvoeren dan ontdekken we dat beide variabele naar dezelfde array verwijzen:
andereNummers[0] = 999;
Console.WriteLine(andereNummers[0]);
Console.WriteLine(nummers[0]);
We zullen dus als output krijgen:
999
999
Hetzelfde gedrag zien we bij objecten:
Student a = new Student("Abba");
Student b = new Student("Queen");
Geeft volgende situatie in het geheugen:
Schrijven we dan het volgende:
b = a;
Console.WriteLine(a.Naam);
Dan zullen we in dit geval dus Abba
op het scherm zien omdat zowel b
als a
naar hetzelfde object in de heap verwijzen. Het originele "Queen"-object zijn we kwijt en zal verdwijnen (zie Garbage Collector verderop).
De meeste klassen zullen met value type-properties en instantievariabelen werken in zich, toch worden deze ook samen met het gehele object in de heap bewaard en niet in de stack. Kortom het hele object ongeacht de vorm (datatypes) van z'n inhoud wordt in de heap bewaard.
De Garbage Collector
Een stille held van .NET is de zogenaamde GC, de Garbage Collector. Dit is een geautomatiseerd onderdeel van ieder C# programma dat ervoor zorgt dat we geen geheugen nodeloos gereserveerd houden. De GC zal geregeld het geheugen doorlopen en kijken of er in de heap objecten staat waar geen referenties naar verwijzen. Indien er geen referenties naar wijzen zal dit object verwijderd worden.
In dit voorbeeld zien we dit in actie:
Held supermand = new Held();
Held batmand = new Held();
batmand = supermand;
Vanaf de laatste lijn zal er geen referentie meer naar het originele object zijn waar batmand
naar verwees in de heap, daar we deze hebben overschreven met een referentie naar het eerste Held
object in supermand
. De GC zal dus dat tweede aangemaakte Held
object verwijderen. Wil je dat niet dan zal je minstens 1 variabele moeten hebben die naar de data verwijst. Volgend voorbeeld toont dit:
Held supermand = new Held();
Held batmand = new Held();
Held bewaarEersteHeld = batmand;
batmand = supermand;
De variabele bewaarEersteHeld
houdt dus een referentie naar die in batmand
bij en we kunnen dus later via deze variabele alsnog aan de originele data.
De GC werkt niet continue daar dit te veel overhead van je computer zou vereisen. De GC zal gewoon om de zoveel tijd alle gereserveerde geheugenplekken van de applicatie controleren en die delen verwijderen die niet meer nodig zijn.
Je kan de GC manueel de opdracht geven om een opkuisbeurt te starten met GC.Collect()
maar dit is ten stelligste af te raden! De GC weet meestal beter dan ons wanneer er gekuist moet worden.