Welkom

Zo, je hebt besloten om C# te leren? Je bent hier aan het juiste adres. Dit boek wordt gebruikt als handboek binnen de opleidingen professionele bachelor elektronica-ict en toegepaste informatica van de AP Hogeschool. Het is gedurende vele jaren gegroeid tot een tweedelige reeks (1 per semester), genaamd "Zie Scherp" en "Zie Scherper". Dit boek is de combinatie van die twee delen.

Eerst zullen we de fundering leggen en zaken behandelen zoals variabelen, loops methoden en arrays. Vervolgens zal (hopelijk) de mystieke, maar oh zo belangrijke, wereld van het Object georiënteerd programmeren uit de doeken gedaan worden.

Je vraagt je misschien af hoe up-to-date dit boek is? Wel, het is origineel samengesteld tijdens de lockdowns in 2020, dus... mmm, het jaar 2020 als kwaliteitslabel gebruiken is een beetje zoals zeggen dat je wijn maakt met rioolwater. Toen eind 2021 een nieuwe versie van Visual Studio verscheen werd het tijd om dit boek grondig te updaten. De versie die je nu in handen hebt werd geüpdatet in de zomer van 2022, een veel fijner jaar dan 2020 ;)

Net zoals spreektalen, evolueert ook de programmeertaal C# constant. Terwijl ik dit schrijf zijn we aan versie 10.0 van C# en staat versie 11 in de startblokken. Bij iedere nieuwe C#-versie worden bepaalde concepten plots veel eenvoudiger of zelfs gewoon overbodig. Een goed programmeur moet natuurlijk zowel met de oude als de nieuwe constructies kunnen werken. Ik heb getracht een gezonde mix tussen oud en nieuw te zoeken, waarbij de nadruk ligt op maximale bruikbaarheid in je verdere professionele carrière. Je zal hier dus geen stoere, state-of-the-art C# innovaties terugvinden die enkel in heel specifieke projecten bruikbaar zijn. Integendeel, ik hoop dat als je aan het laatste hoofdstuk bent, je een zodanige basis hebt, dat je ook zonder problemen in andere 'zustertalen' durft te duiken (zoals Java, C en C++, maar ook zelfs Python of JavaScript).

Dit boek ambieert niet om de volledige C#-taal en alles dat daar rond hangt aan te leren. Het boek daarentegen is gericht op eender wie die interesse heeft in de wondere wereld van programmeren, maar mogelijk nog nooit één letter code effectief heeft geprogrammeerd. Bepaalde concepten die ik te gecompliceerd acht voor een beginnende programmeur werden dan ook uit deze cursus gelaten. Beschouw wat je gaat lezen dus maar als een gatewaydrug naar meer C#, meer programmeertalen en vooral meer (programmeer)plezier! U weze gewaarschuwd.

Veel lees-en programmeerplezier,

Tim Dams Zomer 2022

Over de bronnen

Dit boek is het resultaat van bijna een decennium C# doceren aan de AP Hogeschool (eerst nog Hogeschool Antwerpen, dan Artesis Hogeschool, dan Artesis Plantijn Hogeschool...). De eerste schrijfsels verschenen op een eigen gehoste blog ("Code van 1001 Nacht", die ondertussen ter ziele is gegaan) en vervolgens kreeg deze een iets strakkere, eenduidige vorm als gitbook cursus. Deze cursus, alsook een hele resem oefeningen en andere nuttige extra's kan je terugvinden op ziescherp.be. De inhoud van die cursus loopt integraal gelijk aan die van dit boek. Uiteraard is de kans bestaande dat er in de online versie ondertussen weer wat minder schrijffoutjes staan.

Waarom deze korte historiek? Wel, de kans is bestaande dat er hier en daar flarden tekst, code voorbeelden, of oefeningen niet origineel de mijne zijn. Ik heb getracht zo goed mogelijk aan te geven wat van waar komt, maar als ik toch iets vergeten ben, aarzel dan niet om me er op te wijzen.

Benodigdheden

Alle codevoorbeelden in deze cursus kan je zelf (na)maken met de gratis Visual Studio 2022 Community editie die je kan downloaden op visualstudio.microsoft.com.

Dankwoord

Aardig wat mensen - grotendeels mijn eerstejaars studenten van de professionele bachelor Elektronica-ICT en Toegepaste Informatica van de AP Hogeschool - hebben me met deze cursus geholpen. Hen allemaal afzonderlijk bedanken zou me een extra pagina kosten, en ik heb de meeste al nadrukkelijk bedankt in de vorige editie van dit boek.

Een speciale dank nogmaals aan Maarten Wachters die de originele pixel-art van me maakte waar ik vervolgens enkele varianten op heb gemaakt.

Ook een bos bloemen voor collega's Olga Coutrin en Walter Van Hoof om de ondankbare taak op zich te nemen mijn vele dt-fouten uit de vorige editie te halen op nog geen week voor de deadline. Bedankt!

De trainers van Multimedi BV. die dit handboek ook gebruiken wil ik expliciet bedanken voor hun nuttige feedback op de eerste versie van dit boek, alsook om mij een extra reden te geven om dit boek in de eerste plaats uit te brengen.

Deze cursus wordt ook aangeboden voor de "buitenwereld". Deze online cursus zal ten allen tijde gratis voor de studenten beschikbaar zijn.

De gepubliceerde versie is identiek aan deze cursus.

Kijk op ziescherp.be waar u deze cursus op verschillende manieren kunt aanschaffen (ebook, pdf of papier, etc.)

Nuttige extra's

Er werden 2 Memrise cursussen aangemaakt speciaal voor dit handboek. Opgelet: je kan je enkel via de browser inschrijven op niet-spreektaal-cursussen. De app toont enkel spreektaalcursussen. Gebruik deze app om dagelijks 5 minuten je C# kennis te drillen:

Boeken

Er zijn quasi oneindig veel boeken over C# geschreven, althans zo lijkt het. Hier een selectie van boeken met een korte bespreking waarom ik denk dat ze voor jou een meerwaarde kunnen zijn bij het leren programmeren in C#:

Beginner boeken

  • C# Programming van Mike McGrath: een uiterst compact, maar zeer helder en kleurrijk boekje dat ik ten stelligste aanbeveel als je wat last hebt met de materie van de eerste weken.
  • Microsoft Visual C# 2015: An introduction to OOP van Joyce Farrell: Niet het meest sexy boek, maar wel het meest volledige qua overlap met de leerstof van dit boek. Aanrader voor zij die wat meer in detail willen gaan en op zoek zijn naar oneindig veel potentiele examenvragen ;)
  • Head First C# van Andrew Stellman & Jennifer Greene: laat de ietwat bizarre, bijna kleuterachtige look and feel van de head first boeken je niet afschrikken. Ieder boek in deze serie is goud waar. De head first boeken zijn de ideale manier als je zoekt naar een alternatieve manier om complexe materie te begrijpen. Bekijk zeker ook de Head First Design Patterns en Head First Sql boeken in de reeks!

Geavanceerd

  • C# Unleashed van Bart De Smet: in mijn opinie dé referentie om C# tot op het bot te begrijpen. Geschreven door een Belg die bij Microsoft in Redmond aan C# werkt.
  • Code Complete van Steve McConnell: een referentiewerk over 'programmeren in het algemeen'. Het boek is al jaar en dag het te lezen boek als je je als programmeur wilt verdiepen in wat nu 'correct programmeren' behelst. Als je op je CV kunt zetten dat je dit boek door en door kent dan zal elk IT-bedrijf je stante pede aannemen ;)

Online

Leren programmeren door enkele de opdrachten in dit boek te maken zal je niet ver (genoeg) brengen. Onze dikke vriend het Internet heeft echter tal van schitterende bronnen. Hier een overzicht.

Cheat sheet

Deze twee cheatsheets zijn handig om te gebruiken tijdens het (be)studeren van dit handboek:

Game-based programmeren

Ideale manier om programmeren meer in de vingers te krijgen op een speelse manier:

Apps

  • SoloLearn: Verplichte app! Simple as that!
  • Enki Net zoals SoloLearn maar dan anders.
  • Mimo Speels en vrij beperkt in gratis versie, maar ideale aanvulling op SoloLearn.
  • Screeps Een steam spel om te leren programmeren. Weliswaar JavaScript (nuttig voor Web Programming) maar het concept is te cool om niet hier te vermelden en zoals je zal ontdekken: leren programmeren kan je in eender welke taal, en het zal ook je andere programmeer-ervaring verbeteren. Give it a go!

Websites

Tutorials

Oefenvragen

Streaming programmeurs

Ja hoor, ze bestaan. Meer en meer professionele én beginnende programmeurs streamen terwijl te programmeren. Dit is een ideale manier om te zien hoe andere mensen problemen aanpakken. De meeste programming streamers kan je terugvinden op youtube, maar ook op Twitch zijn er steeds meer. Enkele aanraders (bekijk zeker de filmpjes uit de archieven eens):

De eerste stappen

Wel, wel, wie we hier hebben?! Iemand die de edele kunst van het programmeren wil leren? Dan ben je op de juiste plaats gekomen. Je gelooft het misschien niet, maar reeds aan het einde van dit hoofdstuk zal je je eerste eigen computer-applicaties kunnen maken. De weg naar eeuwige roem, glorie, véél vloeken en code herbruiken ligt voor je. Ben je er klaar voor?

De eerste stappen zijn nooit eenvoudig. We gaan proberen het aantal dure woorden, vreemde afkortingen en ingewikkelde schema's tot een minimum te houden. Maar toch, als je een nieuwe kunst wil leren zal je je handen (én toetsenbord) vuil moeten maken. Wat er ook gebeurt de komende hoofdstukken: blijf volhouden. Leren programmeren is een beetje als een berg leren beklimmen waarvan je nooit de top lijkt te kunnen bereiken. Wat ook zo is. Er is geen "top", en dat is net het mooie van dit alles. Er valt altijd iets nieuws te leren! De zaken waar je de komende pagina's op gaat vloeken zullen over enkele hoofdstukken al kinderspel lijken. Hou dus vol, blijf oefenen, vloek gerust af en toe en vooral: geniet van nieuwe dingen ontdekken!

Voor we verder gaan wil ik je wel even waarschuwen. Dit boek gaat uit van geen enkele kennis van programmeren, laat staan C#. Om die reden heb ik er dan ook voor gekozen om te beginnen bij het prille begin. Verwacht echter niet dat je aan het einde van dit boek vervolgens supercoole grafische applicaties of games zult kunnen maken. Het is zelfs zo dat we hoegenaamd geen woord gaan reppen over "windows applicaties", met knoppen en menu's enz. Alles dat in dit boek gemaakt wordt zal uitgevoerd "in de console", die oeroude DOS-schermen (ook wel een shell genoemd) die je nu nog vaak in films ziet wanneer hackers proberen in een erg beveiligd systeem in te breken. Waarom kies ik voor deze aanpak? Omdat de ervaring leert dat je hierdoor je kan focussen op de essentie van het probleem, en niet afgeleid wordt door existentiële vragen zoals "moet ik deze knop 3 pixel opschuiven?" of "ga ik voor een rode rand of een geel gearceerde?".

De "console". Qua zwarte inkt-verspilling zal deze afbeelding de hoofdprijs winnen!

Wat is programmeren?

Je hoort de termen geregeld: softwareontwikkelaar, programmeur, app-developer, enz. Allen zijn beroepen die in essentie kunnen herleid worden tot hetzelfde: programmeren. Programmeurs hebben geleerd hoe ze computers opdrachten kunnen geven (programmeren) zodat deze hopelijk doen wat je ze vraagt.

In de 21e eeuw is de term computer natuurlijk erg breed. Quasi ieder apparaat dat op elektriciteit werkt bevat tegenwoordig een computertje. Gaande van slimme lampen, tot de servers die het Internet draaiende houden of de smartwatch aan je pols. Zelfs aardig wat ijskasten en wasmachines beginnen (kleine) computers te bevatten.

Het probleem van computers, ongeacht hun grootte of kracht, is dat het in essentie ongelooflijk domme dingen zijn. Ze zullen altijd exact doen wat jij hen vertelt dat ze moeten doen. Als je hen dus de opdracht geeft om te ontploffen, schrik dan niet dat je even later naar de 112 kunt bellen.

Programmeren houdt in dat je leert praten met die domme computers zodat ze doen wat jij wilt dat ze doen.

Het algoritme

First, solve the problem. Then, write the code.

Deze quote van John Johnson wordt door veel beginnende programmeurs soms met een scheef hoofd aanhoort. "Ik wil gewoon code schrijven!" Het is een mythe dat programmeurs constant code schrijven. Integendeel, een goed programmeur zal veel meer tijd in de "voorbereiding" tot code schrijven steken: het maken van een goed algoritme na een grondige analyse van het probleem .

Het algoritme is de essentie van een computerprogramma en kan je beschouwen als het recept dat je aan de computer gaat geven zodat deze jouw probleem op de juiste manier zal oplossen. Het algoritme bestaat uit een reeks instructies die de computer moet uitvoeren telkens jouw programma wordt uitgevoerd.

Het algoritme van een programma moet je zelf verzinnen. De volgorde waarin de instructies worden uitgevoerd zijn echter zeer belangrijk. Dit is exact hetzelfde als in het echte leven: een algoritme om je fiets op te pompen kan zijn:

Haal dop van het ventiel.
Plaats pomp op ventiel.
Begin te pompen.

Eender welke andere volgorde van bovenstaande algoritme zal vreemde (en soms fatale) fouten geven.

Wil je dus leren programmeren, dan zal je logisch moeten leren denken en een analytische geest hebben. Als je eerst tegen een bal trapt voor je kijkt waar de goal staat dan zal de edele kunst van het programmeren voor jou een...speciale aangelegenheid worden.

Vanaf nu ben je trouwens gemachtigd om naar de nieuwsdiensten te mailen telkens ze foutief het woord "logaritme" gebruiken in plaats van "algoritme". Het woord logaritme is iets wat bij sommige nachtmerries uit de lessen wiskunde opwekt en heeft hoegenaamd niets met programmeren te maken. Uiteraard kan het wel zijn dat je ooit een algoritme moet schrijven om een logaritme te berekenen. Hopelijk moet een journalist nooit voorgaande zin in een nieuwsbericht gebruiken.

Programmeertaal

Om een algoritme te schrijven dat onze computer begrijpt dienen we een programmeertaal te gebruiken. Computers hebben hun eigen taaltje dat programmeurs moeten kennen voor ze hun algoritme aan de computer kunnen voeden. Er zijn tal van computertalen, de ene al wat obscuurder dan de andere. Maar wat al deze talen gelijk hebben is dat ze meestal:

  • ondubbelzinnig zijn: iedere opdracht of woord kan door de computer maar op exact één manier geïnterpreteerd worden. Dit in tegenstelling tot bijvoorbeeld het Nederlands waar "wat een koele kikker" zowel een letterlijke, als een figuurlijke betekenis heeft die niets met elkaar te maken heeft.
  • bestaan uit woordenschat: net zoals het Nederlands heeft ook iedere programmeertaal een, meestal beperkte, lijst woorden die je kan gebruiken. Je gaat ook niet in het Nederlands zelf woorden verzinnen in de hoop dat je partner je kan begrijpen.
  • bestaan uit grammaticaregels: Enkel Yoda mag Engels in een verkeerde volgorde gebruiken. Iedereen anders houdt zich best aan de grammatica-afspraken die een taal heeft. "bal rood is" lijkt nog begrijpbaar, maar als we zeggen "bal rood jongen is gooit veel"?

De C# taal

Net zoals er ontelbare spreektalen in de wereld zijn, zijn er ook vele programmeertalen. C# (spreek uit 'siesjarp') is er één van de vele. C# is een taal die deel uitmaakt van de .NET (spreek uit 'dotnet') omgeving die meer dan 20 jaar geleden door Microsoft werd ontwikkeld. Het fijne van C# is dat deze een zogenaamde hogere programmeertaal is. Hoe "hoger" de programmeertaal, hoe leesbaarder deze wordt voor leken omdat hogere programmeertalen dichter bij onze eigen taal aanleunen.

De geschiedenis van de hele .NET-wereld vertellen zou een boek op zich betekenen en gaan we hier niet doen. Het is nuttig om weten dat er een gigantische bron aan informatie over .NET en C# online te vinden is, beginnende met docs.microsoft.com/en-us/dotnet/csharp/getting-started.

Het fijne van leren programmeren is dat je binnenkort op een bepaald punt gaat komen waarbij de keuze van programmeertaal er minder toe doet. Vergelijk het met het leren van het Frans. Van zodra je Frans onder knie hebt is het veel eenvoudiger om vervolgens Italiaans of Spaans te leren. Zo ook met programmeertalen. De C# taal bijvoorbeeld lijkt bijvoorbeeld als twee druppels water op Java, alsook op de talen waar ze van afstamt, C en C++.

Zelfs JavaScript, Python en veel andere moderne talen zullen weinig geheimen voor jou hebben wanneer je aan het einde van dit boek bent.

Anders Hejlsberg

Deze Deen krijgt een eigen sectie in dit boek. Waarom? Hij is niemand minder dan de "uitvinder" van C#. Anders Hejlsberg heeft een stevig palmares inzake programmeertalen verzinnen. Voor hij C# boven het doopvont hield bij Microsoft, schreef hij ook al Turbo Pascal én was hij de chief architect van Delphi. Je zou denken dat hij na 3 programmeertalen wel op z'n lauweren zou rusten, maar zo werkt Anders niet. In 2012 begon hij te werken aan een JavaScript alternatief, wat uiteindelijk het immens populaire TypeScript werd. Dit allemaal om maar te zeggen dat als je één poster in je slaapkamer moet ophangen, het die van Anders zou moeten zijn.

De compiler

Rechtstreeks onze algoritmen tegen de computer vertellen vereist dat we machinetaal kunnen. Deze is echter zo complex dat we tientallen lijnen machinetaal nodig hebben om nog maar gewoon 1 letter op het scherm te krijgen. Er werden daarom dus hogere programmeertalen ontwikkeld die aangenamer zijn dan deze zogenaamde machinetalen om met computers te praten.

Uiteraard hebben we een vertaler nodig die onze code zal vertalen naar de machinetaal van het apparaat waarop ons programma moet draaien. Deze vertaler is de compiler die aardig wat complex werk op zich neemt, maar dus in essentie onze code gebruiksklaar maakt voor de computer.

Vereenvoudigd compiler overzicht.

Merk op dat we hier veel details van de compiler achterwege laten. De compiler is een uitermate complex element, maar in deze fase van je (prille) programmeursleven hoeven we enkel de kern van de compiler te begrijpen: het omzetten van C# code naar een uitvoerbaar bestand geschreven in machinetaal.

Microsoft .NET

Bij de geboorte van .NET in 2000 zat ook de taal C#.

.NET is een zogenaamd framework. Dit framework bestaat uit een grote groep van bibliotheken (class libraries) en een virtual execution system genaamd de Common Language Runtime (CLR). De CLR zal ervoor zorgen dat C#, of andere .NET talen (F#, VB.NET, enz.), kunnen samenwerken met de vele bibliotheken.

Om een uitvoerbaar bestand te maken (executable, vandaar de extensie .exe bij uitvoerbare programma's in Windows) zal de broncode die je hebt geschreven in C# worden omgezet naar Intermediate Language (IL) code. Op zich is deze IL code nog niet uitvoerbaar, maar dat is niet ons probleem. Wanneer een gebruiker een in IL geschreven bestand wil uitvoeren dan zal, achter de schermen, de CLR deze code ogenblikkelijk naar machine code omzetten (Just-In-Time of JIT compilatie) en uitvoeren. De gebruiker zal dus nooit dit proces opmerken (tenzij er geen .NET framework werd geïnstalleerd op het systeem).

Over nummeringen en naamgevingen van .NET en C#

Microsoft heeft er een handje van weg om hun producten ingewikkelde volgnummers-of letters te geven, denk maar aan Windows 10 die de opvolger was van Windows 8 (dat had trouwens een erg goede reden; zoek maar eens op), of Windows 7 dat Windows Vista opvolgde. Het helpt ook niet dat ze geregeld hun producten een nieuwe naam geven. Zo was het binnen .NET tot voor kort erg ingewikkeld om te weten welke versie nu eigenlijk de welke was. Microsoft heeft gelukkig recent de naamgevingen herschikt én hernoemt in de hoop het allemaal wat duidelijker te maken. Laten we daarom even kort te bespreken waar we nu zitten.

.NET 6 (framework)

Telkens er een nieuwe .NET framework werd gereleased verscheen er ook een bijhorende nieuwe versie van Visual Studio. Vroeger had je verschillende frameworks binnen de .NET familie zoals .NET Framework, ".NET Standard", .NET Core enz. die allemaal net niet dezelfde doeleinden hadden wat het erg verwarrend maakte. Om dit te vereenvoudigen bestaat sinds 2020 enkel nog .NET gevolgd door een nummer.

Zo had je in 2020 .NET 5 en verschijnt eind 2022 .NET 7. Dit boek maakt gebruikt van .NET 6 dat verscheen samen met Visual Studio 2022...in november 2021. Je moet er maar aan uit kunnen.

C# 10

De C# taal is eigenlijk nog het eenvoudigst qua nummering. Om de zoveel tijd krijgt C# een update met een nieuwe reeks taal-eigenschappen die je kan, maar niet hoeft te gebruiken. Momenteel zitten we aan C# 10 dat werd uitgebracht samen met .NET 6.

Eind 2023 kwam .NET 8 uit en dus ook alweer een nieuwe versie van C#, namelijk versie 12. De kans is dus groot dat voorgaande zin alweer gedateerd is tegen dat je hem leest. De vernieuwingen in C# zijn niet altijd belangrijk voor beginnende programmeurs. In dit boek hebben we getracht de belangrijkste én meest begrijpbare nieuwe features uit de taal te gebruiken waar relevant, maar over het algemeen gezien mag je stellen dat dit boek tot en met versie .NET 7.3 / C# versie 11 de belangrijkste zaken zal behandelen.

Volgende youtube-video van Johnny does DOTNET geeft een erg goed historisch overzicht van de naamsveranderingen: youtube.com/watch?v=O4Qcg5Uon4g.

Je vraagt je misschien af waarom dit allemaal verteld wordt? Waarom wordt deze geschiedenisles gegeven? De reden is heel eenvoudig. Je gaat zeker geregeld zaken op het internet willen opzoeken tijdens het (leren) programmeren en zal dan ook vaker op artikels stuiten met de oude(re) naamgeving en dan mogelijks niet kunnen volgen.

Recent heeft Microsoft besloten om veel sneller nieuwe updates voor .NET en dus ook C# en Visual Studio uit te brengen, en dat allemaal opensource (je kan zelfs meehelpen via github.com/dotnet). Dit heeft als voordeel dat bugs sneller opgelost worden én dat we sneller toegang krijgen tot de nieuwste features. Het nadeel is echter dat informatie zoals in dit boek van de één op de andere dag out-dated kan zijn.

Kennismaken met C# en Visual Studio

We gaan in dit boek leren programmeren met Microsoft Visual Studio 2022, een softwarepakket waar ook een gratis community versie voor bestaat. Microsoft Visual Studio (vanaf nu VS) is een pakket dat een groot deel van de tools samenvoegt die een programmeur nodig heeft (debugger, code editor, compiler, etc).

VS is een zogenaamde IDE ("Integrated Development Environment") en is op maat gemaakt om in C# geschreven applicaties te ontwikkelen. Je bent echter verre van verplicht om enkel C# applicaties in VS te ontwikkelen, je kan gerust VB.NET, TypeScript, Python en andere talen gebruiken. Ook vice versa ben je niet verplicht om VS te gebruiken om te ontwikkelen. Je kan zelfs in notepad code schrijven en vervolgens compileren (zie hierna). Er bestaan zelfs online C# programmeer omgevingen, zoals dotnetfiddle.net.

De compiler en Visual Studio

Zoals gezegd: jouw taak als programmeur is algoritmes in C# taal uitschrijven. We zouden dit in een eenvoudige tekstverwerker kunnen doen, maar dan maken we het onszelf lastig. Net zoals je tekst in notepad kunt schrijven, is het handiger dit bijvoorbeeld in tekstverwerker zoals Word te doen: je krijgt een spellingchecker en allerlei handige extra's.

Ook voor het schrijven van computer code is het handiger om een IDE te gebruiken, een omgeving die ons zal helpen foutloze C# code te schrijven.

Het hart van Visual Studio bestaat uit de compiler die we hiervoor besproken hebben. De compiler zal je C# code omzetten naar de IL-code zodat jij (of anderen) je applicatie op een computer (of ander apparaat) kunnen gebruiken. Zolang je C# code niet exact voldoet aan de C# syntax en grammatica zal de compiler het vertikken een uitvoerbaar bestand voor je te genereren.

Vereenvoudigd compiler overzicht.

Visual Studio Installeren

In dit boek zullen de voorbeelden steeds met de Community editie van VS gemaakt zijn. Je kan deze gratis downloaden en installeren via visualstudio.microsoft.com/vs.

Het is belangrijk bij de installatie dat je zeker de .NET desktop development workload kiest. Uiteraard ben je vrij om meerdere zaken te installeren.

In dit boek zullen we enkel met de .NET desktop development workload werken.

In dit boek zullen we dus steeds werken met Visual Studio Community 2022. Niet met Visual Studio Code. Visual Studio code is een zogenaamde lightweight versie van VS die echter zeker ook z'n voordelen heeft (makkelijk uitbreidbaar, snel, compact, etc). Visual Studio vindt dankzij VS Code eindelijk ook z'n weg op andere platformen dan enkel die van Microsoft. Je kan de laatste versie ervan downloaden op: code.visualstudio.com.

Visual studio opstarten

Als alles goed is geïnstalleerd kan je Visual Studio starten via het start-menu van Windows.

Allereerste keer opstarten

De allereerste keer dat je VS opstart krijg je 2 extra schermen te zien:

  • Het "sign in" scherm mag je overslaan (kies "Not now, maybe later").
  • Op het volgende scherm kies je best als "Development settings" voor Visual C#. Vervolgens kan je je kleurenthema kiezen. Dit heeft geen invloed op de manier van werken.

Dark is uiteraard het coolste thema om in te coderen. Je voelt je ogenblikkelijk Neo uit The Matrix. Het nadeel van dit thema is dat het veel meer inkt verbruikt indien je screenshots in een boek zoals dit wilt plaatsen. De keuze voor Development Setting kan je naar "Visual C#" veranderen, maar General is even goed (je zal geen verschil merken in eerste instantie).

Je kan dit achteraf nog aanpassen in VS via "Tools" in de menubalk, dan "Import and Export Settings" en kiezen voor "Import and Export Settings Wizard".

Je kan dit nadien ook altijd nog aanpassen. En zelfs personaliseren tot de vreemdste kleur- en lettertypecombinaties.

Project keuze

Na het opstarten van VS krijg je het startvenster te zien van waaruit je verschillende dingen kan doen. Van zodra je projecten gaat aanmaken zullen deze in de toekomst ook op dit scherm getoond worden zodat je snel naar een voorgaand project kunt gaan.

Het startscherm van Visual Studio.

Een nieuw project aanmaken

We zullen nu een nieuw project aanmaken, kies hiervoor "Create a new project".

Kies je projecttype.

Het "New Project" venster dat nu verschijnt geeft je hopelijk al een glimp van de veelzijdigheid van VS. In het rechterdeel zie je bijvoorbeeld alle Project Types staan. M.a.w. dit zijn alle soorten programma’s die je kan maken in VS. Naargelang de geïnstalleerde opties en bibliotheken zal deze lijst groter of kleiner zijn.

In dit boek zullen we altijd het Project Type Console App gebruiken (ZONDER .NET Framework achteraan). Je vindt deze normaal bovenaan de lijst terug, maar kunt deze ook via het zoekveld bovenaan terugvinden door te zoeken op, je raadt het nooit: console. Let er op dat je een klein groen C# icoontje ziet staan bij het zwarte icoon van de Console app. Ook andere talen ondersteunen console applicaties, maar wij gaan natuurlijk met C# aan het werk.

Kies voor C#, niet Visual Basic (VB). Dank bij voorbaat!

Een console applicatie is een programma dat alle uitvoer naar een zogenaamde console stuurt, een shell. Je kan met andere woorden enkel tekst (UNICODE) als uitvoer genereren en dus geen multimedia elementen zoals afbeeldingen, geluid, enz.

Kies dit type en klik 'Next'.

Op het volgende scherm kan je een naam ingeven voor je project alsook de locatie op de harde schijf waar het project dient opgeslagen te worden. Onthoud waar je je project aanmaakt zodat je dit later terugvindt.

Het "Solution name" tekstveld blijf je af. Hier zal automatisch dezelfde tekst komen als die dat je in het "Project name" tekstveld invult.

Geef je projectnamen ogenblikkelijk duidelijke namen zodat je niet opgezadeld geraakt met projecten zoals Project201, enz. waarvan je niet meer weet welke belangrijk zijn en welke niet.

Geef je project de naam "MijnEersteProgramma" en kies een goede locatie (ik raad je aan dit steeds in Dropbox of Onedrive te doen). We raden aan om de checkbox ("Place solution and project in the same directory") onderaan niét aan te vinken. In de toekomst zal het nuttig zijn dat je meer dan 1 project per solution zal kunnen hebben. Lig er nog niet van wakker.

Klik op next en kies als Target Framework de meest recente versie. Duidt hier zeker de checkbox aan met "Do not use-top level statements"!!! Klik nu op Create.

De auteur van dit boek kan fier melden dat die checkbox er staat mede dankzij zijn gezaag op github.com/dotnet/docs/issues/2742.

VS heeft nu reeds een aantal bestanden aangemaakt die je nodig hebt om een ‘Console Applicatie’ te maken.

IDE Layout

Wanneer je VS opstart zal je mogelijk overweldigd worden door de hoeveelheid menu's, knopjes, schermen, enz. Dit is normaal voor een IDE: deze wil zoveel mogelijk mogelijkheden aanbieden aan de gebruiker. Vergelijk dit met Word: afhankelijk van wat je gaat doen gebruikt iedere gebruiker andere zaken van Word. De makers van Word kunnen dus niet bepaalde zaken weglaten, ze moeten net zoveel mogelijk aanbieden.

We zullen nu eerst eens bekijken wat we allemaal zien in VS na het aanmaken van een nieuw programma.

VS IDE overzicht.

  • Je kan meerdere bestanden tegelijkertijd openen in VS. Ieder bestand zal z'n eigen tab krijgen. De actieve tab is het bestand wiens inhoud je in het hoofdgedeelte eronder te zien krijgt. Merk op dat enkel open bestanden een tab krijgen. Je kan deze tabbladen ook "lostrekken" om bijvoorbeeld enkel dat tabblad op een ander scherm te plaatsen.
  • De "solution explorer" aan de rechterzijde toont alle bestanden en elementen die tot het huidige project behoren. Als we dus later nieuwe bestanden toevoegen, dan kan je die hier zien (en openen). Verwijder hier géén bestanden zonder dat je zeker weet wat je aan het doen bent!

Indien je een nieuw project hebt aangemaakt en de code die je te zien krijgt lijkt in de verste verte niet op de code die je hierboven ziet dan heb je vermoedelijk een verkeerd projecttype aangemaakt (of je hebt de "Do not use top-level statements" checkbox niet aangeduid). De meest gemaakte fout in deze fase is dat je een Visual Basic (VB) Console applicatie hebt gekozen en niet een C# versie.

Layout kapot/kwijt/vreemd?

De layout van VS kan je volledig naar je hand zetten. Je kan ieder (deel-)venster en tab verzetten, verankeren en zelfs verplaatsen naar een ander bureaublad. Experimenteer hier gerust mee en besef dat je steeds alles kan herstellen. Het gebeurt namelijk al eens dat je layout een beetje om zeep is:

  • Om eenvoudig een venster terug te krijgen, bijvoorbeeld het properties window of de solution explorer: klik bovenaan in de menubalk op "View" en kies dan het gewenste venster (soms staat dit in een submenu).
  • Je kan ook altijd je layout in z'n geheel resetten: ga naar "Window" en kies "Reset window layout".

Je programma starten

De code in Program.cs die VS voor je heeft gemaakt is reeds een werkend, maar weinig nuttig, programma. Je kan de code compileren en uitvoeren door op de groene driehoek bovenaan te klikken:

Het programma uitvoeren.

Als alles goed gaat krijg je nu "Hello World!" te zien en wat extra informatie omtrent het programma dat net werd uitgevoerd:

Uitvoer van het programma.

Veel doet je programma nog niet natuurlijk, dus sluit dit venster maar terug af door een willekeurige toets in te drukken.

Is dit alles?

Nee hoor. Visual Studio is lekker groot, maar laat je dat niet afschrikken. Net zoals voor het eerst op een nieuwe reisbbestemming komen, kan deze in het begin overweldigend zijn, tot je weet waar het zwembad en de pingpongtafel staat en je van daaruit je weg stilletjes aan leert kennen.

Console-applicaties

Een console-applicatie is een programma dat zijn in- en uitvoer via een klassiek commando/shell-scherm toont. Een console-applicatie draait in dezelfde omgeving als wanneer we in Windows een command-prompt openen (via Start-> Uitvoeren-> cmd [enter] ). We zullen in dit boek enkel console-applicaties leren maken. Grafische frontends zoals WPF komen in dit boek niet aan bod.

In en uit - ReadLine en WriteLine

Een programma zonder invoer van de gebruiker is niet erg boeiend. De meeste programma's die we leren schrijven vereisen dan ook "input" (IN). We moeten echter ook zaken aan de gebruiker kunnen tonen (bijvoorbeeld de uitkomst van een berekening, een foutboodschap, enz.) wat dus vereist dat er "output" (UIT) naar het scherm kan gestuurd worden.

In het begin zullen al je applicaties deze opbouw hebben.

Console-applicaties maken in C# vereist dat je minstens twee belangrijke C# methoden leert gebruiken:

  • Met behulp van Console.ReadLine() kunnen we input van de gebruiker inlezen en in ons programma verwerken.
  • Via Console.WriteLine() kunnen we tekst op het scherm tonen.

Je eerste console programma

Sluit het eerder gemaakte "MyFirstProject" project af en herstart Visual Studio. Maak nu een nieuw console-project aan (noem het Demo1) en open het Program.cs bestand (indien het nog niet open is). Veeg de code die hier reeds staat niet weg!

Voeg onder de lijn Console.WriteLine("Hello World!"); volgende code toe (vergeet de puntkomma niet):

Console.WriteLine("Hoi, ik ben het!");

Zodat je dus volgende code krijgt:

namespace Demo1
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            Console.WriteLine("Hoi, ik ben het");
        }
    }
}

Compileer deze code en voer ze uit: druk hiervoor weer op het groene driehoekje bovenaan. Of via het menu Debug en dan Start Debugging.

Moet ik niets bewaren?

Neen. Telkens je op de groene "build en run" knop duwt worden al je aanpassingen automatisch bewaard. Trouwens: Kies nooit voor "save as..."! want dan bestaat de kans dat je project niet meer compileert. Dit zal aardig wat problemen in je project voorkomen, geloof me maar.

Analyse van de code

We gaan nu iedere lijn code kort bespreken. Sommige lijnen code zullen lange tijd niet belangrijk zijn. Onthoud nu alvast dat: alle belangrijke code staat tussen de accolades onder de lijn static void Main(string[] args)!

Laat je niet afschrikken door wat er nu volgt. We gooien je even in het diepe gedeelte van het zwembad maar zullen je er op tijd uithalen zodat we vervolgens terug in het babybadje rustig op de glijbaan kunnen gaan spelen en C# op een trager tempo kunnen ontdekken.

  • namespace Demo1: Dit is de unieke naam waarbinnen we ons programma zullen plaatsen, en het is niet toevallig de naam van je project. Verander dit nooit tenzij je weet wat je aan het doen bent. We bespreken namespaces in hoofdstuk 10.
  • internal class Program: Hier start je echte programma. Alle code binnen deze Program accolades zullen gecompileerd worden naar een uitvoerbaar bestand. Vanaf hoofdstuk 9 zal deze lijn geen geheimen meer hebben voor je.
  • static void Main(string[] args): Het startpunt van iedere console-applicatie. Wat hier gemaakt wordt is een methode genaamd Main. Je programma kan meerdere methoden (of functies) bevatten, maar enkel degene genaamd Main zal door de compiler als het startpunt van het programma gemaakt worden. Deze lijn gaan we in hoofdstuk 7 en 8 uit de doeken doen.
  • Console.WriteLine("Hello World!");: Dit is een statement dat de WriteLine-methode aanroept van de Console-bibliotheek. Het zal alle tekst die tussen de aanhalingstekens staat op het scherm tonen.
  • Console.WriteLine("Hoi ik ben het");: en ook deze lijn zorgt ervoor dat er tekst op het scherm komt wanneer het programma zal uitgevoerd worden.
  • Accolades: vervolgens moet voor iedere openende accolade eerder in de code nu ook een bijhorende sluitende volgen. We gebruiken accolades om de scope aan te duiden, iets dat we in hoofdstuk 5 geregeld zullen nodig hebben.

Net zoals een recept, zal ook in C# code van boven naar onder worden uitgevoerd. In het geval van ons eerste programma zal het programma (voor ons) starten bij de lijn Console.WriteLine("Hello World!"); en dan verder blijven werken tot het aan het einde van de Main komt, het gebied dat wordt afgebakend door de accolade van lijn 9. Kortom, van zodra lijn 9 wordt bereikt is dat het signaal voor de computer om je applicatie af te sluiten.

Jawadde...Wat was dit allemaal?! We hebben al aardig wat vreemde code zien passeren en het is niet meer dan normaal dat je nu denkt "dit ga ik nooit kunnen". Wees echter niet bevreesd: je zal sneller dan je denkt bovenstaande code als 'kinderspel' gaan bekijken. Een tip nodig? Test en experimenteer met wat je al kunt!

Laat deze info rustig inzinken en onthoud alvast volgende belangrijke zaken:

  • Al je eigen code komt momenteel enkel tussen de Main accolades.
  • Eindig iedere lijn code daar met een puntkomma ( ; ).

De oerman verschijnt wanneer we een stevige stap gezet hebben en je mogelijk even onder de indruk bent van al die nieuwe informatie. Hij zal proberen informatie nog eens vanuit een ander standpunt toe te lichten en te herhalen waarom deze nieuwe kennis zo belangrijk is.

"Hello world" op het scherm laten verschijnen wanneer je een nieuwe programmeertaal leert is ondertussen een traditie bij programmeurs. Er is zelfs een website die dit verzamelt namelijk helloworldcollection.de. Deze site toont in honderden programmeertalen hoe je "Hello world" moet programmeren.

WriteLine: Tekst op het scherm

De WriteLine-methode is een veelgebruikte methode in Console-applicaties. Het zorgt ervoor dat we tekst op het scherm kunnen tonen.

Voeg volgende lijn toe na de vorige WriteLine-lijn in je project:

Console.WriteLine("Wie ben jij?!");

De WriteLine methode zal alle tekst tonen die tussen de aanhalingstekens (" ") staat tussen de haakjes van de methode. De aanhalingstekens aan het begin en einde van de tekst zijn uiterst belangrijk! Alsook het puntkomma helemaal achteraan.

Je code binnen de Main accolades zou nu moeten zijn:

Console.WriteLine("Hello World!");
Console.WriteLine("Hoi, ik ben het");
Console.WriteLine("Wie ben jij?!");

Kan je voorspellen wat de uitvoer zal zijn? Test het eens!

We tonen niet telkens de volledige broncode. Als we dat blijven doen dan wordt dit boek dubbel zo dik. We tonen daarom meestal enkel de code die binnen de Main (of later ook elders) moet komen.

ReadLine: Input van de gebruiker verwerken

Met de Console kan je met een handvol methoden reeds een aantal interessante dingen doen.

Zo kan je bijvoorbeeld input van de gebruiker inlezen en bewaren in een variabele als volgt:

string result;
result = Console.ReadLine();

Wat gebeurt er hier juist?

De eerste lijn code:

  • Concreet zeggen we hiermee aan de compiler: maak in het geheugen een plekje vrij waar enkel data van het type string in mag bewaard worden (wat deze zin exact betekent komt later. Onthoud nu dat geheugen van het type string enkel "tekst" kan bevatten).
  • Noem deze geheugenplek result zodat we deze later makkelijk kunnen in en uitlezen.

Tweede lijn code:

  • Vervolgens roepen we de ReadLine methode aan. Deze methode zal de invoer van de gebruiker van het toetsenbord uitlezen tot de gebruiker op enter drukt.
  • Het resultaat van de ingevoerde tekst wordt bewaard in de variabele result.

Merk op dat de toekenning in C# van rechts naar links gebeurt. Vandaar dat result dus links van de toekenning (=) staat en de waarde krijgt van het gedeelte rechts ervan.

Je programma zou nu moeten zijn:


Console.WriteLine("Hello World!");
Console.WriteLine("Hoi, ik ben het!");
Console.WriteLine("Wie ben jij?!");
string result;
result = Console.ReadLine();

Start nogmaals je programma. Je zal merken dat je programma nu een cursor toont en wacht op invoer nadat het de eerste 3 lijnen tekst op het scherm heeft gezet. Je kan nu eender wat intypen en van zodra je op enter duwt gaat het programma verder. Maar aangezien lijn 5 de laatste lijn van ons algoritme is, zal je programma hierna afsluiten (en we hebben dus de gebruiker voor niets iets laten invoeren).

Je kan gratis op Memrise deze cursus dagelijks instuderen, de ideale manier om snel essentiele C# begrippen voor altijd te onthouden. De cursus is beschikbaar via : app.memrise.com/course/6382184/zie-scherp-scherper-programmeren-in-c-deel-1/.

Input gebruiker gebruiken

Een variabele is een geheugenplekje (met een naam) waar we zaken in kunnen bewaren. In het volgende hoofdstuk gaan we zo vaak het woord variabele vertellen dat je oren en ogen er van gaan bloeden, dus trek je nu nog niet te veel aan van dit woord. We kunnen nu invoer van de gebruiker, die we hebben bewaard in de variabele result, gebruiken en tonen op het scherm.

Console.WriteLine("Dag");
Console.WriteLine(result);
Console.WriteLine("hoe gaat het met je?");

In de tweede lijn hier gebruiken we de variabele result (waar de invoer van de gebruiker in bewaard wordt) als parameter in de WriteLine-methode.

Met andere woorden: de WriteLine methode zal op het scherm tonen wat de gebruiker even daarvoor heeft ingevoerd.

Je volledige programma ziet er dus nu zo uit:

Console.WriteLine("Hello World!");
Console.WriteLine("Hoi, ik ben het!");
Console.WriteLine("Wie ben jij?!");
string result;
result = Console.ReadLine();
Console.WriteLine("Dag ");
Console.WriteLine(result);
Console.WriteLine("hoe gaat het met je?");

Test het programma en voer je naam in wanneer de cursor knippert.

Voorbeelduitvoer (lijn 3 is wat de gebruiker heeft ingetypt)

Hoi, ik ben het!
Wie ben jij?!
tim [enter]
Dag
tim
hoe gaat het met je?

Aanhalingsteken of niet?

Wanneer je de inhoud van een variabele wil gebruiken in een methode zoals WriteLine() dan plaats je deze zonder aanhalingsteken! Bekijk zelf eens wat het verschil wordt wanneer je volgende lijn code Console.Write(result); vervangt door Console.Write("result");.

De uitvoer wordt dan (merk het verschil op op lijn 5):

Hoi, ik ben het!
Wie ben jij?!
tim [enter]
Dag
result
hoe gaat het met je?

Write en WriteLine

Naast WriteLine bestaat er ook Write.

De WriteLine-methode zal steeds een line break (een 'enter') aan het einde van de lijn zetten zodat de cursor naar de volgende lijn springt.

De Write-methode daarentegen zal geen enter aan het einde van de lijn toevoegen. Als je dus vervolgens iets toevoegt (met een volgende Write of WriteLine) dan zal dit aan dezelfde lijn toegevoegd worden.

Vervang daarom eens in de laatste 3 lijnen code in je project WriteLine door Write:

Console.Write("Dag");
Console.Write(result);
Console.Write("hoe gaat het met je?");

Voer je programma uit en test het resultaat. Je krijgt nu:

Hoi, ik ben het!
Wie ben jij?!
tim [enter]
Dagtimhoe gaat het met je?

Wat is er "verkeerd" gelopen? Al je tekst van de laatste lijn plakt zo dicht bij elkaar? Inderdaad, we zijn spaties vergeten toe te voegen. Spaties zijn ook tekens die op scherm moeten komen (ook al zien we ze niet) en je dient dus binnen de aanhalingstekens spaties toe te voegen. Namelijk:

Console.Write("Dag ");
Console.Write(result);
Console.Write(" hoe gaat het met je?");

Je uitvoer wordt nu:

Hoi, ik ben het!
Wie ben jij?!
tim [enter]
Dag tim hoe gaat het met je?

Witregels in C#

C# trekt zich niets aan van witregels die niét binnen aanhalingstekens staan (zowel spaties, enters en tabs worden genegeerd). Met andere woorden: je kan het voorgaande programma perfect in één lange lijn code typen, zonder enters. Dit is echter niet aangeraden want het maakt je code een pak onleesbaarder.

Voorgaande programma in exact 1 lijn. Cool? Ja, in sommige kringen. Dom en onleesbaar? Ook ja.

Opletten met spaties

Let goed op hoe je spaties gebruikt bij WriteLine. Indien je spaties buiten de aanhalingstekens plaatst dan heeft dit geen effect.

Hier een fout gebruik van spaties (de code zal werken maar je spaties worden genegeerd):

//we visualiseren de spaties even als liggende streepjes in volgende voorbeeld
Console.Write("Dag"_);  
Console.Write(result_);
Console.Write("hoe gaat het met je?");

En een correct gebruik:

Console.Write("Dag_");
Console.Write(result);
Console.Write("_hoe gaat het met je?");

Zinnen aan elkaar plakken

We kunnen dit allemaal nog een pak korter tonen zonder dat de code onleesbaar wordt. De plus-operator (+) in C# kan je namelijk gebruiken om tekst aan elkaar te plakken. De laatste 3 lijnen code kunnen dan korter geschreven worden als volgt:

Console.WriteLine("Dag " + result + " hoe gaat het met je?");

Merk op dat result dus NIET tussen aanhalingstekens staat, in tegenstelling tot de andere stukken van de zin. Waarom is dit? Aanhalingstekens in C# duiden aan dat een stuk tekst moet beschouwd worden als tekst van het type string. Als je geen aanhalingsteken gebruikt dan zal C# de tekst beschouwen als een variabele met die naam.

Bekijk zelf eens wat het verschil wordt wanneer je volgende lijn code:

Console.WriteLine("Dag "+ result + " hoe gaat het met je?");

Vervangt door:

Console.Write("Dag "+ "result" + " hoe gaat het met je?");

Meer input vragen

Als je meerdere inputs van de gebruiker wenst te bewaren dan zal je meerdere geheugenplekken (variabelen) nodig hebben. Bijvoorbeeld:

Console.WriteLine("Geef leeftijd");
string leeftijd; //eerste variabele aanmaken
leeftijd = Console.ReadLine();
Console.WriteLine("Geef adres");
string adres; //tweede variabele aanmaken
adres = Console.ReadLine();

Je mag echter ook de variabelen al vroeger aanmaken. In C# zet men de geheugenplek creatie zo dicht mogelijk bij de code waar je die plek gebruikt (zoals vorig voorbeeld), maar dat is geen verplichting. Dit mag dus ook:

string leeftijd; //eerste variabele aanmaken
string adres; //tweede variabele aanmaken
Console.WriteLine("Geef leeftijd");
leeftijd = Console.ReadLine();
Console.WriteLine("Geef adres");
adres = Console.ReadLine();

Je zal vaak Console.WriteLine moeten schrijven als je dit boek volgt. We hebben echter goed nieuws voor je: er zit een ingebouwde "snippet" in VS om sneller Console.WriteLine op het scherm te toveren. We gaan je niet langer in spanning houden...of toch... nog even. Ben je benieuwd? Spannend he!

Hier gaan we: cw [tab] [tab]

Als je dus cw schrijft en dan twee maal op de tab-toets van je toetsenbord duwt verschijnt daar automagisch een verse lijn met Console.WriteLine();.

Fouten oplossen

Je code zal pas compileren indien deze foutloos is geschreven. Herinner je dat computers uiterst dom zijn en dus vereisen dat je code 100% foutloos is qua woordenschat en grammatica.

Zolang er dus fouten in je code staan moet je deze eerst oplossen voor je verder kan. Gelukkig helpt VS je daarmee op 2 manieren:

  • Fouten in code worden met een rode squiggly onderlijnd.
  • Onderaan zie je in de statusbalk of je fouten hebt.

Zie je de fout?

Laat je trouwens niet afschrikken door de gigantische reeks fouten die soms plots op je scherm verschijnen. VS begint al enthousiast fouten te zoeken terwijl je mogelijk nog volop aan het typen bent.

Als je plots veel fouten krijgt, kijk dan altijd vlak boven de plek waar de fouten verschijnen. Heel vaak zit daar de echte fout:en meestal is dat gewoon het ontbreken van een kommapunt aan het einde van een statement.

Fouten sneller vinden

Uiteraard ga je vaak code hebben die meerdere schermen omvat. Je kan via de error-list snel naar al je fouten gaan. Open deze door op het error-icoontje onderaan te klikken:

So many errors?!

Dit zal de "error list" openen (een schermdeel van VS dat we aanraden om altijd open te laten én dus niet weg te klikken). Warnings kunnen we (voorlopig) meestal negeren en deze 'filter' hoef je dus niet aan te zetten.

De error list.

In de error list kan je nu op iedere error klikken om ogenblikkelijk naar de correcte lijn te gaan.

Zou je toch willen compileren en je hebt nog fouten dan zal VS je proberen tegen te houden. Lees nu onmiddellijk wat de voorman hierover te vertellen heeft.

OPLETTEN!

Opletten aub : Indien je op de groene start knop duwt en bovenstaande waarschuwing krijgt KLIK DAN NOOIT OP YES EN DUID NOOIT DE CHECKBOX AAN!

Lees de boodschap eens goed na: wat denk je dat er gebeurt als je op 'yes' duwt? Inderdaad, VS zal de laatste werkende versie uitvoeren en dus niet de code die je nu hebt staan waarin nog fouten staan.

De voorman verschijnt wanneer er iets beschreven wordt waar véél fouten op gemaakt worden, zelfs bij ervaren programmeurs. Opletten geblazen dus.

Fouten oplossen met lampje

Wanneer je je cursor op een lijn met een fout zet dan zal je soms vooraan een geel error-lampje zien verschijnen (dit duurt soms even):

Lampje: de brenger der oplossingen...In tegenstelling tot Clippy de Office assistent uit de jaren '90....

Je kan hier op klikken en heel vaak krijg je dan ineens een mogelijke oplossing. Wees steeds kritisch hierover want VS is niet alwetend en kan niet altijd raden wat je bedoelt. Neem dus het voorstel niet zomaar over zonder goed na te denken of het dat was wat je bedoelde.

Warnings kan je over het algemeen, zeker in het begin, negeren. Bekijk ze gewoon af en toe, wie weet bevatten ze nuttige informatie om je code te verbeteren.

Meest voorkomende fouten

De meest voorkomende fouten die je als beginnende C# programmeur maakt zijn:

  • Puntkomma vergeten.
  • Schrijffouten in je code, bijvoorbeeld RaedLine i.p.v. ReadLine.
  • Geen rekening gehouden met hoofdletter gevoeligheid van C#, bijvoorbeeld Readline i.p.v. ReadLine (zie verder).
  • Per ongeluk accolades verwijderd.
  • Code geschreven op plekken waar dat niet mag (je mag momenteel enkel binnen de accolades van Main schrijven).

Kleuren in console

Je kan in console-applicaties zelf bepalen in welke kleur nieuwe tekst op het scherm verschijnt. Je kan zowel de kleur van het lettertype instellen (via ForegroundColor) als de achtergrondkleur (BackgroundColor).

Je kan met de volgende expressies de console-kleur veranderen, bijvoorbeeld de achtergrond in blauw en de letters in groen:

Console.BackgroundColor = ConsoleColor.Blue;
Console.ForegroundColor = ConsoleColor.Green;

Vanaf dan zal alle tekst die je hierna met WriteLine en Write naar het scherm stuurt met deze kleuren werken. Merk op dat we bestaande tekst op het scherm niét van kleur kunnen veranderen zonder deze eerst te verwijderen en dan opnieuw, met andere kleurinstellingen, naar het scherm te sturen.

Alle kleuren die beschikbaar zijn staan beschreven in ConsoleColor deze zijn: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow.

Wens je dus de kleur Red dan zal je deze moeten aanroepen door er ConsoleColor. voor te zetten: ConsoleColor.Red.

Waarom is dit? ConsoleColor is een zogenaamd enum-type, een concept dat we verderop in hoofdstuk 5 zullen bespreken.

Een voorbeeld:

Console.WriteLine("Tekst in de standaard kleur");
Console.BackgroundColor = ConsoleColor.Yellow;
Console.ForegroundColor = ConsoleColor.Black;
Console.WriteLine("Zwart met gele achtergrond");
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Rood met gele achtergrond");

Als je deze code uitvoert krijg je als resultaat:

Resultaat voorgaande code.

Kleur in console gebruiken is nuttig om je gebruikers een minder eentonig en meer informatieve applicatie aan te bieden. Je zou bijvoorbeeld alle foutmeldingen in het rood kunnen laten verschijnen. Let er wel op dat je applicatie geen aartslelijk, op psychedelische producten geschreven, programma lijkt. Hou er ook rekening mee dat niet iedereen (alle) kleuren kan zien. In de vorige editie van dit boek gebruikte ik rode letters op een groene achtergrond...Dat resulteerde in onleesbare tekst voor mensen met Daltonisme.

Kleur resetten

Soms wil je terug de originele applicatie-kleuren hebben. Je zou manueel dit kunnen instellen, maar wat als de gebruiker slechtziend is en in z'n besturingssysteem andere kleuren als standaard heeft ingesteld?!

De veiligste manier is daarom de kleuren te resetten door de Console.ResetColor() methode aan te roepen zoals volgend voorbeeld toont:

Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("Error!!!! Contacteer de helpdesk");
Console.ResetColor();
Console.WriteLine("Het programma sluit nu af");

De basisconcepten van C#

Om een werkend C#-programma te maken moeten we de C#-taal beheersen. Net zoals iedere taal, bestaat ook C# uit enerzijds grammatica, in de vorm van de C# syntax en anderzijds woordenschat in de vorm van de te gebruiken gereserveerde keywords.

Een C#-programma bestaat uit een opeenvolging van instructies ook wel statements genoemd. Deze eindigen steeds met een puntkomma (;) (zoals ook in het Nederlands een zin eindigt met een punt). Ieder statement kan je vergelijken als één lijn in ons recept, het algoritme.

De volgorde van de woorden (keywords, variabelen, enz.) zijn niet vrijblijvend en moeten aan (grammaticale) regels voldoen. Enkel indien alle statements correct zijn zal het programma gecompileerd worden naar een werkend en uitvoerbaar programma (zoals in het vorige hoofdstuk besproken).

Enkele belangrijke regels van C#:

  • Hoofdlettergevoelig: C# is hoofdlettergevoelig. Dat wil zeggen dat hoofdletter R en kleine letter r totaal verschillende zaken zijn voor C#. Reinhardt en reinhardt zijn dus ook niet hetzelfde.
  • Statements afsluiten met puntkomma: Iedere C# statement wordt afgesloten met een puntkomma ( ; ). Doe je dat niet dan zal C# denken dat de regel gewoon op de volgende lijn doorloopt en deze als één (fout) geheel proberen te compileren.
  • Witruimtes: Spaties, tabs en enters worden door de C# compiler genegeerd. Je kan ze dus gebruiken om de layout van je code (bladspiegel zeg maar) te verbeteren. De enige plek waar witruimtes wél een verschil geven is tussen aanhalingstekens " " die we later (bij string) zullen leren gebruiken.
  • Commentaar toevoegen kan: door // voor een enkele lijn te zetten zal deze lijn genegeerd worden door de compiler. Je kan ook meerdere lijnen code in commentaar zetten door er /* voor en */ achter te zetten. Voorts zijn er 2 handige knoppen die toelaten om een heel blok code in één keer van commentaar te voorzien of uit commentaar te halen (zie verder).
  • Van boven naar onder: je code wordt van boven naar onder uitgevoerd en zal enkel naar andere plaatsen springen als je daar expliciet in je code om vraagt (bijvoorbeeld met behulp van loops (hoofdstuk 6) of methoden (hoofdstuk 7)).
  • Je code begint altijd in de Main-methode!!!

Keywords: de woordenschat

C# bestaat zoals gezegd niet enkel uit grammaticale regels. Grammatica zonder woordenschat is nutteloos. Er zijn binnen C# dan ook momenteel 80 woorden, zogenaamde reserved keywords die de woordenschat voorstellen. Het spreekt voor zich dat deze keywords een eenduidige, specifieke betekenis hebben en dan ook enkel voor dat doel gebruikt kunnen worden. In dit boek zullen we stelselmatig deze keywords leren kennen en gebruiken op een correcte manier om zo werkende code te maken.

Deze keywords zijn:

abstractasbasebool
breakbytecasecatch
charcheckedclassconst
continuedecimaldefaultdelegate
dodoubleelseenum
eventexplicitexternfalse
finallyfixedfloatfor
foreachgotoifimplicit
inintinterfaceinternal
islocklongnamespace
newnullobjectoperator
outoverrideparamsprivate
protectedpublicreadonlyref
returnsbytesealedshort
sizeofstackallocstaticstring
structswitchthisthrow
truetrytypeofuint
ulonguncheckedunsafeushort
usingusing staticvirtualvoid
volatilewhile

De keywords in vet zijn keywords die we in het eerste deel van dit boek zullen bekijken (hoofdstuk 1 tot en met 8). Die in cursief in het tweede deel (9 en verder). De overige zal je zelf moeten ontdekken (of mogelijk zelfs nooit in je carrière gebruiken vanwege hun soms obscure nut).

C# is een levende taal. Soms verschijnen er dan ook nieuwe keywords. De afspraak is echter dat de lijst hierboven niet verandert. Nieuwe keywords maken deel uit van de contextual keywords en zullen nooit gereserveerde keywords worden. We zullen enkele van deze "nieuwere" keywords tegenkomen waaronder: get, set, value en var.

Aandacht, aandacht! Step away from the keyboard! I repeat. Step away from the keyboard. Hierbij wil ik u attent maken op een belangrijke, onbeschreven, wet voor C# programmeurs: "NEVER EVER USE goto"

Het moet hier alvast even uit m'n systeem. goto is weliswaar een officieel C# keyword, toch zal je het in dit boek nooit zien terugkomen in code. Je kan alle problemen in je algoritmes oplossen zonder ooit goto nodig te hebben.

Voel je toch de drang: don't! Simpelweg, don't. Het is het niet waard. Geloof me.

NEVER USE GOTO.

Enneuh, ik hou je in't oog hoor!

Variabelen, identifiers en naamgeving

We hebben variabelen nodig om (tijdelijke) data in op te slaan. Wanneer we een statement schrijven dat bijvoorbeeld input van de gebruiker moet vragen, dan willen we ook die input bewaren zodat we verderop in het programma (het algoritme) iets met deze data kunnen doen. We doen hetzelfde in ons hoofd wanneer we bijvoorbeeld zeggen "tel 3 en 4 op en vermenigvuldig dat resultaat met 5". Eerst zullen we het resultaat van 3+4 in een variabele moeten bewaren. Vervolgens zullen we de inhoud van die variabele vermenigvuldigen met 5 en dat nieuwe resultaat ook in een nieuwe variabele opslaan (om vervolgens bijvoorbeeld naar het scherm te sturen).

Wanneer we een variabele aanmaken, zal deze moeten voldoen aan enkele afspraken. Zo moeten we minstens 2 zaken meegeven:

  • De identifier waarmee we snel aan de variabele-waarde kunnen. Dit is de gebruiksvriendelijke naam die we geven aan een geheugenplek.
  • Het datatype dat aangeeft wat voor data we wensen op te slaan (tekst, getal, afbeelding, enz.). Enkel en alleen dat soort type data zal in deze variabele kunnen bewaard worden.

Regels voor identifiers

De code die we gaan schrijven moet voldoen aan een hoop regels. Wanneer we in onze code zelf namen (identifiers) geven aan variabelen (en later ook methoden, objecten, enz.) dan moeten we een aantal regels volgen, namelijk de volgende:

  • Hoofdlettergevoelig: de identifiers tim en Tim zijn verschillend zoals reeds vermeld.
  • Geen keywords: identifiers mogen geen gereserveerde C# keywords zijn. De keywords van 2 pagina's terug mogen dus niet. Varianten waarbij de hoofdletters anders zijn mogen wel, bijvoorbeeld: gOTO en stRINg mogen dus wel, maar niet goto of string daar beide een gereserveerd keyword zijn maar dankzij de hoofdlettergevoelig-regel is dit dus toegelaten. Een ander voorbeeld INT mag bijvoorbeeld wel, maar int niet.
  • Eerste karakter-regel: het eerste karakter van de identifier mag enkel zijn:
    • kleine of grote letter
    • liggend streepje (_)
  • Alle andere karakters-regels: de overige karakters mogen enkel zijn:
    • kleine of grote letter
    • liggend streepje
    • een cijfer (0 tot en met 9)
  • Lengte: Een legale identifier mag zo lang zijn als je wenst, maar je houdt het best leesbaar.

Volg je voorgaande regels niet dan zal je code niet gecompileerd worden en zal VS de identifiers in kwestie als een error aanduiden. Of beter, als een hele hoop errors. Schrik dus niet als je bijvoorbeeld het volgende ziet:

Zoals je ziet raakt VS volledig de kluts kwijt als je je niet houdt aan de identifier regels.

Enkele voorbeelden

Enkele voorbeelden van toegelaten en niet toegelaten identifiers:

identifiertoegelaten?uitleg indien niet toegelaten
werknemerja
kerst2018ja
pippo de clownneengeen spaties toegestaan
4dPlaatsneenmag niet starten met een cijfer
_ILOVE2022ja
Tor+Bjornneenenkel cijfers, letters en liggende streepjes toegestaan
ALLCAPSMANja
B_A_Lja
classneengereserveerd keyword
WriteLineja
______ja

Naamgeving afspraken

Er zijn geen vaste afspraken over hoe je je variabelen moet noemen toch hanteren we enkele coding richtlijnen:

  • Duidelijke naam: de identifier moet duidelijk maken waarvoor de identifier dient. Schrijf dus liever gewicht of leeftijd in plaats van a of meuh.
  • Camel casing: gebruik camel casing indien je meerdere woorden in je identifier wenst te gebruiken. Camel casing wil zeggen dat ieder nieuw woord terug met een hoofdletter begint. Een goed voorbeeld kan dus zijn leeftijdTimDams of aantalLeerlingenKlas1EA. Merk op dat we liefst het eerste woord met kleine letter starten. Uiteraard zijn er geen spaties toegelaten.

Commentaar

Soms wil je misschien extra commentaar bij je code zetten. Als je dat gewoon zou doen (bv. Dit deel zal alles verwijderen) dan zal je compiler niet begrijpen wat die zin doet. Hij verwacht namelijk C# en niet een Nederlandstalige zin. Om dit op te lossen kan je in je code op twee manieren aangeven dat een stuk tekst gewoon commentaar is en mag genegeerd worden door de compiler.

Enkele lijn commentaar

Eén lijn commentaar geef je aan door de lijn te starten met twee voorwaartse slashes //. Uiteraard mag je ook meerdere lijnen op deze manier in commentaar zetten. Zo wordt dit ook vaak gebruikt om tijdelijk een stuk code "uit te schakelen". Ook mogen we commentaar achter een stuk C# code plaatsen (zie voorbeeld hieronder). // zal alle tekens die volgen tot aan de volgende enter in commentaar zetten:

//De start van het programma
int getal = 3;
//Nu gaan we rekenen
int result = getal * 5;
// result = 3*5;
Console.WriteLine(result); //We tonen resultaat op scherm: 15

Blok commentaar

We kunnen een stuk tekst als commentaar aangeven door voor de tekst /* te plaatsen en */ achteraan. Een voorbeeld:

/*
    Een blok commentaar
    Een heel verhaal, dit wordt mooi
    Is dit een haiku?
*/
int leeftijd = 0;

Je kan ook code in VS selecteren en dan met de comment/uncomment-knoppen in de menubalk heel snel lijnen of hele blokken code van commentaar voorzien, of deze net weghalen:

De linkse knop voegt comment tags toe, de rechtse verwijdert ze.

Datatypes

Een essentieel onderdeel van C# is kennis van datatypes. Binnen C# zijn een aantal types gedefinieerd die je kan gebruiken om data in op te slaan. Wanneer je data wenst te bewaren in je applicatie dan zal je je moeten afvragen wat voor soort data het is. Gaat het om een getal, een geheel getal, een kommagetal, een stuk tekst of misschien een binaire reeks? Ieder datatype in C# kan één welbepaald soort data bewaren en dit zal telkens een bepaalde hoeveelheid computergeheugen vereisen.

Datatypes zijn een belangrijk concept in C# omdat deze taal een zogenaamde "strongly typed language" is (in tegenstelling tot bijvoorbeeld JavaScript). Wanneer je in C# data wenst te bewaren (in een variabele) zal je van bij de start moeten aangeven wat voor data dit zal zijn. Vanaf dan zal de data op die geheugenplek op dezelfde manier verwerkt worden en niet zo maar van 'vorm' kunnen veranderen zonder extra input van de programmeur. Bij JavaScript kan dit bijvoorbeeld wel, wat soms een fijn werken is, maar ook vaak vloeken: je bent namelijk niet gegarandeerd dat je variabele wel het juiste type zal bevatten wanneer je het gaat gebruiken.

Er zijn tal basistypes in C# gedeclareerd (zogenaamde primitieve datatypes). In dit boek leren we werken met datatypes voor:

  • Gehele getallen: sbyte, byte, short, ushort, int, uint, long, ulong
  • Kommagetallen: double, float, decimal
  • Tekst: char, string
  • Booleans: bool
  • Enums (een speciaal soort datatype dat een beetje een combinatie van meerdere datatypes is én dat je zelf deels kan definiëren.)

Ieder datatype wordt gedefinieerd door minstens volgende eigenschappen:

  • Soort data dat in het datatype kan bewaard worden (tekst, getal, enz.)
  • Geheugengrootte: de hoeveelheid bits dat 1 element van dit datatype inneemt in het geheugen. Dit kan belangrijk zijn wanneer je met véél data gaat werken en je niet wilt dat de gebruiker drie miljoen gigabyte RAM nodig heeft.
  • Schrijfwijze van de literals: hoe weet C# of 2 een komma getal (2.0) of een geheel getal (2) is? Hiervoor gebruiken we specifieke schrijfwijzen van deze waarden (literals) wat we verderop uiteraard uitgebreid zullen bespreken.

Het datatype string heb je al gezien in het vorig hoofdstuk. Je hebt toen een variabele aangemaakt van het type string door de zin string result;.

Verderop plaatsen we dan iets waar de gebruiker iets kan intypen in die variabele:

result = Console.ReadLine();

Basistypen voor getallen

Alhoewel een computer digitaal werkt en enkel 0'n en 1'n bewaart zou dat voor ons niet erg handig werken. C# heeft daarom een hoop datatypes gedefinieerd om te werken met getallen zoals wij ze kennen, gehele en kommagetallen. Intern zullen deze getallen nog steeds binair bewaard worden, maar dat is tijdens het programmeren zelden een probleem.

De basistypen van C# om getallen in op te slaan zijn:

  • Voor gehele getallen: sbyte, byte, short, ushort, int, uint, long, ulong en char.
  • Voor kommagetallen: double, float, decimal

Deze datatypes hebben allemaal een verschillend bereik, wat een rechtstreekse invloed heeft op de hoeveelheid geheugen die ze innemen.

Ieder type hierboven heeft een bepaald bereik en hoeveelheid geheugen nodig. Je zal dus steeds moeten afwegen wat je wenst. Op een high-end pc met ettelijke gigabytes aan werkgeheugen (RAM) is geheugen zelden een probleem waar je rekening mee moet houden...Of toch: wat met real-time first person shooters die miljoenen berekeningen per seconde moeten uitvoeren? Daar zal iedere bit en byte tellen. Op andere apparaten (smartphone, arduino, smart fridges, enz.) is iedere byte geheugen nog kostbaarder. Kortom: kies steeds bewust het datatype dat het beste 'past' voor je probleem qua bereik, precisie en geheugengebruik.

Gehele getallen

Voor de gehele getallen zijn er volgende datatypes:

TypeGeheugenBereik (waardenverzameling)
sbyte8 bits-128 tot 127
byte8 bits0 tot 255
short16 bits-32 768 tot 32 767
ushort16 bits0 tot 65535
int32 bits-2 147 483 648 tot 2 147 483 647
uint32 bits0 tot 4 294 967 295
long64 bits-9 223 372 036 854 775 808 tot 9 223 372 036 854 775 807
ulong64 bits0 tot 18 446 744 073 709 551 615
char16 bits0 tot 65 535

Het bereik van ieder datatype is een rechtstreeks gevolg van het aantal bits waarmee het getal in dit type wordt voorgesteld. De short bijvoorbeeld wordt voorgesteld door 16 bits, 1 bit daarvan wordt gebruikt voor het teken (0 of 1, + of -). De overige 15 bits worden gebruikt voor de waarde: van 0 tot 2^15^-1 (= 32767) en van -1 tot -2^15^ (= -32768)

Enkele opmerkingen bij voorgaande tabel:

  • De s vooraan sbyte staat voor signed: m.a.w. 1 bit wordt gebruikt om het + of - teken te bewaren.
  • De u vooraan ushort, uint en ulong staat voor unsigned. Het omgekeerde van signed dus. Kwestie van het ingewikkeld te maken. Deze twee datatypes hebben dus geen teken en zijn altijd positief.
  • char bewaart karakters. We zullen verderop dit datatype uitspitten en ontdekken dat karakters (alle tekens op het toetsenbord, inclusief getallen, leesteken, enz.) als gehele, binaire getallen worden bewaard. Daarom staat char in deze lijst.
  • Het grootste getal bij long is 2^63^-1 ("negen triljoen tweehonderddrieëntwintig biljard driehonderd tweeënzeventig biljoen zesendertig miljard achthonderdvierenvijftig miljoen zevenhonderdvijfenzeventigduizend achthonderd en zeven"). Dit zijn maar 63 bits?! Inderaad, de laatste bit wordt wederom gebruikt om het teken te bewaren.

"Wow. Moet je al die datatypes uit het hoofd kennen? Ik was al blij dat ik tekst op het scherm kon tonen."

Uiteraard kan het geen kwaad dat je de belangrijkste datatypes onthoudt, anderzijds zul je zelf merken dat door gewoon veel te programmeren je vanzelf wel zult ontdekken welke datatypes je waar kunt gebruiken. Laat je dus niet afschrikken door de ellenlange tabellen met datatypes in dit hoofdstuk, we gaan er maar een handvol effectief van gebruiken.

Kommagetallen

Voor de kommagetallen zijn er maar 3 mogelijkheden. Ieder datatype heeft een 'voordeel' tegenover de 2 andere, dit voordeel staat vet in de tabel:

TypeGeheugenBereikPrecisie
float32 bitsgemiddeld~6-9 digits
double64 bitsmeeste~15-17 digits
decimal128 bitsminste28-29 digits

Zoals je ziet moet je bij kommagetallen een afweging maken tussen 3 even belangrijke criteria. Heb je ongelooflijk grote precisie nodig dan ga je voor een decimal. Wil je vooral erg grote of erg kleine getallen kies je voor double. Zoals je merkt zal je dus zelden decimal nodig hebben, deze zal vooral nuttig zijn in financiële en wetenschappelijke programma's waar met erg exacte cijfers moet gewerkt worden.

Bij twijfel opteren we meestal voor kommagetallen om het double datatype te gebruiken. Bij gehele getallen kiezen we meestal voor int.

De precisie van een getal is het aantal beduidende of significante cijfers. 2.2345 (precisie van 5) heeft bijvoorbeeld een hogere precisie dan 2.23 (precisie van 3).

Boolean datatype

bool (boolean) is het eenvoudigste datatype van C#. Het kan maar 2 mogelijke waarden bevatten: true of false. 0 of 1 met andere woorden.

We zullen het bool datatype erg veel nodig hebben wanneer we met beslissingen zullen werken in een later hoofdstuk, specifiek de if statements die afhankelijk van de waarde van een bool bepaalde code wel of niet zullen doen uitvoeren.

Het gebeurt vaak dat beginnende programmeurs een int variabele gebruiken terwijl ze toch weten dat de variabele maar 2 mogelijke waarden zal hebben. Om dus geen onnodig geheugen te verbruiken is het aan te raden om in die gevallen steeds met een bool variabele te werken.

Het bool datatype is uiteraard het kleinst mogelijke datatype. Hoeveel geheugen zal een variabele van dit type innemen denk je? Inderdaad 1 bit.

Tekst/String datatype

We besteden verderop een heel apart hoofdstuk om te tonen hoe je één enkel karakter of volledige flarden tekst kan bewaren in variabelen.

Hier alvast een sneakpreview:

  • Tekst kan bewaard worden in het string datatype.
  • Een enkel karakter wordt bewaard in het char datatype dat we ook hierboven al even hebben zien passeren.

Wat een gortdroge tekst was me dat nu net? Waarom moeten we al deze datatypes kennen? Wel, we hebben deze nodig om variabelen aan te maken. En variabelen zijn het hart van ieder programma. Zonder variabelen ben je aan het programmeren aan een programma dat een soort vergevorderde vorm van dementie heeft en hoegenaamd niets kan onthouden.

Variabelen

De data die we in een programma gebruiken bewaren we in een variabele van een bepaald datatype. Een variabele is een plekje in het geheugen dat in je programma zal gereserveerd worden om daarin data te bewaren van het type dat je aan de variabele hebt toegekend. Een variabele zal intern een geheugenadres hebben (waar de data in het geheugen staat) maar dat zou lastig programmeren zijn indien je steeds dit adres moest gebruiken. Daarom moeten we ook steeds een naam oftewel identifier aan de variabele geven zodat we makkelijk de geheugenplek kunnen aanduiden en niet moeten werken met een lang hexadecimaal geheugen adres (bv 0x4234FE13EF1).

De naam (identifier) van de variabele moet voldoen aan de identifier regels zoals eerder besproken.

Variabelen aanmaken en gebruiken

Om een variabele te maken moeten we deze declareren, door een type en naam te geven. Vanaf dan zal de computer een hoeveelheid geheugen voor je reserveren waar de inhoud van deze variabele in kan bewaard worden. Hiervoor dien je minstens op te geven:

  1. Het datatype (bv int, double).
  2. Een identifier zodat de variabele uniek kan geïdentificeerd worden volgens de naamgevingsregel van C#.
  3. (optioneel) Een beginwaarde die de variabele krijgt bij het aanmaken ervan.

Een variabele declaratie heeft als syntax:

datatype identifier;

Enkele voorbeelden:

int leeftijd;
string leverAdres;
bool isGehuwd;

Indien je weet wat de beginwaarde moet zijn van de variabele dan mag je de variabele ook reeds deze waarde toekennen bij het aanmaken:

int mijnLeeftijd = 37;

Je mag ook meerdere variabelen van het zelfde datatype in 1 enkele declaratie aanmaken door deze met komma's te scheiden:

datatype identifier1, identifier2, identifier3;

Bijvoorbeeld string voornaam, achternaam, adres;

Waarden toekennen aan variabelen

Van zodra je een variabele hebt gedeclareerd kunnen we dus ten allen tijde deze variabele gebruiken om een waarde aan toe te kennen, de bestaande waarde te overschrijven, of de waarde te gebruiken, zoals:

  • Waarde toekennen: Herinner dat de toekenning steeds gebeurt van rechts naar links: het deel rechts van het gelijkheidsteken wordt toegewezen aan het deel links er van, bijvoorbeeld: mijnGetal = 15;
  • Waarde gebruiken: Bijvoorbeeld anderGetal = mijnGetal + 15;
  • Waarde tonen op scherm: Bijvoorbeeld Console.WriteLine(mijnGetal);

Met de toekennings-operator (=) kan je een waarde toekennen aan een variabele. Hierbij kan je zowel een literal toekennen oftewel het resultaat van een expressie .

Je kan natuurlijk ook een waarde uit een variabele uitlezen en toewijzen (kopiëren) aan een andere variabele:

int eenAndereLeeftijd = mijnLeeftijd;

Literal toewijzen

Literals zijn expliciet ingevoerde waarden in je code. Als je in je code expliciet de waarde 4 wilt toekennen aan een variabele dan is het getal 4 in je code een zogenaamde literal. Wanneer we echter data bijvoorbeeld eerst uitlezen of berekenen (via bijvoorbeeld invoer van de gebruiker of als resultaat van een berekening) en het resultaat hiervan toekennen aan een variabele dan is dit geen literal.

Voorbeelden van een literal toekennen:

int temperatuurGisteren = 20; //20 is de literal
int temperatuurVandaag = 25; //25 is de literal

Het is belangrijk dat het type van de literal overeenstemt met dat van de variabele waaraan je deze zal toewijzen. Een string-literal (zie verder) stel je voor door aanhalingstekens. Volgende code zal dan ook een compiler-fout generen, daar je een string-literal aan een int-variabele wil toewijzen, en vice versa.

string eenTekst;
int eenGetal;
eenTekst = 4;
eenGetal = "4";

Als je bovenstaande probeert te compileren dan krijg je volgende error-boodschappen:

Foutboodschap wanneer je literals toekent van een verkeerd datatype.

Literal bepaalt het datatype

De manier waarop je een literal schrijft in je code zal bepalen wat het datatype van die literal is:

  • Gehele getallen worden standaard als int beschouwd, vb: 125.
  • Kommagetallen (met punt .) worden standaard als double beschouwd, vb: 12.5.

Wil je echter andere getaltypes dan int of double een waarde geven dan moet je dat dus expliciet in de literal aanduiden. Hiervoor plaats je een suffix achter de literalwaarde. Afhankelijk van deze suffix duidt je dan aan om welke datatype het gaat:

  • U of u voor uint, vb: 125U (dus bijvoorbeeld uint aantalSchapen = 27u;)
  • L of l voor long, vb: 125L.
  • UL of ul voor ulong, vb: 125ul.
  • F of f voor float, vb: 12.5f.
  • M of m voor decimal, vb: 12.5M.

Naast getallen zijn er uiteraard ook nog andere datatypes waar we de literals van moeten kunnen schrijven:

Voor bool zijn dit enkel true en false.

Voor char wordt dit aangeduid met een enkele apostrof voor en na de literal. Denk maar aan char laatsteLetter = 'z';.

Voor string wordt dit aangeduid met aanhalingsteken voor en na de literal. Bijvoorbeeld string myPoke = "pikachu".

Om samen te vatten, even de belangrijkste literal schrijfwijzen op een rijtje:

int getal = 5;
double anderGetal = 5.5;
uint nogAnderGetal = 15u;
float kleinKommaGetal = 158.9f;
char letter = 'k';
bool isDitCool = true;
string zin = "Ja hoor";

De overige types sbyte, short en ushort hebben geen literal aanduiding. Er wordt vanuit gegaan wanneer je een literal probeert toe te wijzen aan één van deze datatypes dat dit zonder problemen zal gaan (ze worden impliciet geconverteerd). Bijvoorbeeld sbyte start = 127; wordt toegestaan, de int literal 127 zal geconverteerd worden achter de schermen naar een sbyte en dan toegewezen worden.

Hexadecimale en binaire notatie

Je kan ook hexadecimale notatie (starten met 0x of 0X) gebruiken wanneer je bijvoorbeeld met int of byte werkt:

int mijnLeeftijd = 0x0024; //36
byte mijnByteWaarde = 0x00C9; //201

Ook binaire notatie (starten met 0b of 0B) kan:

int mijnLeeftijd = 0b001001000; //72
int andereLeeftijd = 0b0001_0110_0011_0100_0010 //idem, maar met _ als seperator

Deze schrijfwijzen kunnen handig zijn wanneer je met binaire of hexadecimale data wilt werken die je bijvoorbeeld uit een stuk hardware hebt uitgelezen (bijvoorbeeld een Arduino of Raspberry Pi).

Beginwaarden van variabelen

Het is een goede gewoonte om variabelen steeds ogenblikkelijk een beginwaarde toe te wijzen. Alhoewel C# altijd vers gedeclareerde variabelen een standaard beginwaarde zal geven, is dit niet zo in oudere programmeertalen. In sommige talen zal een variabele een volledig willekeurige beginwaarde krijgen. Gelukkig in C# is dat niet, maar geef toch maar direct steeds een waarde, al was het om je literals te oefenen.

De standaard beginwaarde van een variabele hangt natuurlijk van het datatype af:

  • Voor getallen is dat steeds de nulwaarde (dus 0 bij int, 0.0 bij double enzovoort).
  • Bij variabelen van het type bool is dat false.
  • Bij char is dat de literal: \0 (in het volgende hoofdstuk leggen we die vreemde backslash uit).
  • En bij tekst is dat de lege string-literal: "" (maar je mag ook String.Empty voor de leesbaarheid).

Nieuwe waarden overschrijven oude waarden

Wanneer je een reeds gedeclareerde variabele een nieuwe waarde toekent dan zal de oude waarde in die variabele onherroepelijk verloren zijn. Probeer dus altijd goed op te letten of je de oude waarde nog nodig hebt of niet. Wil je de oude waarde ook nog bewaren dan zal je een nieuwe, extra variabele moeten aanmaken en daarin de nieuwe waarde moeten bewaren:

int temperatuurGisteren = 20;
temperatuurGisteren = 25;

In dit voorbeeld zal er dus voor gezorgd worden dat de oude waarde van temperatuurGisteren, 20, overschreven zal worden met 25.

Volgende code toont hoe je bijvoorbeeld eerst de vorige waarde kunt bewaren en dan overschrijven:

int temperatuurGisteren = 20;
//Doe van alles
//...
//Vervolgens: vorige temperatuur in eergisteren bewaren
int temperatuurEerGisteren = temperatuurGisteren; 
//temperatuur nu overschrijven
temperatuurGisteren = 25; 

We hebben dus aan het einde van het programma zowel de temperatuur van eergisteren, 20, als die van gisteren, 25.

Een veel gemaakte fout is variabelen meer dan één keer declareren. Dit hoeft niet én mag niet. Van zodra je een variabele declareert is deze bruikbaar in de scope (zie hoofdstuk 5) tot het einde. Volgende code zal dus een fout geven:

double kdRating = 2.1;
//even later...
double kdRating = 3.4; 

De foutboodschap vertelt duidelijk wat het probleem is: A local variable or function named 'kdRating' is already defined in this scope.

Lijn 3 moet dus worden:

kdRating = 3.4; 

Expressies en operators

Zonder expressies is programmeren saai: je kan dan enkel variabelen aan elkaar toewijzen. Expressies zijn als het ware eenvoudige tot complexe sequenties van bewerkingen die op 1 resultaat uitkomen met een specifiek datatype. De volgende code is bijvoorbeeld een expressie: 3+2.

Het resultaat van deze expressie is 5 (en dus van type int).

Expressie-resultaat toewijzen

Meestal zal je expressies schrijven waarin je bewerkingen op en met variabelen uitvoert. Vervolgens zal je het resultaat van die expressie willen bewaren voor verder gebruik in je code. In de volgende code kennen we het expressie-resultaat toe aan een variabele:

int temperatuursVerschil = temperatuurGisteren - temperatuurVandaag;

Hierbij zal de temperatuur uit de rechtse 2 variabelen worden uitgelezen, van elkaar worden afgetrokken en vervolgens bewaard worden in temperatuursVerschil.

Een ander voorbeeld van een expressie-resultaat toewijzen maar nu met literals:

int temperatuursVerschil = 21 - 25;

Uiteraard mag je ook combinaties van literals en variabelen gebruiken in je expressies:

int breedte = 15;
int oppervlakte = 20 * breedte;

Operators

Om expressies te gebruiken hebben we ook zogenaamde operators nodig. Operators in C# zijn de welgekende wiskundige bewerkingen zoals optellen (+), aftrekken (-), vermenigvuldigen (*) en delen (/). Deze volgen de klassieke wiskundige regels van volgorde van berekeningen:

  1. Haakjes
  2. Vermenigvuldigen, delen en modulo: * (vermenigvuldigen), / (delen) en % (rest na deling, ook modulo genoemd)
  3. Optellen en aftrekken: + en -, enz.

We spreken over operators en operanden. Een operand is het element dat we links en/of rechts van een operator zetten. In de som 3+2 zijn 3 en 2 de operanden, en + de operator. In dit voorbeeld spreken we van een binaire operator omdat er twee operanden zijn.

Er bestaan ook unaire operators die maar 1 operand hebben. Denk bijvoorbeeld aan de - operator om het teken van een getal om te wisselen: -6.

In hoofdstuk 5 zullen we nog een derde type operator ontdekken: de ternaire operator die met 3 operands werkt!

Net zoals in de wiskunde kan je in C# met behulp van de haakjes verplichten het deel tussen de haakjes eerst te berekenen, ongeacht de andere operators en hun volgorde van berekeningen:

3+5*2 // zal 13 (type int) als resultaat geven
(3+5)*2 // zal 16 (type int) geven

Je kan nu complexe berekeningen doen door literals, operators en variabelen samen te voegen. Bijvoorbeeld om te weten hoeveel je op Mars zou wegen:

double gewichtOpAarde = 80.3; //kg
double gAarde = 9.81;  
double gMars = 3.711; 
double gewichtOpMars = (gewichtOpAarde/gAarde) * gMars; //kg
Console.WriteLine("Je weegt op Mars " + gewichtOpMars + " kg");

Modulo operator %

De modulo operator die we in C# aanduiden met % verdient wat meer uitleg. Deze operator zal als resultaat de gehele rest teruggeven wanneer we het linkse getal door het rechtse getal delen:

int resultaat = 7%2; // zal 1 geven, daar 7 gedeeld door 2, 3 met rest 1 geeft 
int resultaat2 = 10%5; // zal 0 geven, daar 10 gedeeld door 5, 2 met rest 0 geeft 

De modulo-operator zal je geregeld gebruiken om bijvoorbeeld te weten of een getal een veelvoud van iets is. Als de rest dan 0 is weet je dat het getal een veelvoud is van het getal waar je het door deelde.

Bijvoorbeeld om te testen of getal even is gebruiken we %2:

int getal = 1234234;
int rest = getal%2;
Console.WriteLine("Indien het getal als rest 0 geeft is deze even."); 
Console.WriteLine("De rest is: " + rest);

Verkorte operator notaties

Heel vaak wil je de inhoud van een variabele bewerken en dan terug bewaren in de variabele zelf. Bijvoorbeeld een variabele vermenigvuldigen met 10 en het resultaat ervan terug in de variabele plaatsen. Hiervoor zijn enkele verkorte notaties in C#. Stel dat we een variabele int getal hebben:

Verkorte notatieLange notatieBeschrijving
getal++;getal= getal+1;variabele met 1 verhogen
getal--;getal= getal-1;variabele met 1 verlagen
getal+=3;getal= getal+3;variabele verhogen met een getal
getal-=6;getal= getal-6;variabele verminderen met een getal
getal*=7;getal= getal*7;variabele vermenigvuldigen met een getal
getal/=2;getal= getal/2;variabele delen door een getal

Je zal deze verkorte notatie vaak tegenkomen. Ze zijn identiek aan elkaar en zullen dus je code niet versnellen. Ze zal enkel compacter zijn om te lezen. Bij twijfel, gebruik gewoon de lange notatie.

Bovenstaande verkorte notaties hebben ook een variant waarbij de operator links en de operand rechts staat. Bijvoorbeeld --getal. Beide doen het zelfde, maar niet helemaal. Je merkt het verschil in volgende voorbeeld:

int getal = 1;
int som = getal++; //som wordt 1, getal wordt 2
int som2 = ++som; //som2 wordt 2, som wordt 2

Als je de operator achter de operand zet (som++) dan zal eerst de waarde van de operand worden teruggegeven, vervolgens wordt deze verhoogd. Bij de andere (++som) is dat omgekeerd: eerst wordt de operand aangepast, en de nieuwe waarde wordt als resultaat teruggegeven.

Expressiedatatypes

Gegroet! Zet je helm op en let alsjeblieft goed op. Als je het volgende stuk goed begrijpt (en blijft begrijpen) dan heb je al een grote stap vooruit gezet in de wondere wereld van C#.

We vertelden al dat variabelen het hart van programmeren zijn. Wel, expressies zijn het bloedvatensysteem dat ervoor zorgt dat al je variabelen ook effectief gecombineerd kunnen worden tot wondermooie nieuwe dingen.

Succes!

Lees deze zin enkele keren luidop voor, voor je verder gaat: De types die je in je expressies gebruikt bepalen ook het type van het resultaat. Als je bijvoorbeeld twee int variabelen of literals optelt zal het resultaat terug een int geven (klink logisch, maar lees aandachtig verder):

int result = 3 + 4;

Je kan echter geen kommagetallen aan int toewijzen. Als je dus twee double variabelen deelt is het resultaat terug een double en zal deze lijn een fout geven daar je probeert een double aan een int toe te wijzen:

int otherResult = 3.1 / 45.2; //dit is fout!!!

Bovenstaande code geeft volgende fout: Cannot implicitly convert double to int.

Let hier op!

But wait... it gets worse!

Wat als je een int door een int deelt? Het resultaat is terug een int. Je bent echter alle informatie na de komma kwijt. Kijk maar:

int getal1 = 9;
int getal2 = 2;
int result = getal1/getal2;
Console.WriteLine(result);

Er zal 4 op het scherm verschijnen! (niet 4.5 daar dat geen int is).

Datatypes mengen in een expressie

Wat als je datatypes mengt? Als je een berekening doet met bijvoorbeeld een int en een double dan zal C# het 'grootste' datatype kiezen. In dit geval een double.

Volgende code zal dus werken:

double result = 3/5.6;

Volgende code niet:

int result = 3/5.6;

En zal weer dezelfde fout genereren: "Cannot implicitly convert type 'double' to 'int'. An explicit conversion exists (are you missing a cast?)"

Wil je dus het probleem oplossen om 9 te delen door 2 en toch 4.5 te krijgen (en niet 4) dan zal je minstens 1 van de 2 literals of variabelen naar een double moeten omzetten.

Het voorbeeld van hierboven herschrijven we daarom naar:

int getal1 = 9;
double getal2 = 2.0; //slim he
double result = getal1/getal2;
Console.WriteLine(result);

En nu krijgen we wel 4.5 aangezien we nu een int door een double delen en C# dus ook het resultaat dan als een double zal teruggeven.

Begrijp je nu waarom dit een belangrijk deel was? Je kan snel erg foute berekeningen en ongewenste afrondingen krijgen indien je niet bewust omgaat met je datatypes.

Laten we eens kijken of je goed hebt opgelet, het kan namelijk subtiel en ambetant worden in grotere berekeningen.

Stel dat ik afspreek dat je van mij de helft van m'n salaris krijgt. Ik verdien 10000 euro per maand (I wish).

Ik stel je voor om volgende expressie te gebruiken om te berekenen wat je van mij krijgt:

double helft = 10000.0 * (1 / 2);

Hoeveel krijg je van me?

0.0 euro, MUHAHAHAHA!!!

Begrijp je waarom? De volgorde van berekeningen zal eerst het gedeelte tussen de haakjes doen:

  • 1 delen door 2 geeft 0, daar we een int door een int delen en dus terug een int als resultaat krijgen.
  • Vervolgens zullen we deze 0 vermenigvuldigen met 10000.0 waarvan ik zo slim was om deze in double te zetten. Niet dus. We vermenigvuldigen weliswaar een double (het salaris) met een int maar die int is reeds 0 en we krijgen dus 0.0 als resultaat.

Als ik dus effectief de helft van m'n salaris wil afstaan dan moet ik de expressie aanpassen naar bijvoorbeeld:

double helft = 10000.0 * (1.0 / 2);

Nu krijgt het gedeelte tussen de haakjes een double als resultaat, namelijk 0.5 dat we dan kunnen vermenigvuldigen met het salaris om 5000.0 te krijgen, wat jij vermoedelijk een fijner resultaat vindt.

Voorgaande voorbeeld is gebaseerd op een oefening uit het handboek "Programmeren in C#" van Douglas Bell en Mike Parr, een boek dat werd vertaald door collega lector Kris Hermans bij de Hogeschool PXL. Als je de console-applicaties beu bent en liever leert programmeren door direct grafische Windows-applicatie te maken, dan raad ik je dit boek ten stelligste aan!

Constanten

Je zal het const keyword hier en daar in codevoorbeelden zien staan. Je gebruikt dit om aan te geven dat een variabele onveranderlijk is én niet per ongeluk kan aangepast worden. Door dit keyword voor de variabele declaratie te plaatsen zeggen we dat deze variabele na initialisatie niet meer aangepast kan worden. Dit heeft 2 voordelen:

  1. Jij (of mede-ontwikkelaars) zullen niet per ongeluk deze variabele aanpassen waardoor andere stukken code plots vreemde bugs lijken te krijgen.
  2. Het is ook een vorm van documentatie. Meestal staan constanten bovenaan je code gegroepeerd en zo weten lezers van jouw code ogenblikkelijk welke constanten er zullen gebruikt (want meestal komen deze op meerdere plaatsen voor én zijn ze dus belangrijk voor de goede werking van je code).

Volgende voorbeeld toont in de eerste lijn hoe je het const gebruikt. De volgende lijn zal dankzij dit keyword een error geven reeds bij het compileren en jou dus waarschuwen dat er iets niet klopt.

const double G_AARDE = 9.81;
G_AARDE = 10.48; //ZAL ERROR GEVEN

Merk op de schrijfwijze van const identifiers: deze zetten we in ALLCAPS, waarbij we liggende streepjes gebruiken om het onderscheid tussen de onderlinge woorden aan te geven ("gaarde" is anders een vreemd woord).

Constanten in code worden ook soms magic numbers genoemd. De reden hiervoor is dat ze vaak plotsklaps ergens in de code voorkomen, maar wel op een heel andere plek werden gedeclareerd. Hierdoor is het voor de ontwikkelaar niet altijd duidelijk wat de variabele juist doet. Het is daarom belangrijk dat je goed nadenkt over het gebruik van magic numbers én deze zeer duidelijke namen geeft.

Er worden vele filosofische, bijna theologische, strijden gevoerd tussen ontwikkelaars over de plek van magic numbers in code. In de C/C++ tijden werden deze steeds aan de start van de code gegroepeerd. Op die manier zag de ontwikkelaar in één oogopslag alle belangrijke variabelen en konden deze ook snel aangepast worden. In C# prefereert men echter om variabelen zo dicht mogelijk bij de plek waar ze nodig zijn te schrijven, dit verhoogt de modulariteit van de code: je kan sneller een flard code kopiëren en op een andere plek herbruiken.

De applicaties die wij in dit boek ontwikkelen zijn niet groot genoeg om over te debatteren. Veel bedrijven hanteren hun eigen coding guidelines en het gebruik, naamgeving en plaatsing van magic numbers zal zeker daarin zijn opgenomen.

Solutions, projecten en folderstructuren

Het wordt tijd om eens te kijken hoe Visual Studio jouw code juist organiseert wanneer je een nieuw project start. Zoals je al hebt gemerkt in de Solution Explorer wordt er meer aangemaakt dan enkel een Program.cs codebestand. Visual Studio werkt volgens volgende hiërarchie:

  1. Een solution is een folder waarbinnen één of meerdere projecten bestaan.
  2. Een project is een verzameling bestanden (meestal codebestanden) die kunnen gecompileerd worden tot een uitvoerbaar bestand (we vereenvoudigen bewust het concept project in dit handboek).

Wanneer je dus aan de start van een nieuwe opdracht staat en in VS kiest om voor "Create a new project" dan zal je eigenlijk aan een nieuwe solution beginnen met daarin één project. Je bent dus echter niet beperkt om binnen een solution maar één project te bewaren. Integendeel, vaak kan het handig zijn om meerdere projecten samen te houden. Ieder project bestaat op zichzelf, maar wordt wel logisch bij elkaar gehouden in de solution. Dat is ook de reden waarom we vanaf de start hebben aangeraden om nooit het vinkje "Place solution and project in the same directory" aan te duiden.

Folderstructuur van een solution

Wanneer je in VS een nieuw project start ben je niet verplicht om de "Project name" en "Solution name" dezelfde waarde te geven. Je zal wel merken dat bij het invoeren van de "Project name" de "Solution name" dezelfde invoer krijgt. Je mag echter vervolgens perfect de "Solution name" aanpassen.

Stel dat we een nieuw VS project aanmaken met volgende informatie:

  1. Naam van het project = Opdracht1
  2. Naam van de solution = Huiswerk

En plaatsen deze in de folder C:\Temp.

Dit zal resulteren in volgende beeld in de solution explorer:

Je ziet duidelijk een hiërarchie: bovenaan de solution Huiswerk, met daarin een project Opdracht1, gevuld met informatie zoals de Program.cs.

Rechterklik nu op de solution en kies "Open folder in file explorer" Je kan deze optie kiezen voor eender welk item in de solution explorer. Het zal er voor zorgen dat de verkenner wordt geopend op de plek waar het item staat waar je op rechter klikte. Op die manier kan je altijd ontdekken waar een bestand of of folder zich fysiek bevindt op je harde schijf.

We zien nu een tweede belangrijke aspect dat we in deze sectie willen uitleggen: Een solution wordt in een folder geplaatst met dezelfde naam én bevat één .sln bestand. Binnenin deze folder wordt een folder aangemaakt met de naam van het project.

Je kan dus je volledige solution, inclusief het project, openen door in deze folder het .SLN bestand te selecteren. Dit bestand zelf bevat echter geen code

Een sln-bestand op zichzelf bevat dus géén code . Je moet de hele folderstructuur verplaatsen/doorsturen indien je aan je project op een andere plek wilt werken. Open gerust eens een .sln-bestand in notepad en je zal zien dat het bestand onder andere oplijst waar het onderliggende project zich bevindt.

Lijn 5 is relevant voor ons.

Folderstructuur van een project

Laten we nu eens bekijken hoe de folderstructuur van het project is. Rechterklik deze keer op het project (Opdracht1) en kies weer "Open folder in file explorer." Hier staat een herkenbaar bestand! Inderdaad, het Program.cs codebestand! In dit bestand staat de actuele code van Opdracht1.

Een .cs-bestand rechtstreeks vanuit de verkenner openen werkt niet. VS zal weliswaar de inhoud van het bestand tonen, maar je kan verder niets doen. Je kan niet compileren, debuggen, etc. De reden is eenvoudig: een .cs bestand op zichzelf is nutteloos. Het heeft pas een bestaansreden wanneer het wordt geopend in een project. Het project zal namelijk beschrijven hoe dit specifieke bestand juist moet gebruikt worden in het huidige project.

Voorts zien we ook een .csproj bestand genaamd Opdracht1. Net zoals het .sln-bestand zal dit bestand beschrijven welke bestanden én folder deel uitmaken van het huidige project. Je kan dit bestand dus ook openen vanuit de verkenner en je zal dan je volledige project zien worden ingeladen in Visual Studio.

De bin-folder

De "obj" folder gaan we in dit handboek negeren. Maar kijk eens wat er in de "bin" folder staat. Een folder"debug". In deze folder zal je gecompileerde (debug) versie van je project terecht komen indien je je huidige project compileert. Je zal wat moeten doorklikken tot de binnenste folder (die de naam van de huidige .net versie bevat waarin je compileert).

Inhoud van bin/debug/net8.0 nadat project werd gecompileerd

Je kan in principe vanuit deze map ook je gecompileerde project uitvoeren door te dubbelklikken op Opdracht1.exe. Je zal echter merken dat het programma ogenblikkelijk terug afsluit omdat het programma aan het einde van de code altijd afsluit. Voeg daarom volgende lijn code toe onderaan in je Main: Console.ReadLine() . Het programma zal nu pas afsluiten wanneer je op enter hebt gedrukt en de gecompileerde versie kan dus nu vanuit de verkenner gestart worden!

Merk op dat je de volledige inhoud van deze folder moet meegeven indien je je gecompileerde resultaat aan iemand wilt geven om uit te voeren.

Meerdere projecten

We vertelden net dat een solution meerdere projecten kan bevatten. Maar hoe voeg je een extra project toe? Heel eenvoudig: terwijl je huidige solution open is (waar je een project wenst aan toe te voegen) kies je in het menu voor File->Add->New project...

e moet nu weer het klassieke proces doorlopen om een console-project, alleen ontbreekt deze keer de "Solution name" tekstveld, daar dit reeds gekend is.

Wanneer je klaar bent zal je zien dat in de Solution Explorer een tweede project is verschenen. Als we wederom de folderstructuur van onze solution zouden bekijken in de verkenner dan zouden we ontdekken dat er een nieuwe folder, genaamd Opdracht2, is verschenen met ook daarin een eigen Program.cs en .csproj-bestand.

Nu rest ons nog één belangrijke stap: selecteren welk project moet gecompileerd en uitgevoerd worden. In de solution explorer kan je zien welk het actieve project is, namelijk het project dat vet gedrukt staat.

Je kan nu op 2 manieren het actieve, uit te voeren, project kiezen.

Manier 1: Rechterklik in de Solution Explorer op het actief te zetten project en kies voor "Set as startup project." Manier 2: bovenaan, links van de groene "compiler/run" knop, staat een selectieveld met het actieve project. Je kan hier een andere project selecteren.

Controleer altijd goed dat je in het juiste Program.cs bestand bent aan het werken. Je zou niet de eerste zijn die maar niet begrijpt waarom de code die je invoert z'n weg niet vindt naar je debugvenster. Inderdaad, vermoedelijk heb je het verkeerde Program.cs bestand open OF heb je het verkeerde actieve project gekozen.

Tekst gebruiken in code

Ieder teken dat je op je toetsenbord kunt intypen is een char. Je toetsenbord bevat echter maar een kleine selectie van alle mogelijkheden (vergelijk jouw toetsenbord bijvoorbeeld maar eens met dat van iemand in pakweg Spanje, Tunesië of China). Voor we gaan kijken hoe in C# input van het toetsenbord wordt uitgelezen moeten we even kijken hoe al die duizenden tekens in een computer eigenlijk worden voorgesteld. Het antwoord: de UNICODE standaard. Heel lang geleden was er al een standaard die uniformiseerde hoe een tekens moest worden voorgesteld: de ASCII-standaard. Deze standaard zei letterlijk "dat teken wordt voorgesteld door die hexadecimale waarde". Iedereen die de ASCII-standaard volgde kon dan zo alle, in ASCII gedefinieerde, tekens naar elkaar communiceren.

UNICODE is de standaard die de ASCII-standaard opvolgt omdat die te klein (qua aantal bits) bleek te zijn om naar de toekomst toe de ontelbare nieuwe tekens in voor te stellen. De ASCII standaard kan 128 karakters voorstellen (m.b.v. 7 bit), wat uiteraard in het niets valt in vergelijking met de meer dan 1 miljoen tekens in UNICODE (dankzij de 16 bit voorstelling). Uiteraard heeft de UNICODE-standaard die eerste 128 van ASCII als eerste gezet en zijn beide tabellen dus compatibel (UNICODE is een superset van ASCII). Dankzij UNICODE kunnen we nu elke smiley, letter uit elke alfabet en zelfs gewoon icoontjes, wereldwijd delen met elkaar op dezelfde manier.

Voor de statistieknerds onder ons: er zijn 1,111,998 UNICODE karakters mogelijk. Momenteel zijn er daarvan 137,929 gedefinieerd. We hebben dus nog wel wat plek.

De eerste 128 karakters met hun waarden (bron Wikipedia)

De eerste 32 karakters zijn "onzichtbare" karakters die een historische reden (in ASCII) hebben om in de lijst te staan, maar sommige ervan zijn ondertussen niet meer erg nuttig. Origineel werd ASCII ontwikkeld als standaard om via de telegraaf te combineren. Vandaar dat vele van deze karakters commando's lijken om oude typemachines aan te sturen (line feed, bell, form feed, etc) want dat zijn ze dus ook effectief!

Tekst datatypes

In het vorige hoofdstuk werkten we vooral met getallen en haalden we maar kort het string en char datatype aan. In dit hoofdstuk gaan we dieper in op deze 2 veelgebruikte datatypes.

Char

Een enkel karakter (cijfer, letter, leesteken, enz.) als 'tekst' opslaan kan je doen door het char-type te gebruiken. Zo kan je bijvoorbeeld één enkel karakter als volgt tonen:

char eenLetter = 'X';
Console.WriteLine("eenLetter=" + eenLetter);

Het is belangrijk dat je de apostrof (') niet vergeet voor en na het karakter dat je wenst op te slaan daar dit de literal voorstelling van char-literals is. Zonder die apostrof denkt de compiler dat je een variabele wenst aan te roepen van die naam.

Je kan eender welk UNICODE-teken in een char bewaren, namelijk een letter, een cijfer of een speciaal teken zoals %, $, *, #, enz. Intern wordt de UNICODE van het character bewaard in de variabele, zijnde een 16 bit getal.

Merk dus op dat volgende lijn: char eenGetal = '7'; weliswaar een getal als teken opslaat, maar dat intern de compiler deze variabele steeds als een character zal gebruiken. Als je dit cijfer zou willen gebruiken als effectief cijfer om wiskundige bewerkingen op uit te voeren, dan zal je dit eerst moeten converteren naar een getal (we zullen dit in hoofdstuk 4 uitleggen).

String

Een string is een reeks van 0, 1 of meerdere char-elementen.

We gebruiken het string datatype om tekst voor te stellen. Je begrijpt waarschijnlijk zelf wel waarom het string datatype een belangrijk en veelgebruikt type is in eender welke programmeertaal: er zijn maar weinig applicaties die niet minstens enkele lijnen tekst vertonen (ja, zelfs Flappy Bird had tekst, of hoe denk je dat je score werd voorgesteld op het scherm?).

In hoofdstuk 8 zullen we ontdekken dat strings eigenlijk zogenaamde arrays zijn.

Strings declareren

Merk op dat we bij een string literal gebruik maken van aanhalingstekens (") terwijl bij een char literal we een apostrof gebruiken ('). Dit is de manier om een string van een char te onderscheiden (naast het feit dat een string uit meer dan 1 element kan bestaan)

Volgende, uiterst boeiende, code geeft drie keer het cijfer 1 onder elkaar op het scherm, maar de eerste keer gaat het om het een char (enkelvoudig teken), dan en een string (reeks van tekens) en dan een int (effectief getal):

char eenKarakter = '1'; 
string eenString = "1"; 
int eenGetal = 1;
 
Console.WriteLine(eenKarakter);
Console.WriteLine(eenString);
Console.WriteLine(eenGetal);

Het programma zal driemaal een 1 onder elkaar tonen. Boeiend programma hoor.

Escape characters

De voorman hier! Escape characters zijn niet de boeiendste materie om te bespreken. Je zou nog kunnen hopen dat het een opvolger is van Prison Break of zo. Helaas is dat niet zo. Echter: als je escape characters beheerst zal je veel eenvoudiger én mooier tekst op je scherm kunnen toveren. Let dus even goed op a.u.b.

Naast letters en tekens mogen in string en chars ook escape characters staan. In C# hebben bepaalde tekens namelijk een speciale functie, zoals de dubbele aanhalingstekens (") om het begin of einde van een string-literal aan te geven. We hebben dus een manier nodig om aan te duiden wanneer de compiler het eerstvolgende teken als een char moet beschouwen, of als een teken dat deel uitmaakt van de code zelf.

Zonder aan te geven dat we letterlijk dat teken willen tonen, en het niet in z’n C# functie gebruiken, zouden we problemen krijgen. Escape characters worden met een backslash (\) aangeduid, gevolgd door het karakter dat we wensen te tonen.

Voorbeeld van escape chars

Laten we eens kijken naar de werking van het afkappingsteken als voorbeeld (de zogenaamde apostrof, om bijvoorbeeld 's avonds te schrijven) Volgende code zal de compiler verkeerd interpreteren, daar hij denkt dat we een leeg karakter wensen op te slaan:

char apostrof = ''';

Het gevolg is een litanie aan vreemde foutboodschappen omdat er na de sluitende apostrof (het tweede) plots nog een apostrof (het derde) verschijnt. VS is volledig in de war zo!

Hulp! VS snapt er niets van!

De juiste manier is om dus een escape character te gebruiken. We gaan met de backslash aanduiden dat het volgende teken (de tweede apostrof) een char voorstelt en niet het sluitende teken in de code.

char apostrof = '\'';

Veel gebruikte escape chars

Er zijn verschillende escape characters in C# toegelaten, we lijsten hier de belangrijkste op (voor een totaal overzicht kijk eens op docs.microsoft.com/dotnet/csharp/programming-guide/strings/):

\'      //de apostrof zoals zonet besproken.
\"      //een aanhalingsteken zodat je dat ook in je string kunt gebruiken zonder deze af te sluiten.
\\      //een backslash in je tekst tonen. Hoe toon je dan twee backslashes? \\\\
\n      //een nieuwe lijn (zogenaamde enter of newline).
\t      //Horizontale tab.
\uxxxx  //een met als hexadecimale UNICODE waarde xxxx.

Escape characters in strings

Aangezien strings eigenlijk bestaan uit 1 of meerdere char-elementen, is het logisch dat je ook in een string met escape characters kunt werken. Het woord "'s avonds" schrijf je bijvoorbeeld als volgt:

string woord = "\'s avonds";

Idem met aanhalingstekens. Stel je voor dat je een programma wilt schrijven dat C# code op het scherm toont. Dat doe je dan met volgende, nogal Inception-achtige, manier:

string inceptionCode = "Console.WriteLine(\"Cool he\");";
Console.WriteLine(inceptionCode);

Merk op dat we voorgaande code nog meer Inception-like kunnen maken door de string ineens in de WriteLine methode te plaatsen:

Console.WriteLine("Console.WriteLine(\"Cool he\");");

Beide voorbeelden zullen dus volgende tekst op het scherm geven: Console.WriteLine("Cool he");

Biep biep

\a mag je enkel gebruiken als je een koptelefoon op hebt daar dit het escape character is om de computer een biep te laten doen (mogelijk doet dit niets bij jou, dit hangt van de je computerinstellingen af). Volgende codevoorbeeld zal, als alles goed gaat, een zin op het scherm tonen en dan ogenblikkelijk erna een biepje:

Console.WriteLine("Een zin en dan nu de biep\a");

Witregels en tabs

We gebruiken vooral escape characters in strings om bijvoorbeeld witregels en tabs aan te geven. Test bijvoorbeeld volgende lijn code eens:

string eenString = "Een zin.\t na een tab \nDan eentje op een nieuwe regel";
Console.WriteLine(eenString);

Dit zal als output geven:

Een zin.         na een tab
Dan eentje op een nieuwe regel

Over tabstops

Als je het niet gewoon bent de tab-toets op je toetsenbord te gebruiken dan is de eerste werking van \t mogelijk verwarrend. Nochtans is \t in een string gebruiken exact hetzelfde als op de tab-toets duwen.

In je console-scherm zijn de tab stops vooraf bepaald. Wanneer je dus een tab invoegt zal de cursor zich verplaatsen naar de eerstvolgende tab stop.

In volgende tekstuitvoer zie je de tabstops op de tweede lijn "gevisualiseerd":

01234567890123456789012345678901234567890123456789
        1       2       3       4       5

Bovenstaande uitvoer werd als volgt gemaakt:

Console.WriteLine("01234567890123456789012345678901234567890123456789");
Console.WriteLine("\t1\t2\t3\t4\t5");

Tabstops zijn nuttig om je data mooi uitgelijnd in een console-applicatie in een tabel te plaatsen. Als je dat dan nog eens combineert met de UNICODE karakters om tabellen te tekenen kan je toffe dingen maken. Deze karakters, de zogenaamde "Box Drawing" subset, staan in UNICODE gedefinieerd als de tekens met hexadecimale code 0x2500 en verder. Bekijk zeker eens volgende datasheet met alle tekens: www.unicode.org/charts/PDF/U2500.pdf.

Het apenstaartje om escape characters te negeren

Het apenstaartje voor een string literal plaatsen is zeggen "beschouw alles binnen de aanhalingstekens als effectieve karakters die deel uitmaken van de inhoud van de tekst". Dit teken heet daarom binnen C# niet voor niets het verbatim karakter. Het is belangrijk te beseffen dat escape characters genegeerd worden wanneer we het verbatim karakter gebruiken. Dit is vooral handig als je bijvoorbeeld een netwerkadres wilt schrijven en niet iedere \ wilt escapen:

string zonderAt = "C:\\Temp\\Myfile.txt";
string metAt = @"C:\Temp\Myfile.txt";

Merk op dat aanhalingstekens nog steeds ge-escape'd moeten worden. Heb je dus een stuk tekst met een aanhalingsteken in dan zal je zonder het apenstaartje moeten werken.

Uiteraard kan je ook het apenstaartje gebruiken in Console.WriteLine. Volgende zal dus de escape karakters tonen in plaats van "uitvoeren":

Console.WriteLine(@"Om een tab te tonen gebruik je \t in je c# strings.");

Wat zal resulteren in volgende uitvoer:

Om een tab te tonen gebruik je \t in je c# strings.

Strings samenvoegen

Je kan strings en variabelen samenvoegen tot een nieuwe string op verschillende manieren:

  • +-operator
  • $ string interpolation
  • Of de oude manier: String.Format()

In dit boek verkiezen we manier 2, de string interpolatie. Dit is de meest moderne aanpak.

In de volgende sectie gaan we van volgende informatie uit:

  • Stel dat je 2 variabelen hebt int leeftijd = 13 en string naam = "Finkelstein".
  • We willen de inhoud van deze variabelen samenvoegen in een nieuwe string result die zal bestaan uit de tekst: Ik ben Finkelstein en ik ben 13 jaar oud.

Volgende 3 manieren tonen hoe je steeds tot voorgaande string zal komen.

Manier 1: String samenvoegen met de +-operator

Je kan strings en variabelen eenvoudig bij elkaar 'optellen' zoals we in het begin van dit boek hebben gezien. Ze worden dan achter elkaar geplakt (geconcateneerd).

string result = "Ik ben " + naam + " en ik ben " + leeftijd+ " jaar oud.";

Let er op dat je tussen de aanhalingsteken (binnen de strings) spaties zet indien je het volgende deel niet tegen het vorige stringstuk wilt 'plakken'.

Toch even goed opletten hier. De volgorde van strings met andere types samenvoegen (concateneren) bepaalt wat de uitvoer zal zijn! Kijk zelf:

Console.WriteLine("1"+1+1);
Console.WriteLine(1+1+"1");
Console.WriteLine("1" + (1 + 1));

Geeft als uitvoer:

111
21
12

Was dit de uitvoer die je voorspeld had?

Ook in dit soort code wordt de volgorde van bewerkingen gerespecteerd. De concatenatie gebeurt van links naar rechts en de linkse operand zal steeds bepalen wat het resultaat van de bewerking zal zijn indien er twijfel is. Dit nieuw samengevoegde deel wordt dan de linkse operand voor het volgende deel.

Kijken we dus naar "1"+1+1 dan wordt dit eerst "11"+1 en vervolgens de string "111". Bij 1+1+"1" krijgen we eerste 2+"1", dit geeft vervolgens 21 (aangezien C# niet kan bepalen dat de string iets bevat wat een getal kan zijn, en dus besluit om beide operanden als een string te zien wat altijd de veiligste oplossing is).

Manier 2: String interpolation met $

In de oude dagen van C# gebruikten we String.Format() (zie hierna) om meerdere strings en variabelen samen te voegen tot één string. Het nadeel van de +-operator uit manier 1 is dat je strings erg lang en onleesbaar worden.

Dankzij string interpolation kan dit wel waarbij we het $-teken gebruiken vooraan de string om aan te geven dat specifieke delen van de zin geïnterpoleerd moeten worden

Door het $-teken VOOR de string te plaatsen geef je aan dat alle delen in de string die tussen accolades staan als code mogen beschouwd worden. Een voorbeeld maakt dit duidelijk:

string result = $"Ik ben {naam} en ik ben {leeftijd} jaar oud.";

In dit geval zal de inhoud van de variabele naam tussen de string op de plek waar nu {naam} staat geplaatst worden. Idem voor leeftijd. Zoals je kan zien is dit veel meer leesbare code dan de eerste manier.

Het resultaat zal dan worden: Ik ben Finkelstein en ik ben 13 jaar oud.

Berekeningen doen bij string interpolatie

Je mag eender welke expressie tussen de accolades zetten bij string interpolation, denk maar aan:

string result = $"Ik ben {naam} en ik ben {leeftijd+4} jaar oud.";

Alle expressies tussen de accolades zullen eerst uitgevoerd worden voor ze tussen de string worden geplaatst. De uitvoer wordt nu dus: Ik ben Finkelstein en ik ben 17 jaar oud.

Eender welke expressie is toegelaten, dus je kan ook complexe berekeningen of zelfs andere methoden aanroepen:

string result = $"Ik ben {leeftijd*leeftijd+(3*2)} jaar oud.";

Uiteraard mag je dit dus ook gebruiken wanneer je eenvoudigere zaken naar het scherm wenst te sturen gebruik makende van Console.WriteLine en interpolatie:

Console.WriteLine($"3 maal 9 is {3*9}");

Mooier formatteren

Zowel bij string interpolation (manier 2) als de manier hierna kan je ook bepalen hoe de te tonen variabelen en expressies juist weergegeven moeten worden. Je geeft dit aan door na de expressie, binnen de accolades, een dubbelpunt te plaatsen gevolgd door de manier waarop moet geformatteerd worden.

Wil je bijvoorbeeld een kommagetal tonen met maar 2 cijfers na de komma dan schrijf je:

double number = 12.345;
Console.WriteLine($"{number:F2}");

Er zal 12.35 op het scherm verschijnen. F2 geeft aan dat je een float wilt met 2 beduidende cijfers na de komma.

Merk op dat bij string formatting er afgerond wordt.

Nog enkele nuttige vormen:

  • D5: toon een geheel getal als een 5 cijfer getal (123 wordt 00123) (werkt uiteraard enkel op gehele getallen!)
  • E2: wetenschappelijke notatie met 2 cijfers precisie (12000000 wordt 1,20E+007 i.e. "1 komma 2 maal tien tot de zevende")
  • C: geldbedrag (12,34 wordt $ 12,34 : teken van valuta afhankelijk van instellingen pc). Het euro teken zal als een ? getoond worden. In de volgende sectie tonen we hoe je dit kan oplossen.

Alle overige format specifiers staan hier opgelijst: docs.microsoft.com/dotnet/standard/base-types/standard-numeric-format-strings.

Een andere eenvoudige manier om strings te formatteren is door middel van een soort masker bestaande uit 0'n. Dit ziet er als volgt uit:

double number = 12.345;
Console.WriteLine($"{number:0.00}");

We geven hierbij aan dat de variabele tot 2 cijfers na de komma moet getoond worden. Indien deze maar 1 cijfer na de komma bevat dan deze toch met twee cijfers getoond worden. Volgende voorbeeld toont dit:

double number = 12.3;
Console.WriteLine($"{number:0.00}");

Er zal 12,30 op het scherm verschijnen.

Je kan dit masker ook gebruiken om te verplichten dat getallen bijvoorbeeld steeds met minimum 3 cijfers voor de komma getoond worden. Volgende voorbeeld toont dit:

double number = 12.3;
double number2 = 99999.3;
Console.WriteLine($"{number:000.00}");
Console.WriteLine($"{number2:000.00}");

Geeft als uitvoer:

012.30
99999.30

Manier 3: String.Format()

String interpolatie met het $-teken is een nieuwe C# aanwinst. Je zal echter geregeld documentatie en online code tegenkomen die nog met String.Format werkt (ook zijn er nog zaken waar het te verkiezen is om String.Format te gebruiken i.p.v. 1 van vorige manieren). Om die reden bespreken we dit nog in dit boek.

String.Format is een ingebouwde methode die string-interpolatie toelaat op een iets minder intuïtieve manier, als volgt:

string result = String.Format("Ik ben {0} en ik ben {1} jaar.", naam, leeftijd);

Het getal tussen de accolades geeft telkens aan de hoeveelste parameter na de string hier in de plaats moet gezet worden (0= de eerste, 1= de tweede, enz). De eerste parameter is naam, de tweede is leeftijd.

Volgende code zal een ander resultaat geven:

string result = String.Format("Ik ben {1} en ben {1} jaar.", naam, leeftijd);

Namelijk: Ik ben 13 en ik ben 13 jaar oud.

Je kan deze vorm van formateren ook toepassen in Console.WriteLine zonder dat je expliciet String.Format hiervoor moet aanroepen:

Console.WriteLine("Gratis formateren. {0} maal hoera voor .NET!", 3);

Wanneer we in hoofdstuk 8 arrays uit de doeken gaan doen zal je ontdekken dat alles in de digitale wereld begint te tellen vanaf 0, en niet 1 zoals wij gewend zijn. Het eerste element in een lijst, zoals hier boven, heeft daarom index 0, het tweede 1, enz.

Optellen van char variabelen

We hebben al gezien dat intern een char als een geheel getal (de UNICODE) wordt voorgesteld. Stel dat we volgende char-variabelen aanmaken.

char letter1 = 'A';
char letter2 = 'B';

Bij string mogen we de +-operator gebruiken om 2 strings aan elkaar te plakken. Bij char mag dat niet! Of beter, dit mag maar zal niet het resultaat geven dat je mogelijk verwacht wanneer je voor het eerst hiermee leert werken. Oordeel zelf:

Console.WriteLine(letter1 + letter2);

Wanneer je deze code uitvoert dan krijg je 131 te zien (en dus niet "AB" zoals je misschien had verwacht).

Had je dit verwacht? Denk eraan dat het char-type z’n waarde als getallen bijhoudt, de zogenaamde UNICODE-voorstelling van het karakter. Als de compiler het volgende ziet staan:

letter1 + letter2

dan zal de compiler deze twee waarden letterlijk optellen en het nieuw verkregen getal als resultaat geven:

  • De UNICODE-voorstelling van A is 0x041 oftewel 65. In het geheugen staat dus het geheel getal 65.
  • B wordt voorgesteld door 66.
  • Als we dus de variabelen letter1 en letter2 optellen geeft dit 131.

Je zou misschien verwachten dat C# vervolgens het element op plaats 131 in de UNICODE tabel zou tonen. Dat is niet zo: omdat de + operator niet is gedefinieerd voor het char datatype maar wel voor het int datatype, besluit de compiler om de twee operanden (letter1 en letter2) als int operanden te hanteren. Aangezien int+int een int als resultaat geeft, krijgen we dus 131 op het scherm en niet het UNICODE element 131 (we zien in het volgende hoofdstuk hoe je dit wel kunt doen).

Vreemde tekens in console tonen

Niets is zo leuk als de vreemdste UNICODE tekens op het scherm tonen. In oude console-games werden deze tekens vaak gebruikt om complexe tekeningen op het scherm te tonen. Om je ietwat saaie applicaties dus wat toffer te maken leggen we daarom uit hoe je dit kan doen.

Dwarf fortress: een van de bekendste (én meest complexe) console-games ooit waar nog steeds aan ontwikkeld, wordt gebruikt ongelooflijk veel bizarre karakters om zo een erg 'cool' ogende user interface te maken.

UNICODE karakters tonen

Je toetsenbord heeft maar een beperkt aantal toetsen. Er zijn echter tal van andere tekens gedefinieerd die console-applicaties ook kunnen gebruiken. We zagen reeds dat al deze tekens, UNICODE-karakters, een eigen unieke code hebben die je kan opzoeken om vervolgens dat teken in je code te gebruiken, daar het char type hiermee werkt.

Dit gaat als volgt in z'n werk:

  1. Zoek het teken(s) dat je nodig hebt in een UNICODE-tabel, bijvoorbeeld op UNICODE-table.com.
  2. Plaats bovenaan je Main: Console.OutputEncoding = System.Text.Encoding.UTF8;
  3. Je kan nu op 2 manieren dit teken in console plaatsen.

Stel je voor dat we het copyright karakter wensen te gebruiken (de letter c in een cirkeltje) in onze applicatie. Deze heeft hexadecimale UNICODE waarde 0x00A9.

Manier 1: copy/paste

Kopieer het karakter zelf en plaats het in je code waar je het nodig hebt, bijvoorbeeld:

Console.WriteLine("<plak hier je speciale teken>"); 

Merk op dat niet alle lettertypes dit karakter kennen en dus mogelijk als een vierkantje dit op je scherm zullen tonen. Dit hangt af van het lettertype dat jouw shell-venster gebruikt (meestal is de standaard Courier).

Manier 2: hexadecimale code casten naar char

Casting leggen we pas in het volgende hoofdstuk uit, maar het kan geen kwaad om al eens een voorproefje hiervan te krijgen. Noteer de hexadecimale code van het karakter dat in de tabel staat. In dit geval is de code 0x00A9. Om dit teken te tonen schrijf je dan:

char copyright = (char)0x00A9;
Console.WriteLine(copyright);

In C# schrijf je hexadecimale getallen als volgt als je ze rechtstreeks in een string wilt plaatsen: \u00A9

Wil je dus bovenstaande teken schrijven dan kan dat ook als volgt:

Console.WriteLine("\u00A9");

UNICODE-kunst tonen

Soms zou je multiline UNICODE-kunst (ook wel ASCII-art genoemd) willen tonen in je C# applicatie. Dit kan je eenvoudig oplossen door gebruik te maken van het @ teken voor een string.

Stel dat je een toffe titel of tekening bijvoorbeeld via ASCIIflow.com maakt.

Je kan het resultaat eenvoudig naar je klembord kopiëren en vervolgens in je C#-code integraal copy pasten als literal voor een string op voorwaarde dat je het laat voorafgaan door @" en uiteraard eindigt met ";.

Bijvoorbeeld:

string myname = @"
___________________   
\__    ___/\______ \  
  |    |    |    |  \ 
  |    |    |    `   \
  |____|   /_______  /
                   \/ ";
Console.WriteLine(myname);

Zowel de $-notatie (voor string interpolatie) als het @-teken kan je gecombineerd gebruiken bij een string:

Console.WriteLine($@"1/1={1+1}. \tGeen tab");

Dit geeft als output (\t wordt door het apenstaartje genegeerd):

1/1=2. \tGeen tab

In de vorige sectie legden we uit dat we tekst kunnen formateren als een geld bedrag m.b.v. Console.WriteLine($"{12.3456:C}");. Het probleem was dat het euro-teken als een ? op het scherm verscheen. Dit is omdat het euro-teken een nieuwe karakter is en dus binnen de UNICODE tabellen bestaat, maar niet binnen de klassieke ASCII-tabel. Willen we dit teken dus gebruiken dan moeten we de regel Console.OutputEncoding = System.Text.Encoding.UTF8; gebruiken:

Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine($"{12.3456:C}")

Environment bibliotheek

De Console bibliotheek is maar 1 van de vele bibliotheken die je in je C# programma's kunt gebruiken.

Een andere nuttige bibliotheek is de Environment-bibliotheek. Deze geeft je applicatie allerlei informatie over de computer waarop het programma op dat moment draait. Denk maar aan het werkgeheugen, gebruikersnaam van de huidige gebruiker, het aantal processoren enz.

De laatste zin in vorige alinea is belangrijk: als je jouw programma op een andere computer laat uitvoeren zal je mogelijk andere informatie verkrijgen.

Wil je een programma dus testen dat deze bibliotheek gebruikt, is het aangeraden om het op meerdere systemen met verschillende eigenschappen te testen.

Hier enkele voorbeelden hoe je deze bibliotheek kunt gebruiken (kijk zelf of er nog nuttige properties over je computer in staan):

bool is64bit = Environment.Is64BitOperatingSystem;
string pcname = Environment.MachineName;
int proccount = Environment.ProcessorCount;
string username = Environment.UserName;
long memory = Environment.WorkingSet; //zal ongeveer 10 Mb teruggeven.

Vervolgens zou je dan de inhoud van die variabelen kunnen gebruiken om bijvoorbeeld aan de gebruiker te tonen wat z'n machine naam is:

Console.WriteLine($"Je computernaam is {pcname}");
Console.WriteLine($"en dit programma gebruikt {memory} byte geheugen");
Console.WriteLine($"En je usernaam is {Environment.UserName}");

In de laatste lijn code tonen we dat je uiteraard ook rechtstreeks de variabelen uit Environment in je string interpolatie kunt gebruiken en dus niet met een tussenvariabele moet werken.

Je kan op docs.microsoft.com/dotnet/api/system.environment opzoeken welke nuttige zaken je nog met de bibliotheek kunt doen.

WorkingSet geeft terug hoeveel geheugen het programma van Windows toegewezen krijgt. Als je dus op 'run' klikt om je code te runnen dan zal dit programma geheugen krijgen en via WorkingSet kan het programma dus zelf zien hoeveel het krijgt. (Wat een vreemde lange zin.). Test maar eens wat er gebeurt als je programma maakt dat uit meer lijnen code bestaat.

Programma afsluiten

De Environment bibliotheek heeft ook een methode om je applicatie af te sluiten. Je doet dit met behulp van Environment.Exit(0); Het getal tussen haakjes mag je zelf bepalen en is de zogenaamde foutcode die je wilt meegeven bij het afsluiten (als je dan later via logbestanden wilt onderzoeken waarom het programma stopte dan kan je opzoeken welke foutcode er werd opgeworpen via de logs van je besturingssysteem).

Wanneer we met complexere programma's gaan leren werken zal het soms nuttig zijn om Environment.Exit(0); te gebruiken.

In deze fase ga je er nog niet veel aan hebben, daar alle code na de Exit nooit zal uitgevoerd worden.

Mogelijk was deze laatste sectie wat verwarrend. Dat is bewust gedaan...sort of. C# lineair aangeleerd krijgen kan vrij saai zijn in den beginnen. Daarom dat ik ervoor kies om hier en daar een bepaald onderwerp of bibliotheek aan te snijden, zodat je zin krijgt om dit onderwerp te gaan verkennen. Zoals al eerder verteld: C# en dan vooral alle bestaande .NET-bibliotheken is erg groot. Voor zover ik weet bestaat er niemand die iedere bibliotheek of klasse kent. Het is aan jou, als gepassioneerde programmeur, om zelf te verkennen en te onthouden welke bibliotheken je nuttig lijken en welke je links kan laten liggen (gegeven je huidige probleem of interesses).

Werken met data

Aah, Data, een geliefkoosd personage uit Star Trek. Maar daar gaan we het niet over hebben. Het wordt tijd dat we onze werkkledij aantrekken en ons echt vuil gaan maken.

De wereld draait op data, en dus ook de meeste applicaties die wij gaan schrijven. Echter, we hebben al gezien dat C# met verschillende datatypes werkt, dus wat gebeurt er als we data van twee verschillende datatypes willen combineren?! In Star Trek resulteerde dat 50% van de tijd in een aanval van de Borg, 20% van de tijd van de Klingons en in de overige 30% in een oersaaie aflevering (Star Wars for life!). Ahum, sorry. I got carried away. Laten we eens onderzoeken hoe we data van 'vorm' kunnen veranderen.

May the force be with you! Euh, ik bedoel: Make it so!

Wanneer je de waarde van een variabele wilt toekennen aan een variabele van een ander type mag dit niet zomaar. Volgende code zal bijvoorbeeld een dikke error geven:

int leeftijd = 4.3;

Een zogenaamde 'impliciete casting' error.

Je kan geen appelen in peren veranderen zonder magie: in het geval van C# zal je moeten converteren of casten.

Dit kan op 3 manieren:

  • Via casting: de (klassieke) manier die ook werkt in veel andere programmeertalen.
  • Via de Convert. bibliotheek van .NET.
  • Via parsing : Deze manier is enkel bruikbaar om strings om te zetten naar andere datatypes.

Casting

Het is onmogelijk om een kommagetal aan een geheel getal toe te wijzen zonder dat er informatie verloren zal gaan. Toch willen we dit soms doen. Van zodra we een variabele van het ene type willen toekennen aan een variabele van een ander type en er dataverlies zal plaatsvinden dan moeten we aan casting doen.

Wat is casting

Casting heb je nodig om een variabele van een bepaald type voor een ander type te laten doorgaan. Stel dat je een complexe berekening hebt waar je werkt met verschillende types (bijvoorbeeld int, double en float). Door te casten voorkom je dat je vreemde resultaten krijgt. Je gaat namelijk bepaalde types even als andere types gebruiken.

Het is belangrijk in te zien dat het casten van een variabele naar een ander type enkel een gevolg heeft tijdens het uitwerken van de expressie waarbinnen je werkt. De variabele in het geheugen zal voor eeuwig en altijd het type zijn waarin het origineel gedeclareerd werd.

Je dient enkel aan casting te doen wanneer je aan narrowing doet. Bij narrowing gaan we een datatype omzetten naar een ander datatype dat een verlies aan data met zich zal meebrengen.

Casting duid je aan door voor de variabele of literal het datatype tussen haakjes te plaatsen naar wat het omgezet moet worden:

int mijngetal = (int)3.5;

of

double kommagetal = 13.8;
int kommaNietWelkom = (int)kommagetal;

Hierbij dien je aan de compiler te zeggen: "Volgende variabele die van het type double is, moet aan deze variabele van het type int toegekend worden. Ik besef dat hierbij data verloren kan gaan (namelijk het deel na de komma), maar zet de variabele toch maar om naar het nieuwe type, ik draag alle verantwoordelijkheid voor het verlies".

Het is als het ware een soort Amerikaanse reflex om te voorkomen dat de compiler later door ons kan aangeklaagd worden omdat hij uiterst belangrijke data heeft doen verloren gaan tijdens de omzetting. Via casting geven we aan dat we de compiler niet zullen aanklagen.

Narrowing

Casting doe je wanneer je een variabele wilt toekennen aan een andere variabele van een ander type dat daar eigenlijk niet inpast zonder dataverlies. We moeten dan aan narrowing doen, letterlijk het versmallen van de data.

Bekijk eens het volgende voorbeeld:

double hoofdMeting;
int secundaireMeting;
hoofdMeting = 20.4;
secundaireMeting = hoofdMeting;

Dit zal niet gaan. Je probeert namelijk een waarde van het type double in een variabele van het type int te steken. Dat gaat enkel als je informatie weggooit (namelijk het gedeelte na de komma). Je moet aan narrowing doen.

Dit gaat enkel als je expliciet aan de compiler zegt: het is goed, je mag informatie weggooien, ik begrijp dat en zal er rekening mee houden. Dit proces van narrowing noemen we casting.

En je lost dit op door voor de variabele die tijdelijk dienst moet doen als een ander type, het nieuwe type, tussen ronde haakjes te typen, als volgt:

double hoofdMeting;
int secundaireMeting; 
hoofdMeting = 20.4;
secundaireMeting = (int)hoofdMeting;

Het resultaat in secundaireMeting zal 20 zijn (alles na de komma wordt weggegooid bij casting van een double naar een int ).

Merk op dat hoofdMeting nooit van datatype is veranderd; enkel de inhoud ervan (20.4) werd eruit gehaald, omgezet ("gecast") naar 20 en dan aan secundaireMeting toegewezen dat enkel int aanvaardt.

Narrowing in de praktijk

Stel dat tempGisteren en tempVandaag van het type int zijn, maar dat we nu de gemiddelde temperatuur willen weten. De formule voor gemiddelde temperatuur over 2 dagen is:

int tempGemiddeld = (tempGisteren + tempVandaag)/2;

Test dit eens met de waarden 20 en 25. Wat zou je verwachten als resultaat? Inderdaad: 22,5 (omdat (20+25)/2 = 22.5) . Nochtans krijg je 22 op scherm te zien en zal de variabele tempGemiddeld ook effectief de waarde 22 bewaren en niet 22.5.

Het probleem is dat het gemiddelde van 2 getallen niet noodzakelijk een geheel getal is. Echter, omdat de expressie enkel integers bevat (tempGisteren, tempVandaag en de literal 2) zal ook het resultaat een int zijn. In dit geval wordt alles na de komma gewoon weggegooid, vandaar de uitkomst. Dit is narrowing.

Hoe krijgen we de correctere uitslag te zien? Eens testen wat er gebeurt als we tempGemiddeld als double declareren:

double tempGemiddeld = (tempGisteren + tempVandaag) / 2;

Als we dit testen zal nog steeds de waarde 22.0 aan tempGemiddeld toegewezen worden. De expressie rechts van de toekenning bevat nog steeds enkel integers en de computer zal dus ook de berekening en het resultaat als integer beschouwen, ongeacht dat deze in een double moet gezet worden.

We moeten dus ook de rechterkant van de toekenning als double beschouwen. We doen dit, zoals eerder vermeld, door middel van casting, als volgt:

double tempGemiddeld = ((double)tempGisteren + (double)tempVandaag) / 2;

Nu zal tempGemiddeld wel de waarde 22.5 bevatten.

Er zijn ook andere oplossingen die het gewenste resultaat geven, namelijk:

(tempGisteren + tempVandaag)/2.0;
((double)(tempGisteren + tempVandaag))/2;
((double)tempGisteren + tempVandaag)/2;
(tempGisteren + (double)tempVandaag)/2;

Let echter op dat niet alle oplossingen bij dit soort oefeningen steeds dezelfde resultaten geeft. Goed testen is de boodschap en nadenken over de volgorde van berekeningen en wat het datatype van ieder tussenresultaat zal zijn. Laten we dat eens analyseren bij voorgaande 4 voorbeelden:

  1. Eerst tellen we twee integers op, wat dus een nieuwe integer geeft, die we vervolgens delen door een double, wat dus een double als resultaat geeft.
  2. Eerst tellen we twee integers op, wat weer een integer geeft, vervolgens zetten we dit resultaat om naar een double en delen dit door een int wat dus een double geeft.
  3. Eerst zetten tempGisteren om naar een double. Vervolgens tellen we een double met een int op, wat een double als tussenresultaat geeft. Dit delen we dan door een int wat een double finaal geeft.
  4. Hetzelfde als de vorige stap, maar nu zetten we eerst tempVandaag om naar een double.

Merk op dat er een subtiel verschil is tussen volgende 2 lijnen code:

(double)(tempGisteren + tempVandaag) / 2; //geeft 22.5
(double)((tempGisteren + tempVandaag) / 2); //geeft 22

In het eerste zullen we het resultaat van de som naar double omzetten. In het tweede, door de volgorde van berekeningen door de haakjes, zullen we de casting pas doen na de deling en zal dus 22 in plaats van 22.5 als resultaat geven.

Widening

Casting is niet nodig als je aan widening doet: een kleiner type in een groter type steken (met groter/kleiner wordt de geheugengrootte van het datatype bedoeld), als volgt:

int hoofdMeting;
double secundaireMeting;
hoofdMeting = 20;
secundaireMeting = hoofdMeting; //secundaireMeting krijgt de waarde 20.0

Deze code zal zonder problemen werken: secundaireMeting zal de waarde 20.0 bevatten. De inhoud van hoofdMeting wordt verbreed naar een double, eenvoudigweg door er een kommagetal van te maken.

Er gaat geen inhoud verloren echter. Je hoeft dus niet expliciet de casting-notatie zoals (double)hoofdMeting te doen, de computer ziet zelf dat hij de inhoud van hoofdMeting zonder dataverlies kan toekennen aan secundaireMeting en is dus niet bang dat hij later aangeklaagd zal worden.

Merk op dat je perfect casting hier mag gebruiken, maar daar de conversie impliciet zonder problemen kan plaatsvinden hoeft dit dus niet. Deze code is echter even juist (en soms een veilige gewoonte om te doen, better safe than sorry):

secundaireMeting = (double) hoofdMeting;

Conversie

Casting is de 'oldschool' manier van data omzetten die vooral zeer nuttig is daar deze compacte code geeft en ook werkt in andere C#-gerelateerde programmeertalen zoals C, C++ en Java.

Echter, .NET heeft ook ingebouwde conversie-methoden die je kunnen helpen om data van het ene type naar het andere te brengen. Het nadeel is dat ze iets meer typwerk (en dus meer code) vereisen dan bij casting.

Al deze methoden zitten binnen de Convert-bibliotheek van .NET.

Het gebruik hiervan is zeer eenvoudig. Enkele voorbeelden:

int getal = Convert.ToInt32(3.2); //double to int
double anderGetal = Convert.ToDouble(5); //int to double
bool isWaar = Convert.ToBoolean(1); //int to bool
int leeftijd = Convert.ToInt32("19"); //string to int
int andereLeeftijd = Convert.ToInt32(anderGetal); //double to int

Je plaatst tussen de ronde haakjes de variabele of literal die je wenst te converteren naar een ander type. Merk op dat naar een int converteren met .ToInt32() moet gebeuren. Om naar een short te converteren is dit met behulp van .ToInt16().

Convert.ToBoolean verdient extra aandacht: Wanneer je een getal, eender welk, aan deze methode meegeeft zal deze altijd naar True geconverteerd worden.

Enkel indien je 0 (als int) of 0.0 (als double) ingeeft, dan krijg je False. In quasi alle andere gevallen krijg je True.

De conversie zal zelf zo goed mogelijk de data omzetten en dus indien nodig widening of narrowing toepassen. Zeker bij het omzetten van een string naar een ander type kijk je best steeds de documentatie na om te weten wat er intern juist zal gebeuren.

Je kan alle conversie-mogelijkheden bekijken op msdn.microsoft.com/system.convert.

Parsing

Naast conversie en casting bestaat er ook nog parsing.

Parsing is anders dan conversie en casting. Parsing zal je in dit boek enkel nodig hebben om tekst(string) naar getallen om te zetten. Echter, intern zal bijna altijd Convert.To... gebruikt worden indien je een Parse methode aanroept.

Ieder ingebouwd datatype in C# heeft een .Parse() methode die je kan aanroepen om strings om te zetten naar het gewenste type.

Voorbeeld van parsing:

int numVal = Int32.Parse("-105");
Console.WriteLine(numVal);

Gebruik parsing enkel wanneer je:

  1. een string hebt waarvan je weet dat deze altijd van een specifieke vorm zal zijn die omgezet kan worden naar een ander datatype, bv. een int, dan kan je Int32.Parse() gebruiken.
  2. input van de gebruiker vraagt (bv. via Console.ReadLine) en niet 100% zeker bent dat deze een getal zal bevatten, gebruik dan Int32.TryParse() (meer info in de appendix).

Invoer van de gebruiker verwerken

En applicatie die geen input van de gebruiker vergt kan even goed een screensaver zijn. We hebben reeds gezien hoe we met Console.ReadLine() de gebruiker tekst kunnen laten invoeren en die we dan vervolgens kunnen verwerken om bijvoorbeeld z'n naam op het scherm te tonen:

Deze vereenvoudiging van de meeste van onze applicaties blijft gelden.

De uitdaging met ReadLine is dat deze ALTIJD een string teruggeeft:

string userInput = Console.ReadLine();

Dit mag dus niet: int userInput = Console.ReadLine(); en zal in een conversion error resulteren.

Willen we dat de gebruiker een getal invoert, bijvoorbeeld zijn of haar leeftijd, dan zal dit nog steeds als string moeten worden opvangen en zullen we dit vervolgens moeten CONVERTEREN .

Invoer van de gebruiker verwerken (dat een andere type dan string moet zijn) zal dus uit 3 stappen bestaan:

  1. Input uitlezen met Console.ReadLine().
  2. Input bewaren in een string variabele.
  3. De variabele parsen met .Parse() bibliotheek naar het gewenste type.

Stel dat we aan de gebruiker z'n gewicht vragen, dan moeten we dus doen:

Console.WriteLine("Geef je gewicht:");
string inputGewicht = Console.ReadLine();
double gewicht = double.Parse(inputGewicht);

Voorgaande code kan nog 1 lijntje sneller door ReadLine ogenblikkelijk als invoer aan de Parse-methode te geven:

Console.WriteLine("Geef je gewicht:");
double gewicht = double.Parse(Console.ReadLine());

Foutloze input

Voorgaande code veronderstelt dat de gebruiker géén fouten invoert. De conversie zal namelijk mislukken indien de gebruiker bijvoorbeeld IKWEEG10KG invoert in plaats van 10,3.

In de komende hoofdstukken mag je er altijd van uitgaan dat de gebruiker foutloze input geeft.

De invoer van kommagetallen door de gebruiker is afhankelijk van de landinstellingen van je besturingssysteem. Staat deze in Belgisch/Nederlands dan moet je kommagetallen met een KOMMA(,) invoeren (dus 9,81), staat deze in het Engels dan moet je een PUNT(.) gebruiken (9.81).

In je C# code moet je kommagetal literals altijd met een punt schrijven. Dit is onafhankelijk van je taalinstellingen.

En wat als je toch foute invoer wilt opvangen? Zoals eerder aangegeven: dan is TryParse je vriend (zie appendix).

Berekeningen met System.Math

Een groot deel van je leven als ontwikkelaar zal bestaan uit het bewerken van variabelen in code. Meestal zullen die bewerkingen voorafgaan van berekeningen. De System.Math bibliotheek zal ons hier bij kunnen helpen. Zoals de naam al doet vermoeden staat deze bibliotheek voor Mathematics: wiskunde!

De Math-bibliotheek

De Math-bibliotheek bevat methoden voor een groot aantal typische wiskundige bewerkingen (sinus, cosinus, vierkantswortel, macht, afronden, enz.) en kan je dus helpen om leesbaardere en kortere expressies te schrijven.

Stel dat je de derde macht van een variabele getal wenst te berekenen. Zonder de Math-bibliotheek zou dat er zo uitzien:

double result = getal * getal * getal; //SLECHTE MANIER

Dit valt nog mee, maar wat als je 3 tot de zevende macht moest berekenen? Kortom, laten we eens kijken hoe Math ons kan helpen. Met de Math-bibliotheek kunnen we gebruik maken van de Pow (Power) methode:

double result = Math.Pow(getal, 3);

Deze methode vereist twee parameters:

  • De eerste is het grondtal
  • De tweede is de exponent ("tot de hoeveelste macht")

De Math bibliotheek ontdekken

Als je in Visual Studio Math schrijft in je code, gevolgd door een punt (.) krijg je alles te zien wat de Math-bibliotheek kan doen:

De sterretjes geven de meestgebruikte methoden in deze bibliotheek aan. Vervolgens verschijnen alle overige methoden, properties, enz. alfabetisch.

Een kubusje voor een naam wil zeggen dat het om een Methode gaat (zoals Console.ReadLine()). Een vierkantje met twee streepjes in zijn constanten (zoals Pi en het getal van Euler (e)).

Methoden gebruiken

De meeste methoden zijn zeer makkelijk in gebruik en werken bijna allemaal op een soortgelijk manier. Meestal moet je 1 of meerdere parameters tussen de haken meegeven en het resultaat moet je altijd in een nieuwe variabele opvangen.

Enkele voorbeelden:

double sineHoekA = Math.Sin(345); //IN RADIALEN!
double derdeMachtVan20 = Math.Pow(20, 3);
double complexer = 3 + derdeMachtVan20 * Math.Round(sineHoekA);

Twijfel je over de werking van een methode, gebruik dan de help als volgt:

  1. Schrijf de Methode zonder parameters. Bijvoorbeeld Math.Pow() (je mag de error negeren).
  2. Plaats je cursor op Pow.
  3. Druk op F1 op je toetsenbord.
  4. Je krijgt nu de help-files te zien van deze methode.
  5. In hoofdstuk 7 leggen we uit hoe je die help-files moet lezen.

PI

Ook het getal Pi (3.141...) is beschikbaar in de Math-bibliotheek. Het witte icoontje voor PI bij Intellisense toont aan dat het hier om een field gaat: een eenvoudige variabele met een specifieke waarde. In dit geval gaat het zelfs om een const field, met de waarde van Pi van het type double.

public const double PI;

Je kan deze als volgt gebruiken in berekeningen zoals

double straal = 5.5;
double omtrek = Math.PI * 2 * straal;

Bereik in code weten

Het bereik van datatypes ligt weliswaar vast (zie hoofdstuk 2). Maar het is nuttig om weten dat deze ook in de compiler gekend is. Ieder datatype heeft een aantal ingebouwde zaken die je kan gebruiken om onder andere de maximum en minimum-waarde van een datatype te gebruiken. Volgende voorbeeld toont hoe dit kan:

string startZin = "Het bereik van het type double is:";
Console.WriteLine($"{startZin} {double.MinValue} tot {double.MaxValue}.");

Dit geeft op het scherm:

Het bereik van het type double is: -1.7976931348623157E+308 tot 1.7976931348623157E+308.

Je kan met andere woorden met int.MaxValue en int.MinValue het minimum- en maximumbereik van het type int verkrijgen. Wil je dit van een double, dan gebruik je double.MaxValue enz. Zelfs oneindig is beschikbaar bij kommagetallen als .PositiveInfinity en .NegativeInfinity.

Random getallen genereren

Willekeurige (random) getallen genereren in je code kan leuk zijn om de gebruiker een interactievere ervaring te geven. Beeld je in dat je monsters steeds dezelfde weg zouden bewandelen of dat er steeds op hetzelfde tijdstip een orkaan op je stad neerdwaalt. SAAI!

Random generator

De Random-bibliotheek (eigenlijk klasse, wat we in hoofdstuk 9 zullen toelichten) laat je toe om willekeurige gehele en komma-getallen te genereren. Je moet hiervoor twee zaken doen:

  1. Maak eenmalig een Random-generator object aan.
  2. Roep de Next methode aan op dit object telkens je een nieuw willekeurig getal nodig hebt.

Als volgt:

Random randomGenerator = new Random();
int mijnLeeftijd = randomGenerator.Next();

De eerste stap dien je maar 1 keer te doen. De naam die je het generatorobject geeft (hier randomGenerator) mag je kiezen, dit is een variabele en moet dus aan de identifier regels voldoen.

Vanaf nu kan je telkens aan het generatorobject een nieuw getal vragen m.b.v. de Next-methode.

Volgende code toont bijvoorbeeld 3 random getallen op het scherm:

Random myGen = new Random();

int getal1 = myGen.Next();
int getal2 = myGen.Next();
int getal3 = myGen.Next();
Console.WriteLine(getal1);
Console.WriteLine(getal2);
Console.WriteLine(getal3);

Uiteraard mag dit ook

Console.WriteLine(myGen.Next());
Console.WriteLine($"Nog een getal: {myGen.Next()}");

De new Random() code is iets wat in hoofdstuk 9 en verder volledig uit de doeken zal gedaan worden. Lig er dus nog niet van wakker.

Next mogelijkheden

Je kan de Next methode ook 2 parameters meegeven, namelijk de grenzen waarbinnen het getal moet gegenereerd worden. De tweede parameter is exclusief dit getal zelf. Wil je dus een willekeurig geheel getal tot en met 10 dan schrijf je 11, niet 10, als tweede parameter:

Enkele voorbeelden:

Random someGenerator = new Random();
int a = someGenerator.Next(0,11); //getal tussen 0 tot en met 10
int b = someGenerator.Next(55,100); //getal tussen 55 tot en met 99
int c = someGenerator.Next(0,b); //getal tussen 0 tot en met (b-1)

Genereer kommagetallen met NextDouble

Met de NextDouble methode kan je kommagetallen genereren tussen 0.0 en 1.0 (1.0 zal niet gegenereerd worden).

Wil je een groter kommagetal dan zal je dit gegenereerde getal moeten vermenigvuldigen naar de range die je nodig hebt. Stel dat je een getal tussen 0.0 en 10.0 nodig hebt, dan schrijf je:

Random myRan = new Random();
double randomGetal = myRan.NextDouble() * 10.0;

Je vermenigvuldigt eenvoudigweg je gegenereerde getal met het bereik dat je wenst (10.0 in dit geval)

En wat als je een kommagetal tussen 5.0 en 12.5 wenst? Als volgt:

Random myRan = new Random();
double randomGetal = 5.0 + (myRan.NextDouble() * 7.5);

Visualisatie van hoe je het bereik van Random kan aanpassen (rechte is niet op schaal)

Je bereik is 7.5, namelijk 12.5 - 5.0 en vermenigvuldig je het resultaat van je generator hiermee. Vervolgens verschuif je dat bereik naar 5 en verder door er 5 bij op te tellen. Merk op dat we de volgorde van berekeningen sturen met onze ronde haakjes.

"Help! Ik krijg steeds dezelfde random getallen? Wat nu?"

Wel wel, wie we hier hebben. Werkt je Random generator niet naar behoren? Wil je het ding in de vuilbak gooien omdat het niet zo willekeurig lijkt te werken als je hoopte? Gelukkig ben ik er! Zet je helm dus op en luister.

Wanneer je twee Random objecten aanmaakt op quasi hetzelfde tijdstip in je code, dan zullen deze twee generators ook dezelfde getallen genereren:

Random a = new Random();
Random b = new Random();
Console.WriteLine(a.Next());
Console.WriteLine(b.Next());

De Random bibliotheek gebruikt namelijk de tijd als een soort "willekeurig" startpunt (de tijd is de zogenaamde seed). Het is namelijk een pseudo-willekeurige getal generator.

Dit is de reden waarom je in je code steeds maar 1 Random generator mag aanmaken! Er zijn weinig redenen om er meerdere aan te maken. Bovenstaande code is dus niet aan te raden.

Wil je toch dezelfde willekeurige reeks getallen na elkaar genereren telkens je je programma opstart (bijvoorbeeld om je code te testen met steeds dezelfde reeks getallen) dan kan je bij het aanmaken van je generator ook een parameter meegeven die als seed zal werken.

In het volgende voorbeeld zal generator a steeds dezelfde reeks willekeurige getallen genereren, telkens je je programma uitvoert. De waarde die je meegeeft moet uiteraard niet 666 zijn. Ieder getal dat je meegeeft is een andere seed:

Random a = new Random(666);

Debuggen

"Joepie!! M'n code werkt!" Je ontkurkt de champagne/bier/melk/frisdrank/water, doet een Fortnite danske en laat je programma door duizenden, neen miljoenen gebruikers ontdekken. Nog geen uur later staat er een meute met hooivorken en toortsen voor je kantoor.

Helaas, er zaten nog "een paar bugs" in je code...

Tijd dus om je debugger boven te halen en die (logische) fouten uit je code te halen.

Logische fouten vs C# fouten

Code die compileert is enkel code die foutloos geschreven is volgens de C# afspraken qua grammatica en syntax. De code zal met andere woorden gecompileerd worden, maar wat er daarna gebeurd is volledig afhankelijk van wat juist de betekenis is van wat je hebt geschreven.

Volgend algoritme bijvoorbeeld is perfecte Nederlandstalige code en zal dus door een fictieve compiler kunnen gecompileerd worden. Het vervolgens uitvoeren is echter niet aan te raden (natrium en water samen geeft een stevige exotherme reactie):

Neem natrium
Neem water
Voeg beide samen

Dit is dus een logische fout, oftewel bug (in dit geval een BOEM!!!!).

Debuggen met Visual Studio

Standaard wanneer je je code uitvoert met de grote groene playknop start jouw programma in zogenaamde "debug modus". Dit laat je toe om je code ten allen tijde te onderbreken en naar de huidige staat van je programma te kijken. Je kan dan bijvoorbeeld onderzoeken wat de waarden van bepaalde variabelen zijn op dat moment en of die wel correct zijn. Dit is bughunting en zal je héél vaak doen in je programmeer-carrière.

Om dit te doen moet je een breakpoint (of meerdere) in je code plaatsen. Een breakpoint zet je aan een lijn code en wanneer je programma dan aan deze lijn komt tijdens de uitvoer zal de debugger alles pauzeren.

Een breakpoint plaats je door op het grijze gedeelte links van de lijn code te klikken. Als alles goed gaat verschijnt er dan een grote rode "breakpointbol":

De rode bol! Merk op dat je er ook meer dan één mag plaatsen. Iedere bol stelt een breakpoint voor waar de uitvoer zal stoppen als de code hier aankomt (wat later niet altijd het geval zal zijn wanneer we met loops en methoden leren programmeren).

In bovenstaande figuur plaatsen we een breakpoint aan lijn 11. De code uitvoer zal dus nog wel lijn 10 uitvoeren, maar niet lijn 11.

Als je nu je project uitvoert zal de code pauzeren aan die lijn en zal VS in "debug modus" openspringen wat er vervolgens als volgt uit ziet:

Als je niet alle debugknoppen ziet kan je deze ook aanroepen via het "Debug" in het menu bovenaan.

In dit "nieuwe" scherm zijn er momenteel 2 belangrijke delen:

  • Onderaan zie je de autos en locals . In deze tabs kan je de waarden van iedere variabele in je huidige code zien op het moment van pauzeren. Ideaal om te onderzoeken waarom een bepaalde berekening of expressie niet doet wat ze moet doen.
  • Bovenaan zijn enkele debug-knoppen verschenen. Deze lichten we in de volgende sectie toe.
  • Voorts kan je in debug-modus met je muis over eender welke variabele of expressie hoveren om het resultaat van dat element te bekijken:

Kan je trouwens verklaren waarom deze deling 0 geeft en niet 1.667?

Door je code steppen

Wanneer je gepauzeerd bent kan je de nieuw verschenen debug-knoppen bovenaan VS gebruiken om het verdere verloop te bepalen:

Debug knoppen.

We lichten hier de knoppen toe die je zeker zal nodig hebben:

  • De continue knop is logisch: hier op klikken zal je programma terug voortzetten vanaf het breakpoint waar je gepauzeerd bent. Het zal vervolgens verder gaan tot het weer een breakpoint bereikt of wanneer het einde van het programma wordt bereikt.
  • De step in knop zullen we in hoofdstuk 7 toelichten daar deze knop je toelaat om in een methode te springen.
  • De rode stop knop gebruik je indien je niet verder wilt debuggen en ogenblikkelijk terug je code wilt aanpassen.
  • De step-over knop (het gebogen pijltje) is een belangrijke knop. Deze zal je code één lijn code verder uitvoeren en dan weer pauzeren. Het laat je dus toe om letterlijk doorheen je code te stappen. Je kan dit doen om de flow van je programma te bekijken (zie volgende hoofdstukken) en om te zien hoe bepaalde variabelen evolueren doorheen je code.

Pfft. Debuggen. Waarom moet ik me daar nu mee bezig houden?

Even je oren open zetten aub, ik ga iets roepen:"Debugging is een ESSENTIËLE SKILL!!!". Ik laat mijn metselaars ook geen huizen bouwen zonder dat ze ooit een truweel hebben vastgepakt. Een programmeur die niet kan debuggen...is als een vis die niet kan zwemmen!

Zorg dus dat je vlot breakpoints kunt plaatsen om zo tijdens de uitvoer te pauzeren om de inhoud van je variabelen te bekijken (via het watch-venster). Gebruik vervolgens de "step"-buttons om door je code te 'stappen', lijn per lijn.

Is that all?! NEEN! Een goede programmeur zal telkens eerst voorspellen wat er gaat gebeuren: welke waarden zullen de variabelen hebben als ik naar de volgende lijn ga? Wat gaat er op het scherm komen? enz. Als je dan vervolgens naar de volgende lijn of breakpoint gaat en er gebeuren dingen die je niet voorspeld had, dan is de kans groot dat je een bug hebt gevonden.

De grootste fout die je kunt doen is gewoon door je code te "steppen" en hopen dat de bug magisch zal tevoorschijn komen. Neen, zo werkt het dus niet. Je moet actief mee denken of dat je programma effectief op een logische manier, zoals jij het voor ogen had, werkt.

Dit geldt trouwens ook wanneer je niet aan het debuggen bent, maar gewoon je programma uitvoert om het te testen. Eigenlijk ben je dan ook aan het debuggen. Ook dan moet je voorspellen wat het eindresultaat zal zijn en of dit overeen komt met wat er op het scherm gebeurt. Wees kritisch!

Beslissingen

Nu we de elementaire zaken van C# en Visual Studio kennen is het tijd om onze programma's wat interessanter te maken. De ontwikkelde programma's tot nog toe waren steevast lineair van opbouw, ze werden lijn per lijn uitgevoerd, van start tot einde, zonder de mogelijkheid om de program flow van het programma aan te passen. Het programma doorliep de lijnen code braaf na elkaar en wanneer deze aan het einde kwam sloot het programma zich af.

Onze programma's waren met andere woorden niet meer dan een eenvoudige lijst van opdrachten. Je kan het vergelijken met een lijst die je over hoe je een brood moet kopen:

Neem geld uit spaarpot
Wandel naar de bakker om de hoek
Vraag om een brood
Krijg het brood
Betaal het geld aan de bakker
Keer huiswaarts
Smullen maar

Alhoewel dit algoritme redelijk duidelijk is en goed zal werken, zal de realiteit echter zelden zo rechtlijnig zijn. Van zodra 1 van de stappen faalt (bijvoorbeeld omdat de bakker toe is) zal ook de rest van het algoritme niet meer werken.

Een beter algoritme, dat foutgevoeliger én interactief voor de eindgebruiker is, zal afhankelijk van de omstandigheden (bakker gesloten, geen geld meer, enz.) mogelijke andere stappen ondernemen. Het programma zal beslissingen maken gebaseerd op keuzes doorheen het programma:

Neem geld uit spaarpot
Als het geld op is stop dan hier, anders: ga verder
Wandel naar de bakker om de hoek
Als de bakker toe is stop dan hier, anders: ga verder
Vraag om een brood
Krijg het brood
Betaal het geld aan de bakker
Als je honger hebt, sla dan volgende lijn over, anders: ga verder
Keer huiswaarts
Smullen maar

Relationele en logische operators

Om beslissingen te kunnen nemen in C# hebben we een nieuw soort operators nodig. Operators waarmee we kunnen testen of iets waar of niet waar is. C# kan dan bij waar de ene actie doen, en bij niet waar iets anders (of een bepaalde stap overslaan).

Dit doen we met de zogenaamde relationele operators en logische operators.

Booleaanse expressies

Een booleaanse expressie is een stuk C# code dat een bool als resultaat zal geven. De logische en relationele operators die we hierna bespreken zijn operators die een bool teruggeven. Ze zijn zogenaamde test-operators: ze testen of iets waar is of niet.

Relationele operators

Relationele operators zijn het hart van booleaanse expressies. En guess what, je kent die al van uit het lager onderwijs. Enkel de "gelijk aan" ziet er iets anders uit dan we gewoon zijn:

OperatorBetekenis
>groter dan
<kleiner dan
==gelijk aan
!=niet gelijk aan
<=kleiner dan of gelijk aan
>=groter dan of gelijk aan

Deze operators hebben steeds twee operanden nodig en geven een bool als resultaat terug. Beide operanden links en rechts van de operator moeten van hetzelfde datatype zijn (je kan geen appelen met peren vergelijken).

Daar dit operators zijn kan je deze dus gebruiken in eender welke expressie. Het resultaat van de expressie 12 > 6 zal true als resultaat hebben daar 12 inderdaad groter is dan 6. Eenvoudig toch.

We weten al dat je het resultaat van een expressie altijd in een variabele kunt bewaren. Ook bij het gebruik van relationele operators kan dat dus:

bool isKleiner = 65 > 67 ;
Console.WriteLine(isKleiner);

Er zal false als output op het scherm verschijnen.

Er is een groot verschil tussen de = operator en de == operator. De eerste is de toekenningsoperator en zal de rechtse operand aan de linkse operand toewijzen. De tweede zal de linkse met de rechtse operand op gelijkheid vergelijken en een bool teruggeven.

Logische operators

Vaak wil je meer complexe keuzes maken ("ga verder indien ik honger heb EN genoeg geld bij heb"). Dit doen we met de zogenaamde logische operators. Er zijn 3 operators die je hiervoor kunt gebruiken: de EN-, OF- en NIET-operators (and, or, not). Deze ken je mogelijk ook nog van de booleaanse algebra:

  • && (En) : Geeft enkel true als beide operanden true zijn
  • || (Of) : Geeft true indien minstens 1 operand true is
  • ! (Niet) : Inverteert de waarde van de expressie (true wordt false en omgekeerd)

De logische operators geven ook steeds een bool terug maar verwachten enkel operanden van het type bool. Als je dus schrijft true||false ("true OF false") zal het resultaat true zijn.

Aangezien onze relationele operators bool als resultaat geven, kunnen we dus de uitvoer van deze operators gebruiken als operanden voor de logische operators. We gebruiken hierbij haakjes om zeker de volgorde juist te krijgen:

bool result = (4 < 6) && ("ja" == "nee");

In voorgaande code zal het achterste deel false teruggeven ("ja is niet gelijk aan nee"), het eerste deel zal true geven (4 is kleiner dan 6). De &&-expressie wordt dan: true && false wat false zal geven.

Je kan de niet-operator voor een expressie zetten om het resultaat hiervan om te draaien. Bijvoorbeeld:

bool result = !(0==2) //zal true geven in result

Test jezelf

Wat zal de uitkomst zijn van volgende expressies?

  • 3>2
  • 4!=4
  • 4<5 && 4<3
  • "a"=="A" || 4>=3
  • (3==3 && 2<1) || 5!=4
  • !(4<=3)
  • true || false
  • !true && false

Bekijk zeker de tabel op docs.microsoft.com/en-us/dotnet/csharp/language-reference/operators waar de volgorde van alle operators wordt beschrijven. Samengevat is dit de volgorde van prioriteit, waarbij we met haakjes even operators groeperen indien deze dezelfde volgorde hebben: ! , (<, >, <= en >=) , (== en !=), &&, ||.

If

De if (als) uitdrukking is één van de meest elementaire uitdrukkingen in een programmeertaal en laat ons toe 'vertakkingen' in onze programmaflow in te bouwen. Ze laat toe om "als dit waar is doe dan dat"-beslissingen te maken.

De syntax is als volgt:

if (booleaanse expressie) 
{
    //deze code wordt uitgevoerd indien
    //de booleaanse expressie true is
}

Enkel indien de booleaanse expressie waar is, en dus true als resultaat heeft, zal de code binnen de accolades van het if-blok uitgevoerd worden. Indien de expressie niet waar is (false) dan wordt het blok overgeslagen en gaat het programma verder met de code eronder.

Een voorbeeld:

int  nummer = 3;
if (  nummer < 5 )
{
    Console.WriteLine ("Ja");
}
Console.WriteLine("Nee");

De uitvoer van dit programma zal zijn:

JaNee

Indien nummer groter of gelijk aan 5 was dan zou er enkel Nee op het scherm zijn verschenen. De lijn Console.WriteLine("Nee"); zal sowieso uitgevoerd worden zoals je ook kan zien aan de flowchart er naast.

code2flow.com is een handige tool om je reeds geschreven C# code om te zetten naar een flowchart. Het kan je helpen om vreemde bugs te ontdekken. Uiteraard is de eerste stap debuggen en door je code steppen: vaak zal je ogenblikkelijk zien waar je code verkeerd loopt.

De bijhorende flowchart van het vorige voorbeeld.

if met een block

Het is aangeraden om steeds na de if-expressie met accolades te werken. Dit zorgt ervoor dat alle code tussen het block (de accolades) zal uitgevoerd worden indien de booleaanse expressie waar was. Gebruik je geen accolades dan zal enkel de eerste lijn na de if uitgevoerd worden bij true.

Een voorbeeld:

if ( nummer < 5 )
{
    Console.WriteLine ("Ja");
    Console.WriteLine ("Nee");
}

Accolades zijn duidelijk belangrijk.

Veelgemaakte fouten Voorman hier! Je hebt me gemist. Ik merk het. Het ging goed de laatste tijd. Maar nu wordt het tijd dat ik je weer even wakker schud want de code die je nu gaat bouwen kan érg vreemde gedragingen krijgen als je niet goed oplet. Luister daarom even naar deze lijst van veel gemaakte fouten wanneer je met if begint te werken:

Appelen en peren vergelijken De types in je booleaanse expressie moeten steeds vergelijkbaar zijn. Volgende code zal niet compileren:

if( "4" > 3)

daar we hier een string met een int vergelijken.

Accolades vergeten Accolades vergeten plaatsen om een codeblock aan te duiden, maar je code toch zodanig uitlijnen (met tabs of spaties) dat het lijkt of je een heel codeblock hebt. Het gevolg zal zijn dat enkel de eerste lijn na de if zal uitgevoerd worden indien true. Gebruiken we de if met block van daarnet maar zonder accolades dan zal de laatste lijn altijd uitgevoerd worden ongeacht de if:

if ( nummer < 5 )
    Console.WriteLine ( "Ja");
    Console.WriteLine ( "Nee"); //nee verschijnt altijd op scherm

Merk ook op dat je code anders uitlijnen géén invloed heeft op de uitvoer (wat bijvoorbeeld wel zo is bij de programmeertaal Python).

Een puntkomma plaatsen na de booleaanse expressie.

Dit zal ervoor zorgen dat er eigenlijk geen codeblock bij de if hoort en je dus een nietszeggende if hebt geschreven. De code na het puntkomma zal uitgevoerd worden ongeacht de if:

if ( nummer < 5 );
    Console.WriteLine ( "Ja");
    Console.WriteLine ( "Nee");

De uitvoer van voorgaande zal altijd de volgende zijn:

Ja
Nee

Gebruik relationele en logische operators

We kunnen ook meerdere booleaanse expressie combineren zodat we complexere uitdrukkingen kunnen maken. Hierbij kan je gebruik maken van de logische operators (&&, ||, !) .

Een voorbeeld:

Console.WriteLine("Voer a in");
int a = int.Parse(Console.ReadLine());
Console.WriteLine("Voer b in");
int b = int.Parse(Console.ReadLine());
Console.WriteLine("Voer c in");
int c = int.Parse(Console.ReadLine());
 
if (a == b)
{
    Console.WriteLine("A en B zijn even groot");
}
 
if ((a > c) || (a == b))
{
    Console.WriteLine("A is groter dan C en/of gelijk aan B");
}
 
if ((a >= c) && (b <= c))
{
    Console.WriteLine("A is groter dan of gelijk aan C én");
    Console.WriteLine("B is kleiner of gelijk aan C");
}

If/else

Met if/else kunnen we niet enkel zeggen welke code moet uitgevoerd worden als de conditie waar is maar ook welke specifieke code moet uitgevoerd indien de conditie niet waar is. Volgend voorbeeld geeft een typisch gebruik van een if/else structuur om 2 waarden met elkaar te vergelijken:

int nummer = 10;
int max = 5;
 
if ( nummer > max )
{
         Console.WriteLine ($"Nummer is groter dan {max}!");
}
else
{
         Console.WriteLine ($"Nummer is NIET groter dan {max}!");
}

Flowchart van bovenstaande code.

Een veel gemaakte fout is bij de else sectie ook een booleaanse expressie plaatsen. Dit kan niet: de else sectie zal gewoon uitgevoerd worden indien de if sectie NIET uitgevoerd werd. Volgende code MAG DUS NIET:

if(a > b) 
{...}
else (a <= b) //<FOUT!
{...}

If/else if

Met een if/else if constructie kunnen we meerdere criteria opgeven die waar/niet waar moeten zijn voor een bepaald stukje code kan uitgevoerd worden. Sowieso begint men steeds met een if. Als men vervolgens een else if plaatst dan zal de expressie van deze else if getest worden enkel en alleen als de eerste expressie (van de if) niet waar was. Als de expressie van deze else if wel waar is zal de bijhorende code uitgevoerd worden, zo niet wordt deze overgeslagen.

Een voorbeeld:

int x = 9;
 
if (x == 10)
{
     Console.WriteLine ("x is 10");
}
else if (x == 9)
{
     Console.WriteLine ("x is 9");
}
else if (x == 8)
{
     Console.WriteLine ("x is 8");
}

Voorts mag men ook steeds nog afsluiten met een finale else die zal uitgevoerd worden indien geen enkele andere expressie ervoor waar bleek te zijn:

if(x>100)
{
    Console.WriteLine("Groter dan 100");
}
else if(x>10)
{
    Console.WriteLine("Groter dan 10");
}
else
{
    Console.WriteLine("Getal kleiner dan of gelijk 10");
}

De volgorde van opeenvolgende if/if-else tests is uiterst belangrijk. Als we in voorgaande code de twee tests omdraaien dan zal er nooit in het tweede block (x>100) gekomen worden. Logisch: neem een getal groter dan 100 en laat het door volgende code lopen. Stel, we nemen 110. Al bij de eerste test (x>10) is deze true en verschijnt er dus "Groter dan 10". Alle andere tests worden daarna niet meer gedaan en de code gaat verder na het else-blok.


if(x>10)
{
    Console.WriteLine("Groter dan 10");
}
else if(x>100)
{
    Console.WriteLine("Groter dan 100");
}
else
//...

Hoe minder tests de computer moet doen, hoe meer performant de code zal uitgevoerd worden. Voor complexe applicaties die bijvoorbeeld in realtime veel berekeningen moeten doen kan het dus een gigantische invloed hebben of een reeks if/if-else testen vlot wordt doorlopen. Het is dan ook een goede gewoonte, indien de logica van het algoritme het toelaat, om de meest voorkomende test bovenaan te plaatsen.

Dit zelfde geldt ook binnen een test zelf wanneer we met logische operators werken. Deze worden altijd volgens de regels van de volgorde van berekeningen uitgevoerd. Volgende test wordt van links naar rechts uitgevoerd:

x > 100 && a != "stop"

Omdat beide operanden van de EN-operatie true moeten zijn om een juiste test te krijgen, zal de computer de test automatisch stoppen indien reeds de linkse operand (x > 100) niet waar is. Bij dit soort tests probeer je dus ervoor te zorgen dat de tests die het minste kans op slagen hebben (of beter: het vaakst niét zal slagen) eerst te laten testen, zodat de computer geen onnodige extra tests doet.

Nesting

We kunnen met behulp van nesting (meerdere code blokken in elkaar plaatsen) ook complexere programma flows maken. Hierbij gebruiken we de accolades om het blok code aan te duiden dat bij een if/else if/else hoort. Binnen dit blok kunnen nu echter opnieuw if/else if/else structuren worden aangemaakt.

Volgende voorbeeld toont dit aan (bekijk wat er gebeurt als je dokterVanWacht aan iets anders gelijkstelt dan een lege string):

const double MAX_TEMP = 40;		
double huidigeTemperatuur = 36.5;
string dokterVanWacht = "";

if (huidigeTemperatuur < MAX_TEMP)
{
    Console.WriteLine("Temperatuur normaal");
}
else
{
    Console.WriteLine("Temperatuur te hoog!");
    if (dokterVanWacht == "")
    {
        Console.WriteLine("Oei oei! Geen dokter van wacht!");
    }
    else
    {
        Console.WriteLine($"{dokterVanWacht} gecontacteerd");
    }							  
}

Laat deze tiental bladzijden uitleg je niet de indruk geven dat code schrijven met if-structuren een eenvoudige job is. Vergelijk het met van je pa leren hoe je met pijl en boog moet jagen, wat vlekkeloos gaat op een stilstaande schijf, tot je in het bos voor een mammoet staat die op je komt afgestormd. Da's andere kak hé?

Het is dan ook aangeraden om, zeker in het begin, om steeds een flowchart te tekenen van wat je juist wilt bereiken. Dit zal je helpen om je code op een juiste manier op te bouwen (denk maar aan nesting en het plaatsen van meerdere if\else structuren in of na elkaar). Bezint eer ge begint.

Scope van variabelen

De locatie waar je een variabele aanmaakt bepaalt de scope, oftewel de zichtbaarheid, van de variabele. Eenvoudig gezegd zullen steeds de omliggende accolades de scope van de variabele bepalen. Indien je de variabele dus buiten die accolades nodig hebt dan heb je een probleem: de variabele is enkel bereikbaar binnen de accolades vanaf het punt in de code waarin het werd gedeclareerd.

Zeker wanneer je begint met if, loops, methoden, enz. zal de scope belangrijk zijn: deze code-constructies gebruiken steeds accolades om codeblocks aan te tonen. Een variabele die je dus binnen een if-blok aanmaakt zal enkel binnen dit blok bestaan, niet erbuiten.

if( iLoveCSharp == true)
{
    Console.WriteLine("Hoeveel punten op 10 geef je C#?"):
    int getal ; //Start scope getal
    getal = int.Parse(Console.ReadLine());
} // einde scope getal

Console.WriteLine(getal); // FOUT! getal niet in deze scope

Wil je dus getal ook nog buiten de if gebruiken zal je je code moeten herschrijven zodat getal VOOR de if wordt aangemaakt:

{
    int getal = 0 ; //Start scope getal
    if( iLoveCSharp == true)
    {
        Console.WriteLine("Hoeveel punten op 10 geef je C#?"):
         getal = int.Parse(Console.ReadLine());
    } 
    Console.WriteLine(getal); 
} // einde scope getal

De buitenste accolades zetten we er even om de scope te benadrukken (maar hoeven dus niet).

Merk op dat indien je aan nesting doet, de scope doorheen de inner geneste codeblocken doorloopt en pas eindigt bij de accolade van het block waarbinnen de variabele werd gedeclareerd.

Variabelen met zelfde naam

Zolang je in de scope van een variabele bent, kan je geen nieuwe variabele met dezelfde naam aanmaken:

Volgende code is dus niet toegestaan:

int getal = 0;
{
    int getal = 5; //Deze lijn is niet toegestaan
}

Je krijgt de error: A local variable named 'getal' cannot be declared in this scope because it would give a different meaning to 'getal', which is already used in a 'parent or current' scope to denote something else

Enkel de tweede variabele een andere naam geven is toegestaan in het voorgaande geval.

In volgende voorbeeld is dit dus wel geldig, daar de scope van de eerste variabele afgesloten wordt door de accolades:

{
    int getal = 0 ;
    //....
}
//Verder in code
{
    int getal = 5;
}

Switch

Een switch statement is een element om een veelvoorkomende constructie van if/if else...else eenvoudiger te schrijven. Vaak komt het voor dat we bijvoorbeeld aan de gebruiker vragen om een keuze te maken (bijvoorbeeld een getal van 1 tot 10, waarbij ieder getal een ander menu-item uitvoert van het programma), zoals:

Console.WriteLine("Kies: 1)afbreken\n2)opslaan\n3)laden:");
int option = int.Parse(Console.ReadLine());
 
if (option == 1)
    Console.WriteLine("Afbreken gekozen");
else if (option == 2)
    Console.WriteLine("Opslaan gekozen");
else if (option == 3)
    Console.WriteLine("Laden gekozen");
else
    Console.WriteLine("Onbekende keuze");

Met een switch kan dit eenvoudiger wat we zo meteen zullen tonen. Eerst bekijken we hoe switch juist werkt. De syntax van een switch is specialer dan de andere programma flow-elementen (if, while, enz.), namelijk als volgt:

switch (value)
{
    case constant:
        statements
        break;
    case constant:
        statements
        break;
    default:
        statements
        break;
}

value is de variabele die wordt gebruikt als booleaanse test in de switch (option in ons voorbeeld hier boven). Iedere case begint met het case keyword gevolgd door de waarde die value moet hebben om in deze case te springen. Na het dubbelpunt volgt vervolgens de code die moet uitgevoerd worden in deze case. De case zelf mag eender welke code bevatten (methoden, nieuwe program flow elementen, enz.), maar moet zeker afgesloten worden met het break keyword.

Tijdens de uitvoer zal het programma value vergelijken met iedere case constant van boven naar onder. Wanneer een gelijkheid wordt gevonden dan wordt die case uitgevoerd. Indien geen case wordt gevonden die gelijk is aan value dan zal de code binnen de default-case uitgevoerd worden (de else achteraan indien alle vorige if else-tests negatief waren).

Het menu van zonet kunnen we nu herschrijven naar een switch:

int option;
Console.WriteLine("Kies: 1)afbreken\n2)opslaan\n3)laden:");
option = int.Parse(Console.ReadLine());

switch (option)
{
    case 1:
        Console.WriteLine("Afbreken gekozen");
        break;
    case 2:
        Console.WriteLine("Opslaan gekozen");
        break;
    case 3:
        Console.WriteLine("Laden gekozen");
        break;
    default:
        Console.WriteLine("Onbekende keuze");
        break;
}

De case waarden moeten constanten zijn en mogen dus geen variabelen zijn. Constanten zijn de welgekende literals (1, "1", 1.0, 1.d, '1', enz.). Uiteraard moeten de case waarden van hetzelfde datatype zijn als die van de testwaarde.

Sinds C# 7 is de switch met enkele krachtige uitbreidingen vergroot. We hebben bewust gekozen om deze niét in dit boek op te nemen omdat ze anders je eerste contact met switch nodeloos moeilijker maakt dan zou moeten.

Toch nieuwsgierig wat de nieuwe switch kan? Lees dan zeker eens thomasclaudiushuber.com/2021/02/25/c-9-0-pattern-matching-in-switch-expressions voor een mooi overzicht van alle nieuwigheden.

Fallthrough

Soms wil je dat dezelfde code uitgevoerd wordt bij 2 of meer cases. Je kan ook zogenaamde fallthrough cases beschrijven wat er als volgt uit ziet:

switch (option)
{
    case 1:
        Console.WriteLine("Afbreken gekozen");
        break;
    case 2:
    case 3:
        Console.WriteLine("Laden of opslaan gekozen");
        break;
    default:
        Console.WriteLine("Onbekende keuze");
        break;
}

In dit geval zullen zowel de waarden 2 en 3 resulteren in de zin "Laden of opslaan gekozen" op het scherm.

Enum

Helm op alsjeblieft! enum is een erg onderschat concept bij beginnende programmeurs. Enums zijn wat raar in het begin, maar van zodra je er mee weg bent zal je niet meer zonder kunnen en zal je code zoveel eleganter en stoerder worden. Zet je helm dus op en begin er aan!

De bestaansreden voor enums

Stel dat je een programma moet schrijven dat afhankelijk van de dag van de week iets anders moet doen. In een wereld zonder enums (enumeraties, letterlijk opsommingen) zou je dit kunnen schrijven op 2 zeer foutgevoelige manieren:

  1. Met een int die een getal van 1 tot en met 7 kan bevatten, afhankelijk van de dag (bv. 1 voor maandag, enz.)
  2. Met een string die de naam van de dag bevat (bv. "woensdag")

Slechte oplossing 1: Met int

De waarde van de dag staat in een variabele int dagKeuze. We bewaren er 1 in voor maandag, 2 voor dinsdag, enzovoort. Vervolgens kunnen we dan schrijven:

if(dagKeuze == 1)
{
    Console.WriteLine("We doen de maandag dingen");
}
else if (dagKeuze == 2)
{
    Console.WriteLine("We doen de dinsdag dingen");
}
else if 
//enz.

Deze oplossing heeft 2 grote nadelen:

  • Wat als we per ongeluk dagKeuze een niet geldige waarde geven, zoals 9, 2000 of -4 ?
  • De code is niet erg leesbaar. Wat was dagKeuze ==2 nu weer? Was 2 nu dinsdag of woensdag (want misschien was maandag 0 i.p.v. 1) ?

Slechte oplossing 2: Met strings

Laten we tweede manier eens bekijken: de waarde van de dag bewaren we in een variabele string dagKeuze. We bewaren de dagen als "maandag", "dinsdag", enz.

if(dagKeuze == "maandag")
{
    Console.WriteLine("We doen de maandag dingen");
}
else if (dagKeuze == "dinsdag")
{
    Console.WriteLine("We doen de dinsdag dingen");
}
else if //enz.

De code wordt nu wel leesbaarder, maar toch is ook hier 1 groot nadeel:

  • De code is veel foutgevoeliger voor typefouten. Wanneer je "Maandag" i.p.v. "maandag" bewaart dan zal de if al niet werken. Iedere schrijffout of variant zal falen.

Enumeraties: het beste van beide werelden

Enumeraties (enum) zijn een C# syntax dat bovenstaand probleem oplost en het beste van beide slechte oplossingen samenvoegt :

  1. Leesbaardere code.
  2. Minder foutgevoelige code, en dus minder potentiële bugs.
  3. VS kan je helpen met sneller de nodige code te schrijven.

Het keyword enum geeft aan dat we een nieuw datatype maken dat maar enkele mogelijke waarden kan hebben. Nadat we dit nieuwe datatype hebben gedefinieerd kunnen we variabelen van dit nieuwe datatype aanmaken. Deze variabelen mogen enkel waarden bevatten die in het datatype werden gedefinieerd. Ook zal IntelliSense van Visual Studio je de mogelijke waarden helpen invullen.

In C# zitten al veel enum-types ingebouwd. Denk maar aan ConsoleColor: wanneer je de kleur van het lettertype van de console wilt veranderen gebruiken we een enum-type. Er werd reeds gedefinieerd wat de toegelaten waarden zijn, bijvoorbeeld: Console.ForegroundColor = ConsoleColor.Red;

Zelf enum maken

Zelf een enum type maken en gebruiken gebeurt in 2 stappen:

  1. Het nieuwe datatype en de mogelijke waarden definiëren.
  2. Variabele(n) van het nieuwe type aanmaken en gebruiken in je code.

Stap 1: het nieuwe datatype definiëren

We maken eerst een enum type aan. In je console-applicaties moet dit binnen class Program gebeuren, maar niét binnen de (main) methoden:

enum Weekdagen{Maandag,Dinsdag,Woensdag,Donderdag,Vrijdag,Zaterdag,Zondag};

Als volgt dus:

enum Weekdagen{Maandag,Dinsdag,Woensdag,Donderdag,Vrijdag,Zaterdag,Zondag};

static void Main(string[] args)
{
    Console.WriteLine("Hello enum");
}

We hebben nu letterlijk een nieuw datatype aangemaakt, genaamd Weekdagen.

Stap 2: variabelen van het nieuwe datatype aanmaken en gebruiken

Net zoals int, double enz. kan je nu ook variabelen van het type Weekdagen aanmaken. Hoe cool is dat!? Bijvoorbeeld:

Weekdagen dagKeuze;
Weekdagen andereKeuze;

En vervolgens kunnen we waarden aan deze variabelen toewijzen als volgt:

dagKeuze = Weekdagen.Donderdag;

Kortom: we hebben variabelen zoals we gewoon zijn, het enige verschil is dat we nu beperkt zijn in de waarden die we kunnen toewijzen. Deze kunnen enkel de waarden krijgen die in het type gedefinieerd werden. De code is nu ook een pak leesbaarder geworden.

Enums en beslissingen werken graag samen

Ook de beslissingsstructuren worden leesbaarder:

if(dagKeuze == Weekdagen.Woensdag)

of een switch:

switch(dagKeuze)
{
    case Weekdagen.Maandag:
        Console.WriteLine("It's monday!");
        break;
    case Weekdagen.Dinsdag:
     //enz.
}

Visual Studio houdt van enums (ik ook) en zal je helpen bij het schrijven van een switch indien je test-variabele een enum-type bevat. Hoe?

  • Schrijf switch en druk op 2 maal op tab. Normaal verschijnt er nu een "prefab" switch structuur met een test-waarde genaamd switch_on die een gele achtergrond heeft
  • Overschrijf switch_on met de variabele die je wilt testen (bv. dagKeuze)
  • Klik nu met de muis eender waar binnen de accolades van de switch
  • Profit!

Conversie van en naar enum variabelen

De waarde van een enum-variabelen wordt intern als een int bewaard. In het geval van de Weekdagen zal maandag standaard de waarde 0 krijgen, dinsdag 1, enz.

Volgende conversies met behulp van casting zijn dan ook perfect toegelaten:

int keuze = 3;
Weekdagen dagKeuze = (Weekdagen)keuze;
//dagKeuze zal de waarde Weekdagen.Donderdag hebben

Wil je dus bijvoorbeeld 1 dag bijtellen dan kan je schrijven:

Weekdagen dagKeuze= Weekdagen.Dinsdag;
int extradag= (int)dagKeuze + 1;
Weekdagen nieuweDag= (Weekdagen)extradag;
//extraDag heeft de waarde Weekdagen.Woensdag

Let er wel op dat je geen extra dag op Zondag probeert bij te tellen. Dat zal niet werken.

Andere interne waarde toekennen

Standaard worden de enum waarden intern dus genummerd beginnende bij 0. Je kan dit ook manueel veranderen door bij het maken van de enum expliciet aan te geven wat de interne waarde moet zijn, als volgt:

enum WeekDagen 
    {Maandag=1, Dinsdag, Woensdag, Donderdag, Vrijdag, Zaterdag, Zondag}

De dagen zullen nu vanaf 1 genummerd worden, dus WeekDagen.Woensdag zal de waarde 3 hebben.

We kunnen ook nog meer informatie meegeven, bijvoorbeeld:

enum WeekDagen 
    {Maandag=1, Dinsdag, Woensdag, Donderdag, Vrijdag, Zaterdag=50, Zondag=60}

In dit geval zullen Maandag tot Vrijdag intern als 1 tot en met 5 bewaard worden, Zaterdag als 50, en Zondag als 60.

De individuele enum waarden moeten steeds met een hoofdletter starten.

Gebruikersinvoer naar enum

Heel vaak zal je een programma schrijven waarbij de gebruiker een keuze moet maken uit een menu of iets dergelijks. Dit menu kan je voorstellen met een enum. Het probleem is vervolgens vragen wat de keuze van de gebruiker is en deze dan verwerken. Je zou dit kunnen doen met behulp van een reeks if-testen (if(userinput=="demo") ), of je zou het feit kunnen gebruiken dat we nu enum kennen.

Volgende code toont hoe je dit kunt doen:

enum Menu {Demo=1, Start, Einde}
static void Main(string[] args)
{
    Console.WriteLine("Wat wil je doen?");
    Console.WriteLine("1. Demo");
    Console.WriteLine("2. Start");
    Console.WriteLine("3. Einde");
    int userkeuze = int.Parse(Console.ReadLine());

    Menu keuze = (Menu)userkeuze;

    switch (keuze)
    {
        //...

Parsen van enum

Sinds .NET 5, dat in 2020 uitkwam, is er een meer gebruiksvriendelijke manier verschenen om een string te parsen naar een enum variabele. Hierbij wordt gebruikt gemaakt van generics (herkenbaar aan < >), een concept dat uit de doeken wordt gedaan in de appendix van dit boek.

Echter, zelfs zonder generics te begrijpen zou volgende code toch begrijpbaar moeten zijn. We gebruiken terug het eerder gedefinieerde Menu type en de nieuw beschikbare Enum.Parse< >- methode :

Menu keuze = Enum.Parse<Menu>(Console.ReadLine());

We plaatsen tussen de < > het enum datatype naar waar we willen parsen.

Optioneel kan je via een tweede argument van het type bool aangeven of de parsing hoofdlettergevoelig is (false) of niet (true) :

Menu keuze = Enum.Parse<Menu>(Console.ReadLine(), true); 

Ah, de tijden zonder enum. Ik weet nog hoe we onze grotten beschilderden zonder ons druk te moeten maken in enumeraties. Om maar te zeggen: je kan perfect leven zonder enum. Vele programmeurs voor je hebben dit bewezen. Echter, van zodra ze enum ontdekten (en begrepen) zijn nog maar weinig programmeurs er terug van afgestapt.

De eerste kennismaking met enumeraties is wat bevreemdend: je kan plots je eigen datatypes aanmaken?! Van zodra je ze in de vingers hebt zal je ontdekken dat je veel leesbaardere code kunt schrijven én dat Visual Studio je kan helpen met het opsporen van bugs.

Wanneer gebruik je enum? Telkens je een variabele (of meerdere) nodig hebt waarvan je perfect op voorhand weet welke (handvol) mogelijke waarden ze mogen hebben. Ze worden bijvoorbeeld vaak gebruikt in finite state machines.

Bij game development willen we bijhouden in welke staat het programma zich bevindt: Intro, Startmenu, Ingame, Gameover, Optionsscreen, enz.

Dit is een typisch enum verhaal. We definiëren hiervoor het volgende type:

enum gamestate {Intro, Startmenu, Ingame, Gameover, Optionsscreen}

En vervolgens kunnen we dan met een eenvoudige switch in ons hoofdprogramma snel de relevante code uitvoeren:

//Bij opstart:
gamestate playerGameState= gamestate.Intro;
// ...
//later
switch(playerGameState)
{
    case gamestate.Intro:
        //show fancy movie
        break;
    case gamestate.Startmenu:
        //show start menu
        break;
    //enz.

Een ander typisch voorbeeld is schaken. We maken een enum om de speelstukken voor te stellen (Pion, Koning, Toren enz.) en kunnen hen dan laten bewegen en vechten in uiterst leesbare code:

if(spelstuk == Schaakstuk.Paard)

Herhalingen Herhalingen Herhalingen

In het vorige hoofdstuk leerden we hoe we met behulp van beslissingen onze code konden aftakken (branching) zodat andere code werd uitgevoerd afhankelijk van de staat van bepaalde variabelen of invoer van de gebruiker.

Wat we nog niet konden was terug naar boven vertakken. Soms willen we dat een heel stuk code 2 of meerdere keren moet uitgevoerd worden tot aan een bepaalde conditie wordt voldaan. "Voer volgende code uit tot dat de gebruiker 666 invoert."

Herhalingen (loops of iteraties) creëer je wanneer bepaalde code een aantal keer moet herhaald worden. Hoe vaak de herhaling moet duren is afhankelijk van de conditie die je hebt bepaald.

Door herhalende code met loops te schrijven maken we onze code korter en bijgevolg ook minder foutgevoelig en beter onderhoudbaar.

Soorten loops

Er zijn verschillende soorten loops:

  • Definite of counted loop: een loop waar het aantal iteraties vooraf van gekend is (bv. alle getallen van 0 tot en met 100 tonen)
  • Indefinite of sentinel loop: een loop waarvan op voorhand niet kan gezegd worden hoe vaak deze zal uitgevoerd worden. Input van de gebruiker of een interne test zal bepalen wanneer de loop stopt (bv. "Voer getallen in, voer -1 in om te stoppen" of "Bereken de grootste gemene deler")
  • Oneindige loop: een loop die nooit stopt. Soms gewenst (bv. de game loop) of, vaker, een bug.

Van zodra je dezelfde lijn(en) code onder elkaar in je code ziet staan (door bijvoorbeeld te copy pasten) is de kans zéér groot dat je dit korter kunt schrijven met behulp van loops (of methoden, wat we in volgende hoofdstuk zullen zien).

Loops in C#

Er zijn 3 standaard manieren om loops te maken in C#:

  • while: zal 0 of meerdere keren uitgevoerd worden.
  • do while: zal minimaal 1 keer uitgevoerd worden.
  • for: een alternatieve, iets compactere manier om loops te beschrijven wanneer je exact weet hoe vaak de loop zal moeten herhalen.

Voorts zullen we ook een speciale loop variant zien we in hoofdstuk 9 wanneer we arrays en objecten leren kennen:

  • foreach: een iets meer leesbare manier van loopen die vooral nuttig is wanneer je met objecten gaat werken.

While

De syntax van een while loop is eenvoudig:

while (conditie) 
{
  // C# die zal uitgevoegd worden zolang de conditie waar is
}

Waarbij, net als bij een if statement, de conditie uitgedrukt wordt als een booleaanse expressie met 1 of meerdere relationele operators. Zolang de conditie true is zal de code binnen de accolades uitgevoerd worden. Indien de conditie reeds vanaf het begin false is dan zal de code binnen de while-loop nooit worden uitgevoerd.

Telkens wanneer het programma aan het einde van het while codeblock komt springt het terug naar de conditie bovenaan en zal de test wederom uitgevoerd worden. Is deze weer true dan wordt de code weer uitgevoerd. Van zodra de test false is zal de code voorbij het codeblock springen en na het while codeblok doorgaan. De flowchart is duidelijk:

While flowchart.

Een voorbeeld van een eenvoudige while loop:

int myCount = 0;
while (myCount < 100)
{
    myCount++;
    Console.WriteLine(myCount);
}

Zolang myCount kleiner is dan 100 (myCount < 100) zal myCount met 1 verhoogd worden en zal de huidige waarde van myCount getoond worden. We krijgen met dit programma dus alle getallen van 1 tot en met 100 op het scherm onder elkaar te zien. Daar de test gebeurt aan het begin van de loop wil dit zeggen dat het getal 100 nog wel getoond zal worden. Begrijp je waarom? Test dit zelf!

Complexe condities

Uiteraard mag de conditie waaraan een loop moet voldoen complexer zijn door middel van de relationele operators.

Volgende while bijvoorbeeld zal uitgevoerd worden zolang teller groter is dan 5 én de variabele naam van het type string niet gelijk is aan "tim":

while(teller > 5 && naam != "tim")
{
  //Keep repeating
}

Oneindige loops

Indien de loop-conditie nooit false wordt dan heb je een oneindige loop gemaakt. Soms is dit gewenst gedrag (bijvoorbeeld bij de gameloop) soms is dit een bug en zal je dit moeten debuggen.

Volgende twee voorbeelden tonen dit:

Een bewust oneindige loop:

while(true)
{
  //"To infinity and beyond!"
}

Een bug die een oneindige loop veroorzaakt:

int teller = 0; 
while(teller<10)
{
  Console.WriteLine(teller);
  teller--; //oeps, dit had teller++ moeten zijn
}

Probeer er altijd zeker van te zijn dat de variabele(n) die je gebruikt in je test-conditie ook in de loop aangepast worden. Als deze in de loop niet verandert dan zal ook de test-conditie dezelfde blijven en heb je dus een oneindige loop gemaakt.

Scope van variabelen in loops

Let er op dat de scope van variabelen bij loops zeer belangrijk is. Indien je een variabele binnen de loop definieert dan zal deze steeds terug "gereset" worden wanneer de volgende iteratie van de loop start. Volgende code toont bijvoorbeeld foutief hoe je de som van de eerste 10 getallen (1+2+3+...+10) zou maken:

int teller = 1;
while(teller <= 10)
{
   int som = 0;
   som = som+teller;
   teller++;
}
Console.WriteLine(som); //deze lijn zal een fout genereren

Voorgaande code zal volgende VS error geven: The name 'som' does not exist in the current context.

De correcte manier om dit op te lossen is te beseffen dat de variabele som enkel binnen de accolades van de while-loop gekend is. Op de koop toe wordt deze steeds terug op 0 gezet en er kan dus geen som van alle teller-waarden bijgehouden worden. Hier de oplossing:

int teller = 1;
int som = 0;
while(teller <= 10)
{
   som = som+teller;
   teller++;
}
Console.WriteLine(som); 

Van zodra je dezelfde lijn(en) code onder elkaar in je code ziet staan (door bijvoorbeeld te copy pasten) is de kans zéér groot dat je dit korter kunt schrijven met behulp van loops (of methoden, wat we in volgende hoofdstuk zullen zien).

Do while

In tegenstelling tot een while loop, zal een do-while loop sowieso minstens 1 keer uitgevoerd worden, omdat de conditie aan het eind van iteratie wordt gecontroleerd, en niet aan de start.

Vergelijk volgende flowchart van de do while met die van de while:

Do while flowchart.

De syntax van een do-while is eveneens verraderlijk eenvoudig:

do{
     // C# die zal uitgevoegd worden zolang de conditie waar is
} while (conditie);

Merk op dat achteraan de testconditie een puntkomma na het ronde haakje staat. Deze vergeten is een véél voorkomende fout. Bij een while is dit niet!

Daar de test van een do-while achteraan de code van de loop gebeurt is het logisch dat een do-while dus minstens 1 keer wordt uitgevoerd.

Het volgende eenvoudige aftelprogramma toont de werking van de do-while loop:

int i = 10;
do
{
    i--;
    Console.WriteLine(i);
} while (i > 0);

Begrijp je wat dit programma zal doen? Inderdaad, dit zal alle getallen van 9 tot en met 0 onder elkaar op het scherm zetten.

Foute input van gebruiker met loops verwerken

Dankzij loops kunnen we nu ook eenvoudiger omgaan met foutieve input van de gebruiker. Stel dat we volgende vraag hebben:

Console.WriteLine("Geef uw keuze in: a, b of c");
string input = Console.ReadLine();

Met een loop kunnen we nu deze vragen blijven stellen tot de gebruiker een geldige input geeft:

string input;
do
{
  Console.WriteLine("Geef uw keuze in: a, b of c");
  input = Console.ReadLine();
}while(input != "a" && input != "b" && input != "c");

Zolang (while) de gebruiker niet "a", "b" of "c" invoert zal de loop zichzelf blijven herhalen.

Merk op dat we de variabele string input voor de do while moeten aanmaken. Zouden we die in de loop pas aanmaken dan zou de variabele niet als test kunnen gebruikt worden aan het einde van de loop. De reden? Wederom de scope van variabelen. De accolades van de do while creëren een duidelijke scope die iedere iteratie verdwijnt en terug wordt aangemaakt, inclusief dus variabelen die binnen deze accolades worden aangemaakt.

We herhalen voorgaande nog eens nadrukkelijk omdat hier vaak fouten op gemaakt worden: Je ziet dat de test achteraan (while(input...);) buiten de accolades van de loop ligt en dus een andere scope heeft.

De booleaanse expressie input != "a" && input != "b" && input != "c" kan ook anders geschreven met dezelfde interne logica (en dus werking) als !(input == "a" || input == "b" || input == "c"). Sommige mensen prefereren deze tweede vorm. Maar dat is persoonlijke smaak.

Voorgaande logica is een gevolg van de Wetten van De Morgan (ook wel dualiteit van De Morgen genoemd) die het verband leggen tussen de logische operatoren EN, OF en de negatie.

Deze wetten zeggen dat (uitgedrukt even in C# voor de duidelijkheid):

  • !(A && B ) is hetzelfde als !A || !B
  • !(A || B ) is hetzelfde als !A && !B

Zie je hoe we de tweede wet gebruikt hebben in het voorgaande voorbeeld om de alternatieve logica te vinden?

For-loops

Een veelvoorkomende manier van while-loops gebruiken is waarbij je een bepaalde teller bijhoudt die je telkens met een bepaalde waarde verhoogt. Wanneer de teller een bepaalde waarde bereikt moet de loop afgesloten worden.

Bijvoorbeeld volgende code om alle even getallen van 0 tot 10 te tonen:

int i = 0;
while(i<11)
{
    Console.WriteLine(i);
    i = i + 2;
}

Met een for-loop kunnen we deze veel voorkomende code-constructie verkort schrijven.

For syntax

De syntax van een for-loop is de volgende:

for (setup; finish test; update)
{
    // C# die zal uitgevoerd worden zolang de finish test true geeft
}
  • setup: In het setup gedeelte zetten we de "wachter-variabele" op de beginwaarde. De wachter-variabele is de variabele die we tijdens de loop in het oog zullen houden en die zal bepalen hoe vaak de loop moet uitgevoerd worden (bv. int i = 0;).
  • finish test: Hier plaatsen we een booleaanse expressie die de wachter-variabele uit de setup gebruikt om te testen of de loop-code moet uitgevoerd worden (bv. i<11).
  • update: Hier plaatsen we wat er moet gebeuren telkens de loop z'n codeblock heeft uitgevoerd. Meestal zullen we hier de wachter-variabele verhogen of verlagen (bv. i = i + 2).

For flowchart.

Gebruiken we deze kennis, dan kunnen we de eerder vermelde code om de even getallen van 0 tot en met 10 tonen als volgt:

for (int i = 0; i < 11; i += 2)
{
    Console.WriteLine(i);
}

Voor de setup-variabele kiest men meestal i, maar dat is niet noodzakelijk. In de setup wordt dus een variabele op een start-waarde gezet. De finish test zal aan de start van iedere loop kijken of de finish test nog waar is, indien dat het geval is dan wordt een nieuwe loop gestart en wordt i met een bepaalde waarde, zoals in update aangegeven, verhoogd.

for-tab-tab

Als je in Visual Studio for typt en dan tweemaal op [tab] duwt krijg je een kant en klare for-loop.

continue en break

Het continue keyword laat toe om in een loop de huidige iteratie te eindigen en weer naar de start van de volgende iteratie te gaan. In het volgende voorbeeld gebruiken we continue om alle getallen van 1 tot 10 te tonen waarbij we het getal 5 zullen overslaan:

for (int i = 1; i <= 10; i++)
{
    if (i == 5)
    {
        continue;
    }
    Console.WriteLine(i);
}

En met break kan je loops (alle types) altijd vroegtijdig stopzetten. Je springt dan als het ware ogenblikkelijk uit de loop. Je ziet het aankomen zeker? Yups, daar is ie....

Olla!? Wat denken we dat we aan het doen zijn? Gelieve die keywords ogenblikkelijk terug uit je code te verwijderen. Bedankt.

break en continue zijn de meer subtiele vrienden van goto. Ze leven, net als goto meer in de schemerzone tussen wat mag en niet mag. Dat maakt hen extra gevaarlijk. Voor je break als oplossing wilt gebruiken probeer je best eerst of je de loop niet mooier kan afsluiten door bijvoorbeeld de juiste booleaanse expressie te beschrijven in de test-conditie. Hetzelfde geldt voor continue dat ook snel goto-achtige bugs tot gevolg kan hebben.

Nested loops

Wanneer we 1 of meerdere loops in een andere loop plaatsen dan spreken we over geneste loops. Geneste loops komen vaak voor, maar zijn wel een ander paar mouwen wanneer je deze zaken wilt debuggen en correct schrijven.

Voorbeeld van geneste loops.

We spreken steeds over de outer loop als de omhullende of "grootste" loop. Waarbij de binnenste loop(s) de inner loop(s) is.

Volgende code toont bijvoorbeeld 2 loops die genest werden:

int tellerA = 0;
int tellerB = 0;

while(tellerA < 3 ) //outer loop
{
    tellerA++;
    tellerB = 0;
    while(tellerB < 5)
    {
        tellerB++;
        Console.WriteLine($"Teller A:{tellerA}, Teller B: {tellerB}")
    }
}

De uitvoer hiervan zal als volgt zijn:

Teller A: 1, Teller B: 1
Teller A: 1, Teller B: 2
Teller A: 1, Teller B: 3
Teller A: 1, Teller B: 4
Teller A: 1, Teller B: 5
Teller A: 2, Teller B: 1
Teller A: 2, Teller B: 2
Teller A: 2, Teller B: 3
Teller A: 2, Teller B: 4
Teller A: 2, Teller B: 5
Teller A: 3, Teller B: 1
Teller A: 3, Teller B: 2
Teller A: 3, Teller B: 3
Teller A: 3, Teller B: 4
Teller A: 3, Teller B: 5

Merk het 'ritme' op in de uitvoer. De linkse teller gaat een pak trager dan de rechtse.

Geneste loops tellen

Om te tellen hoe vaak de inner code zal uitgevoerd worden dien je te weten hoe vaak iedere loop afzonderlijk wordt uitgevoerd. Vervolgens vermenigvuldig je al deze getallen met elkaar.

Een voorbeeld: Hoe vaak zal het woord Hallo op het scherm verschijnen bij volgende code?

for (int i = 0; i < 10; i++)
{
    for (int j = 0; j < 5; j++)
    {
        Console.WriteLine("Hallo");
    }
}

De outer loop zal 10 maal uitgevoerd worden (i zal de waarden 0 tot en met 9 krijgen). De inner loop zal telkens 5 maal (j zal de waarden 0 tot en met 4 krijgen) uitgevoerd worden per iteratie van de outer loop. In totaal zal dus 50 maal Hallo op het scherm verschijnen (5x10).

Break in nested loops

Let er op dat break je enkel uit de huidige loop zal halen. Indien je dit dus gebruikt in de inner loop dan zal de outer loop nog steeds voortgaan. Nog een reden om zéér voorzichtig om te gaan in het gebruik van break. Of beter nog: gewoon niet gebruiken!

Methoden

Ene Bill Gates, je weet wel, de oprichter van een bedrijfje genaamd Microsoft zei ooit: "I will always choose a lazy person to do a difficult job. Because, he will find an easy way to do it."

Het is je misschien nog niet opgevallen, maar sinds het vorige hoofdstuk zijn we de jacht begonnen op zo weinig mogelijk code te schrijven met zoveel mogelijk rendement. Loops waren een eerste stap in de goede richting. De volgende zijn methoden! Tijd om nog luier te worden.

Veel code die we hebben geschreven wordt meerdere keren, al dan niet op verschillende plaatsen, gebruikt. Dit verhoogt natuurlijk de foutgevoeligheid. Door het gebruik van methoden kunnen we de foutgevoeligheid van de code verlagen omdat de code maar op 1 plek staat én maar 1 keer dient geschreven te worden. Echter, ook de leesbaarheid en dus onderhoudbaarheid van de code wordt verhoogd.

Beeld je eens dat we geen gebruik konden maken van de vele .NET bibliotheken. Stel je voor dat Console.WriteLine niet bestond? Telkens als we dan iets in C# naar het scherm wilden sturen moesten we de volledige interne code van WriteLine uitschrijven. Voor de geïnteresseerden, dat zou er (ongeveer) als volgt uitzien:

fixed (byte* p = bytes)
{
    if (useFileAPIs)
    {
        int numBytesWritten;
        Interop.Kernel32.WriteFile(hFile, p, bytes.Length, out numBytesWritten, IntPtr.Zero));
    }
    else
    {
        //enz.

Dat is aardig wat bizarre code he? En we tonen maar een stuk. Kortom: we mogen onze beide pollekes kussen dat methoden bestaan. Tijd om ze eens van dichterbij te bekijken!

Het is heel normaal dat voorgaande code je zenuwachtig maakt. Negeer ze maar! Toch nieuwsgierig hoe wat er allemaal achter de schermen gebeurt? Voorgaande code komt uit github.com/dotnet/runtime/blob/main/src/libraries/System.Console/src/System/ConsolePal.Windows.cs, waar je ook alle andere broncode van de dotnet runtime zal terugvinden.

Werking van methoden

Een methode, ook vaak functie genoemd, is in C# een stuk code ('block') bestaande uit 0, 1 of meerdere statements. De methode kan herhaaldelijk opgeroepen worden, al dan niet met extra parameters, en kan ook een resultaat terug geven. Een methode kan van eender waar in je code aangeroepen worden.

Je gebruikt al sinds les 1 methoden. Telkens je Console.WriteLine() bijvoorbeeld gebruikte, roep je een methode aan. Methoden in C# zijn namelijk herkenbaar aan de ronde haakjes achteraan, al dan niet met actuele parameters tussen. Kortom, alles wat we nu gaan zien heb je (onbewust) al gebruikt. Het grote verschil zal zijn dat we nu ook zelf methoden gaan definiëren, en niet enkel bestaande methoden gebruiken.

Methoden gebruiken heeft als voordeel dat je (kleine) herbruikbare stukken code kunt gebruiken en dus niet steeds deze code overal moet copy pasten. Daarnaast zullen methoden je code ook overzichtelijker maken.

Methode syntax

De basis-syntax van een methode ziet er als volgt uit (de werking van het keyword static zien we in hoofdstuk 11):

static returntype MethodeNaam(optioneel_parameters)
{
    //code van methode
}

Vervolgens kan je deze methode elders oproepen als volgt, indien de methode geen parameters vereist:

MethodeNaam();

Dat is een mondvol. We gaan daarom de methoden even stapsgewijs leren kennen. Let's go!

Een eenvoudige methode

Beeld je in dat je een applicatie moet maken waarin je op verschillende plaatsen de naam van je programma moet tonen. Zonder methoden zou je telkens moeten schrijven Console.WriteLine("Timsoft XP");

Als je later de naam van het programma wilt veranderen naar iets anders (bv. Timsoft 11) dan zal je manueel overal de titel moeten veranderen in je code. Met een methode hebben we dat probleem niet meer. We schrijven daarom een methode ToonTitel als volgt:

static void ToonTitel()
{
    Console.WriteLine("Timsoft XP");
}

Vanaf nu kan je eender waar in je programma deze methode aanroepen door te schrijven:

ToonTitel();

Volgend programma'tje toont dit:

namespace Demo1
{
    internal class Program
    {
        static void ToonTitel()
        {
            Console.WriteLine("Timsoft XP");
        }

        static void Main(string[] args)
        {
            ToonTitel();
            Console.WriteLine("Welkom!");
            Console.WriteLine("Geef je naam aub");
            //....
            Console.WriteLine("Vaarwel");
            ToonTitel();
        }
    }
}

Volgende afbeelding toont hoe je programma doorheen de code loopt. De pijlen geven de flow aan:

Visualisatie van bovenstaande code.

Main is ook een methode

Zoals je misschien al begint te vermoeden is dus de Main waar we steeds onze code schrijven ook een methode. Een console-applicatie heeft een startpunt nodig en daarom begint ieder programma in deze methode, maar in principe kan je even goed je programma op een andere plek laten starten.

Wat denk je trouwens dat je dit doet?

static void Main(string[] args)
{
    Console.WriteLine("Ik zit vast!");
    Main(); //Endless loop incoming!
}

string[] args is een verhaal apart en zullen we in het volgende hoofdstuk bekijken. We verklappen alvast dat je via deze args opstartparameters aan je programma kan meegeven tijdens het opstarten (bijvoorbeeld explorer.exe google.com) zodat je code hier iets mee kan doen.

Returntypes van methoden

Voorgaande methode gaf niets terug. Dat kon je zien aan het keyword void (letterlijk: leegte).

Vaak willen we echter wel dat de methode iets teruggeeft. Bijvoorbeeld het resultaat van een berekening.

Het returntype van een methode geeft aan wat het type is van de data die de methode als resultaat teruggeeft bij het beëindigen ervan. Eender welk type dat je kent kan hiervoor gebruikt worden, zoals int, string, char, float, enz. Ook zelfgemaakte (of bestaande) enum datatypes kunnen als returnwaarde door het leven (en later ook objecten, wat we in hoofdstuk 10 zullen ontdekken).

Het is belangrijk dat in je methode het resultaat ook effectief wordt teruggegeven, dit doe je met het keyword return gevolgd door de variabele die moet teruggeven worden.

Denk er dus aan dat deze variabele van het type is dat je hebt opgegeven als zijnde het returntype. Van zodra je return gebruikt zal je op die plek uit de methode 'vliegen'.

Wanneer je een methode maakt die iets teruggeeft (dus ander returntype dan void) is het ook de bedoeling dat je het resultaat van die methode opvangt en gebruikt. Je kan bijvoorbeeld het resultaat van de methode in een variabele bewaren. Dit vereist dat die variabele dan van hetzelfde returntype is!

Volgend voorbeeld bestaat uit een methode die de naam van de auteur van je programma teruggeeft:

static string GetNameAuthor()
{
    string name = "Tim Dams";
    return name;
}

Een mogelijke manier om deze methode in je programma te gebruiken zou nu kunnen zijn:

string myName = GetNameAuthor();

Visualisatie van de flow.

Zoals je merkt is er niet veel verschil met wat je al wist aangaande het gebruik van variabelen. Als je dus twijfelt wat methoden kunnen, beschouw ze als een soort "slimme variabelen" die finaal ook gewoon een waarde hebben, maar deze waarde kan het resultaat van een complex stuk code in de methode zijn.

Je mag zowel literals als variabelen en zelfs andere methode-aanroepen plaatsen achter het return keyword. Zolang het maar om een expressie gaat die een resultaat heeft kan dit. Voorgaande methode kunnen we dus ook schrijven als:

static string GetNameAuthor()
{
    return "Tim Dams";
}

Hier een voorbeeld van een methode die de faculteit van 5 berekent. De oproep van de methode gebeurt vanuit de Main-methode:

internal class Program
{
    static int FaculteitVan5()
    {
        int resultaat = 1;
        for (int i = 1; i <= 5; i++)
        {
            resultaat *= i;
        }
        return resultaat;
    }
 
    static void Main(string[] args)
    {
       Console.WriteLine($"Faculteit van 5 is {FaculteitVan5()}");
    }
}

void

Indien je methode niets teruggeeft wanneer de methode eindigt (bijvoorbeeld indien de methode enkel tekst op het scherm toont) dan dien je dit ook aan te geven. Hiervoor gebruik je het keyword void. Een voorbeeld:

static void ShowProgramVersion()
{
    Console.Write("The version of this program is: ");
    Console.Write(2.16 + "\n");
}

Het void keyword geeft aan dat deze methode niets "teruggeeft" van resultaat aan de code die de methode aanriep. Zaken naar het scherm sturen met Console.WriteLine() heeft hier niets mee te maken.

return

Je mag het return keyword eender waar in je methode gebruiken. Weet wel dat van zodra een statement met return wordt bereikt de methode ogenblikkelijk afsluit en het resultaat achter return teruggeeft. Soms is dit handig zoals in volgende voorbeeld:

static string WindRichting()
{
    Random r = new Random();
    switch (r.Next(0,4))
    {
        case 0:
            return "noord";
            break;
        case 1:
            return "oost";
            break;
        case 2:
            return "zuid";
            break;
        case 3:
            return "west";
            break;
    }
    return "onbekend";
}

Merk op dat de onderste return "onbekend"; nooit zal bereikt worden. Toch vereist C# dit!

Dacht je nu echt dat ik weg was?! Het is me opgevallen dat je niet altijd de foutboodschappen in VS leest. Ik blijf alvast uit jouw buurt als je zo doorgaat. Doe jezelf (en mij) dus een plezier en probeer die foutboodschappen in de toekomst te begrijpen. Er zijn er maar een handvol en bijna altijd komen ze op hetzelfde neer. Neem nou de volgende:Not all code paths return a value Die ga je nog vaak tegenkomen!

Bovenstaande error zal je vaak krijgen en geeft altijd aan dat er bepaalde delen binnen je methode zijn waar je kan komen zonder dat er een return optreedt. Het einde van de methode wordt met andere woorden bereikt zonder dat er iets uit de methoden terug komt (wat enkel bij void mag).

Foutboodschappen hebben de neiging om gecompliceerder te klinken dan de effectieve fout die ze beschrijven. Een beetje zoals een lector die lesgeeft over iets waar hij zelf niets van begrijpt.

Parameters doorgeven

Methoden zijn handig vanwege de herbruikbaarheid. Wanneer je een methode hebt geschreven om de sinus van een hoek te berekenen, dan is het echter ook handig dat je de hoek als parameter kunt meegeven zodat de methode kan gebruikt worden voor eender welke hoekwaarde.

Indien er wel parameters nodig zijn dan geef je die mee als volgt:

MethodeNaam(parameter1, parameter2, …);

Je hebt dit ook al geregeld gebruikt. Wanneer je tekst op het scherm wilt tonen dan roep je de WriteLine methode aan en geef je 1 parameter mee, namelijk hetgeen dat op het scherm moet komen. Bij de Math bibliotheek waren er bijvoorbeeld methoden waar je 2 parameters aan kon meegeven, waarbij duidelijk was dat de volgorde belangrijk was: Math.Pow(6,3); 6 tot de 3e is niet hetzelfde als 3 tot de 6e wat je als volgt zou schrijven Math.Pow(3, 6);.

Parameters kunnen op 2 manieren worden doorgegeven aan een methode:

  1. Wanneer een parameter by value wordt meegegeven aan een methode, dan wordt een kopie gemaakt van de huidige waarde die wordt meegegeven.
  2. Wanneer echter een parameter by reference wordt meegegeven dan zal een pointer worden meegegeven aan de methode. Deze pointer bevat het adres van de eigenlijke variabele die we meegeven. Aanpassingen aan de actuele parameters zullen daardoor ook zichtbaar zijn binnen de scope van de originele variabele. Parameters by reference komen pas vanaf hoofdstuk 9 van pas.

Het tweede punt mag je volledig negeren als je geen flauw benul had wat er net werd gezegd. We komen hier later in de volgende hoofdstukken nog uitgebreid op terug!

Methoden met formele parameters

Om zelf een methode te definiëren die 1 of meerdere parameters aanvaardt, dien je per parameter het datatype en een tijdelijk naam (identifier) te definiëren (formele parameters) in de methode-signatuur

Als volgt:

static returntype MethodeNaam(type parameter1, type parameter2)
{
    //code van methode
}

Deze formele parameters zijn nu beschikbaar binnen de methode om mee te werken naar believen.

Stel bijvoorbeeld dat we onze FaculteitVan5 willen veralgemenen naar een methode die voor alle getallen werkt, dan zou je volgende methode kunnen schrijven:

static int BerekenFaculteit(int grens)
{
    int resultaat = 1;
    for (int i = 1; i <= grens; i++)
    {
        resultaat *= i;
    }
    return resultaat;
}

De naam grens kies je zelf. Maar we geven hier dus aan dat de methode BerekenFaculteit enkel kan aangeroepen worden indien er 1 actuele parameter van het type int wordt meegegeven.

Aanroepen van de methode gebeurt dan als volgt:

int getal = 5;
int resultaat = BerekenFaculteit(getal);

Of sneller:

int resultaat = BerekenFaculteit(5);

Als we even later resultaat dan zouden gebruiken zal er de waarde 120 in zitten.

Parameters worden "by value" meegegeven (zie het hoofdstuk over Arrays hierna) wat wil zeggen dat een kopie van de waarde wordt meegegeven. Als je dus in de methode de waarde van de parameter aanpast, dan heeft dit géén invloed op de waarde van de originele parameter waar je de methode aanriep.

Je zou nu echter de waarde van getal kunnen aanpassen (door bijvoorbeeld aan de gebruiker te vragen welke faculteit moet berekend worden) en je code zal nog steeds werken.

Veel beginnende programmeurs zijn soms verward dat de naam van de parameter in de methode (bv. grens) niet dezelfde moet zijn als de naam van de variabele (of literal) die we bij de aanroep meegeven.

Het is echter logisch dat deze niet noodzakelijk gelijk moeten zijn: het enige dat er gebeurt is dat de methodeparameter de waarde krijgt die je meegeeft, ongeacht van waar de parameter komt.

En wat als je de faculteiten wenst te kennen van alle getallen tussen 1 en 10? Dan zou je schrijven:

for (int i = 1; i < 11; i++)
{
    Console.WriteLine($"Faculteit van {i} is {BerekenFaculteit(i)}" );
}

Visualisatie flow.

Dit zal als resultaat geven

Faculteit van 1 is 1
Faculteit van 2 is 2
Faculteit van 3 is 6
Faculteit van 4 is 24
Faculteit van 5 is 120
Faculteit van 6 is 720
Faculteit van 7 is 5040
Faculteit van 8 is 40320
Faculteit van 9 is 362880
Faculteit van 10 is 3628800

Merk op dat dankzij je methode, je véél code maar één keer moet schrijven, wat de kans op fouten verlaagt.

Volgorde van actuele parameters

De volgorde waarin je je parameters meegeeft bij de aanroep van een methode is belangrijk. De eerste variabele wordt aan de eerste parameter toegekend, en zo voort.

Het volgende voorbeeld toont dit. Stel dat je een methode hebt:

static void ToonDeling(double teller, double noemer)
{
    if(noemer != 0)
        Console.WriteLine(teller/noemer);
    else
        Console.WriteLine("Een zwart gat ontstaat!");
}

Stel dat we nu in onze main volgende aanroep doen:

double n = 4.2;
double t = 5.2;
ToonDeling(n, t);

Dit zal een ander resultaat geven dan wanneer we volgende code zouden uitvoeren:

ToonDeling(t, n);

Ook de volgorde is belangrijk zeker wanneer je met verschillende types als formele parameters werkt:

static void ToonInfo(string name, int age)
{
   Console.WriteLine($"{name} is {age} old");
}

Deze aanroep is correct:

ToonInfo("Tim", 37);

Deze is FOUT en zal niet compileren:

ToonInfo(37, "Tim");

Methoden nesten

In het begin ga je vooral vanuit je main methoden aanroepen, maar dat is geen verplichting. Je kan ook vanuit methoden andere methoden aanroepen, en van daaruit weer andere, en zo voort. Volgende (nutteloze) programma'tje toont dit in actie:

static void SchrijfT()
{
    Console.WriteLine("T");
}
static void SchrijfI()
{
    Console.WriteLine("I");
}
static void SchrijfM()
{
    Console.WriteLine("M");
}
static void SchrijfNaam()
{
    SchrijfT();
    SchrijfI();
    SchrijfM();
    SchrijfM();
    SchrijfI();
}
public static void Main()
{
    SchrijfNaam();
}

Visualisatie van bovenstaande code zonder terugkerende pijlen.

Bugs met methoden

Wanneer je programma's complexer worden moet je zeker opletten dat je geen oneindige lussen creëert, zonder dat je loop-code gebruikt. Zie je de fout in volgende code?

public static void Main()
{
    SchrijfNaam();
}
static void SchrijfNaam()
{
    SchrijfNaam();
    Console.WriteLine("Klaar?");
}

Deze code heeft een methode die zichzelf aanroept, zonder dat deze ooit afsluit, waardoor we dus in een oneindige aanroep van de methode komen. Dit programma zal een leeg scherm tonen (daar er nooit aan de tweede lijn in de methode wordt geraakt) en dan crashen wanneer het werkgeheugen van de computer op is (daar de methoden nooit afsluit en telkens een kopie aanroept).

Deze keer zijn er bewust geen terugkerende pijlen getekend: ze zijn er niet.

Lokale methoden...en waarom je ze beter niet gebruikt

Sinds C# 7.0 kan je methoden definiëren binnenin een andere methode. Dit noemt men local functions en alhoewel ze zeker hun nut hebben, is het in deze fase van C# leren geen goed idee om lokale methoden te gebruiken. Het is véél belangrijker dat je eerst deftig methoden leert schrijven daar sommige beginnende programmeurs soms per ongeluk een lokale methode schrijven en vervolgens ontdekken dat ze die methode nergens kunnen aanroepen (local functions zijn enkel aanroepbaar binnenin de methode waarin ze gedefinieerd werd).

Kortom, zorg dat je nooit dit schrijft!

static void Main(string[] args)
{
    TimVindtDitNietLeuk();

    static void TimVindtDitNietLeuk()
    {
        Console.WriteLine("Doe dit niet!");
    }
}

Even ingrijpen en je wijzen op recursie zodat je code niet in je gezicht blijft ontploffen. Recursie is een geavanceerd programmeerconcept wat niet in dit boek wordt besproken, maar laten we het hier kort toelichten. Recursieve methoden zijn methoden die zichzelf aanroepen maar wél op een gegeven moment stoppen wanneer dat moet gebeuren. Volgend voorbeeld is een recursieve methode om de som van alle getallen tussen start en stop te berekenen:

static int BerekenSomRecursief(int start, int stop)
{
    int som = start;
    if(start < stop)
    {
        start++;
        return som += BerekenSomRecursief(start, stop);
    }
    return som;
}

Je herkent recursie aan het feit dat de methode zichzelf aanroept. Maar een controle voorkomt dat die aanroep blijft gebeuren zonder dat er ooit een methode wordt afgesloten. We krijgen 6 terug (1+2+3) als we de methode als volgt aanroepen:

int einde = BerekenSomRecursief(1,3);

Flow van de recursie.

Commentaar aan methoden toevoegen

Het is aan te raden om steeds boven een methode een nieuwe vorm van commentaar te plaatsen als volgt (dit werkt enkel bij methoden): ///

Visual Studio zal dan automatisch de parameters verwerken van je methode zodat je vervolgens enkel nog het doel van iedere parameter moet schrijven.

Stel dat we een methode hebben geschreven die de macht van een getal berekent (wat dom is...er bestaat al zoiets als Math.Pow). We zouden dan volgende commentaar toevoegen:

/// <summary>
/// Berekent de macht van een getal.
/// </summary>
/// <param name="grondtal">Het getal dat je tot macht wilt verheffen</param>
/// <param name="exponent">De exponent van de macht</param>
/// <returns></returns>
static int Macht(int grondtal, int exponent)
{
    int result = grondtal;
    for (int i = 1; i < exponent; i++)
    {
        result *= grondtal;
    }
    return result;
}

Wanneer we nu elders de methode Macht gebruiken dan krijgen we automatische extra informatie:

Het is aanbevolen om je documentatie in het Engels te doen, niet zoals in dit voorbeeld dus.

Regions

Je kan trouwens delen van je code in handige inklapbare secties zetten door deze als regions aan te duiden, als volgt:

#region My Epic code
Console.WriteLine("I am the greatest!");
Console.WriteLine("Echt waar!");
#endregion

Je zal vanaf dan in Visual Studio rechts van de start van de region een minnetje zien waar je op kunt klikken om de hele region tot aan #endregion in te klappen. De code zal nog steeds gecompileerd worden, maar je bladspiegel is weer wat ordelijker geworden én het ingeklapte deel zal nog steeds herkenbaar zijn door de tekst die je achter de region-start (My Epic code in dit geval).

Bestaande methoden en bibliotheken

Laten we eens kijken naar de vele methoden die reeds ingebouwd zitten in .NET en hoe we ze nu (hopelijk) beter kunnen gebruiken dankzij onze nieuwe kennis over methoden.

Sommige methoden, zoals WriteLine(), vereisen dat je een aantal parameters meegeeft. De parameters dien je tussen de ronde haakjes te zetten. Hierbij weten we nu dat het uiterst belangrijk dat je de volgorde respecteert die de ontwikkelaar van de methode heeft gebruikt. Indien je echter niet weet wat deze volgorde is kan je altijd Intellisense gebruiken. Typ gewoon de methode in je code en stop met typen na het eerste ronde haakje, vervolgens verschijnen alle mogelijke manieren waarop je deze methoden kan oproepen (met de pijltjes kan je alle mogelijke manieren bekijken)

Dit soort popups bevat een schat aan informatie.

We zien telkens duidelijke de methode-signatuur: het return type (in dit geval void) gevolgd door de naam van de methode en dan de formele parameters en hun datatype(s). Zoals al herhaardelijk aangehaald: de naam van de formele parameters doet er niet toe!

Merk trouwens op dat je de WriteLine-methode ook mag aanroepen zonder parameters, dit zal resulteren in een lege lijn in de console.

Met behulp van de F1-toets kunnen meer info over de methode in kwestie tonen. Hiervoor dien je je cursor op de Methode in je code te plaatsen, en vervolgens op F1 te drukken. Je komt dan op de online documentatie van de methode waar erg veel informatie terug te vinden is over het gebruik ervan. Scroll naar de overload list, daar zien we de verschillende manieren waarop je de methode in kwestie kan aanroepen (het concept overloaden bespreken we in de volgende sectie). Je kan vervolgens op iedere methode klikken voor meer informatie en een codevoorbeeld.

Intellisense

Wat kan deze .NET bibliotheek eigenlijk? is een veelgestelde vraag. Zeker wanneer je de basis van C# onder de knie hebt en je stilletjes aan met bestaande .NET bibliotheken wilt gaan werken. Wat volgt is een essentieel onderdeel van VS dat veel gevloek en tandengeknars zal voorkomen.

De online documentatie van VS is zeer uitgebreid en dankzij IntelliSense krijg je ook aardig wat informatie tijdens het typen van de code zelf. IntelliSense is de achterliggende technologie in VS die ervoor zorgt dat je minder moet typen. Als een soort assistent probeert IntelliSense een beetje te voorspellen wat je gaat typen en zal je daarmee helpen.

Type eens het volgende in:

System.Console.

Wacht nu even en er zal na het punt (.) een lijst komen van methoden en fields die beschikbaar zijn. Dit is IntelliSense in actie. Als er niets verschijnt of iets dat je niet had verwacht, dan is de kans groot dat er een (schrijf)fout staat in hetgene je net schreef.

Je kan door deze lijst met de muis doorheen scrollen en zo zien welke methoden allemaal bij de Console bibliotheek horen. Indien gewenst kan je vervolgens de gewenste methode selecteren en op spatie duwen zodat deze in je code verschijnt.

De icoontjes geven aan of het om een methode (kubus), een eigenschap (Engelse sleutel) of een "event" (bliksem) gaat. Events behandelen we niet in dit boek.

Vaak moet je code schrijven waarin je een getal aan de gebruiker vraagt:

Console.WriteLine("Geef leeftijd");
int leeftijd = int.Parse(Console.ReadLine());

Als deze constructie op meerdere plekken in een project voorkomt dan is het nuttig om deze twee lijnen naar een methode te verhuizen die er dan zo kan uitzien:

static int VraagInt(string zin)
{
    Console.WriteLine(zin);
    return  int.Parse(Console.ReadLine());
}

De code van zonet kan je dan nu herschrijven naar:

int leeftijd = VraagInt("Geef leeftijd");

Het voorgaande voorbeeld toont ook ineens aan waarom methoden helpen om je code leesbaarder en onderhoudbaarder te maken. Je Main blijft gevrijwaard van veel repeterende lijnen code en heeft aanroepen naar (hopelijk) goed benoemde methoden die ieder een specifiek ding doen. Dit maakt het debuggen ook eenvoudiger: je ziet in één oogopslag meestal wat een methode doet (als je ze niet te lang hebt gemaakt natuurlijk).

IntelliCode

Sinds Visual Studio 2022 heeft IntelliSense een ongelooflijk krachtig broertje bijgekregen, genaamd IntelliCode. Deze tool zal ervoor zorgen dat je nog betere aanbevelingen krijgt van VS terwijl je aan het typen bent. Het gaat soms zo ver dat het lijkt alsof IntelliCode in je hoofd kan kijken en perfect kan voorspellen wat je wilt typen. Let hier echter goed voor op: de aanbevelingen zijn meestal erg accuraat, maar:

  1. Ze zorgen ervoor dat je zelf minder moet typen en daardoor ook mogelijk jezelf niet genoeg traint. Zeker als beginnende programmeur. Ik raad je eigenlijk af om IntelliCode uit te schakelen (via het Tools&Options menu-item). Waarom? Laten we de analogie van het leren fietsen er nog eens bijhalen. Wat IntelliCode eigenlijk doet is je af en toe optillen en enkele meters hoger op de berg plaatsen. Handig, dat wel, maar je trainen in het fietsen doe je niet.
  2. De aanbevelingen zijn natuurlijk soms gewoon fout of bevatten bugs die later bijvoorbeeld door hackers kunnen misbruikt worden. Of wat te denken van aanbevelingen die op zich wel zullen werken, maar wel 10x zoveel geheugen vereisen? Kortom, wees steeds kritisch over de aanbevelingen van IntelliCode

IntelliCode zal ook IntelliSense verbeteren door de belangrijkste, meest gebruikte methoden bovenaan te zetten. Je zal echter IntelliCode vooral herkennen wanneer er plots een hele lijn code verschijnt in het lichtgrijs, met achteraan een tandwiel. Via het tandwiel kan je deze magische, door artificiële intelligentie (A.I.) aangedreven tool ook uitschakelen.

Wanneer we later klassen gaan schrijven, zoals in deze screenshot, zal IntelliCode soms griezelig correcte voorstellen doen.

Google Copilot project is zelfs nog krachtiger en komt dus met een nog grotere disclaimer: beginnende programmeurs, laat dit soort tools beter nog even links liggen! Je leert ook niet hoofdrekenen door vanaf dag 1 met een zakrekenmachine aan de slag te gaan.

Sinds 2023 is er een gigantische opkomst van nog straffere A.I. tools, met ChatGPT voorop. Alhoewel deze tools vaak heel goede C# code kunnen genereren, raden we af deze te gebruiken, om dezelfde redenen dat je best IntelliCode niet gebruikt. Vraag daarom nooit aan ChatGPT om "oefening x" voor je op te lossen. Moet je dan ChatGPT volledig links laten liggen? Uiteraard niet. Gebruik hem als extra leermiddel om bijvoorbeeld stukken code toe te lichten, bepaalde concepten op een andere manier uit te leggen etc.

Geavanceerde methode-technieken

Nu we methoden in de vingers krijgen, is het tijd om naar enkele gevorderde aspecten te kijken. Je hebt vermoedelijk al door dat methoden een erg fundamenteel concept zijn van een programmeertaal en dus hoe beter we ermee kunnen werken, hoe beter.

Wat nu volgt is grotendeels gebaseerd op docs.microsoft.com/dotnet/csharp/programming-guide/classes-and-structs/named-and-optional-arguments.

Named parameters

Wanneer je een methode aanroept is de volgorde van je actuele parameters belangrijk: deze moeten meegeven worden in de volgorde zoals de methode ze verwacht.

Met behulp van named parameters kan je echter expliciet aangeven welke actuele parameters aan welke formele parameter moet meegegeven worden.

Stel dat we een methode hebben met volgende signatuur:

static void PrintDetails(string seller, int orderNum, string product)
{
    //do stuff
}

Zonder named parameters zou een aanroep van deze methode als volgt kunnen zijn:

PrintDetails("Gift Shop", 31, "Red Mug");

We kunnen named parameters aangeven door de naam van de parameter gevolgd door een dubbel punt en de waarde. Als we dus bovenstaande methode willen aanroepen kan dat ook als volgt met named parameters:

 PrintDetails(orderNum: 31, product: "Red Mug", seller: "Gift Shop");

of ook:

PrintDetails(product: "Red Mug", seller: "Gift Shop", orderNum: 31);

Kortom, op deze manier maakt de volgorde van parameter niets uit. Dit werkt echter enkel als je alle parameters op deze manier gebruikt.

Named en unnamed mixen: volgorde wél belangrijk

Je mag echter ook een combinatie gebruiken van named en gewone parameters, maar dan is de volgorde belangrijk: je moet je dan houden aan de volgorde van de methode-volgorde. Je verbetert hiermee de leesbaarheid van je code dus (maar krijgt niet het voordeel van een eigen volgorde te hanteren). Enkele geldige voorbeelden:

PrintDetails("Gift Shop", 31, product: "Red Mug");
PrintDetails(seller: "Gift Shop", 31, product: "Red Mug"); 

Enkele niet geldige voorbeelden:

PrintDetails(product: "Red Mug", 31, "Gift Shop");
PrintDetails(31, seller: "Gift Shop", "Red Mug");

Optionele parameters

Soms wil je dat een methode een standaardwaarde voor een parameter gebruikt indien de programmeur in z'n aanroep geen waarde meegaf. Dat kan met behulp zogenaamde van optionele of default parameters. Je geeft aan dat een parameter optioneel is door deze een default waarde te geven in de methode-signatuur. Deze waarde zal dan gebruikt worden indien de parameter geen waarde van de aanroeper heeft gekregen. Let er op: Optionele parameters worden steeds achteraan de parameterlijst van de methode geplaatst .

In het volgende voorbeeld maken we een nieuwe methode aan en geven aan dat de laatste twee parameters (optName en age) optioneel zijn door er met de toekenningsoperator een default waarde aan te geven:

static void BookFile(int required, string optName = "unknown", int age = 10)

Wanneer nu een parameter niet wordt meegegeven, dan zal deze default waarde in de plaats gebruikt worden:

BookFile(15, "tim", 25); //klassieke aanroep, age zal 25 en optName zal "tim" zijn
BookFile(20, "dams"); //age zal 10 zijn, optName "dams"
BookFile(35); //optName zal "unknown" en age zal 10 zijn

Je mag enkel de optionele parameters van achter naar voor weglaten. Volgende aanroep is dus niet geldig:

BookFile(3, 4); //daar de tweede param een string moet zijn

Met optionele parameters kunnen we dit echter, indien gewenst, omzeilen. Volgende aanroep is wel geldig:

BookFile(3, age: 4);

Method overloading

Method overloading wil zeggen dat je een methode met dezelfde naam en returntype meerdere keren definieert maar met andere formele parameters qua datatype en/of aantal. De compiler zal dan zelf bepalen welke versie moet aangeroepen worden, gebaseerd op het aantal en type actuele parameters dat je meegeeft.

Volgende methoden zijn overloaded:

static int BerekenOppervlakte(int lengte, int breedte)
{
    int opp = lengte*breedte;
    return opp;
}

static int BerekenOppervlakte(int straal)
{
    int opp = (int)(Math.PI*straal*straal);
    return opp;
}

Afhankelijk van de aanroep zal dus de ene of andere methode uitgevoerd worden. Volgende code zal dus werken:

Console.WriteLine($"Rechthoek: {BerekenOppervlakte(5, 6)}");
Console.WriteLine($"Cirkel: {BerekenOppervlakte(7)}");

Betterness rule

Indien de compiler twijfelt tijdens de overload resolution (welke versie moet aangeroepen worden) zal de betterness rule worden gehanteerd: de best 'passende' methode zal aangeroepen worden.

Stel dat we volgende overloaded methoden hebben:

static int BerekenOppervlakte(int straal) //versie A
{
    int opp = (int)(Math.PI*straal*straal);
    return opp;
}
static int BerekenOppervlakte(double straal) //versie B
{
    int opp = (int)(Math.PI * straal * straal);
    return opp;
}

Volgende aanroepen zullen dus als volgt uitgevoerd worden, gebaseerd op de betterness rule:

Console.WriteLine($"Cirkel 1: {BerekenOppervlakte(7)}"); //versie A
Console.WriteLine($"Cirkel 2: {BerekenOppervlakte(7.5)}"); //versie B
Console.WriteLine($"Cirkel 3: {BerekenOppervlakte(7.3f)}"); //versie B

Volgende tabel geeft de betternes rule weer. In de linkse kolom staat het datatype van de parameter die wordt meegegeven. De rechtse kolom toont welk datatype het argument in de methodesignatuur meer voorkeur heeft van links naar rechts indien dus het originele type niet beschikbaar is.

ParametertypeVoorkeur van meeste voorkeur naar minste
byteshort, ushort, int, uint, long, ulong, float, double, decimal
sbyteshort, int long, float, double, decimal
shortint, long, float, double, decimal
ushortint, uint, long, ulong, float, double, decimal
intlong, float, double, decimal
uintlong, ulong, float, double, decimal
longfloat, double, decimal
ulongfloat, double, decimal
floatdouble
charushort, int, uint, long, ulong, float, double, decimal

Als je bijvoorbeeld een parameter van het type int meegeeft bij een methode aanroep (eerste kolom), dan zal een methode waar het argument een long verwacht geprefereerd worden boven een methode die voor datzelfde argument een float verwacht, enz.

Indien de betterness rule niet werkt, dan zal de eerste parameter bepalen wat er gebruikt wordt. Dat zien we in volgende voorbeeld:

static void Main(string[] args)
{
    Toonverhouding(5, 3.4); //versie A
    Toonverhouding(6.2, 3); //versie B
}

static void Toonverhouding(int a, double b) //versie A
{
    Console.WriteLine($"{a}/{b}");
}

static void Toonverhouding(double a, int b) //versie B
{
    Console.WriteLine($"{a}/{b}");
}

Indien ook die regel niet werkt dan zal volgende foutmelding verschijnen:

We zien aan de foutboodschap duidelijk dat er eerst naar de eerste parameter wordt gekeken bij twijfel.

static void Main(string[] args)
{ 
    Toonverhouding(5.6, 3.4);
}

static void Toonverhouding(int a, double b)
{
    Console.WriteLine($"{a}/{b}");
}

static void Toonverhouding(double a, int b)
{
    Console.WriteLine($"{a}/{b}");
}

Methoden debugen met step-in

Herinner je je dat we in hoofdstuk 4 debuggen uitlegden en één knopje toen later gingen bekijken? Wel die tijd is nu gekomen. Tijd om de step in knop toe te lichten.

Je vind deze knop bovenaan in je menu wanneer je in debug-modus bent

Wanneer je een breakpoint zet in je code en in debugermode komt dan kan je doorheen je code stappen, wat je hopelijk al geregeld hebt gedaan. Het nadeel was dat je niet in een methode ging wanneer je daar over stapte. Wel, met de "step in" knop kan je dat nu wel. Wanneer je aan een lijn met een eigen geschreven methode komt dan zorgt deze knop ervoor dat je in de methode gaat en vervolgens daar verder kunt stappen over de verschillende lijnen code.

Het klinkt simpel, maar oefen het toch best een paar keer!

Arrays

Arrays zijn een veelgebruikt principe in vele programmeertalen. Het grote voordeel van arrays is dat je één enkele variabele kunt hebben die een grote groep waarden voorstelt van eenzelfde type. Hierdoor wordt je code leesbaarder en eenvoudiger in onderhoud. Arrays zijn een zeer krachtig hulpmiddel, maar er zitten wel enkele venijnige addertjes onder het gras.

Op papier zijn arrays eenvoudig...helaas programmeren we niet (of zelden) op papier. In essentie is een array niets meer dan een verzameling variabelen van hetzelfde type (bijvoorbeeld een verzameling ints, doubles of chars). Deze waarden kunnen benaderd worden via 1 enkele variabele, de array zelf. Door middel van een index kan ieder afzonderlijk element uit de array aangepast of uitgelezen worden.

Een nadeel van arrays is dat, eens we de lengte van een array hebben ingesteld, deze lengte niet meer kan veranderd worden. In het hoofdstuk 12 zullen we leren werken met lists en andere collections die dit nadeel niet meer hebben.

De nadelen zullen we echter met plezier erbij nemen wanneer we programma's beginnen schrijven die werken met véél data van dezelfde soort: eenvoudigweg kan je stellen dat van zodra je 3 of meer variabelen hebt die dezelfde soort data bevatten (en dus van hetzelfde datatype zijn), een array bijna altijd de oplossing zal zijn.

Nut van arrays

Stel dat je de dagelijkse neerslag wenst te bewaren om zo later de gemiddelde regen te berekenen. Dit kan je zonder arrays eenvoudig:

int dag1 = 34;
int dag2 = 45;
int dag3 = 0;
int dag4 = 34;
int dag5 = 12;
int dag6 = 0;
int dag7 = 23;

Als we je nu vragen om de gemiddelde neerslag te berekenen dan krijg je al een redelijk lang statement:

double gemiddelde = (dag1+dag2+dag3+dag4+dag5+dag6+dag7)/7.0;

Maar wat als je plots de neerslag van een heel jaar, 365 dagen, wenst te bewaren. Of een hele eeuw? Of een millennium?! Dat is niet werkbaar zonder een nieuw concept, dat van arrays, te introduceren. Van zodra je een bepaalde soort informatie hebt die je veelvuldig wenst te bewaren dan zijn arrays dus de oplossing.

Voorgaande lijst van 7 aparte variabelen kunnen we eenvoudiger definiëren met 1 array (we bespreken de details verderop), genaamd regen:

int[] regen = {34, 45, 0, 34, 12, 0, 23}; 

Een schematische voorstelling van een lijst van aparte variabelen en het equivalent met een array.

Het gemiddelde berekenen kan dan als volgt:

double gemiddelde = (regen[0]+regen[1]+regen[2]+regen[3]+regen[4]+regen[5]+regen[6])/7.0;

Dat lijkt niet veel beter, integendeel, we zitten nu ook nog met een hoop vierkante haakjes ([]).

De kracht van arrays komt nu: het getal tussen die vierkante haakjes (de index) kan je als een variabele beschouwen en dus ook dynamisch genereren in een loop. Volgend voorbeeld toont hoe we bijvoorbeeld een langere array van elementen met een for-loop overlopen om de som van alle elementen te berekenen:

int[] regen = {34, 45, 0, 34, 12, 0, 23, 7, 20, 34, 7, 42}; //aanmaken array
double som = 0;
for(int i = 0; i<regen.Length;i++)
{
    som += regen[i]; //element per element uit array optellen
}
double gemiddelde = som/regen.Length;

Sorry dat we weer even in het diepe water zijn gedoken. Het leek ons nuttig om even het totaalplaatje van arrays alvast uit de doeken te doen, zodat je snapt waarom er hier zo enthousiast over arrays wordt gedaan.

A propos, kijk eens achterom! Schrik je van hé. Je hebt al een aardige weg afgelegd als we vergelijken met de eerste keer toen ik je in het zwembad gooide. Herinner je je nog dat ik volgende code liet zien. En ik je vervolgens moest gerust stellen?

namespace Demo1
{
    internal class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            // enz.

Alles wordt kinderspel, als je maar lang genoeg met iets bezig bent. Zelfs de code die we net toonden met die arrays zou je niet meer zo erg mogen afschrikken als die eerste keer. Ok, er staan wat nieuwe termen tussen, maar al bij al zouden de grote lijnen van het algoritme en de werking ervan duidelijk moeten zijn.

Blijf dus maar hier lekker in het diep dobberen en ontdek verder waarom arrays zo'n krachtig concept zijn.

Werken met arrays

Arrays declareren

Een array creëren (declareren) kan op verschillende manieren.

Manier 1

De eenvoudigste variant is deze waarbij je een array variabele aanmaakt, maar deze nog niet initialiseert (i.e. je maakt enkel een identifier in aan). De syntax is als volgt:

type[] arraynaam;

Type kan dus eender welk bestaand datatype zijn dat je reeds kent. De [] (vierkante haken of square brackets) duiden aan dat het om een array gaat.

Voorbeelden van array declaraties kunnen dus bijvoorbeeld zijn:

int[] verkoopCijfers;
double[] gewichtHuisdieren;
bool[] examenAntwoorden;
ConsoleColor[] mijnKleuren;

Op dit punt bestaan de arrays nog niet echt. Hun lengte ligt nog niet vast en in het geheugen is enkel een klein stukje geheugen gereserveerd voor een referentie (wat we zo meteen gaan uitleggen).

Stel dat je een array van strings wenst waarin je verschillende kleuren zal plaatsen dan schrijf je:

string[] myColors;

Vervolgens kunnen we later waarden toekennen aan de array:

string[] myColors;
myColors = {"red", "green", "yellow", "orange", "blue"};

Je array zal vanaf dit punt een lengte van 5 hebben en kan niet meer groeien of krimpen.

Manier 2

Indien je ogenblikkelijk waarden wilt toekennen (initialiseren) tijdens het aanmaken van de array zelf dan mag dit ook als volgt:

string[] myColors = {"red", "green", "yellow", "orange", "blue"};

Ook hier zal vanaf dit punt je array een vaste lengte van 5 elementen hebben.

Merk op dat deze manier dus enkel werkt indien je reeds weet welke waarden in de array moeten. In manier 1 kunnen we perfect een array aanmaken en pas veel later in het programma ook effectief waarden toekennen (bijvoorbeeld door ze stuk per stuk door een gebruiker te laten invoeren).

Manier 3

Nog een andere manier om arrays aan te maken is de volgende, waarbij je aangeeft hoe groot de array moet zijn, zonder reeds effectief waarden toe te kennen:

string[] myColors;
myColors = new string[5];

Uiteraard kan dit ook in 1 stap:

string[] myColors = new string[5];

We geven hier aan dat de array vanaf z'n prille bestaan 5 elementen kan bevatten. Deze elementen zullen allemaal de defaultwaarde van hun datatype krijgen. In het geval van string hier zal de array dus 5 lege string-elementen bevatten ("" of string.Empty).

Ook hier geldt dat de lengte vanaf dan vastligt en niet meer kan veranderen.

Elementen van een array aanpassen en uitlezen

Van zodra er waarden in een array staan of moeten bijgeplaatst worden kan je deze benaderen met de zogenaamde array accessor notatie. Deze notatie is heel eenvoudigweg de volgende:

myColors[2]; //element met index 2

We plaatsen de naam van de array, gevolgd door vierkante haakjes waarbinnen een getal, 2 in dit voorbeeld, aangeeft het hoeveelste element we wensen te benaderen (lezen en/of schrijven). Deze nummering start vanaf 0.

De index van een C#-array start steeds bij 0. Indien je dus een array aanmaakt met lengte 5 dan heb je de indices 0 tot en met 4.

Lengte is 5, index laatste element is 4, eerste element is 0.

Veelgemaakte fouten bij arrays gebeuren op de lengte en indexering ervan

Het gebeurt vaak dat beginnende programmeurs verward geraken omtrent het aanmaken van een array aan de hand van de lengte en het indexeren erna. Maar niet getreurd, ik zal je hier extra tips geven.

De regels zijn duidelijk:

  • Bij het maken van een array is de lengte van een array gelijk aan het aantal elementen dat er in aanwezig is. Dus een array met 5 elementen heeft als lengte 5.
  • Bij het schrijven en lezen van individuele elementen uit de array (zie hierna) gebruiken we een indexering die start bij 0. Bijgevolg is 4 de index van het laatste element in een array met lengte 5.

Lezen

We weten nu hoe we individuele waarden in een array kunnen benaderen. Ze gebruiken is exact hetzelfde zoals we in het verleden al met eender welke andere variabele hebben gedaan. Het enige verschil is dat de identifier vierkante haken met een index in bevat om aan te geven welke element we nodig hebben van de array.

Wanneer je dus het tweede element van een array wenst te gebruiken kan dit bijvoorbeeld als volgt:

Console.WriteLine(myColors[1]);

of ook

string kleurkeuze = myColors[1];

of zelfs

if(myColors[1] == "pink")

Kortom, alles wat je al kon, kan ook met arrays. Je kan ze zelfs als parameters aan methoden meegeven of terugkrijgen (zie verder). De individuele elementen in een array zijn gewoon variabelen (enkel hun naamgeving is gekoppeld aan die van de array en de index van het element in de array).

Een array proberen te tonen als volgt gaat niet:

Console.WriteLine(myColors);

De enige manier alle elementen van een array te tonen is door manueel ieder element individueel naar het scherm te sturen. Bijvoorbeeld:

for(int i = 0 ; i<myColors.Length;i++)
{
    Console.WriteLine($"{myColors[i]}");
}

Stel dat we een array van getallen hebben, dan kunnen we bijvoorbeeld 2 waarden uit die array optellen en opslaan in een andere variabele als volgt:

int[] numbers = {5, 10, 30, 45};
int som = numbers[0] + numbers[1];

De variabele som zal dan vervolgens de waarde 15 bevatten (5+10).

Stel dat we alle elementen uit de array numbers met 5 willen verhogen, dan kunnen we schrijven:

int[] numbers = {5, 10, 30, 45};
numbers[0] += 5;
numbers[1] += 5;
numbers[2] += 5;
numbers[3] += 5;

Maar eigenlijk zijn we dan het voordeel van arrays niet aan het gebruiken. Met loops maken we bovenstaande oplossing beter zodat deze zal werken, ongeacht het aantal elementen in de array:

for(int teller = 0; teller < numbers.Length; teller++)
{
    numbers[teller] += 5;
}

Zoals je merkt zijn loops en arrays dikke vrienden.

Schrijven

Ook schrijven van waarden naar een array gebruikt dezelfde notatie. Enkel moet je dus deze keer de array accessor-notatie links van de toekenningsoperator plaatsen. Stel dat we bijvoorbeeld de waarde van het eerste element uit de myColors array willen veranderen van red naar indigo, dan gebruiken we volgende notatie:

myColors[0] = "indigo";

Als we bij aanvang nog niet weten welke waarden de individuele elementen moeten hebben in een array, dan kunnen we deze eerst definiëren, en vervolgens individueel toekennen:

string[] myColors;
myColors = new string[5];
// ...
myColors[0] = "red";
myColors[1] = "green";
myColors[2] = "yellow";
myColors[3] = "orange";
myColors[4] = "blue";

Een veel gestelde vraag wanneer een programmeur het nut van arrays nog niet 100% ziet is het volgende. Stel dat je deze code hebt;

int dag1 = 34;
int dag2 = 45;
int dag3 = 0;
int dag4 = 34;
int dag5 = 12;
int dag6 = 0;
int dag7 = 23;

"Kan ik die namen (dag1, dag2, enz.) met een loop genereren/bereiken zodat ik iets kan doen als volgt?" OPGELET! Hier komt een zeer fout voorbeeld aan...

for(int i=1; i<=7; i++)
    dagi = ...

Dat gaat niet! Van zodra je van plan bent om variabele-namen "dynamisch" in je code te proberen aan te roepen, moeten er tal van alarmbelletjes afgaan. De kans is dan héél groot dat je probleem beter met een array wordt opgelost dan met een boel variabelen met soortgelijke namen.

De lengte van de array te weten komen

Soms kan het nodig zijn dat je in een later stadium van je programma de lengte van je array nodig hebt. De Length-eigenschap van iedere array geeft dit weer. Volgend voorbeeld toont dit:

string[] myColors = {"red", "green", "yellow", "orange", "blue"};
Console.WriteLine($"Length of array = {myColors.Length}" );

De Length-eigenschap wordt vaak gebruikt in for/while loops waarmee je de hele array wenst te doorlopen. Door de Length-eigenschap te gebruiken als grenscontrole verzekeren we er ons van dat we nooit buiten de grenzen van de array zullen lezen of schrijven:

//Alle elementen van een array tonen
for (int i = 0; i < getallen.Length; i++)
{
    Console.WriteLine(getallen[i]);
}

Elementen benaderen buiten de range van een array geeft erg dikke errors. Het jammerlijke is dat VS dit soort subtiele 'out of range' bugs niet kan detecteren tijdens het compileren. Je zal ze pas ontdekken bij de uitvoer. Volgende code zal perfect gecompileerd worden, maar bij de uitvoer zal er op lijn 2 een error verschijnen en het programma zal stoppen:

string[] myColors = { "red", "green", "yellow", "orange", "blue" };
Console.WriteLine(myColors[9]);

Dit zal resulteren in een "Out of Range exception".

Hackers misbruiken dit soort fouten in code om toegang tot delen van het geheugen te krijgen waar ze eigenlijk niet mochten zijn. Dit zijn zogenaamde buffer overflow attacks.

Sorry dat ik je al weer lastig val. Maar ik wil je nog eens extra goed naar bovenstaande fout (exception) laten kijken. Prent dieOut of Range fout goed in je hoofd.

Deze fout zegt exact wat er mis is: je probeert elementen in een array te benaderen die niet bestaan omdat je buiten het bereik (range) van de array bent gegaan. Het is hetzelfde als wanneer ik tegen m'n personeel zeg "ga jij de muur alvast metsen op de zesde verdieping (etage[5])" terwijl we een flatgebouw met maar 3 verdiepingen hebben (.Length is dus 3).

Volledig voorbeeldprogramma met arrays

Met al de voorgaande informatie is het nu mogelijk om vlot complexere programma's te schrijven, die veel data moeten kunnen verwerken. Meestal gebruikt men een for-loop om een bepaalde operatie over de hele array toe te passen.

Het volgende programma zal een array van integers aanmaken die alle gehele getallen van 0 tot 99 bevat. Vervolgens zal ieder getal met 3 vermenigvuldigd worden. Finaal tonen we enkel die getallen die een veelvoud van 4 zijn na de bewerking.

//Array aanmaken
int[] getallen = new int[100]; 
//Array vullen
for (int i = 0; i < getallen.Length; i++)
{
    getallen[i] = i;
}
//Alle elementen met 3 vermenigvuldigen
for (int i = 0; i < getallen.Length; i++)
{
    getallen[i] = getallen[i] * 3;
}
//Enkel veelvouden van 4 op het scherm tonen
for (int i = 0; i < getallen.Length; i++)
{
    if(getallen[i] % 4 == 0)
        Console.WriteLine(getallen[i]);
}

Opstartparameters via args

Zoals al in het vorige hoofdstuk beloofd wordt hopelijk nu ook duidelijk(er) wat string[] args wil zeggen in je Main. Iedere Main heeft volgende methode-signatuur:

static void Main(string[] args)

De args arrays kunnen we in ons programma uitlezen om eventuele opstartparameters te verwerken die de gebruiker meegaf aan het programma. We hebben dit nog nooit in-depth bekeken, maar laten we eens kijken hoe je dit doet. Volg daarom volgende stappenplan:

  1. Maak een nieuw console-project aan genaamd argstest.
  2. Voeg volgende code toe in je Main:
for (int i = 0; i < args.Length; i++)
{
    Console.WriteLine(args[i]);
}
  1. Compileer je programma. Run het gerust al eens, je zal zien dat het programma nog niet veel doet. Waarom? Omdat we geen opstartparameters hebben meegegeven. Laten we dat oplossen!
  2. Ga via je verkenner naar je project-folder (vanuit VS kan dit snel door in de Solution Explorer te rechterklikken op je project en dan de optie "Open folder in Explorer" te kiezen).
  3. Open de bin folder, en open daarin dan de debug folder, gevolgd door de net6.0 folder (die laatste kan mogelijk anders zijn, afhankelijk van welke .NET versie je gebruikt). Hier staat je gecompileerde programma. In principe kan je hier dubbelklikken op je applicatie, maar dat zal niet veel doen, daar we nog steeds geen opstartparameters hebben meegegeven.
  4. Nu goed opletten: klik in je verkenner bovenaan in de adresbalk, rechts van de tekst (niet er op). Je kan nu zelf iets intypen. Typ nu cmd in en druk enter.
  5. Cool he. Je zit nu in een shell in de juiste folder.
  6. Nu kan je je programma runnen mét opstartparameters. Kijk maar eens wat er gebeurt als je typt: argstest ziescherp is cool

Inderdaad. De spaties gelden als "splitsing" tussen ieder argument. En dus ieder woord zal een apart element in de args array worden. Je zou nu bijvoorbeeld code kunnen schrijven die iets doet afhankelijk van de parameter, etc.

Geheugengebruik bij arrays

Met arrays komen we voor het eerst iets dichter tot één van de sterktes van C#, namelijk het aspect referenties. Vanaf het volgende hoofdstuk zullen we hier ongelooflijk veel mee doen, maar laten we nu alvast eens kijken waarom arrays met referenties werken.

Reference types en value types

In C# heb je twee soorten variabelen die we nu kort toelichten maar in het volgende hoofdstuk verder zullen uitdiepen:

  • Value types: deze variabelen bevatten effectief de waarde die de variabele moet hebben. Als we schrijven int age = 5 dan bewaren we de binaire voorstelling voor het geheel getal 5 in het geheugen.
  • Reference types: deze variabelen bewaren een geheugenadres naar een andere plek in het geheugen waar de effectieve waarde(n) van de variabele te vinden is. Reference types zijn als het ware een wegwijzer en worden ook soms pointers genoemd.

Alle datatypes die we tot nog toe zagen (string is een speciaal geval en negeren we om nachtmerries te vermijden) werken steevast by value. Momenteel zijn het enkel arrays die we kennen die by reference werken in C#. In het volgende hoofdstuk zullen we zien dat er echter nog een hele hoop andere mysterieuze dingen (genaamd objecten) zijn die ook by reference werken.

Arrays kopiëren

Het probleem als je arrays wilt kopiëren

Arrays worden 'by reference' gebruikt in C#. Dit wil zeggen dat als we schrijven:

int[] getallen = {5,42,2};
int age = 5

we in getallen enkel een geheugenadres bewaren dat wijst naar de plek waar de effectieve waarden staan elders in het geheugen. De afbeelding op volgende pagina geeft dit weer.

De wolk stelt het werkgeheugen voor. De geheugenadressen zijn willekeurig.

Het gevolg van voorgaande is dat volgende code niet zal doen wat je vermoedelijk wenst:

string[] ploegen = {"Beerschot", "Antwerp"};
string[] nieuwePloegen = {"Anderlecht", "Brugge"};
nieuwePloegen = ploegen;

De situatie wanneer lijn 2 werd uitgevoerd is de volgende:

Beerschot is de ploeg van't stad ;)

Zonder het bestaan van references zou je verwachten dat op lijn 3 nieuwePloegen een kopie krijgt van de inhoud van ploegen.

De derde lijn(nieuwePloegen = ploegen;) zal perfect werken. Wat er echter is gebeurd, is dat we de referentie naar ploegen ook in nieuwePloegen hebben geplaatst. Bijgevolg verwijzen beide variabelen naar dezelfde array, namelijk die waar ploegen al naar verwees. We hebben een soort alias gemaakt en kunnen nu op twee manieren de array met de Antwerpse voetbalploegen benaderen. De nieuwe situatie na lijn 3 is dus de volgende geworden:

Beerschot is nog steeds de ploeg van't stad!

Als je vervolgens schrijft:

nieuwePloegen[1] = "Beerschot";

Dan is dat hetzelfde als onderstaande schrijven daar beide variabele naar dezelfde array-inhoud verwijzen. Het effect zal dus hetzelfde zijn.

ploegen[1] = "Beerschot";

En waar staan de ploegen in de nieuwePloegen array ("Anderlecht" en "Brugge")? Die array in het geheugen is niet meer bereikbaar (de garbage collector zal deze ten gepaste verwijderen, wat in hoofdstuk 10 zal toegelicht worden).

Dus, hoe moet je wel te werk gaan? Draai snel deze pagina om !

De oplossing als je arrays wilt kopiëren

Wil je arrays kopiëren dan kan dat niet als volgt:

string[] ploegen = {"Beerschot", "Antwerp"};
string[] nieuwePloegen = {"Anderlecht", "Brugge"};
nieuwePloegen = ploegen; //FAIL!!!

Je moet manueel ieder individueel element van de ene naar de andere array kopiëren als volgt:

for(int i = 0; i < ploegen.Length; i++)
{
    nieuwePloegen[i] = ploegen[i];
}

Er is een ingebouwde methode in de Array-bibliotheek (deze bibliotheek zien we in de volgende sectie) die ook toelaat om arrays te kopiëren genaamd Copy.

Wanneer je met arrays van objecten (zie hoofdstuk 12) werkt dan zal bovenstaande mogelijk niet het gewenste resultaat geven daar we nu ook de individuele referenties van een object kopiëren!

System.Array

Je kan de System.Array bibliotheek gebruiken om je array-code te vereenvoudigen. Deze bibliotheek bevat naast de .Length eigenschap, ook enkele nuttige methoden zoals BinarySearch(), Sort(), Copy en Reverse(). Het gebruik hiervan is bijna steeds hetzelfde zoals volgende voorbeelden tonen.

De eerste zin is een vereenvoudiging (wat we in hoofdstuk 13 zullen ontdekken). Technisch gezien erven alle arrays over van System.Array omdat dit niet een bibliotheek, maar een klasse is.

Sort: Arrays sorteren

Om arrays te sorteren roep je de Sort()-methode op en geef je als parameter de array mee die gesorteerd moet worden. Volgend voorbeeld toont hier het gebruik van:

string[] myColors = {"red", "green", "yellow", "orange", "blue"};
Array.Sort(myColors); //Sorteren maar
//Toon resultaat van sorteren
for (int i = 0; i < myColors.Length; i++)
{
    Console.WriteLine(myColors[i]);
}

Wanneer je de Sort-methode toepast op een array van strings dan zullen de elementen alfabetisch gerangschikt worden. Uiteraard werkt dit ook op arrays van andere datatypes, zolang C# maar weet hoe dit type gesorteerd moet worden (getallen van klein naar groot, tekst volgens de regels van het alfabet, enums volgens hun interne voorstelling).

Reverse: Arrays omkeren

Met de Array.Reverse()-methode kunnen we dan weer de volgorde van de elementen van de array omkeren (dus het laatste element vooraan zetten en zo verder):

Array.Reverse(myColors);

Clear: Arrays leegmaken

Een array volledig leegmaken waarbij alle elementen op hun standaard waarde zetten (bv. 0 bij int, enz.) doe je met de Array.Clear()-methode, als volgt:

Array.Clear(myColors,0, myColors.Length);

Hierbij geeft de tweede parameter aan vanaf welke index moet leeggemaakt worden, en de derde hoeveel elementen vanaf die index.

Copy : array by value kopiëren

De .Copy() behelst iets meer werk, daar deze methode:

  • een reeds aangemaakte, nieuwe array nodig heeft, waar naar gekopiëerd moet worden.
  • moet meekrijgen hoe lang de bronarray (source) is, of hoeveel elementen uit de bronarray moeten gekopiëerd worden.

Volgend voorbeeld toont hoe we alle elementen uit myColors kunnen kopiëren naar een nieuwe array copyColors. De eerste parameter is de bron-array, dan de doel-array en finaal het aantal elementen dat moet gekopiëerd worden:

string[] myColors = { "red", "green", "yellow", "orange", "blue" };
string[] copyColors = new string[myColors.Length];
Array.Copy(myColors, copyColors, myColors.Length);

Willen we enkel de eerste twee elementen kopiëren dan zou dat er als volgt uitzien:

Array.Copy(myColors, copyColors, 2);

Bekijk zeker ook de overloaded versies die de .Copy() methode heeft. Zo kan je ook een bepaald stuk van een array kopiëren en ook bepalen waar in de doel-array dit stuk moet komen.

BinarySearch: Zoeken in arrays

De BinarySearch-methode maakt het mogelijk om te zoeken naar de index van een gegeven element in een array.

De BinarySearch-methode werkt enkel indien de elementen in de array gesorteerd staan!

Je geeft aan de methode 2 parameters mee: enerzijds de array in kwestie en anderzijds het element dat we zoeken. Als resultaat wordt de index van het gevonden element teruggegeven. Indien niets wordt gevonden zal het resultaat negatief zijn.

Volgende code zal bijvoorbeeld de index teruggeven van de kleur "red" indien deze in de array myColors staat:

int indexRed = Array.BinarySearch(myColors, "red");

Volgend voorbeeld toont het gebruik van deze methode:

int[] metingen = {224, 34, 156, 1023, -6};
Array.Sort(metingen); //anders zal BinarySearch niet werken

Console.WriteLine("Welke meting zoekt u?");
int keuze = int.Parse(Console.ReadLine());

int index = Array.BinarySearch(metingen, keuze);
if(index >= 0)
    Console.WriteLine($"{keuze} gevonden op {index}");
else
    Console.WriteLine("Niet gevonden");

Algoritmes en arrays

Omdat arrays ongelooflijk groot kunnen worden, is het nuttig dat je algoritmes kunt schrijven die vlot met arrays kunnen werken. Je wilt niet dat je programma er 3 minuten over doet om gewoon te ontdekken of een bepaalde waarde in een array voorkomt of niet.

Bij jobsollicaties voor programmeurs word je soms gevraagd om dergelijke algoritmes zonder hulp uit te schrijven.

Manueel zoeken in arrays

Het nadeel van BinarySearch is dat deze vereist dat je array-elementen gesorteerd staan. Uiteraard is dit niet altijd gewenst. Stel je voor dat je een simulatie maakt voor een fietswedstrijd en wilt weten of een bepaalde wielrenner in de top 5 staat.

Het zoeken in arrays kan met behulp van loops tamelijk snel. Volgend programmaatje gaat zoeken of het getal 12 aanwezig is in de array (de wielrenners werken met rugnummers). Indien ja dan wordt de index bewaard van de positie in de array waar het getal staat:

int teZoekenGetal = 12;
int[] top5 = { 5, 10, 12, 25, 16 };
bool gevonden = false;
int index = 0;
do
{
    if (top5[index] == teZoekenGetal)
    {
        gevonden = true;
    }
    index++;
} while ( !gevonden && index < top5.Length);

if (gevonden)
{
    Console.WriteLine($"Rugnummer {teZoekenGetal} eindigde op plek {index}");
    
}

Manueel zoeken met while

We tonen nu een voorbeeld van hoe je kan zoeken in een array wanneer we bijvoorbeeld 2 arrays hebben die 'synchroon' zijn. Daarmee bedoelen we: de eerste array bevat bijvoorbeeld producten, de tweede array bevat de prijs van ieder product. De prijs van de producten staat steeds op dezelfde index in de andere array (de prijs van peren is dus 6.2, meloenen 2.9, enz.) :

string[] producten = {"appelen", "peren", "meloenen"};
double[] prijzen = {3.3, 6.2, 2.9};

We vragen nu aan de gebruiker van welk product de prijs getoond moet worden:

Console.WriteLine("Welke productprijs wenst u?");
string keuzeGebruiker = Console.ReadLine();

We tonen vervolgens hoe we met while eerst het juiste product zoeken en dan vervolgens die index bewaren en gebruiken om de prijs te tonen:

bool gevonden = false;
int productIndex = -1;
int teller = 0;
while (teller < producten.Length && keuzeGebruiker != producten[teller])
{
    teller++;
}
if (teller != producten.Length) //product gevonden!
{
    gevonden = true;
    productIndex = teller;
}
if (gevonden)
{
    Console.WriteLine($"Prijs van {keuzeGebruiker} is {prijzen[productIndex]}");
}
else
{
    Console.WriteLine("Niet gevonden");
}

Dat was het? Er zijn tal van andere algoritmes. Denk maar aan de verschillende manieren om arrays te sorteren (bijvoorbeeld de fameuze bubblesort en quicksort algoritmes). Al deze algoritmes hier bespreken zou een boek apart vereisen. We toonden daarom enkele ter illustratie.

String en arrays

Het type string is niet meer dan een arrays van karakters, char[]. Het is dan ook logisch dat we dit erg belangrijke datatype even apart toelichten en enkele nuttige methoden tonen om strings te manipuleren.

String naar char array

Om een string per karakter te bewerken is het aanbevolen om deze naar een char-array om te zetten (en nadien terug naar een string). Dit kan gebruikmakend van .ToCharArray() als volgt:

string origineleZin = "Ik ben Tom";
char[] karakters = origineleZin.ToCharArray();
karakters[8] = 'i';

De array zal nu het volgende bevatten:Ik ben Tim.

Char array naar string

Ook de omgekeerde weg is mogelijk. De werking is iets anders en maakt gebruik van new string(), let vooral op hoe we de char array doorgeven als argument bij het aanmaken van een nieuwe string in lijn 3:

char[] alleKarakters = {'h', 'a', 'l', 'l', 'o'};
alleKarakters[2] = 'x';
string woord = new string(alleKarakters);
Console.WriteLine(woord);

De uitvoer van deze code zal zijn: haxlo.

Andere nuttige methoden met strings

Volgende methoden kan je rechtstreeks op string-variabelen oproepen:

Length

Geeft het totaal aantal karakters in de string wat logisch is, daar het om een array gaat:

string myName = "Tim";
Console.WriteLine(myName.Length); //er verschijnt 3 op het scherm

IndexOf

Deze methode geeft een int terug die de index bevat waar de string die je als parameter meegaf begint. Je kan deze index gebruiken om te ontdekken of een bepaald woord bijvoorbeeld in een grote lap tekst voorkomt zoals volgend voorbeeld toont:

string boek = "Ik ben Reinhardt";
int index = boek.IndexOf("ben");
Console.WriteLine(index); 

Er zal 3 verschijnen, daar "ben" start op positie 3 ("ik" staat op positie 0 en 1, gevolgd door een spatie op positie 2). Indien de string niet gevonden werd, zal index de waarde -1 krijgen.

Trim

Trim() verwijdert alle onnodige spaties en andere onzichtbare tekens vooraan en achteraan de string. Deze methode geeft de opgekuiste string terug als resultaat, je moet deze dus bewaren. In het volgende voorbeeld overschrijven we de originele string met z'n opgekuiste versie:

string boek = "   Ik ben Reinhardt   ";
Console.WriteLine(boek);
boek = boek.Trim();
Console.WriteLine(boek);

Dit zal de output op het scherm zijn (de spaties achteraan op lijn 1 zie je niet, maar zijn er dus wel):

   Ik ben Reinhardt   
Ik ben Reinhardt

ToUpper en ToLower

ToUpper zal de meegegeven string naar ALLCAPS omzetten en geeft de nieuwe string als resultaat terug. ToLower()doet het omgekeerde.

string boek = "Ik ben Reinhardt";
Console.WriteLine(boek.ToUpper());
Console.WriteLine(boek.ToLower());

Output op het scherm:

IK BEN REINHARDT
ik ben reinhardt

Replace

Replace(string old, string news) zal in de string alle substrings die gelijk zijn aan old vervangen door de meegegeven news string en deze nieuwe string als resultaat teruggeven.

Volgende voorbeeld toont dit en zal "Mercy" vervangen door "Reinhardt":

string boek = "Ik ben Mercy";
boek = boek.Replace("Mercy","Reinhardt");
Console.WriteLine(boek);

Replace kan je ook misbruiken om bijvoorbeeld alle woorden uit een stuk tekst te verwijderen door deze te vervangen door een lege string met de waarde "". Volgende code zal alle "e"'s uit de tekst verwijderen:

string boek = "Ik ben Mercy";
boek = boek.Replace("e", "");
Console.WriteLine(boek);

Waardoor we Ik bn Mrcy op het scherm krijgen.

Remove

Remove(int start, int lengte) zal op de index start alle lengte volgende karakters in de string verwijderen en een nieuwe, kortere string als resultaat geven.

Volgend voorbeeld zal het stukje "ben " uit de string weghalen:

string boek = "Ik ben Mercy";
boek = boek.Remove(3,4);
Console.WriteLine(boek);

Output op het scherm:

Ik Mercy

In voorgaande voorbeeld vertelden we de methode "verwijder alles vanaf het element met index 3 (de b) en dit gedurende 4 tekens (dus tot en mét de spatie na ben)".

Split

Volgende twee methoden zijn static en moet je via de klasse String doen en niet via de objecten zelf. We leggen in hoofdstuk 11 uit waarom dat is.

De Split methode laat toe een string te splitsen op een bepaald teken. Het resultaat is steeds een array van strings.

string data = "12,13,20";
string[] gesplitst = data.Split(',');

for(int i = 0; i<gesplitst.Length;i++)
{
    Console.WriteLine(gesplitst[i]);
}

Uiteraard kan je dit dus gebruiken om op eender welk char te splitsen en dus niet enkel een ',' (komma).

Join

Via Join kunnen we een array van strings terug samenvoegen. Het resultaat is een nieuwe string.

Volgende voorbeeld zal de eerder gesplitste array van het vorige voorbeeld opnieuw samenvoegen maar nu met telkens een ; tussen iedere string:

string joined = String.Join(";", gesplitst);

Methoden en arrays

Zoals alle datatypes kan je ook arrays van eender welk datatype als parameter gebruiken bij het schrijven van een methode. Lees nu volgende waarschuwing extra aandachtig, a.u.b:"

Herinner je dat arrays by reference werken. Je werkt dus steeds met de origineel meegegeven array (of beter, de referentie er naar), ook in de methode. Als je dus aanpassingen aan de array aanbrengt in de methode, dan zal dit ook gevolgen hebben op de array van de methode van waaruit we de methode aanriepen (logisch: het gaat om dezelfde array).

Stel dat je bijvoorbeeld een methode hebt die als parameter 1 array van ints meekrijgt. De methode zou er dan als volgt uitzien.

static void EenVoorbeeldMethode(int[] inArray)
{
 
}

Om deze methode aan te roepen volstaat het om een bestaande array als parameter mee te geven:

int[] getallen = {1, 2, 3};
EenVoorbeeldMethode(getallen);

Array grootte in de methode

Een array als parameter meegeven kan dus, maar een ander aspect waar rekening mee gehouden moet worden is dat je niet kan ingeven in de parameterlijst hoe groot de array is. Je zal dus in je methode steeds de grootte van de array moeten uitlezen met de .Length-eigenschap.

Volgende methodesignatuur is dus FOUT!

static void EenVoorbeeldMethode(ref int[6] inArray)
{
 
}

En zal volgende error genereren:

Duidelijk toch!

Arraymethode voorbeeld

Volgend voorbeeld toont een methode die alle getallen van de meegegeven array op het scherm zal tonen:

static void ToonArray(int[] getalArray)
{
    Console.WriteLine("Array output:");
    for (int i = 0; i < getalArray.Length; i++)
    {
        Console.WriteLine(getalArray[i]);
    }
}

De ToonArray methode aanroepen kan dan als volgt:

int[] leeftijden = {2, 5, 1, 6};
ToonArray(leeftijden);

En de output zal dan zijn:

Array output:
2
5
1
6

Voorbeeldprogramma met methoden

Volgend programma toont hoe we verschillende onderdelen van de code in methoden hebben geplaatst zodat:

  1. de lezer van de code sneller kan zien wat het programma juist doet
  2. code herbruikbaar is

Begrijp je wat dit programma doet? En kan je voorspellen wat er op het scherm zal komen?

static void VulArray(int[] getalArray)
{
    for (int i = 0; i < getalArray.Length; i++)
    {
        getalArray[i] = i;
    }
}

static void VermenigvuldigArray(int[] getalArray, int multiplier)
{
    for (int i = 0; i < getalArray.Length; i++)
    {
        getalArray[i] = getalArray[i] * multiplier;
    }
}

static void ToonVeelvouden(int[] getalArray, int veelvoudenvan)
{
    for (int i = 0; i < getalArray.Length; i++)
    {
        if (getalArray[i] % veelvoudenvan == 0)
            Console.WriteLine(getalArray[i]);
    }
}

static void Main(string[] args)
{
    int[] getallen = new int[100];
    VulArray(getallen);
    VermenigvuldigArray(getallen, 3);
    ToonVeelvouden(getallen, 4);
} 

Array als return-type bij een methode

Een array kan ook gebruikt worden als het returntype van een methode. Hiervoor zet je gewoon het type array als returntype (wederom zonder de grootte) in de methodesignatuur.

Stel bijvoorbeeld dat je een methode hebt die een int-array aanmaakt van een gegeven grootte waarbij ieder element van de array reeds een beginwaarde heeft die je ook als parameter meegeeft:

static int[] MaakArray(int lengte, int beginwaarde)
{
    int[] resultArray = new int[lengte];
    for (int i = 0; i < lengte; i++)
    {
        resultArray[i] = beginwaarde;
    }
    return resultArray;
}

De aanroep van deze methode vereist dan dat je het resultaat opvangt in een nieuwe variabele, als volgt:

int[] mijnNieuweArray = MaakArray(4,666);

Onthoud dat arrays altijd by reference naar en van methoden komen. Je werkt dus op de originele array, niet op een kopie er van!

Snel, zet je helm op, voor er ongelukken gebeuren! We hadden al enkele keren gezegd dat arrays by reference worden meegegeven, maar wat is daar nu het gevolg van? Wel, laten we eens naar volgende programmaatje kijken dat ik heb geschreven om de nummering van de appartementen in een flatgebouw aan te passen. Zoals je weet is het gelijkvloers in sommige landen 0, terwijl in andere dit 1 is. Volgende programma past het nummer van het gelijkvloers aan:

static void PasAan(int[] inarr)
{
        inarr[0] = 0;
}

public static void Main()
{
    int[] verdiepnummers = {1,2,3};
    Console.WriteLine($"VOOR:{verdiepnummers[0]}"); // VOOR:1
    PasAan(verdiepnummers);
    Console.WriteLine($"NA:{verdiepnummers[0]}"); // NA:0
}

Dankzij het feit dat we aan PasAan een array meegeven by reference zal de methode werken op de originele array en is deze code dus mogelijk.

Vergelijk dit met volgende voorbeeld waar we een int als parameter meegeven die by value en niét by reference wordt meegegeven:

static void PasAan(int inArray)
{
        inArray = 0; //inArray wordt 0
}
public static void Main()
{
    int[] getallen = {1,2,3};
    PasAan(getallen[0]);
    Console.WriteLine(getallen[0]); // NA:1
}

Daar de methode nu werkt met een kopie, zal de aanpassing in de methode dus geen invloed hebben op de origineel meegegeven int (ongeacht dat die deel uitmaakt van een array).

Meer-dimensionale Arrays

Voorlopig hebben we enkel met zogenaamde 1-dimensionale arrays gewerkt. Je kan echter ook meerdimensionale arrays maken. Denk maar aan een n-bij-m array om een matrix voor te stellen. Denk maar aan het voorbeeld aan de start van dit hoofdstuk waarin we de regenval gedurende 7 dagen wilden meten. Wat als we dit gedurende 4 weken wensen te doen, maar wel niet alle data in één lange array willen plaatsen? We zouden dan een 2-dimensionale array kunnen maken als volgt:

int[,] regen = 
            {
                {34,45,0,34,12,0,23 },
                {34,5,0,74,1,4,5 },
                {7,45,8,24,12,12,13 },
                {34,4,0,34,2,0,23 }
            };

Een tweedimensionale array.

We behandelen meerdimensionale arrays maar kort om de eenvoudige reden dat je die in de praktijk minder vaak zal nodig hebben.

De arrays die we nu behandelen zullen steeds "rechthoekig" zijn. Daarmee bedoelen we dat ze steeds per rij of kolom evenveel elementen zullen bevatten als in de andere rijen of kolommen.

Arrays die per rij of kolom een andere hoeveelheid elementen hebben zijn zogenaamde jagged arrays, welke we verderop kort zullen bespreken.

n-dimensionale arrays aanmaken

Door een komma tussen rechte haakjes te plaatsen tijdens de declaratie van een array, kunnen we meer-dimensionale arrays maken.

Bijvoorbeeld om een 2D array te maken schrijven we:

string[,] boeken;

Een 3D-array:

short[,,] temperaturen;

(enz.)

Ja, dit kan dus ook een 10-dimensionale array aanmaken. Kan handig zijn als je een fysicus bent die rond de supersnaartheorie onderzoek doet.

int[,,,,,,,,,] jeBentGekAlsJeHierMeeWiltWerken;

Ja, 11 kan ook als je meer in de M-theorie gelooft. En zelfs 26 moest de bosonische snaartheorie meer je ding zijn:

int[,,,,,,,,,,,,,,,,,,,,,,,,,] jeBentNogGekkerAlsJeHierMeeWiltWerken;

Initialisatie

Ook om nu effectief een array aan te maken gebruiken we de komma-notatie, alleen moeten we nu ook de effectieve groottes aangeven. Voor een 5 bij 10 array bijvoorbeeld schrijven we (merk op dat dit dus een 2D-array is):

int[,] matrix = new int[5,10];

Om een array ook onmiddellijk te initialiseren met waarden gebruiken we de volgende uitdrukking :

string[,] boeken = 
    {
        {"Macbeth", "Shakespeare", "ID12341"},
        {"Before I Get Old", "Dave Marsh", "ID234234"},
        {"Security+", "Mike Pastore", "ID3422134"},
        {"Zie scherp", "Tim Dams", "ID007"}
    };

Merk op dat we dus nu een 3 bij 4 array maken maar dat dit dus nog steeds een 2D-array is. Iedere rij bestaat uit 3 elementen. We maken letterlijk een array van arrays.

Of bij een 3D:

int[,,] temperaturen = 
    {
        {
            {3,4}, {5,4}
        },
        {
            {12,34}, {35,24}
        },
        {
            {-12,27}, {3,24}
        },
    };

Die we als volgt kunnen visualiseren:

De derde dimensie bestaat uit 3 2-dimensionale 2 bij 2 arrays...

Zoals je ziet worden meerdimensionale arrays snel een kluwen van komma's, accolades en haakjes. Probeer dus je dimensies te beperken. Je zal zelden een 3 -of meer dimensionale array nodig hebben.

De regel is eenvoudig: als je een 7-dimensionale array nodig hebt, is de kans groot dat je een volledig verkeerd algoritme hebt verzonnen, of dat je nog niet aan hoofdstuk 9 bent geraakt, of dat je een topwetenschapper in CERN bent. Choose your reason!

Stel dat we uit de boeken-array de auteur van het derde boek wensen te tonen dan kunnen we schrijven:

Console.WriteLine(boeken[2, 1]);

Dit zal Mike Pastore op het scherm zetten.

En bij de temperaturen:

Console.WriteLine(temperaturen[2, 0, 1]);

Zal 27 terug geven: we vragen van de laatste array ([2]), daarbinnenin de eerste array (rij [0]) en daarvan het tweede (kolom [1]) element.

Lengte van iedere dimensie in een n-dimensionale matrix

Indien je de lengte opvraagt van een meer-dimensionale array dan krijg je de som van iedere lengte van iedere dimensie. Dit is logisch: in het geheugen van een computer worden arrays altijd als 1 dimensionale arrays voorgesteld. Onze boeken array zal bijvoorbeeld dus lengte 12 hebben (3*4) en temperaturen toevallig ook (3x2x2).

Je kan echter de lengte van iedere aparte dimensie te weten komen met de .GetLength() methode die iedere array heeft. Als parameter geef je de dimensie mee waarvan je de lengte wenst:

int arrayRijen = boeken.GetLength(0); //geeft 4 
int arrayKolommen = boeken.GetLength(1); //geeft 3

Het aantal dimensies van een array wordt trouwens weergegeven door de .Rank eigenschap die ook iedere array heeft. Bijvoorbeeld:

Console.WriteLine(boeken.Rank); //geeft 2
Console.WriteLine(temperaturen.Rank); //geeft 3

Willen we dus de lengte van iedere dimensie van bijvoorbeeld de temperaturen array op het scherm krijgen dan kan dat als volgt:

for (int i = 0; i < temperaturen.Rank; i++)
{
    Console.WriteLine(temperaturen.GetLength(i));
}

Jagged Arrays

Jagged arrays (letterlijk gekartelde arrays) zijn arrays van arrays maar van verschillende lengte. De arrays die we totnogtoe zagen moesten steeds rechthoekig zijn. Jagged arrays, zoals de naam doet vermoeden, hoeven dat niet te zijn:

Voorbeeld van een jagged array.

We gaan niet al te diep in deze array ingaan omdat deze, alhoewel erg nuttig, vaak omslachtige, meer foutgevoelige code zullen creëren. Meestal zijn er gezondere alternatieven te gebruiken zoals de verschillende collectie-klassen uit hoofdstuk 12.

Jagged arrays aanmaken

Het grote verschil bij het aanmaken van bijvoorbeeld een 2D jagged array is het gebruik van de vierkante haken (en dus niet bijvoorbeeld tickets[,]):

(double[][]tickets=
    {
      new double[] {3.0, 40, 24},
      new double[] {123, 31.3 },
      new double[] {2.1}
    };)

Indexering bij jagged arrays

De indexering blijft dezelfde, maar ook hier dus niet met komma's, maar met vierkante haken (bijvoorbeeld tickets[0][1]).

Uiteraard moet je er wel rekening mee houden dat niet eender welke index binnen een bepaalde sub-array zal werken, het is dan ook aangeraden om zeker de Length-methode te gebruiken om de sub-arrays op hun lengte te bevragen. Wanneer je .Length bevraagt van de tickets array dan zal je 3 als antwoord krijgen, daar deze 2D array uit 3 sub-arrays bestaat.

Wil je vervolgens de lengte kennen van de middelste sub-array (met dus index 1) dan gebruik je tickets[1].Length.

Object Oriented Programming

Totnogtoe leerden we eigenlijk gestructureerd programmeren wat een programmeerparadigma is uit de jaren zestig. Hierbij schrijven we code gebruik makend van methoden, loops en beslissingsstructuren. Op zich blijft dit een erg nuttige manier van programmeren. Wanneer we echter bij complexere applicaties komen dan merken we dat met gestructureerd programmeren we redelijk snel tot minder intuïtieve en soms nodeloos complexe code aanbelanden.

Dat moet dus anders kunnen. Komt u binnen, Object georiënteerd programmeren (OOP). OOP is een manier van programmeren die voortbouwt op gestructureerd programmeren, maar die toelaat veel krachtigere applicaties te ontwikkelen.

Bij OOP draait alles rond klassen en objecten die intern nog steeds gestructureerde code zullen bevatten (loops, methoden en beslissingsstructuren), maar die onze code (hopelijk) een pak overzichtelijker en minder complex gaan maken. Dankzij OOP gaan we onze code meer modulair, leesbaarder en onderhoudsvriendelijker maken én tegelijkertijd zal ze veel krachtiger worden en daardoor complexere zaken eenvoudiger kunnen "oplossen".

Hier zijn we weer!

Ik zet "oplossen" tussen aanhalingstekens. Net zoals alles binnen dit domein ben jij als programmeur uiteindelijk degene die het boeltje moet oplossen. Code, programmeerparadigma's en bibliotheken zijn niet meer dan nuttig gereedschap in jouw arsenaal van programmeertools. Als jij beslist om een hamer als zaag te gebruiken, tja, dan houd ik m'n hart vast voor het resultaat. Dit geldt ook voor de technieken die je nog in dit boek gaat leren: ze zijn "een tool", niets meer. Jij zal ze nog steeds zo optimaal mogelijk moeten leren gebruiken. Uiteraard is het doel van dit boek je zo duidelijk mogelijk het verschil én de bruikbaarheid van de verschillende nieuwe technieken aan te leren.

Toen C# werd ontwikkeld in 2001 was één van de hoofddoelen van de programmeertaal om "een eenvoudige, moderne, objectgeoriënteerde programmeertaal voor algemene doeleinden" te worden. C# is van de grond af opgebouwd met het OOP programmeerparadigma als primaire drijfveer.

Wanneer we nieuwe programma's in C# ontwikkelden dan zagen we hier reeds bewijzen van. Zo zagen we steeds het keyword class bovenaan staan, telkens we een nieuw project aanmaakten:

namespace WorldDominationTool
{
    internal class Program
    {

De klasse Program zorgt ervoor dat ons programma voldoet aan de C# afspraken die zeggen dat alle C# code in klassen moet staan.

Duizend mammoeten en sabeltandtijgers! Ik dacht dat ik nu wel mee zou zijn met alles wat C# me zou voorschotelen. Helaas, wolharige neushoorn-kaas, niet dus. Ik ga een voorspelling doen: van alle hoofdstukken in dit boek, wordt dit hoofdstuk hetgene waar je het meest je tanden op gaat stuk bijten. Hou dus vol, geef niet te snel op en kom geregeld hier terug. Succes gewenst!

Een wereld zonder OOP: Pong

Om de kracht van OOP te demonstreren gaan we een applicatie van lang geleden (deels) herschrijven gebruik makende van de kennis van gestructureerd programmeren. We gaan de arcadehal klassieker "Pong" namaken, waarbij we als doel hebben om een balletje alvast op het scherm te laten botsen. Een rudimentaire oplossing zou de volgende kunnen zijn:

Console.CursorVisible = false;
int balX = 20;
int balY = 20;
int VectorX = 2;
int VectorY = 1;
while (true)
{
    //Xvector van richting veranderen aan de randen
    if (balX + VectorX >= Console.WindowWidth || balX+VectorX < 0)
    {
        VectorX = -VectorX;
    }
    balX = balX + VectorX; //X positie updaten
    //Yvector van richting veranderen aan de randen
    if (balY + VectorY >= Console.WindowHeight || balY+VectorY < 0)
    {
        VectorY = -VectorY;
    }
    balY = balY + VectorY; //Y positie updaten
    //Output naar scherm sturen
    Console.SetCursorPosition(balX, balY);
    Console.Write("O");
    System.Threading.Thread.Sleep(50); //50 ms wachten
    Console.Clear();
}

Hopelijk begrijp je deze code. Test ze maar eens in een programma. Zoals je zal zien krijgen we een balletje ("O") dat over het scherm vliegt en telkens van richting verandert wanneer het aan de randen van het applicatievenster komt. De belangrijkste informatie zit in de variabelen balX, balY die de huidige positie van het balletje bevatten. Voorts zijn ook VectorX en VectorY belangrijk: hierin houden we bij in welke richting (en met welke snelheid) het balletje beweegt (een zogenaamde bewegingsvector).

Extra balletjes?

Dit soort applicatie in C# schrijven met behulp van gestructureerde programmeer-concepten is redelijk eenvoudig. Maar wat als we nu 2 balletjes nodig hebben? Laten we arrays even links laten liggen en het gewoon eens naïef oplossen. Al na enkele lijnen kopiëren merken we dat onze code ongelooflijk rommelachtig gaat worden en we bijna iedere lijn moeten dupliceren:

Console.CursorVisible = false;
int balX = 20;
int balY = 20;
int vectorX = 2;
int vectorY = 1;

int bal2X = 10;
int bal2Y = 8;
int vector2X = 2;
int vector2Y = -1;

while (true)
{
    if (balX + vectorX >= Console.WindowWidth || balX+ vectorX < 0)
    {
        vectorX = -vectorX;
    }
    if (bal2X + vector2X >= Console.WindowWidth || bal2X + vector2X < 0)
    {
        vector2X = -vector2X;
    }

    balX = balX + vectorX;
    bal2X = bal2X + vector2X;
    //enzovoort

Bijna iedere lijn code moeten we verdubbelen. Arrays zouden dit probleem deels kunnen oplossen, maar we krijgen dan in de plaats de complexiteit van werken met arrays op ons bord, wat voor 2 balletjes misschien wat overdreven is én de code ook weer wat minder leesbaar maakt.

Een wereld met OOP: Pong

Uiteraard zijn we nu eventjes gestructureerd programmeren aan het demoniseren, dit is echter een bekend 21e eeuws trucje om je punt te maken.

Wanneer we Pong vanuit een OOP paradigma willen aanpakken dan is het de bedoeling dat we werken met klassen en objecten. Net zoals aan de start van dit boek ga ik je ook nu even in het diepe gedeelte van het bad gooien. Wees niet bang, ik zal je er tijdig uithalen (en je zal versteld staan hoeveel code je eigenlijk zult herkennen).

Om Pong in OOP te maken hebben we eerst een klasse nodig waarin we ons balletje gaan beschrijven, zonder dat we al een balletje hebben. En dat ziet er zo uit:

class Balletje
{
    //Eigenschappen
    public int X { get; set; }
    public int Y { get; set; }
    public int VectorX { get; set; }
    public int VectorY { get; set; }

    //Methoden
    public void Update()
    {
        if (X + VectorX >= Console.WindowWidth || X + VectorX < 0)
        {
            VectorX = -VectorX;
        }
        X = X + VectorX;


        if (Y + VectorY >= Console.WindowHeight || Y + VectorY < 0)
        {
            VectorY = -VectorY;
        }
        Y = Y + VectorY;
    }

    public void TekenOpScherm()
    {
        Console.SetCursorPosition(X, Y);
        Console.Write("O");      
    }
}

De code voor een nieuwe klasse schrijf je best in een apart bestand in je project. Klik bovenaan in de menu balk op "Project" en kies dan "Add class...". Geef het bestand de naam "Balletje.cs".

Bijna alle code van zonet hebben we hier geïntegreerd in een class Balletje, maar er zit duidelijk een nieuw sausje over. Vooral aan het begin zien we onze 4 variabelen terugkomen in een nieuw kleedje, namelijk als eigenschappen oftewel properties (herkenbaar aan de get en set keyword, waarover later meer). Maar al bij al lijkt de code grotendeels op wat we al kenden. En dat is goed nieuws. OOP gooit de vorige hoofdstukken niet in de vuilbak, het gaat als het ware een extra laag over het geheel leggen. Let ook op het essentiële woordje class bovenaan, daar draait alles natuurlijk om: klassen en objecten.

Een klasse is een blauwdruk van een bepaalde soort 'dingen' of objecten. Objecten zijn de "echte" dingen die werken volgens de beschrijving van de klasse. Ja ik heb zonet 2x hetzelfde verteld, maar het is essentiëel dat je het verschil tussen de termen klasse en object goed begrijpt.

Laten we eens een balletje-object in het leven roepen. In de main schrijven we daarom dit:

Console.CursorVisible = false;
Balletje bal1 = new Balletje();
bal1.X = 20;
bal1.Y = 20;
bal1.VectorX = 2;
bal1.VectorY = 1;

Ok, interessant. Die new heb je al gezien wanneer je met Random ging werken en de code erna is ook nog begrijpbaar: we stellen eigenschappen van het nieuwe bal1 object in. En nu komt het! Kijk hoe eenvoudig onze volledig main nu is geworden:

static void Main(string[] args)
{
    Balletje bal1 = new Balletje();
    bal1.X = 20;
    bal1.Y = 20;
    bal1.VectorX = 2;
    bal1.VectorY = 1;

    while (true)
    {
        bal1.Update();
        bal1.TekenOpScherm();

        System.Threading.Thread.Sleep(50);
        Console.Clear();
    }
}

De loopcode is herleid tot 2 aanroepen van methoden op het bal1 object: .Update() en .TekenOpScherm.

Run deze code maar eens. Inderdaad, deze code doet exact hetzelfde als hiervoor. Ook nu krijgen we 1 balletje dat op het scherm over en weer botst.

En nu - abracadabra - kijk goed hoe eenvoudig onze code blijft als we 2 balletjes nodig hebben:

Console.CursorVisible = false;
Balletje bal1 = new Balletje();
bal1.X = 20;
bal1.Y = 20;
bal1.VectorX = 2;
bal1.VectorY = 1;

Balletje bal2 = new Balletje();
bal2.X = 10;
bal2.Y = 8;
bal2.VectorX = 2;
bal2.VectorY = -1;

while (true)
{
    bal1.Update();
    bal2.Update(); //zo simpel!
    bal1.TekenOpScherm();
    bal2.TekenOpScherm(); //wow, zooo simpel :)
    System.Threading.Thread.Sleep(50);
    Console.Clear();
}

Dit is de volledige code om 2 balletjes te hebben. Hoe mooi is dat?!

De kracht van OOP zit hem in het feit dat we de logica IN DE OBJECTEN ZELF plaatsen. De objecten zijn met andere woorden verantwoordelijk om hun eigen gedrag uit te voeren gebaseerd op externe impulsen en hun eigen interne toestand. In onze main zeggen we aan beide balletjes "update je zelf eens", gevolgd door "teken je zelf eens".

Een artistieke benadering van hoe Pong er vroeger uitzag.

Wanneer we 3 of meer balletjes zouden nodig hebben dan zullen we best arrays in de mix moeten gooien. Onze code blijft echter véél eenvoudiger én krachtiger dan wanneer we in het voorgaande enkel de kennis gebruikten die we totnogtoe hadden. Omdat we toch al in het diepe eind zitten, zal ik hier toch al eens tonen hoe we 100 balletjes op het scherm kunnen laten botsen (we gaan Random gebruiken zodat er wat willekeurigheid in de balletjes zit):

const int AANTAL_BALLETJES = 100;
Random r = new Random();
Balletje[] veelBalletjes = new Balletje[AANTAL_BALLETJES];
for (int i = 0; i < veelBalletjes.Length; i++) //balletjes aanmaken
{
    veelBalletjes[i] = new Balletje();
    veelBalletjes[i].X = r.Next(10, 20);
    veelBalletjes[i].Y = r.Next(10, 20);
    veelBalletjes[i].VectorX = r.Next(-2, 3);
    veelBalletjes[i].VectorY = r.Next(-2, 3);
}

while (true)
{
    for (int i = 0; i < veelBalletjes.Length; i++)
    {
        veelBalletjes[i].Update(); //update alle balletjes
    }
    for (int i = 0; i < veelBalletjes.Length; i++)
    {
        veelBalletjes[i].TekenOpScherm(); //teken alle balletjes
    }
    System.Threading.Thread.Sleep(50);
    Console.Clear();
}

De reden dat we 2 loops gebruiken, in plaats van 1, is omdat we in de update fase eerst alle objecten willen updaten (soms ten opzichte van andere objecten) voor we alles terug op het scherm tekenen. Anders kan het zijn dat je vreemde effecten te zien krijgt als je bijvoorbeeld balletjes tegen elkaar wil laten wegbotsen.

Ok, zwem maar snel naar de kant. We gaan al het voorgaande van begin tot einde uit de doeken doen! Leg die handdoek niet te ver weg, we gaan hem nog nodig hebben.

Draai deze pagina pas om wanneer je uitgeslapen bent. Je opperste concentratie zal voor de volgende 2 pagina's vereist zijn!

Klassen en objecten

Een elementair aspect binnen OOP is het verschil begrijpen tussen een klasse en een object.

Wanneer we meerdere objecten gebruiken van dezelfde soort dan kunnen we zeggen dat deze objecten allemaal deel uitmaken van een zelfde klasse. Het OOP paradigma houdt ook in dat we de echte wereld gaan proberen te modeleren in code. OOP laat namelijk toe om onze code zo te structureren zoals we dat ook in het echte leven doen. Alles (objecten) om ons heen behoort tot een bepaalde klasse die alle objecten van dat type beschrijven.

Neem eens een kijkje aan een druk kruispunt waar fietsers, voetgangers, auto's en allerlei andere zaken samenkomen1. Het is een erg hectisch geheel, toch kan je alles dat je daar ziet classificeren. We zien bijvoorbeeld allemaal mens-objecten die tot de klasse van de Mens behoren, maar ook:

  • Alle mensen hebben gemeenschappelijke eigenschappen (binnen deze beperkte context van een kruispunt): ze bewegen of staan stil (gedrag), ze hebben een bepaalde kleur van jas (eigenschap).
  • Alle auto's behoren tot een klasse Auto. Ze hebben gemeenschappelijke zaken zoals: ze hebben een bepaald bouwjaar (eigenschap), ze werken op een bepaalde vorm van energie (eigenschap) en ze staan stil of bewegen (gedrag).
  • Ieder verkeerslicht behoort tot de klasse VerkeersLicht.
  • Fietsers behoren tot de klasse Fietser.
1

Dit voorbeeld is gebaseerd op de inleiding van het inzichtvolle boek "Handboek objectgeoriënteerd programmeren" door Jan Beurghs (EAN: 9789059406476)

Definitie klasse en object

Volgende 2 definities druk je best af op een grote poster die je boven je bed hangt:

  • Een klasse is als een blauwdruk (of prototype) dat het gedrag en toestand beschrijft van alle objecten van deze klasse.
  • Een individueel object is een instantie van een klasse en heeft een eigen toestand, gedrag en identiteit.

Objecten zijn instanties met een eigen levenscyclus die wordt gekenmerkt door:

  • Gedrag: deze wordt beschreven door de methoden in de klasse.
  • Toestand: deze kan wijzigen door zijn eigen gedrag, of het gedrag van externe impulsen en wordt bepaald door datavelden die beschreven staan in de klasse (properties en instantievariabelen).
  • Identiteit : een unieke naam van object zodat andere objecten ermee kunnen interageren.

Je zou dit kunnen vergelijken met het grondplan voor een huis dat tien keer in een straat zal gebouwd worden. Het plan is de klasse. De effectieve huizen die we, gebaseerd op dat grondplan, bouwen zijn de instanties of objecten van deze klasse en hebben elk een eigen toestand (ander type bakstenen, wel of geen zonnepannelen) en gedrag (rolluiken gaan open als de zon opkomt).

De klasse beschrijft het algemene gedrag van de individuele objecten. Dit gedrag wordt meestal bepaald door de interne staat van ieder object op zichzelf, de zogenaamde eigenschappen. Nemen we het voorbeeld van de klasse Auto: de huidige snelheid van een individueel auto-object is mogelijks gebaseerd op het merk (eigenschap) van die auto, alsook welke energiebron (eigenschap) die auto heeft.

Voorts kunnen objecten ook beïnvloed worden door 'de buitenwereld': naast de interne staat van ieder object, leven de objecten natuurlijk in een bepaalde context, zoals een druk kruispunt. Andere objecten op dat kruispunt kunnen invloed hebben op wat een auto-object doet. Met andere woorden: we kunnen 'van buiten uit' vaak ook het gedrag en de interne staat van een object aanpassen. We hebben dit reeds zien gebeuren in het Pong-voorbeeld: de interne staat van ieder individueel balletjes-object is z'n positie alsook z'n richtingsvector. De buitenwereld, in dit geval onze Main methode kon echter de objecten manipuleren:

  • Het gedrag van een balletje konden we aanpassen met behulp van de Update en TekenOpScherm methode.
  • De interne staat via de eigenschappen die zichtbaar zijn aan de buitenwereld (dankzij het public keyword) .

Wanneer je later de specificaties voor een opdracht krijgt en snel wilt ontdekken wat potentiële klassen zijn, dan is het een goede tip om op zoek te gaan naar de zelfstandige naamwoorden (substantieven) in de tekst. Dit zijn meestal de objecten en/of klassen die jouw applicatie zal nodig hebben.

95% van de tijd zullen we in dit boek de voorgaande definitie van een klasse beschrijven, namelijk de blauwdruk voor de objecten die er op gebaseerd zijn. Je zou kunnen zeggen dat de klasse een fabriekje is dat objecten kan maken. Echter, wanneer we het static keyword zullen bespreken gaan we ontdekken dat heel af en toe een klasse ook als een soort object door het leven kan gaan. Heel vreemd allemaal!

Ook voor dit hoofdstuk en alle hoofdstukken hierna is een Memrise cursus beschikbaar: https://app.memrise.com/course/6383638/zie-scherp-scherper-programmeren-in-c-deel-2/

Abstractie principe

Een belangrijk concept bij OOP is het Black-box principe waarbij we de afzonderlijke objecten en hun werking als zwarte dozen gaan beschouwen.

Neem het voorbeeld van de auto: deze is in de echte wereld ontwikkeld volgens het blackbox-principe. De werking van de auto kennen tot in het kleinste detail is niet nodig om met een auto te kunnen rijden. De auto biedt een aantal zaken aan de buitenwereld aan (het stuur, pedalen, het dashboard), wat we de "interface" noemen, die je kan gebruiken om de interne staat van de auto uit te lezen of te manipuleren. Stel je voor dat je moest weten hoe een auto volledig werkte voor je ermee op de baan kon...

Binnen OOP wordt dit blackbox-concept abstractie genoemd. Het doel van OOP is andere programmeurs (en jezelf) zoveel mogelijk af te schermen van de interne werking van je klasse code. Vergelijk het met de methoden uit hoofdstuk 7: "if it works, it works" en dan hoef je niet in de code van de methode te gaan zien wat er juist gebeurt telkens je de methode wil gebruiken.

Kortom, hoe minder de buitenwereld moet weten om met een object te werken, hoe beter. Beeld je in dat je 10 lijnen code nodig had om een random getal te genereren. Niemand zou de klasse Random nog gebruiken. Dankzij de ontwikkelaar van deze klasse hoeven we maar 2 zaken te kunnen:

  • Een Random-object aanmaken: Random ranGen = new Random();
  • De Next-methode aanroepen om een getal uit het object te krijgen: int getal = ranGen.Next();. Wat er nu juist in die methode gebeurt boeit ons niet. It just works! Met dank aan abstractie en de kracht van OOP.

Objecten in de woorden van Steve Jobs

Steve Jobs, de oprichter van Apple, was een fervent fan van OOP. In een interview in 1994 voor het Rolling Stone magazine gaf hij volgende uitleg:

"Objects are like people. They’re living, breathing things that have knowledge inside them about how to do things and have memory inside them so they can remember things. And rather than interacting with them at a very low level, you interact with them at a very high level of abstraction, like we’re doing right here.

Here’s an example: If I’m your laundry object, you can give me your dirty clothes and send me a message that says, "Can you get my clothes laundered, please." I happen to know where the best laundry place in San Francisco is. And I speak English, and I have dollars in my pockets. So I go out and hail a taxicab and tell the driver to take me to this place in San Francisco. I go get your clothes laundered, I jump back in the cab, I get back here. I give you your clean clothes and say, "Here are your clean clothes."

You have no idea how I did that. You have no knowledge of the laundry place. Maybe you speak French, and you can’t even hail a taxi. You can’t pay for one, you don’t have dollars in your pocket. Yet, I knew how to do all of that. And you didn’t have to know any of it. All that complexity was hidden inside of me, and we were able to interact at a very high level of abstraction. That’s what objects are. They encapsulate complexity, and the interfaces to that complexity are high level."

Objecten in de woorden van Bill Gates

En, omdat het vloeken in de kerk is om Steve Jobs in een C# boek aan het woord te laten, hier wat Microsoft-oprichter Bill Gates over OOP te zeggen had:

"Another trick in software is to avoid rewriting the software by using a piece that’s already been written, so called component approach which the latest term for this in the most advanced form is what’s called Object Oriented Programming."

Ik zie dat je gereedsschapkist al aardig gevuld is. Zoals je misschien al gemerkt hebt aan deze sectie, zullen we vanaf nu ook geregeld minder "praktische" en eerder "filosofische" zaken tegenkomen. Maar wees gerust, je zal toch een grotere gereedsschapkist nodig hebben. Echter, net zoals een voorman niet alleen moet kunnen metsen en timmeren, maar ook stabiliteitsplannen begrijpen, zal ook jij moeten begrijpen wat de grotere ideeën achter bepaalde concepten zijn.

Zet nu je helm maar op, want in de volgende sectie gaan we wel degelijk onze handen lekker vuil maken!

OOP in C#

We kunnen in C# geen objecten aanmaken voor we een klasse hebben gedefinieerd dat de algemene eigenschappen (properties én instantievariabelen) en gedrag (methoden) beschrijft van die objecten.

Klasse maken

Een klasse heeft minimaal de volgende vorm:

class ClassName
{

}

De naam die je een klasse geeft moet voldoen aan de identifier regels uit hoofdstuk 2. Het is echter een goede gewoonte om klassenamen altijd met een hoofdletter te laten beginnen.

Volgende code beschrijft de klasse Auto in C#

class Auto
{

}

Binnen het codeblock dat bij deze klasse hoort zullen we verderop dan de werking via properties en methoden beschrijven.

Klassen in Visual Studio toevoegen

Je kan "eender waar" een klasse aanmaken in een project, maar het is een goede gewoonte om per klasse een apart bestand te gebruiken. Dit kan op 2 manieren.

Manier 1:

  • In de Solution Explorer, rechterklik op je project.
  • Kies "Add".
  • Kies "Class..".
  • Geef een goede naam voor je klasse.

Manier 2:

  • Klik in de menubalk bovenaan op "Project".
  • Kies "Add class..." .

Manier 2 is de snelste. Tip: of maak een eigen toetsenbord shortcut, dat is nog sneller natuurlijk.

Objecten aanmaken

Je kan nu objecten aanmaken van de klasse die je hebt gedefinieerd. Dit kan op alle plaatsen in je code waar je in het verleden ook al variabelen kon declareren, bijvoorbeeld in een methode of je Main-methode.

Je doet dit door eerst een variabele te definiëren en vervolgens een object te instantiëren met behulp van het new keyword. De variabele heeft als datatype Auto:

Auto mijnEersteAuto = new Auto();
Auto mijnAndereAuto = new Auto();

We hebben nu twee objecten aangemaakt van het type Auto die we verderop zouden kunnen gebruiken.

Let goed op dat je dus op de juiste plekken dit alles doet:

  • Klassen maak je aan als aparte bestanden in je project.
  • Objecten creëer je in je code op de plekken waar je deze nodig hebt, bijvoorbeeld in je Main methode bij een Console-applicatie.

De new operator

In het volgende hoofdstuk gaan we kijken wat er allemaal gebeurt in het geheugen wanneer we een object met new aanmaken. Het is echter nu al belangrijk te beseffen dat objecten niet kunnen gemaakt worden zonder new. De new operator vereist dat je aangeeft van welke klasse je een object wilt aanmaken, gevolgd door ronde haakjes (bijvoorbeeld new Student()). We roepen hier een constructor aan (zie verder) die het object in het geheugen zal aanmaken. Vervolgens geeft new een adres terug waar het object zich bevindt. Het is dit adres dat we vervolgens kunnen bewaren in een variabele die links van de toekenningsoperator (=) staat.

Test maar eens wat er gebeurt als je volgende code probeert te compileren:

Auto mijnEersteAuto = new Auto();
Auto mijnAndereAuto;
Console.WriteLine(mijnEersteAuto);
Console.WriteLine(mijnAndereAuto);

Je zal een "Use of unassigned local variable mijnAndereAuto" foutboodschap krijgen. Inderaad, je hebt nog geen object aangemaakt met new en mijnAndereAuto is dus voorlopig een lege doos (het heeft de waarde null).

Dit concept is dus fundamenteel verschillend van de klassieke valuetypes die we al kenden (int, double, enz.). Daar zal volgende code wél werken:

int balans;
Console.WriteLine(balans);

Klassen zijn gewoon nieuwe datatypes

In hoofdstuk 2 leerden we dat er allerlei datatypes bestaan. We maakten vervolgens variabelen aan van een bepaald datatype zodat deze variabele als inhoud enkel zaken kon bevatten van dat ene datatype.

Zo leerden we toen volgende datatypes:

  • Valuetypes zoals int, char en bool.
  • Het enum keyword liet ons toe om een nieuw datatype te maken dat maar een eindig aantal mogelijke waarden (values) kon hebben. Intern bewaarden variabelen van zo'n enum-datatype hun waarde als een int.
  • Arrays waren het laatste soort datatypes. We ontdekten dat we arrays konden maken van eender welk datatype (valuetypes en enums) dat we tot dan kenden.

Wel nu, klassen zijn niet meer dan een nieuw soort datatypes. Kortom: telkens je een klasse aanmaakt, kunnen we in dat project variabelen en arrays aanmaken met dat datatype. We noemen variabelen die een klasse als datatype hebben objecten.

Het grote verschil dat deze objecten zullen hebben is dat ze vaak veel complexer zijn dan de eerdere datatypes die we kennen:

  • Ze zullen meerdere "waarden" tegelijk kunnen bewaren (een int variabele kan maar één waarde tegelijkertijd in zich hebben).
  • Ze zullen methoden hebben die we kunnen aanroepen om de variabele "voor ons te laten werken".

Het blijft ingewikkeld hoor. Heel boeiend om de theorie van een speer te leren, maar ik denk dat ik toch beter een paar keer met een speer naar een mammoet werp om echt te voelen wat OOP is.

Ik onthoud nu alvast "klassen zijn gewoon een nieuwe vorm van complexere datatypes" dan diegene die ik totnogtoe heb geleerd? Ok?

Correct. Er verandert dus niet veel. Enkel je variabelen worden krachtiger!

De anatomie van een klasse

We zullen nu enkele basisconcepten van klassen en objecten toelichten aan de hand van praktische voorbeelden.

Object methoden

Stel dat we een klasse willen maken die ons toelaat om objecten te maken die verschillende mensen voorstellen. We willen aan iedere mens kunnen zeggen "Praat eens".

We maken een nieuwe klasse Mens en plaatsen in de klasse een methode Praat:

class Mens
{
    public void Praat()
    {
        Console.WriteLine("Ik ben een mens!");
    }
}

We zien twee nieuwe aspecten:

  • Het keyword static mag je niet voor een methode signatuur zetten (later ontdekken we wanneer dat soms wel moet) .
  • Voor de methode plaatsen we public : dit is een access modifier die aangeeft dat de buitenwereld deze methode op het object kan aanroepen.

Je kan nu elders objecten aanmaken en ieder object z'n methode Praat aanroepen:

Mens joske = new Mens();
Mens alfons = new Mens();
joske.Praat();
alfons.Praat();

Er zal twee maal Ik ben een mens! op het scherm verschijnen. Waarbij telkens ieder object (joske en alfons) zelf verantwoordelijk was dat dit gebeurde.

Public en private access modifiers

De access modifier geeft aan hoe zichtbaar een bepaald deel van de klasse is. Wanneer je niet wilt dat "van buiten" een bepaalde methode kan aangeroepen worden, dan dien je deze als private in te stellen. Wil je dit net wel dat moet je er expliciet public voor zetten.

Test in de voorgaande klasse eens wat gebeurt wanneer je public vervangt door private. Inderdaad, je zal de methode Praat niet meer op de objecten kunnen aanroepen.

Wanneer je geen access modifier voor een methode zet in C# dan zal deze als private beschouwd worden. Dit geldt voor alle zaken waar je access modifiers voor kan zetten: niets ervoor zetten wil zeggen private.

Volgende twee methoden-signaturen zijn dus identiek:

private void NiemandMagDitGebruiken()
{
    //...
}

void NiemandMagDitGebruiken()
{
    //...
}

Het is een héél slechte gewoonte om géén access modifiers voor iedere methode te zetten. Maak er dus een gewoonte van dit steeds ogenblikkelijk te doen.

Test volgende klasse eens, kan je de methode VertelGeheim vanuit de Main op joske aanroepen?

class Mens
{
    public void Praat()
    {
        Console.WriteLine("Ik ben een mens!");
    }

    private void VertelGeheim()
    {
        Console.WriteLine("Ik ben verliefd op Anneke");
    }
}

Naast private (het meest beschermd) en public (het meest open) zijn er nog een aantal access modifiers die allemaal de toegang tot een klasse meer of minder kunnen inperken. Verderop in het boek zullen we nog protected (enkel zichtbaar voor overgeërfde klassen) bekijken. Maar weet dat ook nog internal (enkel binnen dezelfde assembly), protected internal en private protected bestaan. In dit boek gaan we ons beperken tot private, protected en public. Je zal echter merken dat VS 2022 standaard met internal werkt, wat voor ons niet echt uitmaakt.

Reden van private

Waarom zou je bepaalde zaken private maken?

De code binnenin een klasse kan overal aan binnen de klasse zelf. Stel dat je dus een erg complexe publieke methode hebt, en je wil deze opsplitsen in meerdere delen, dan ga je die andere delen private maken. Dit voorkomt dat programmeurs die je klasse later gebruiken, stukken code aanroepen die helemaal niet bedoeld zijn om rechtstreeks aan te roepen.

Volgende voorbeeld toont hoe je binnenin een klasse andere zaken van de klasse kunt aanroepen: we roepen in de methode Praat de methode VertelGeheim aan (die private is voor de buitenwereld, maar niet voor de code binnen de Praat-methode).

class Mens
{
    public void Praat()
    {
        Console.WriteLine("Ik ben een mens!");
        VertelGeheim();
    }

    private void VertelGeheim()
    {
        Console.WriteLine("Ik ben verliefd op Anneke");
    }
}

Als we nu elders een object laten praten als volgt:

Mens rachid = new Mens();
rachid.Praat();

Dan zal de uitvoer worden:

Ik ben een mens!
Ik ben verliefd op Anneke

Met behulp van de dot-operator (.) kunnen we aan alle informatie die ons object aanbiedt aan de buitenwereld. Ook dit zagen we reeds toen we een Random-object hadden: we konden maar een handvol zaken aanroepen op zo'n object, waaronder de Next methode.

Het is natuurlijk een beetje vreemd dat nu al onze objecten zeggen dat ze verliefd zijn op Anneke. Dit is niet het smurfendorp met maar 1 meisje! Dit gaan we verderop oplossen. Stay tuned!

Instantievariabelen

Voorlopig doen alle objecten van het type Mens hetzelfde. Ze kunnen praten en zeggen hetzelfde. We weten echter dat objecten ook een interne staat hebben die per object individueel is (we zagen dit reeds toen we balletjes over het scherm lieten botsen: ieder balletje onthield z'n eigen richtingsvector en positie). Dit kunnen we dankzij instantievariabelen (ook wel datavelden of datafields genoemd) oplossen. Dit zullen variabelen zijn waarin zaken kunnen bewaard worden die verschillen per object.

Stel je voor dat we onze mensen een geboortejaar willen geven. Ieder object zal zelf in een instantievariabele bijhouden wanneer ze geboren zijn (het vertellen van geheimen zullen we verderop behandelen):

class Mens
{
    private int geboorteJaar = 1970; //instantievariabele

    public void Praat()
    {
        Console.WriteLine("Ik ben een mens! ");
        Console.WriteLine($"Ik ben geboren in {geboorteJaar}.");
    }
}

Enkele belangrijke concepten:

  • De instantievariabele geboorteJaar zetten we private: we willen niet dat de buitenwereld het geboortejaar van een object kan aanpassen. Beeld je in dat dat in de echte wereld ook kon. Dan zou je naar je kameraad kunnen roepen "Hey Adil, jouw geboortejaar is nu 1899! Ha!" Waarop Adil vloekend verandert in een steenoud mannetje.
  • We geven de variabele een beginwaarde 1970. Alle objecten zullen dus standaard in het jaar 1970 geboren zijn wanneer we deze met new aanmaken.
  • We kunnen de inhoud van de instantievariabelen lezen (en veranderen) vanuit andere delen in de code. Zo gebruiken we geboorteJaar in de tweede lijn van de Praat methode. Als je die methode nu zou aanroepen dan zou het geboortejaar van het object dat je aanroept mee op het scherm verschijnen.

We moeten ook dringend enkele extra niet-officiële identifier regels in het leven roepen:

  • Klassenamen en methoden in klassen beginnen altijd met een hoofdletter.
  • Alles dat public is in een klasse begint ook met een hoofdletter.
  • Alles dat private is begint met een kleine letter (of liggend streepje), tenzij het om een methode gaat, die begint altijd met een hoofdletter.

Dit zijn geen officiële regels, maar afspraken die veel programmeurs onderling hebben gemaakt. Het maakt de code leesbaarder.

Wat?! Ik ben hier niet voor jou? Omdat je geen goto hebt gebruikt?! Flink hoor. Maar daarvoor ben ik hier niet. Ik zag je wel denken: "Als ik nu die instantievariabele ook eens public maak."

Niet doen. Simpel! Instantievariabele mogen NOOIT public gezet worden. De C# standaard laat dit weliswaar toe, maar dit is één van de slechtste programmeerdingen die je kan doen. Wil je toch de interne staat van een object kunnen aanpassen dan gaan we dat via properties en methoden kunnen doen, wat we zo meteen gaan uitleggen. Zie dat ik hier niet te vaak tussenbeide moet komen. Dank!

Ok, we zullen maar luisteren naar meneer de agent. Stel nu dat we een verjongingsstraal hebben waarmee we het geboortejaar van de mensen steeds met 1 jaar kunnen verhogen (en ze dus een jaar jonger maken).

class Mens
{
    private int geboorteJaar = 1970;
    public void Praat()
    {
        Console.WriteLine("Ik ben een mens! ");
        Console.WriteLine($"Ik ben geboren in {geboorteJaar}.");
    }
    public void StartVerjongingskuur()
    {
        Console.WriteLine("Jeuj. Ik word jonger!");
        geboorteJaar++;
    }
}

Zoals al gezegd: Ieder object zal z'n eigen geboortejaar hebben.

Die laatste opmerking is een kernconcept van OOP: ieder object heeft z'n eigen interne staat die kan aangepast worden individueel van de andere objecten van hetzelfde type. We zullen dit testen in volgende voorbeeld waarin we 2 objecten maken en enkel 1 ervan verjongen. Kijk wat er gebeurt:

Mens elvis = new Mens();
Mens bono = new Mens();
elvis.StartVerjongingskuur();
elvis.Praat();
bono.Praat();

Als je voorgaande code zou uitvoeren zal je zien dat het geboortejaar van Elvis verhoogd en niet die van Bono wanneer we StartVerjongingskuur aanroepen. Zoals het hoort!

De uitvoer zal zijn:

Jeuj. Ik word jonger!
Ik ben een mens! 
Ik ben geboren in 1971.
Ik ben een mens! 
Ik ben geboren in 1970.

"Ja maar, nu pas je toch het geboortejaar van buiten aan via een methode, ook al gaf je aan dat dit niet de bedoeling was want dan zou je Adil ogenblikkelijk erg jong kunnen maken."

Correct. Maar dat was dus maar een voorbeeld. De hoofdreden dat we instantievariabelen niet zomaar public mogen maken is om te voorkomen dat de buitenwereld instantievariabelen waarden geeft die de werking van de klasse zouden stuk maken. Stel je voor dat je dit kon doen: adil.geboortejaar = -12000;

Dit kan nefaste gevolgen hebben voor de klasse.

Daarom gaan we de toegang tot instantievariabelen als het ware controleren door deze enkel via properties en methoden toe te laten. We zouden dan bijvoorbeeld het volgende kunnen doen:

class Mens
{
    private int geboorteJaar = 1970;

    public void VeranderGeboortejaar(int geboorteJaarIn)
    {
        if(geboorteJaarIn >= 1900)
            geboorteJaar = geboorteJaarIn;
    }

Mooi he. Zo voorkomen we dus dat de buitenwereld illegale waarden aan een variabele kan geven (in dit geval kan dus niet voor 1900 geboren zijn). Objecten zijn verantwoordelijk voor zichzelf en moeten zichzelf dus ook beschermen zodat de buitenwereld niets met hen doet dat hun eigen werking om zeep helpt.

Andere lieven

We kunnen nu het probleem oplossen dat al onze mensen verliefd zijn op Anneke. Volgende code toont dit:

class Mens
{
    private string lief = "niemand";

    public void VeranderLief(string nieuwLief)
    {
        lief = nieuwLief;
    }
    public void Praat()
    {
        Console.WriteLine("Ik ben een mens!");
        VertelGeheim();
    }

    private void VertelGeheim()
    {
        if( lief != "niemand")
            Console.WriteLine($"Ik ben verliefd op {lief}");
        else
            Console.WriteLine("Ik ben op niemand verliefd.");
    }
}

Nu kunnen we dus "Temptation Island - de OOP editie" beginnen:

Mens deelnemer1 = new Mens();
Mens deelnemer2 = new Mens();
deelnemer1.Praat();
deelnemer2.Praat();

deelnemer2.VeranderLief("phoebe");
deelnemer1.Praat();
deelnemer2.Praat();

deelnemer1.VeranderLief("camilla");
deelnemer1.Praat();
deelnemer2.Praat();

De uitvoer van voorgaande code zal zijn:

Ik ben een mens!
Ik ben op niemand verliefd.
Ik ben een mens!
Ik ben op niemand verliefd.
Ik ben een mens!
Ik ben op niemand verliefd.
Ik ben een mens!
Ik ben verliefd op phoebe
Ik ben een mens!
Ik ben verliefd op camilla
Ik ben een mens!
Ik ben verliefd op phoebe

Klasse "Studenten" of "Student"?

Veel beginnende programmeurs maken fouten op het correct kunnen onderscheiden wat de klassen en wat de objecten in hun opgave juist zijn. Het is altijd belangrijk te begrijpen dat een klasse weliswaar beschrijft hoe alle objecten van dat type werken, maar op zich gaat die beschrijving steeds over 1 object uit de verzameling. Say what now?!

Als je een klasse Student hebt, dan zal deze eigenschappen hebben zoals Punten, Naam en Geboortejaar. Als je een klasse Studenten daarentegen hebt, dan is dit vermoedelijk een klasse die beschrijft hoe een groep studenten moet werken in je applicatie. Mogelijk zal je dan properties hebben zoals KlasNaam, AantalAfwezigen, enz. Kortom, eigenschappen over de groep, niet over 1 student.

"Level" of "Level1"?

Een andere veelgemaakte fout is klassen te schrijven, die maar exact één object kan en moet creëren (dit heet een singleton). Stel je voor dat je een spel maakt waarin verschillende levels zijn. Een logische keuze zou dan zijn om een klasse Level te maken (niét Levels) die properties heeft zoals MoeilijkheidsGraad, HeeftGeheimeGrotten, AantalVijanden, enz.

Vervolgens kunnen we dan instanties maken: 1 object stelt 1 level in het spel voor. De speler kan dan van level naar level gaan en de code start dan bijvoorbeeld telkens de BeginLevel methode:

Level level1 = new Level();
level1.BeginLevel();

Wat dus niet mag zijn klassen met namen zoals level1, level2, enz. Vermoedelijk hebben deze klasse 90% gelijkaardige code en is er dus een probleem met wat we de architectuur van je code zouden kunnen noemen. Of duidelijker: je snapt niet wat het verschil is tussen klassen en objecten! Objecten met namen zoals level1 en level2 zijn wél dus toegestaan, daar ze dan vermoedelijk allemaal van het type Level zijn. Maar opgelet: als je variabelen hebt die een genummerd zijn (bv. bal1, bal2, enz.) dan is de kans groot dat je vervolgens een array van objecten nodig hebt (wat we in hoofdstuk 12 uit de doeken zullen doen).

Properties

We zagen zonet dat instantievariabelen nooit public mogen zijn om te voorkomen dat de buitenwereld onze objecten 'vult' met slechte zaken. Het voorbeeld waarbij we vervolgens een methode StartVerjongingskuur gebruikten om op gecontroleerde manier toch aan de interne staat van objecten te komen is één oplossing, maar een nogal oldschool oplossing.

Deze manier van werken - methoden gebruiken om instantievariabelen aan te passen of uit te lezen- is wat voorbij gestreefd binnen C#. Onze programmeertaal heeft namelijk het concept properties (eigenschappen) in het leven geroepen die toelaten op een veel eenvoudigere manier aan de interne staat van objecten te komen.

Properties (eigenschappen) zijn de C# manier om objecten hun interne staat in en uit te lezen. Ze zorgen voor een gecontroleerde toegang tot de interne structuur van je objecten.

Star Wars en de nood aan properties

In het Star Wars universum heb je goede oude "Darth Vader". Hij behoort tot de mysterieuze klasse van de Sith Lords. Deze lords lopen met een geheim rond: ze hebben een zogenaamde Sithnaam, een naam die ze enkel mogen bekend maken aan andere Sith Lords, maar aan niemand anders. Voorts heeft een Sith Lord ook een hoeveelheid energie (The Force) waarmee hij kattekwaad kan uithalen. Deze energie mag natuurlijk nooit onder nul gezet worden.

We kunnen voorgaande als volgt schrijven:

class SithLord
{
    private int energie;
    private string sithName;
}

Het is uit den boze dat we eenvoudige instantievariabelen (energie en name) public maken. Zouden we dat wel doen dan kunnen externe objecten deze geheime informatie uitlezen!

SithLord palpatine = new SithLord();
Console.WriteLine(palpatine.sithName); //dit zal niet werken dankzij private

We willen echter wel van buiten uit het energie-level van een sithLord kunnen instellen. Maar ook hier hetzelfde probleem: wat als we de energie-level op -1000 instellen? Terwijl energie nooit onder 0 mag gaan.

Properties lossen dit probleem op.

2 soorten properties

Er zijn 2 soorten properties in C#:

  • Full properties: deze stijl van properties verplicht ons véél code te schrijven, maar we hebben ook volledige controle over wat er gebeurt.
  • Auto-properties zijn exact het omgekeerde van full properties: weinig code, maar ook weinig (eigenlijk géén) controle.

We behandelen eerst full properties, daar auto-properties een soort afgeleide van full properties zijn (bepaalde aspecten van full properties worden bij auto-properties achter de scherm verstopt zodat jij als programmeur er geen last van hebt).

In één van de volgende versies van C# (normaal versie 11) zal er nog een derde type verschijnen: de zogenaamde semi-auto properties. Een - je raadt het nooit- propertytype dat zich tussen beide bestaande types zal bevinden. De details en exacte gebruik ervan worden nog besproken op github (github.com/dotnet/csharplang/issues/140) door de ontwikkelaars, dus het is nog te vroeg om deze al op te nemen in dit boek.

Full properties

Properties herken je aan de get en set keywords in een klasse. Een property is een beschrijving van wat er moet gebeuren indien je informatie uit (get) een object wilt halen of informatie net in (set) een object wilt plaatsen.

In volgende voorbeeld maken we een property, genaamd Energie aan. Deze doet niets anders dan rechtstreeks toegang tot de instantievariabele energie te geven:

class SithLord
{
    private int energie;

    public int Energie
    {
        get
        {
            return energie;
        }
        set
        {
            energie = value;
        }
    }
}

Dankzij voorgaande code kunnen we nu buiten het object de property Energie gebruiken als volgt:

SithLord Vader = new SithLord();
Vader.Energie = 20; //set
Console.WriteLine($"Vaders energie is {Vader.Energie}"); //get

Laten we eens inzoomen op de full property code.

Full property: identifier en datatype

De eerste lijn van een full property beschrijft de naam (identifier) en datatype van de property: public int Energie

Een property is altijd public daar dit de essentie van een property net is "de buitenwereld gecontroleerde toegang tot de interne staat van een object geven".

Vervolgens zeggen we wat voor datatype de property moet zijn en geven we het een naam die moet voldoen aan de identifier regels van weleer. Voor de buitenwereld zal een property zich gedragen als een gewone variabele, met de naam Energie van het type int.

Indien je de property gaat gebruiken om een instantievariabele naar buiten beschikbaar te stellen, dan is het een goede gewoonte om dezelfde naam als dat veld te nemen maar nu met een hoofdletter (dus Energie i.p.v. energie).

Full property: get gedeelte

Indien je wenst dat de property data naar buiten kan sturen, dan schrijven we de get-code. Binnen de accolades van de get schrijven we wat er naar buiten moet gestuurd worden.

get
{
    return energie;
}

Dit werkt dus identiek aan een methode met een returntype. Het element dat je met return teruggeeft in de get code moet uiteraard van hetzelfde type zijn als waarmee je de property hebt gedefinieerd (int in dit geval).

We kunnen nu van buitenaf toch de waarde van energie uitlezen via de property en het get-gedeelte, bijvoorbeeld int uitgelezen = palpatine.Energie;.

We mogen eender wat doen in het get-gedeelte (net zoals bij methoden) zolang er finaal maar iets uitgestuurd wordt m.b.v. return. We gaan hier verderop meer over vertellen, want soms is het handig om getters te schrijven die de data transformeren voor ze uitgestuurd wordt.

Full property: set gedeelte

In het set-gedeelte schrijven we de code die we moeten hanteren indien men van buiten een waarde aan de property wenst te geven om zo een instantievariabele aan te passen.

set
{
    energie = value;
}

De waarde die we van buiten krijgen (als een parameter zeg maar) zal altijd in een lokale variabele value worden bewaard binnenin de set-code. Deze zal van het type van de property zijn.

Deze value parameter is een essentiëel onderdeel van de set syntax en kan je niet hernoemen.

Vervolgens kunnen we value toewijzen aan de interne variabele indien gewenst: energie = value;. Uiteraard kunnen we die toewijzing dus ook gecontroleerd laten gebeuren, wat we zo meteen zullen uitleggen.

We kunnen vanaf nu van buitenaf waarden toewijzen aan de property en zo energie toch bereiken: palpatine.Energie = 50;.

Je bent niet verplicht om een property te maken wiens naam overeen komt met een bestaande instantievariabele (maar dit wordt ten stelligste afgeraden). Dit mag dus ook:

class Auto
{
    private int benzinePeil;

    public int FuelLevel
    {
        get { return benzinePeil; }
        set { benzinePeil = value; }
    }
}

Visual Studio heeft een ingebouwde snippet om snel een full property, inclusief een bijhorende private instantievariabele, te schrijven. Typ propfull gevolgd door twee maal op de tab-toets te duwen.

Full property met toegangscontrole

De full property Energie heeft nog steeds het probleem dat we negatieve waarden kunnen toewijzen (via de set) die dan vervolgens zal toegewezen worden aan energie.

Properties hebben echter de mogelijkheid om op te treden als wachters van en naar de interne staat van objecten.

We kunnen in de set code extra controles inbouwen. Daar de value variabele de waarde krijgt die we aan de property van buiten af geven, kunnen we deze dus controleren en, indien nodig, bijvoorbeeld niet toewijzen. Volgende voorbeeld zal enkel de waarde toewijzen indien deze groter of gelijk aan 0 is:

public int Energie
{
    get
    {
        return energie;
    }
    set
    {
        if(value >= 0)
            energie = value;
    }
}

Volgende lijn zal dus geen effect hebben: palpatine.Energie = -1;

We kunnen de code binnen set (en get) zo complex maken als we willen.

Probeer wel steeds de OOP-principes te hanteren wanneer je met properties werkt: in de get en set van een property mogen enkel die dingen gebeuren die de verantwoordelijkheid van de property zelf zijn. Je gaat dus bijvoorbeeld niet controleren of een andere property geen illegale waarden krijgt, daar is die andere property voor verantwoordelijk.

Property variaties

We zijn niet verplicht om zowel de get en de set code van een property te schrijven. Dit laat ons toe om een aantal variaties te schrijven:

  • Write-only property: heeft geen get.
  • Read-only property: heeft geen set.
  • Read-only property met private set (het omgekeerde, een private get, zal je zelden tegenkomen).
  • Read-only property die data transformeert: om interne data in een andere vorm uit je object te krijgen.

De verschillende full properties mooi opgelijst.

Write-only property

Dit soort properties zijn handig indien je informatie naar een object wenst te sturen dat niet mag of moet uitgelezen kunnen worden. Het meest typische voorbeeld is een property Pincode van een klasse BankRekening.

public int Energie
{
    set
    {
        if(value >= 0)
            energie = value;
    }
}

We kunnen dus enkel energie een waarde geven, maar niet van buiten uitlezen.

Read-only property

Letterlijk het omgekeerde van een write-only property. Deze gebruik je vaak wanneer je informatie uit een object wil kunnen uitlezen uit een instantievariabele dat NIET door de buitenwereld mag aangepast worden.

public int Energie
{
    get
    {
        return energie;
    }
}

We kunnen enkel energie van buiten uitlezen, maar niet aanpassen.

Het readonly keyword heeft andere doelen en wordt NIET gebruikt in C# om een readonly property te maken.

Read-only property met private set

Soms gebeurt het dat we van enkel voor de buitenwereld de property read-only willen maken. We willen echter intern (in de klasse zelf) nog steeds controleren dat er geen illegale waarden aan private instantievariabelen worden gegeven. Op dat moment definiëren we een read-only property met een private setter:

public int Energie
{
    get
    {
        return energie;
    }
    private set
    {
        if(value >= 0)
            energie = value;
    }
}

Van buiten zal enkel code werken die de get van deze property aanroept, bijvoorbeeld:

Console.WriteLine(palpatine.Energie);

Code die de set van buiten nodig heeft (bv. palpatine.Energie = 65;) zal een fout geven ongeacht of deze geldig is of niet.

Het is een goede gewoonte om altijd via de properties je interne variabele aan te passen en niet rechtstreeks via de instantievariabele zelf. Dit is zo'n nuttige tip dat we op de volgende pagina de voorman hier ook nog even over aan het woord gaan laten.

Lukt het een beetje? Properties zijn in het begin wat overweldigend, maar geloof me: ze zijn zowat dé belangrijkste bewoners in de .NET/C# wereld.

Nu even goed opletten: indien we IN het object de instantievariabelen willen aanpassen dan is het een goede gewoonte om dat via de property te doen (ook al zit je in het object zelf en heb dus eigenlijk de property niet nodig). Zo zorgen we ervoor dat de bestaande controle in de property niet wordt omzeilt. Kijk zelf naar volgende slechte codevoorbeeld:

class SithLord
{
    private int energie;
    private string sithName;
    public void ResetLord(int resetWaarde)
    {
        energie = resetWaarde;
    }
    public int Energie
    {
        get
        {
            return energie;
        }
        private set
        {
            if(value >= 0) 
                energie = value;
        }
    }
}

De nieuw toegevoegde methode ResetLord willen we gebruiken om de lord z'n energie terug te verlagen. Als we deze methode met een negatieve waarden aanroepen zullen we alnsog energie op een verkeerde waarde instellen. Nochtans is dit een illegale waarde volgens de set-code van de property.

We moeten dus in de methode ook expliciet via de property gaan om bugs te voorkomen en dus gaan we in ResetLordschrijven naar de property Energie én niet rechtstreeks naar de instantievariabele energie:

public void ResetLord(int resetWaarde)
{
    Energie = resetWaarde; // Energie i.p.v. energie
}

Read-only properties die transformeren

Transformerende properties: Erg nuttig, maar vaak wat stiefmoederlijk behandeld.

Je bent uiteraard niet verplicht om voor iedere instantievariabele een bijhorende property te schrijven. Omgekeerd ook: mogelijk wil je extra properties hebben voor data die je 'on-the-fly' kan genereren dat niet noodzakelijk uit een instantievariabele komt. Stel dat we volgende klasse hebben:

class Persoon
{
    public string Voornaam {get;set;}
    public string Achternaam {get;set;}
}

We willen echter ook soms de volledige naam of emailadres krijgen, beide gebaseerd op de inhoud van de instantievariabelen voornaam en achternaam. Via een read-only property die transformeert kan dit:

class Persoon
{
    public string Voornaam {get;set;}
    public string Achternaam {get;set;}
    public string VolledigeNaam
    {
        get
        { 
            return $"{Voornaam} {Achternaam}";
        }
    }
    public string Email
    {
        get
        {
            return $"{Voornaam}@ziescherp.be";
        }
    }
}

Methode of property?

Een veel gestelde vraag bij beginnende OOP-ontwikkelaars is: "Moet dit in een property of in een methode geplaatst worden?"

De regels zijn niet in steen gebeiteld, maar ruwweg kan je stellen dat:

  • Betreft het een actie of gedrag: iets dat het object moet doen (tekst tonen, iets berekenen of aanpassen, enz.) dan plaats je het in een methode.
  • Betreft het een eigenschap van het object, dan gebruik je een property indien het om data gaat die snel verkregen of berekend kan worden. Gaat het om data die zwaardere en/of langere berekeningen vereist dan is een methode nog steeds aangeraden.

Auto-properties

Automatische eigenschappen (automatic properties oftewel "auto-implemented properties", soms ook autoprops genoemd) laten toe om snel properties te schrijven zonder dat we de achterliggende instantievariabele moeten beschrijven.

Een auto-property herken je aan het feit dat ze een pak korter zijn qua code, omdat er veel meer (onzichtbaar) achter de schermen wordt opgelost:

public string Voornaam { get; set; }

Heel vaak wil je heel eenvoudige variabelen aan de buitenwereld van je klasse beschikbaar stellen. Omdat je instantievariabelen echter niet public mag maken, moeten we dus properties gebruiken die niets anders doen dan als doorgeefluik fungeren. auto-properties doen dit voor ons: het zijn vereenvoudigde full properties waarbij de achterliggende instantievariabele onzichtbaar voor ons is. Je kan echter bij auto-properties ook geen verdere controle op de in-of uitvoer doen.

Zo kan je eenvoudig de volgende klasse Persoon herschrijven met behulp van auto-properties. De originele klasse mét full properties:

public class Person
{
    private string voornaam;
    public string Voornaam
    {
        get { return voornaam; }
        set { voornaam = value; }
    }

    private int geboorteJaar;
    public int Geboortejaar
    {
        get { return geboorteJaar; }
        set { geboorteJaar = value; }
    }
}

De herschreven klasse met auto-properties wordt:

public class Person
{
    public string Voornaam { get; set; }
    public int Geboortejaar { get; set; }
}

Beide klassen hebben exact dezelfde functionaliteit, echter is de laatste klasse aanzienlijk korter en dus eenvoudiger om te lezen. De private instantievariabelen zijn niét meer aanwezig. C# gaat die voor z'n rekening nemen. Alle code zal dus via de properties moeten gaan.

Het is belangrijk te benadrukken dat de achterliggende instantievariabele onzichtbaar is in auto-properties en onmogelijk kan gebruikt worden. Alles gebeurt via de auto-property, altijd. Je hebt dus niet meer dan een publieke variabele, die conform de afspraken is ("maak geen instantievariabelen publiek"). Gebruik dit dus enkel wanneer je 100% zeker bent dat de auto-property geen waarden kan krijgen die de interne werking van je klasse kan verstoren.

Vaak zal je nieuwe klassen eerst met auto-properties beschrijven. Naarmate de specificaties dan vereisen dat er bepaalde controles of transformaties moeten gebeuren, zal je stelselmatig auto-properties vervangen door full properties.

Dit kan trouwens automatisch in VS: selecteer de autoprop in kwestie en klik dan vooraan op de schroevendraaier en kies "Convert to full property".

Opgelet: Merk op dat de syntax die VS gebruikt om een full property te schrijven anders is dan wat we hier uitleggen. Wanneer je VS laat doen krijg je een oplossing met allerlei => tekens. Dit is zogenaamde Expression Bodied Member syntax (EBM). We behandelen deze (nieuwere) C# syntax in de appendix.

Beginwaarden van auto-properties

Je mag auto-properties beginwaarden geven door de waarde achter de property te schrijven, als volgt:

public int Geboortejaar {get;set;} = 2002;

Al je objecten zullen nu als geboortejaar 2002 hebben wanneer ze geïnstantieerd worden.

Nut auto-properties?

Merk op dat je auto-properties dus enkel kan gebruiken indien er geen extra logica in de property (bij de set of get) aanwezig moet zijn.

Stel dat je bij de setter van geboorteJaar wil controleren op een negatieve waarde, dan zal je dit zoals voorheen moeten schrijven en kan dit niet met een automatic property:

set
{
    if( value > 0)
        geboorteJaar = value;
}

Voorgaande property kan dus NIET herschreven worden met een automatic property. auto-properties zijn vooral handig om snel klassen in elkaar te knutselen, zonder je zorgen te moeten maken om andere vereisten. Vaak zal een klasse in het begin met auto-properties gevuld worden. Naarmate je project vordert zullen die auto-properties meer en meer omgezet worden in full properties.

Alleen-lezen auto-properties

Je kan auto-properties ook gebruiken om bijvoorbeeld een read-only property met private setter te definiëren. Als volgt:

public string Voornaam { get; private set; }

Een andere manier die ook kan wanneer we enkel een read-only property nodig hebben, is als volgt:

public string Voornaam { get; } = "Tim";

Hierbij zijn we dan wel verplicht om ogenblikkelijk deze property een beginwaarde te geven, daar we deze op geen enkele andere manier nog kunnen aanpassen.

Als je in Visual Studio in je code prop typt en vervolgens twee keer de tabtoets indrukt dan verschijnt al de nodige code voor een automatic property. Via propg gevolgd door twee maal de tabtoets krijg je een auto-property met private setter.

OOP in de praktijk : DateTime

Doe die zwembroek maar weer aan! We gaan nog eens zwemmen.

Zoals je vermoedelijk al doorhebt hebben we met properties en methoden nog maar een tipje van de klasse-ijsberg besproken. Vreemde dingen zoals constructors, static methoden, overerving en arrays van objecten staan ons nog allemaal te wachten.

Om je toch al een voorsmaakje van de kracht van klassen en objecten te geven, gaan we eens kijken naar één van de vele klassen die je tot je beschikking hebt in C#. Je hebt al leren werken met bijvoorbeeld de Random klasse, maar ook al met wat speciale static klassen zoals de Math- en Console-bibliotheek die je kan gebruiken ZONDER dat je er objecten van moet aanmaken (het keyword static is daar de oorzaak van).

Nog zo'n handige ingebouwde klasse is de DateTime klasse, die, je raadt het nooit, toelaat om de tijd en/of datum in een object voor te stellen.

De .NET klasse DateTime is de ideale manier om te leren werken met objecten. Het is een nuttige en toegankelijk klasse (terzijde: technisch gezien is DateTime een struct, niet een class, maar dit onderscheid is in dit hoofdstuk niet relevant).

DateTime objecten aanmaken

Er zijn 2 manieren om DateTime objecten aan te maken:

  1. Door aan de klasse de huidige datum en tijd te vragen via DateTime.Now.
  2. Door manueel de datum en tijd in te stellen met het new keyword en de klasseconstructor (een concept dat we binnen 2 hoofdstukken uit de doeken gaan doen)

DateTime.Now

Volgend voorbeeld toont hoe we een object kunnen maken dat de huidige datum tijd van het systeem bevat. Vervolgens printen we dit op het scherm:

DateTime currentTime = DateTime.Now;
Console.WriteLine(currentTime);

DateTime.Now is een zogenaamde static property wat verderop in het boek zal uitgelegd worden.

Met constructor en new

De constructor van een klasse laat toe om bij het maken van een nieuw object, beginwaarden voor bepaalde instantievariabelen of properties mee te geven. De DateTime klasse heeft meerdere constructors gedefiniëerd zodat je bijvoorbeeld een object kan aanmaken dat bij de start reeds de geboortedatum van de auteur bevat:

DateTime verjaardag = new DateTime(1981, 3, 18); //jaar, maand, dag

Ook is er een constructor om startdatum én -tijd mee te geven bij de objectcreatie:

//Volgorde: jaar, maand, dag, uur, minuten, seconden
DateTime trouwMoment = new DateTime(2017, 4, 21, 10, 00,34 ); 

DateTime methoden

Van zodra je een DateTime object hebt gemaakt zijn er tal van nuttige methoden die je er op kan aanroepen. Visual Studio is zo vriendelijk om dit te visualiseren wanneer we de dot-operator typen achter een object:

Iedere kubus stelt een methode voor. Iedere Engelse sleutel een property.

Add-methoden

De ingebouwde methoden beginnen allemaal met Add, gevolgd door wat er moet bijgevoegd worden: AddDays, AddHours, AddMilliseconds, AddMinutes, AddMonths, AddSeconds, AddTicks, AddYears.

Een tick is 100 nanoseconden, oftewel 1 tien miljoenste van een seconden. Dat lijkt een erg klein getal (wat het voor ons ook is) maar voor computers is dit het soort tijdsintervals waar ze mee werken.

Deze methoden kan je gebruiken om een bepaalde aantal dagen, uren, minuten op te tellen bij de huidige tijd en datum van een object.

Het object zal voor ons de "berekening" hiervan doen en vervolgens een nieuw DateTime object teruggeven dat je moet bewaren wil je er iets mee doen.

In volgende voorbeeld wil ik ontdekken op welke datum de wittebroodsweken van m'n huwelijk eindigen (pakweg 5 weken na de trouwdag).

DateTime eindeWitteBroodsweken = trouwMoment.AddDays(35);
Console.WriteLine(eindeWitteBroodsweken);

DateTime properties

Dit hoofdstuk heeft al aardig wat woorden verspild aan properties, en uiteraard heeft ook de DateTime klasse een hele hoop interessante properties die toelaten om de interne staat van een DateTime object te bewerken of uit te lezen.

Enkele nuttige properties van DateTime zijn: Date, Day, DayOfWeek, DayOfYear, Hour, Millisecond, Minute, Month, Second, Ticks, TimeOfDay, Today, UtcNow, Year.

Alle properties van DateTime zijn read-only en hebben dus een private setter die we niet kunnen gebruiken.

Een voorbeeld:

Console.WriteLine($"Einde in maand nr: {eindeWitteBroodsweken.Month}.");
Console.WriteLine($"Dat is een {eindeWitteBroodsweken.DayOfWeek}.");

Dit geeft op het scherm:

Je wittebroodsweken eindigen in maand nummer: 5.
Dat is een Friday.

Static methoden

Sommige methoden zijn static dat wil zeggen dat je ze enkel rechtstreeks op de klasse kunt aanroepen. Vaak zijn deze methoden hulpmethoden waar de individuele objecten niets aan hebben. We hebben dit reeds gebruikt, zonder het te weten, bij de Math en Console-klassen.

We behandelen static uitgebreid verderop in het boek.

De tijd uit een string inlezen

Parsen laat toe dat je strings omzet naar een DateTime object. Dit is handig als je bijvoorbeeld de gebruiker via Console.ReadLine() tijd en datum wilt laten invoeren in de Belgische notatie:

string datumInvoer = Console.ReadLine(); 
DateTime datumVerwerkt = DateTime.Parse(datumInvoer, new System.Globalization.CultureInfo("nl-BE"));
Console.WriteLine(datumVerwerkt);

Indien je nu dit programma'tje zou uitvoeren en als gebruiker "8/11/2016" zou intypen, dan zal deze datum geparsed worden en in het object datumVerwerkt komen.

Zoals je ziet roepen we Parse aan op DateTime en dus niet op een specifiek object. Dat was ook zo reeds bijvoorbeeld bij int.Parse wat dus doet vermoeden dat zelfs het int datatype eigenlijk een klasse is!

IsLeapYear

Deze nuttige methode geeft een bool terug om aan te geven of de actuele parameter (type int) een schrikkeljaar voorstelt of niet:

DateTime vandaag = DateTime.Now;
if(DateTime.IsLeapYear(vandaag.Year))
    Console.WriteLine("Dit jaar is een schrikkeljaar.");

TimeSpan

Je kan DateTime objecten ook van elkaar aftrekken (optellen gaat niet!). Het resultaat van deze bewerking geeft echter niet een DateTime object terug, maar een TimeSpan object. Dit is nieuwe object van het type TimeSpan (wat dus een andere klasse is) dat aangeeft hoe groot het verschil is tussen de 2 DateTime objecten kunnen we als volgt gebruiken:

DateTime vandaag = DateTime.Today;
DateTime geboorteDochter = new DateTime(2009,6,17);
TimeSpan verschil = vandaag - geboorteDochter;
Console.WriteLine($"{verschil.TotalDays} dagen sinds geboorte dochter.");

Je zal de DateTime klasse in véél van je projecten kunnen gebruiken waar je iets met tijd, tijdsverschillen of datums wilt doen. We hebben de klasse in deze sectie echter geen eer aangedaan. De klasse is veel krachtiger dan we hier hebben doen uitschijnen. Het is een goede gewoonte als beginnende programmeur om steeds de documentatie van nieuwe klassen er op na te slaan. Wanneer je in je browser zoekt op "C#" gevolgd door de naam van de klasse dan zal je zo goed als zeker als eerste hit de officiële .NET documentatie krijgen op docs.microsoft.com.

Gaat het nog?! Dit was een stevig hoofdstuk he. We hebben zo maar eventjes 4 heel grote fasen doorlopen:

  1. Eerst keken we hoe OOP ons kan helpen in een real-life voorbeeld, Pong. We schreven code die hier en daar herkenbaar was, maar op andere plaatsen totaal nieuw was.
  2. Vervolgens namen we de mammoet bij de horens en bekeken we de theorie van OO, die ons vooral verwarde.
  3. Gelukkig gingen we dan ogenblikkelijk naar de praktijk over en zagen we dat methoden en properties de kern van iedere klasse blijkt te zijn.
  4. Als afsluiter gooiden we dan de DateTime klasse open om een voorproefje te krijgen van hoe krachtig een goedgeschreven klasse kan zijn.

Voor je verder gaat raad ik je aan om dit alles goed te laten bezinken én maximaal de komende oefeningen te maken. Het zal de beste manier zijn om de ietwat bizarre wereld van OOP snel eigen te maken.

Geheugenmanagement, uitzonderingen en namespaces

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. We zullen 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).

Geheugenmanagement in C#

In hoofdstuk 8 deden we 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 voor enum 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. Alhoewel hoofdstuk 8 liet uitschijnen dat vooral value type variabelen veelvuldig in programma's voorkwamen, zal je 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!).

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 toonden we 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:

  1. Het kleine, maar snelle stack geheugen.
  2. 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 typesReference types
Inhoud van de variabeleEigenlijke dataReferentie naar de eigenlijke data
LocatieStackHeap
Beginwaarde0,0.0, "",false, enz.null
Effect = operatorKopieert actuele waardeKopieert adres naar actuele waarde

Stack en heap

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 zal de heap als het ware dienst doen als een grote zandbak waar eender welke plek kan ingepalmd worden om zaken te bewaren (op voorwaarde dat die vrij is natuurlijk). De stack daarentegen is het kleine bankje naast de zandbak: handig, snel, en perfect geweten hoe groot.

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.
  • structs (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 (of pointer) 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.

Bij objecten

We zien dit gedrag bij alle reference types, zoals objecten:

Student stud = new Student();

Wat gebeurt er hier?

  1. new Student() : new roept de constructor van Student aan. Deze zal met behulp van een constructor een object in de heap aanmaken en vervolgens de geheugenlocatie ervan teruggeven.
  2. Een variabele stud wordt in de stack aangemaakt en mag enkel een referentie naar een object van het type Student bewaren.
  3. 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:

Na lijn 1

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:

Na: new Student();

Vervolgens wordt de toekenning toegepast en wordt het geheugenadres van het object in de variabele stud geplaatst:

Na: stud = new Student();

We gaan 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.

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

De situatie op het einde.

Hetzelfde gedrag zien we bij objecten:

Student a = new Student("Abba");
Student b = new Student("Queen");

Geeft volgende situatie in het geheugen:

Merk op dat de geheugenplekken willekeurig zijn. De auteur was te lui om telkens nieuwe geheugenplekken te verzinnen, daarom dat je sommige getallen ziet terugkomen.

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).

Het is een goede gewoonte om dit soort tekeningen met pijlen steeds mentaal (of op papier) te maken wanneer je werken met referenties onder de knie wilt krijgen.

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.

Objecten in de heap waar geen referenties naar wijzen zullen ten gepaste tijde 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.

Objecten en methoden

Objecten als actuele parameters

Klassen zijn "gewoon" nieuwe datatypes. Alle regels die we dus al kenden in verband met het doorgeven van variabelen als parameters in een methoden blijven gelden voor de meeste klassen (behalve static klassen die we in volgend hoofdstuk zullen aanpakken).

Het enige verschil is dat we objecten by reference meegeven aan methoden. Aanpassingen aan het object in de methode zal dus betekenen dat je het originele object aanpast dat aan de methode werd meegegeven, net zoals we bij arrays zagen. Hier moet je dus zeker rekening mee houden.

Stel dat we volgende klasse hebben waarin we temperatuurmetingen willen opslaan, alsook wie de meting heeft gedaan:

class Meting
{
    public int Temperatuur { get; set; }
    public string OpgemetenDoor { get; set; }
}

We voegen vervolgens een methode aan de klasse toe die ons toelaat om deze meting op het scherm te tonen in een bepaalde kleur.

public void ToonMetingInKleur (ConsoleColor kleur)
{
    Console.ForegroundColor = kleur;
    Console.WriteLine($"{Temperatuur} graden C gemeten door: {OpgemetenDoor}");
    Console.ResetColor();
}

Het gebruik van deze klasse zou er als volgt kunnen uitzien:

Meting m1 = new Meting();
m1.Temperatuur = 26; 
m1.OpgemetenDoor = "Lieven Scheire";
Meting m2 = new Meting();
m2.Temperatuur = 34; 
m2.OpgemetenDoor = "Ann Dooms";

m1.ToonMetingInKleur(ConsoleColor.Red);
m2.ToonMetingInKleur(ConsoleColor.Pink);

Objecten in methoden aanpassen

Je kan ook methoden schrijven die meegegeven objecten aanpassen daar we deze by reference doorsturen. Een voorbeeld waarin een meting als parameter meegeven en toevoegen aan een andere meting, waarna we de originele meting "resetten":

public void VoegMetingToeEnVerwijder(Meting inMeting)
{
    Temperatuur += inMeting.Temperatuur;
    inMeting.Temperatuur = 0;
    inMeting.OpgemetenDoor =  "";
}

We zouden deze methode als volgt kunnen gebruiken (ervan uitgaande dat we 2 objecten m1 en m2 van het type Meting hebben):

m1.Temperatuur = 26; 
m1.OpgemetenDoor = "Lieven Scheire";
m2.Temperatuur = 5; 
m2.OpgemetenDoor = "Lieven Scheire";
m1.VoegMetingToeEnVerwijder(m2);
Console.WriteLine($"{m1.Temperatuur} en {m2.Temperatuur});

Dit zal resulteren in volgende output:

31 en 0

Objecten als resultaat

Weer hetzelfde verhaal: ook klassen mogen het resultaat van een methoden zijn. Stel dat we een nieuw meting object willen maken dat de dubbele temperatuur bevat van het object waarop de methode wordt aangeroepen:

public Meting GenereerRandomMeting()
{
    Meting result = new Meting();
    result.Temperatuur = Temperatuur * 2;
    result.OpgemetenDoor = OpgemetenDoor + "Junior";
    return result;
}

Deze methode kan je dan als volgt gebruiken:

m1.Temperatuur = 26; 
m1.OpgemetenDoor = "Lieven Scheire";
Meting m3 = m1.GenereerRandomMeting();

Het object m3 zal een temperatuur van 52 bevatten en zijn opgemeten door Lieven Scheire Junior.

Bevallen in C#

In voorgaande voorbeeld zagen we reeds dat objecten dus objecten van het eigen type kunnen teruggeven. Laten we dat voorbeeld eens, bij wijze van demonstratie, doortrekken naar hoe de bevalling van een kind in C# zou gebeuren.

Baby's zijn kleine mensjes, het is dan ook logisch dat mensen een methode PlantVoort hebben (we laten in het midden wat het geslacht is). Volgende klasse Mens is dus perfect mogelijk:

class Mens
{
    public Mens PlantVoort()
    {
        return new Mens();
    }
}

Vervolgens kunnen we nu het volgende doen:

Mens oermoeder = new Mens();
Mens dochter;
Mens kleindochter;
dochter =  oermoeder.PlantVoort();
kleindochter = dochter.PlantVoort();

Het is een interessante oefening om deze code eens uit te tekenen in de stack en heap inclusief de verschillende referenties.

We gaan voorgaande code over enkele pagina's nog uitbreiden om een meer realistisch voortplantingsscenario te hebben (sommige zinnen verwacht je nooit te zullen schrijven in je leven... I was wrong).

Object referenties en null

Zoals nu duidelijk is bevatten referentievariabelen steeds een referentie naar een object. Maar wat als we dit schrijven:

Student stud1;
stud1.Naam = "Marc Jansens";

Dit zal een fout geven. Het object stud1 bevat namelijk nog geen referentie. Maar wat dan wel?

Deze variabele bevat de waarde null . Net zoals bij value types die een default waarde hebben als je er geen geeft (bv. 0 bij een int ), zo bevatten reference type variabelen altijd null als standaardwaarde.

null is een waarde die je dus kan toekenen aan eender welk reference type (om aan te geven dat er nog geen referentie naar een effectief object in de variabele staat) en waar je dus ook op kan testen.

Van zodra je een referentie naar een object (een bestaand of eentje dat je net met new hebt aangemaakt) aan een reference type variabele toewijst (met de = operator) zal de null waarde uiteraard overschreven worden.

Merk op dat de GC enkel op de heap werkt. Indien er in de stack dus een variabele de waarde null heeft zal de GC deze nooit verwijderen!

NullReferenceException

Een veel voorkomende foutboodschap tijdens de uitvoer van je applicatie is een NullReferenceException. Deze zal optreden wanneer je code een object probeert te benaderen wiens waarde null is (een onbestaande object met andere woorden).

Laten we dit eens simuleren:

Student stud1 = null;
Console.WriteLine(stud1.Name);

Dit zal resulteren in volgende foutboodschap:

NullReferenceException error in Visual Studio.

We moeten in dit voorbeeld expliciet = null plaatsen daar Visual Studio slim genoeg is om je te waarschuwen voor eenvoudige potentiële NullReference fouten en je code anders niet zal compileren.

NullReferenceException voorkomen

Objecten die niet bestaan zullen altijd null hebben. Uiteraard kan je niet altijd al je code uitvlooien waar je misschien vergeten bent een object met new aan te te maken.

Voorts kan het ook soms by design zijn dat een object voorlopig null is.

Gelukkig kan je controleren of een object null is als volgt:

if(stud1 == null)
    Console.WriteLine("Oei. Object bestaat niet.")

Verkorte null controle notatie

Vaak moet je dit soort code schrijven:

if(stud1 != null)
{
    Console.WriteLine(stud1.Name)
}

Op die manier voorkom je een NullReferenceException. Het is uiteraard omslachtig om steeds die check te doen. Je mag daarom ook schrijven:

Console.WriteLine(stud1?.Name)

Het vraagteken direct na het object geeft aan: "Gelieve de code na dit vraagteken enkel uit te voeren indien het object voor het vraagteken niét null is".

Bovenstaande code zal dus gewoon een lege lijn op scherm plaatsen indien stud1 effectief null is, anders komt de naam op het scherm.

Return null

Uiteraard mag je ook expliciet null teruggeven als resultaat van een methode. Stel dat je een methode hebt die in een array een bepaald object moet zoeken. Wat moet de methode teruggeven als deze niet gevonden wordt? Inderdaad, we geven dan null terug.

Volgende methode zoekt in een array van studenten naar een student met een specifieke naam en geeft deze terug als resultaat. Enkel als de hele array werd doorlopen en er geen match is wordt er null teruggegeven (de werking van arrays van objecten wordt later besproken):

static Student ZoekStudent(Student[] array, string naam)
{
    Student gevonden = null;
    for (int i = 0; i < array.Length; i++)
    {
        if (array[i].Name == naam)
            gevonden = array[i];
    }

    return gevonden;
}

Bevalling in C# met ouders

Tijd om het voorbeeld van de voortplanting der mensch er nog eens bij te nemen. Beeld je nu in dat we dichter naar de realiteit willen gaan (meestal het doel van OOP) en de baby eigenschappen van beide ouders geven. Stel dat mensen een maximum lengte hebben die ze genetisch kunnen halen, aangeduid via een auto-property MaxLengte. De maximale lengte van een baby is steeds de lengte van de grootste ouder (in de echte genetica is dat natuurlijk niet, zeker niet omdat er ook zeker een Random factor aanwezig is).

De klasse Mens breiden we uit naar:

class Mens
{
    public int MaxLengte{get;set;}
    public Mens PlantVoort(Mens dePapa)
    {
        Mens baby = new Mens();
        baby.MaxLengte = MaxLengte;
        if(dePapa.MaxLengte >= MaxLengte)
            baby.MaxLengte = papa.MaxLengte;
        return baby;
    }
}

Mooi toch?!

Om het nu volledig te maken zullen we er nu nog voor zorgen dat enkel een vrouw kan voortplanten, en enkel van een man (het is een vrij klassiek wereldbeeld, maar voor deze oefening wordt het te complex als we ook alle 21e-eeuwse voortplantingswijzen moeten implementeren). Veronderstel dat het geslacht via een enumtype (enum Geslachten {Man, Vrouw}) in een auto-property Geslacht wordt bewaard. We voegen daarom bovenaan in de PlantVoort-methode nog een kleine check in én return'n een leeg (null) object als de voortplanting faalt (we zouden ook een Exception kunnen opwerpen):

    public Mens PlantVoort(Mens dePapa)
    {
        if(Geslacht == Geslachten.Vrouw && dePapa.Geslacht == Geslachten.Man)
        {
            Mens baby = new Mens();
            baby.MaxLengte = MaxLengte;
            if(dePapa.MaxLengte >= MaxLengte)
                baby.MaxLengte = papa.MaxLengte;
            return baby;
        }
        return null;
    }

Volgende code produceert nu een kersverse baby:

Mens mama = new Mens();
mama.Geslacht = Geslachten.Vrouw;
mama.MaxLengte = 180;
Mens papa = new Mens();
papa.Geslacht = Geslachten.Man;
papa.MaxLengte = 169;

Mens baby = mama.PlantVoort(papa);

Hopelijk voel je bij dit voorbeeld hetzelfde enthousiasme als toen we Pong naar OOP omzetten. Probeer voorgaande voorbeeld eens te schrijven met je kennis VOOR je klassen en objecten kende? Doenbaar? Zeker. Veel werk? Dat nog meer. En daar is het ons om te doen: krachtige, makkelijker te onderhouden code leren schrijven!

Namespaces en using

Je zal het keyword namespace al vele malen bovenaan je code hebben zien staan .

namespace MyEpicGame
{
    internal class Monster

De naam die achter de namespace staat is altijd die van je project, maar waarom is dit eigenlijk?

Wat zijn namespaces

Een namespace wordt gebruikt om te voorkomen dat 2 projecten die toevallig dezelfde klassenamen hebben in conflict komen. Beeld je in dat je een project van iemand anders toevoegt aan jouw project en je ontdekt dat in dat project reeds een klasse Student aanwezig is. Hoe weet C# nu welke klasse moet gebruikt worden? Want mogelijk wens je beide te gebruiken!

De namespace rondom een klasse is als het ware een extra stukje naamgeving waarmee je kan aangeven welke klasse je juist nodig hebt. In bovenstaand stukje code heb ik een project MyEpicGame gemaakt en zoals je ziet bevat het een klasse Monster. De volledige naam (of Fully Qualified Type Name) van deze klasse is MyEpicGame.Monster.

Als ik dus even later een project met volgende namespace, en zelfde klassenaam, importeer:

namespace NietZoEpicGame
{
    internal class Monster

Dan kan ik deze klasse aanroepen als NietZoEpicGame.Monster en kan er dus geen verwarring optreden.

De politie uw vriend! Inderdaad. De auteur van dit boek heeft klachten gekregen over het feit dat hij het edele beroep van politie-agent ietwat besmeurd. We willen daarom even u attenderen en, zoals een goed agent betaamd, u de weg doorheen de stad wijzen.

Als u ons tegenkomt en vraagt "Waar is de Kerkstraat." Dan zullen wij u meer informatie moeten vragen. Zonder er bij te zeggen in welke gemeente u die straat zoekt, is de kans bestaande dat we u naar de verkeerde Kerkstraat sturen (er zijn er namelijk best veel in België en Nederland). Wel, namespaces zijn exact dat. Een soort stadsnaam (of postcode) die essentiëel is bij een straatnaam om zonder verwarring een straat te kunnen identificeren, in dit geval dus de klassenaam. Nog een fijne dag!

using in je code

Wanneer je een bepaalde namespace nodig hebt (standaard laadt een C# 10 project er maar een handvol in) dan dien je dit bovenaan je bestand aan te geven met using. Bijvoorbeeld using System.Diagnostics. Je zegt dan eigenlijk: "Beste C#, als je een klasse zoekt en je vindt ze niet in dit project: kijk dan zeker in de System.Diagnostics-bibliotheek."

Ontbrekende namespaces terugvinden

Het gebeurt soms dat je een klasse gebruikt en je weet zeker dat ze in jouw project of een bestaande .NET bibliotheek aanwezig is. Visual Studio kan je helpen de namespace van deze klasse te zoeken moest je daar te lui voor zijn.

Je doet dit door de naam van de klasse te schrijven (op de plek waar je deze nodig hebt) en dan op het lampje dat links in de rand verschijnt te klikken. Indien de klasse gekend is door VS zal je nu de optie krijgen om automatisch:

  • oftewel using, met de juiste namespace, bovenaan je huidige codebestand te plaatsen.
  • oftewel de volledige naam van de klasse uit te schrijven (dus inclusief de namespace).

Handig toch!

Trouwens: de optie Generate type .. zal je ook vaak kunnen gebruiken. Wanneer de klasse in kwestie (Fiets hier) nog niet bestaat en je wilt deze automatische laten genereren (in een apart bestand) dan zal deze optie dat voor je doen.

Maar hoe weet C# nu welke bibliotheken allemaal beschikbaar zijn? Wel, je kan in je project via de solution explorer kijken welke bibliotheken (meestal in de vorm van DLL-bestanden) werden toegevoegd. In je solution explorer klik je hiervoor de Dependencies open. Daar kan je dan zien in welke bibliotheken VS mag zoeken als je een klasse nodig hebt die niet gekend is. Klik bijvoorbeeld eens onder Dependencies de sectie FrameWorks open en dan MicrosofT.NETCore.App. Je zal er onder andere alle System. bibliotheken zien staan.

Je kan ook extra bibliotheken toevoegen aan je Dependencies. Rechterklik maar eens op Dependencies en zie wat je allemaal kunt doen. Vooral de NuGet packages zijn een erg nuttig en krachtig hulpmiddel. Lees er alles over op docs.microsoft.com/en-us/nuget/quickstart/install-and-use-a-package-in-visual-studio. Helaas kan niet alles over C# en .NET in één boek verzameld worden, maar weet dat er erg nuttige, toffe en zelfs grappige NuGet packages bestaan, zoek bijvoorbeeld maar eens naar de Colorful.Console NuGet!

Exception handling

*Het wordt tijd om de olifant in de kamer te benoemen. Het wordt tijd om een bekentenis te maken... Ben je er klaar voor?! Hier komt ie. Luister goed, maar zeg het niet door: we hebben al de hele tijd informatie voor je achter gehouden! Ja, sorry, het was sterker dan onszelf. Maar we deden het voor jou. Het was de enige manier om ervoor te zorgen dat je leerde programmeren zonder constant bugs in je code achter te laten. Dus ja, hopelijk neem je het ons niet kwalijk?! *

Het wordt tijd om exception handling er bij te halen! Een essentiële programmeertechniek die ervoor zorgt dat je programma minder snel zal crashen indien er zich uitzonderingen tijdens de uitvoer voordoen.

Wat een dramatische start zeg. Waar was dat voor nodig?! De reden is eenvoudig: exception handling is een tweesnijdend zwaard. Je zou exception handling kunnen gebruiken om al je bugs op te vangen, zodat de eindgebruiker niet ziet hoe vaak je programma zou crashen zonder exception handling. Maar uiteindelijk blijf je wel met slechte code zitten en een gouden regel in programmeren is dat slechte code je altijd zal achtervolgen en je ooit dubbel en hard zal straffen voor iedere bug waar je te lui voor was om op te lossen. Kortom, exception handling is de finale fase van goedgeschreven code, niét van slecht geschreven code.

Waarom exception handling?

Veel fouten in je code zijn het gevolg van:

  • Het aanroepen van data die er niet is (bijvoorbeeld een bestand dat werd verplaatst of hernoemd of het wegvallen van het wifi-signaal net wanneer je programma iets van een online database nodig heeft).
  • Foute invoer door de gebruiker (bijvoorbeeld de gebruiker voert een letter in terwijl het programma aan getal verwacht).
  • Programmeerfouten (bijvoorbeeld de programmeur gebruikt een object dat nog niet met de new operator werd geïnitialiseerd, of een deling door nul in een wiskundige berekening).

Voorgaande zaken zijn niet zozeer fouten dan wel uitzonderingen (exceptions). Ze doen zich zelden voor, maar hebben wel een invloed op de correcte uitvoer van je programma. Je programma zal met deze uitzonderingen rekening moeten houden wil je een gebruiksvriendelijk programma hebben. Veel uitzonderingen gebeuren buiten de wil van het programma om, maar kunnen wel gebeuren (wifi weg, foute invoer, enz.). Door deze uitzonderingen af te handelen (exception handling) in je code kunnen we ons programma alternatieve opdrachten geven bij het optreden van een uitzondering.

Je zal zelf al geregeld exceptions zijn tegengekomen in je console programma's. Wanneer je je programma gewoon uitvoert en er verschijnt plots een hele hoop tekst (met onder andere het woord "Exception" in) gevolgd door het ogenblikkelijk afsluiten ervan, dan heb je dus een exception gegenereerd die je niet hebt afgehandeld.

Een joekel van een foutboodschap die je gebruiker huilend zal wegjagen.

Je moet zelfs niet veel moeite doen om uitzonderingen te genereren. Denk maar aan volgende voorbeeld waarbij je een exception kan genereren door een 0 in te geven, of iets anders dan een getal.

Console.WriteLine("Geef een getal aub");
int noemer = Convert.ToInt32(Console.ReadLine());
double resultaat = 100/noemer;
Console.WriteLine($"100/{noemer} is gelijk aan {resultaat}");

Exception handling met try en catch

Het mechanisme om exceptions af te handelen in C# bestaat uit 2 delen:

  • Een try blok: binnen dit blok staat de code die je wil controleren op uitzonderingen omdat je weet dat die hier kunnen optreden.
  • Een of meerdere catch-blokken: dit blok zal mogelijk exceptions die in het bijhorende try-block voorkomen opvangen. Met andere woorden: in dit blok staat de code die de uitzondering zal verwerken zodat het programma op een deftige manier verder kan of meer elegant zichzelf afsluiten (graceful shutdown).

De syntax is als volgt (let er op dat de catch blok onmiddellijk na het try-blok komt):

try
{
    //code waar exception mogelijk kan optreden
}
catch 
{
    //exception handling code hier
}

Een try catch voorbeeld

In volgend stukje code kunnen uitzonderingen optreden zoals we zonet zagen:

string input = Console.ReadLine();
int converted = Convert.ToInt32(input)

Een FormatException zal optreden wanneer de gebruiker tekst of een kommagetal invoert. De conversie verwacht dit niet. Convert.ToInt32() kan enkel werken met gehele getallen.

We tonen nu hoe we dit met exception handling kunnen opvangen:

try
{
    string input = Console.ReadLine();
    int converted = Convert.ToInt32(input);
}
catch 
{
    Console.WriteLine("Verkeerde invoer!");
}

Indien er nu een uitzondering optreedt dan zal de tekst "Verkeerde invoer" getoond worden. Vervolgens gaat het programma verder met de code die mogelijk na het catch-blok staat.

Merk op dat voorgaande code eleganter kan opgelost worden met TryParse wat in het appendix wordt uitgelegd.

Een exception genereren met throw

Je kan ook zelf eender waar in je code een uitzondering opwerpen. Je doet dit met het throw keyword. De werking is quasi dezelfde als het return keyword. Alleen zal bij een throw je terug gaan tot de eerste plek waar een catch klaarstaat om de uitzondering op te vangen. Om een uitzondering op te werpen dien je eerst een Exception object aan te maken en daar de nodige informatie in te plaatsen. In hoofdstuk 14 gaan we hier nog wat dieper op in, maar hier alvast een voorbeeldje:

//Een error treedt op
throw new Exception("Wow, dit loopt fout");

Afhankelijk van het soort fout kunnen we echter ook andere soort uitzonderingen opwerpen. Draai daarom snel deze pagina om en ontdek hoe dit kan!

Meerdere catchblokken

Exception is een klasse van het .NET framework. Er zijn van deze basis-klasse meerdere Exception-klassen afgeleid die een specifieke uitzondering behelzen. Enkele veelvoorkomende zijn:

KlasseOmschrijving
ExceptionBasisklasse
SystemExceptionKlasse voor uitzonderingen die niet al te belangrijk zijn en die mogelijk verholpen kunnen worden.
IndexOutOfRangeExceptionDe index is te groot of te klein voor de benadering van een array
NullReferenceExceptionBenadering van een niet-geïnitialiseerd object

Je kan in het catch blok aangeven welke soort exceptions je wil vangen in dat blok. Als je bijvoorbeeld alle Exceptions wil opvangen schrijf je:

catch (Exception e)
{
}

Hiermee vangen we dus alle Exceptions op, daar alle Exceptions van de klasse Exception afgeleid zijn en dus ook zelf een Exception zijn. De identifier e kies je zelf en wordt gebruikt om vervolgens in het catch block de nodige informatie uit het opgevangen Exception object (e) uit te lezen. We leggen dit zo meteen uit.

We kunnen nu echter ook specifieke exceptions opvangen. De truc is om de meest algemene exception onderaan te zetten en naar boven toe steeds specifieker te worden. We maken een soort fallthrough mechanisme (wat we ook in een switch al hebben gezien).

Stel bijvoorbeeld dat we weten dat de FormatException kan voorkomen en we willen daar iets mee doen. Volgende code toont hoe dit kan:

try
{
    //...
}
catch (FormatException e)
{
    Console.WriteLine("Verkeerd invoerformaat");
}
catch (Exception e)
{
    Console.WriteLine("Exception opgetreden");
}

Indien een FormatException optreedt dan zal het eerste catch-blok uitgevoerd worden, in alle andere gevallen het tweede. Het tweede blok zal niet uitgevoerd worden indien een FormatException optreedt.

Welke exceptions worden gegooid?

De online .NET documentatie is de manier om te weten te komen welke exceptions een methode mogelijk kan opgooien. Gaan we bijvoorbeeld naar de documentatie van de int32.Parse methode dan zien we daar een sectie "Exceptions" waar klaar en duidelijk wordt beschreven wanneer welke exception wanneer wordt opgeworpen.

De documentatie op docs.microsoft.com/dotnet/api/system.int32.parse.

Werken met de exception parameter

De Exceptions die worden opgegooid door een methode zijn objecten van de Exception-klasse. Deze klasse bevat standaard een aantal interessante zaken, die je kan oproepen in je code.

Bovenaan de declaratie van het catch-blok geef je aan hoe het exception object in het blok zal heten. In de vorige voorbeelden was dit altijd e (standaardnaam).

Alle Exception-objecten bevatten volgende informatie:

ElementOmschrijving
MessageFoutmelding in relatief eenvoudige taal.
StackTraceLijst van methoden die de exception hebben doorgegeven.
TargetSiteMethode die de exception heeft gegenereerd (staat bij StackTrace helemaal bovenaan).
ToString()Geeft het type van de exception, Message en StackTrace terug als string.

We kunnen via deze parameter meer informatie uit de opgeworpen uitzondering uitlezen en bijvoorbeeld aan de gebruiker tonen:

catch (Exception e)
{
    Console.WriteLine("Exception opgetreden");
    Console.WriteLine("Message:"+e.Message);
 
    Console.WriteLine("Targetsite:" + e.TargetSite);
    Console.WriteLine("StackTrace:" + e.StackTrace);
}

Vanuit een security standpunt is het zelden aangeraden om Exception informatie zomaar rechtstreeks naar de gebruiker te sturen. Mogelijk bevat de informatie gevoelige informatie en zou deze door kwaadwillige gebruikers kunnen misbruikt worden om bugs in je programma te vinden.

Waar exception handling in code plaatsen?

De plaats in je code waar je je exceptions zal opvangen, heeft invloed op de totale werking van je code.

Stel dat je volgende stukje code hebt waarin je een methode hebt die een lijst van strings zal beschouwen als urls die moeten gedownload worden. Indien er echter fouten in de string staan dan zal er een uitzondering optreden bij lijn 16. De tweede url ("http:\\www.humo.be") bevat namelijk een bewuste fout: de schuine strepen staan in de verkeerde richting.

Als sneak preview tonen we ook ineens hoe arrays van objecten werken.

static void Main(string[] args)
{
    string[] urllist = new string[3];
    urllist[0] = "http://www.ziescherp.be";
    urllist[1] = "http:\\www.humo.be";
    urllist[2] = "timdams.com";
    DownloadAllUris(urllist);
}
 
static public void DownloadAllUris(string[] urls)
{
    System.Net.WebClient webClient = new System.Net.WebClient();
 
    for(int i = 0; i < urls.Length;i++)
    {
        Uri uri = new Uri(urls[i]);
        string result = webClient.DownloadString(uri);
        Console.WriteLine($"{uri} gedownload. Dit is het resultaat {result}");
    }
}

De WebClient is een handige klasse om te interageren met online zaken (websites, restful api's, webservices, enz.). Je kan er bijvoorbeeld heel makkelijk een webscraper mee maken.

We bekijken nu een aantal mogelijk try/catch locaties in deze code en zien welke impact deze hebben op de totale uitvoer van het programma.

Rondom methode-aanroep in z'n geheel

try
{
    DownloadAllUris(urllist);
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}

Zal resulteren in:

http://www.ziescherp.be gedownload!
Ongeldige URI: kan de Authority/Host niet parsen.

Met andere woorden, zolang de urls geldig zijn zal de download lukken. Bij de eerste fout die optreedt zal de volledige methode echter stoppen. Dit is waarschijnlijk enkel wenselijk indien de code erna de informatie van ALLE urls nodig heeft.

Rond afzonderlijke elementen in de loop

Mogelijk wil je echter dat je programma blijft werken indien er 1 of meerdere urls niet werken. We plaatsen dan de try catch niet rond de methode DownloadAllUris, maar net binnenin de methode zelf rond het gedeelte dat kan mislukken:

 for(int i = 0; i < urls.Length;i++)
{
    try
    {
        Uri uri = new Uri(urls[i]);
        string result = webClient.DownloadString(uri);
        Console.WriteLine($"{uri} gedownload. Dit is het resultaat {result}");
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex.Message);
    }
}

Dit zal resulteren in:

http://www.ziescherp.be gedownload!
Ongeldige URI: kan de Authority/Host niet parsen.
Ongeldige URI: de indeling van de URI kan niet worden bepaald.

Met andere woorden, indien een bepaalde url niet geldig is dan zal deze overgeslagen worden en gaat de methode verder naar de volgende. Op deze manier kunnen we alsnog alle urls trachten te downloaden.

finally

Soms zal je na een try-catch-blok ook nog een finally blok zien staan. Dit blok laat je toe om code uit te voeren die ALTIJD moet uitgevoerd worden, ongeacht of er een exception is opgetreden of niet. Je kan dit gebruiken om bijvoorbeeld er zeker van te zijn dat het bestand dat je wou uitlezen terug afgesloten wordt.

try
{
    Uri uri = new Uri(urls[i]);
    string result = webClient.DownloadString(uri);
    Console.WriteLine($"{uri} gedownload. Dit is het resultaat {result}");
}
catch (Exception ex)
{
    Console.WriteLine(ex.Message);
}
finally
{
    //Plaats hier zaken die sowieso moeten gebeuren. 
}

Gevorderde klasseconcepten

Nu we weten wat er allemaal achter de schermen gebeurt met onze objecten, wordt het tijd om wat meer geavanceerde concepten van klassen en objecten te bekijken.

We hebben al ontdekt dat een klasse kan bestaan uit:

  • Instantievariabelen: variabelen die de toestand van het individuele object bijhouden.
  • Methoden: om objecten voor ons te laten werken (gedrag).
  • Properties: om op een gecontroleerde manier toegang tot de interne staat van de objecten te verkrijgen.

Uiteraard is dit niet alles. In dit hoofdstuk bekijken we:

  • Constructors: een gecontroleerde manier om de beginstaat van een object in te stellen.
  • static: die je de mogelijkheid geeft een (deel van je) klasse te laten werken als een object.
  • Object initializer syntax: een recente C# aanvulling die het aanmaken van nieuwe objecten vereenvoudigd.

Constructors

Werking new operator

Objecten die je aanmaakt komen niet zomaar tot leven. Nieuwe objecten maken we aan met behulp van de new operator zoals we al gezien hebben:

Student frankVermeulen = new Student();

De new operator doet 3 dingen:

  • Het maakt een object aan in het heap geheugen.
  • Het roept de constructor van het object aan voor eventuele extra initialisatie.
  • Het geeft een referentie naar het object in het heap geheugen terug.

Via de constructor van een klasse kunnen we extra code meegeven die moet uitgevoerd worden telkens een nieuw object van dit type wordt aangemaakt.

De constructor is een unieke methode die wordt aangeroepen bij het aanmaken van een object, daarom dat we ronde haakjes zetten bij new Student().

Momenteel hebben we in de klasse Student de constructor nog niet expliciet beschreven, maar zoals je aan bovenstaande code ziet bestaat deze constructor al wel degelijk...maar doet hij niets extra (de instantievariabelen en properties krijgen gewoon hun default waarde toegekend, afhankelijk van hun type).

De naam "constructor" zegt duidelijk waarvoor het concept dient: het construeren van objecten. Constructors mogen maar op 1 moment in het leven van een object aangeroepen worden: tijdens hun geboorte m.b.v. new. Je mag (en kan) een constructor op geen enkel ander moment gebruiken!

Soorten constructors

Als programmeur van eigen klassen zijn er 3 opties voor je:

  • Je gebruikt geen zelfgeschreven constructors: het leven gaat voort zoals het is. Je kunt objecten aanmaken zoals eerder getoond en een onzichtbare standaard (default) constructor wordt voor je uitgevoerd.
  • Je hebt enkel een default constructor nodig. Je kan nog steeds objecten met new Student() aanmaken, maar je gaat zelf beschrijven wat er moet gebeuren bij de default constructor. De default constructor herken je aan het feit dat je geen parameters meegeeft aan de constructor tijdens de new aanroep.
  • Je wenst gebruik te maken van één of meerdere overloaded constructors, hierbij zal je dan actuele parameters kunnen meegeven bij de creatie van een object, bijvoorbeeld: new Student(24, "Jos").

Constructors zijn soms gratis, soms niet. Een lege default constructor voor je klasse krijg je standaard wanneer je een nieuwe klasse aanmaakt. Je ziet deze niet en kan deze niet aanpassen. Je kan echter daarom altijd objecten met new myClass() aanmaken.Van zodra je echter beslist om zelf één of meerdere constructors te schrijven zal C# zeggen "Ok, jij je zin, nu doe je alles zelf". De default constructor die je gratis kreeg zal ook niet meer bestaan en heb je die dus nodig dan zal je die dus zelf moeten schrijven!

Een nadeel van C# is dat het soms dingen voor ons achter de schermen doet, en soms niet. Het is mijn taak je dan ook duidelijk te maken wanneer dat wél en wanneer dat net niét gebeurt. Ik vergelijk het altijd met het werken met aannemers: soms ruimen ze hun eigen rommel op nadien, maar soms ook niet. Alles hangt er van af hoe ik die aannemer heb opgetrommeld.

Default constructors

De default constructor is een constructor die geen extra parameters aanvaardt. Een default constructor bestaat ALTIJD uit volgende vorm:

  • Iedere constructor is altijd public .
  • Heeft geen returntype, ook niet void.
  • Heeft als naam de naam van de klasse zelf.
  • Heeft geen extra formele parameters.

Stel dat we een klasse Student hebben:

class Student
{
    public int UurVanInschrijven {private set; get;}
}

We willen telkens een Student-object wordt aangemaakt bijhouden op welk uur van de dag dit plaatsvond. Eerst schrijven de default constructor, deze ziet er als volgt uit:

class Student
{
    public Student()
    {
        // zet hier de code die bij initialisatie moet gebeuren
    }
    public int UurVanInschrijven {private set; get;}
}

Zoals verteld moet de constructor de naam van de klasse hebben, public zijn en geen returntype definiëren.

Vervolgens voegen we de code toe die we nodig hebben:

class Student
{
    public Student()
    {
        UurVanInschrijven = DateTime.Now.Hour;
    }

    public int UurVanInschrijven {private set; get;}
}

Telkens we nu een object zouden aanmaken met new Student() zal deze een UurVanInschrijven hebben dat afhangt van het moment waarop we de code uitvoeren. Beeld je in dat we dit programma uitvoeren om half twaalf 's morgens:

Student eenStudent = new Student();

Dan zal de property UurVanInschrijven van eenStudent op 11 worden ingesteld.

Constructors zijn soms nogal zwaarwichtig indien je enkel een eenvoudige auto-property een startwaarde wenst te geven. Wanneer dat het geval is mag je dit ook als volgt doen:

class Student
{
    public int UurVanInschrijven {private set; get;} = 2;
}

Overloaded constructors

Soms wil je parameters aan een object meegeven bij de creatie ervan. We willen bijvoorbeeld de bijnaam meegeven die het object moet hebben bij het aanmaken.

Met andere woorden, stel dat we dit willen schrijven:

Student jos = new Student("Lord Oakenwood");

Als we dit met voorgaande klasse uitvoeren, die enkel een default constructor heeft, zal de code een fout geven. C# vindt geen constructor die een string als actuele parameter aanvaardt.

Net zoals bij overloading van methoden kunnen we ook constructors overloaden. De code is verrassend gelijkaardig aan method overloading:

class Student
{
    public Student(string bijnaamIn)
    {
        bijNaam = bijnaamIn;
    }
    public string BijNaam { get; private set;}
}

Dat was eenvoudig, hé?

Maar denk eraan, je hebt een overloaded constructor geschreven en dus heeft C# gezegd: "Ok, je schrijft zelf constructors? Trek je plan nu maar. De default constructor zal je ook nu zelf moeten schrijven."

Je kan nu enkel je objecten nog via de overloaded constructors aanmaken. Schrijf je new Student() dan zal je een error krijgen. Wil je de default constructor toch nog hebben dan zal je die dus ook expliciet moeten schrijven, bijvoorbeeld:

class Student
{
    private const string DEFBIJNAAM = "Geen";
    //Default
    public Student() 
    {
       BijNaam = DEFBIJNAAM;
    }
    //Overloaded
    public Student(string bijnaamIn) 
    {
        BijNaam = bijnaamIn;
    }
    public string BijNaam { get; private set;}
}

Voorgaande wil ik nog eenmaal herhalen. Herinner je m'n voorbeeld van die aannemers die soms wel en soms niet opruimden? Laten we nog eens samenvatten hoe het zit met constructors in C#:

Als je geen constructors schrijft krijg je een default constructor gratis. Die doet echter niets extra buiten alle instantievariabelen en properties default waarden geven.

Van zodra je één constructor zelf schrijft, default of overloaded, krijg je niets meer gratis én zal je dus zelf die constructors moeten bijschrijven die jouw code vereist.

Meerdere overloaded constructors

Wil je meerdere overloaded constructors dan mag dat ook. Je wilt misschien een constructor die de bijnaam vraagt alsook een bool om mee te geven of het om een werkstudent gaat:

class Student
{
     private const string DEFBIJNAAM = "Geen";
    //Default
    public Student() 
    {
       BijNaam = DEFBIJNAAM;
    }

    //Overloaded 1
    public Student(string bijnaamIn) 
    {
        BijNaam = bijnaamIn;
    }

    //Overloaded 2
    public Student(string bijnaamIn, bool isWerkStudentIn) 
    {
        BijNaam = bijnaamIn;
        IsWerkStudent = isWerkStudentIn
    }

    public string BijNaam { get; private set;}
    public string IsWerkStudent { get; private set;}

}

Merk op dat je ook full properties best aanroept in je constructor en niet rechtstreeks de achterliggende instantievariabele. Zo kan je ogenblikkelijk de typische controles in een set in gebruik nemen.

Beeld je in dat het schoolsysteem crasht wanneer een nieuwe student een onbeleefde bijnaam invoert. Wanneer dit gebeurt moet de bijnaam altijd gewoon op "Good boy" gezet worden, ongeacht de effectieve bijnaam van de student. Via een set-controle kunnen we dit doen én vervolgens passen we de auto-property aan naar een full property zodat er een ingebouwde controle kan plaatsvinden:

class Student
{
    private const string DEFBIJNAAM = "Good boy";
    //Default
    public Student() 
    {
       bijNaam = DEFBIJNAAM;
    }

    //Overloaded
    public Student(string bijnaamIn) 
    {
        bijNaam = bijnaamIn;
    }

    public string BijNaam
    {
        private set
        {
            if(value == "stommerik") //pardon my french
            {
                bijNaam = DEFBIJNAAM;
            }
            else 
                bijNaam = value;
        }
        get
        {
            return bijNaam;
        }
    }

    private string bijNaam;
}

Deze manier voorkomt dat de constructors verantwoordelijk zijn opdat properties de juiste waarden krijgen. Leg steeds de verantwoordelijk bij het element zelf. Door dit te doen hoef je ook niet in iedere constructor te controleren doorgegeven parameters wel geldig zijn. Ook hier blijft de regel gelden: als je dubbele code dicht bij elkaar ziet staan dan is de kans groot dat je dit kan vereenvoudigen.

Constructors hergebruiken met this()

Beeld je in dat je volgende klasse hebt:

class Microfoon
{
    public Microfoon(string merkIn, bool isUitverkochtIn)
    {
        IsUitverkocht = isUitverkochtIn;
        Merk = merkIn;
    }

    public Microfoon(string merkIn)
    {
        IsUitverkocht = false;
        Merk = merkIn;
    }

    public Microfoon()
    {
        Merk = "Onbekend";
        isUitverkocht = true;
    }

    public string Merk { get; set;}
    public bool IsUitverkocht {get; set;}
}

Bij voorgaande code gaat er mogelijk bij sommige van jullie een alarmbelletje af vanwege de kans op quasi dezelfde code in de verschillende constructors. En dat is een terecht alarm! Om te voorkomen dat we steeds dezelfde toewijzingen moeten schrijven in constructors laat C# toe dat je een andere constructor kunt aanroepen bij een constructor call. We gebruiken hier een speciale methode aanroep this() bij de constructorsignatuur. Via deze aanroep kunnen we dan eventueel parameters meegeven, afhankelijk wat we nodig hebben. De compiler zal aan de hand van de parameters (of het ontbreken) er aan beslissen welke constructor nodig is met behulp van de klassieke method overload resolution regels en de betterness regel toepassen.

Voorgaande klasse gaan we herschrijven zodat alle constructors de bovenste overloaded constructor gebruiken en zo voorkomen dat we te veel dubbele code hebben:

class Microfoon
{
    public Microfoon(string merkIn, bool isUitverkochtIn)
    {
        IsUitverkocht = isUitverkochtIn;
        Merk = merkIn;
    }

    public Microfoon(string merkIn): this(merkIn, false)
    {  }

    public Microfoon(): this ("Onbekend", true)
    {  }

    public string Merk { get; set;}
    public bool IsUitverkocht {get; set;}
}

Bij de tweede overloaded constructor geven we de binnenkomende parameter merkIn gewoon door naar de this() aanroep en voegen er nog een tweede literal, false, aan toe. De compiler zal nu via method overload resolution op zoek gaan naar de best passende constructor, wat in dit geval de bovenste overloaded constructor zal zijn.

Uiteraard ben je vrij om in de constructor zelf nog steeds code te plaatsen. Het is gewoon belangrijk dat je de volgorde begrijpt waarin de constructor-code wordt doorlopen. Stel dat we volgende constructor toevoegen:

public Microfoon(bool isUitverkochtIn): this("Bovarc", isUitverkochtIn)
{
    Merk = "Wit Product";
}

Wanneer we een object aanmaken als volgt new Microfoon(true) dan zal uiteindelijk dit object van het merk Wit Product zijn. Er gebeurt namelijk het volgende:

  1. De overloaded constructor Microfoon(bool isUitverkochtIn) wordt aangeroepen.
  2. Ogenblikkelijk wordt de meegegeven actuele parameter isUitverkochtIn doorgegeven om de overloaded constructor Microfoon(string merkIn, bool isUitverkochtIn) te benaderen.
  3. Deze constructor zal het Merk op Bovarc zetten en IsUitverkocht op true (daar we die parameter doorgeven).
  4. We keren nu terug naar de contructor Microfoon(bool isUitverkochtIn) en voeren de code hiervan uit. Bijgevolg wordt de waarde in Merk overschreven met Wit Product.

Welke constructors moet ik nu eigenlijk allemaal voorzien?

Dit hangt natuurlijk af van de soort klasse dat je maakt. Een constructor is minimaal nodig om ervoor te zorgen dat alle variabele die essentieel zijn in je klasse een beginwaarde hebben. Beeld je volgende klasse voor die een breuk voorstelt:

class Breuk
{
    public int Noemer {get; private set;}
    private int Teller {get; private set;}
    public double BerekenBreuk()
    {
        return (double)Teller/Noemer;
    }
}

De methode zal een DivideByZeroException opleveren als ik de methode BerekenBreuk zou aanroepen nog voor de Noemer een waarde heeft gekregen (deling door nul, weet je wel):

Breuk eenBreuk = new Breuk();
int resultaat = eenBreuk.BerekenBreuk(); //BAM!Een exception! 

Via een constructor kunnen we dit soort bugs voorkomen. We beschermen ontwikkelaars hiermee dat ze jouw klasse foutief gebruiken. Door een overloaded constructor te schrijven die een noemer en teller vereist verplichten we de ontwikkelaar jouw klasse correct te gebruiken (en kunnen geen breuk-objecten met de default constructor aangemaakt worden).

Eerst veranderen we de auto-property Noemer naar een full property:

private int noemer;
public int Noemer 
{
    get 
    { 
        return noemer;
    }
    private set
    {
        if(value != 0)
            noemer = value; 
        else
            noemer = 1; //of werp Exception op zoals eerder uitgelegd.
    }
}

En vervolgens voegen we een overloaded constructor toe:

public Breuk(int tellerIn, int noemerIn)
{
    Teller = tellerIn;
    Noemer = noemerIn
}

Finaal wordt dan onze klasse:

class Breuk
{
    public Breuk(int tellerIn, int noemerIn)
    {
        Teller = tellerIn;
        Noemer = noemerIn
    }    
    private int Teller {get; private set;}

    private int noemer;
    public int Noemer 
    {
        get 
        { 
            return noemer;
        }
        private set
        {
            if(value != 0)
                noemer = value; 
            else
                noemer = 1; //of werp Exception op zoals eerder uitgelegd.
        }
    }
}

Hierdoor kan ik geen Breuk objecten meer als volgt aanmaken:Breuk eenBreuk = new Breuk(); Maar ben ik verplicht deze als volgt aan te maken:

Breuk eenBreuk = new Breuk(21,8);

Pong met constructors

We zullen deze nieuwe informatie gebruiken om onze Pong-klasse uit het eerste hoofdstuk te verbeteren door deze de nodige constructors te geven. Namelijk een default die een balletje aanmaakt dat naar rechtsonder beweegt, en één overloaded constructor die toelaat dat we zelf kunnen kiezen wat de beginwaarden van X, Y, VectorX en VectorY zullen zijn:

class Balletje
{
    public Balletje(int xin, int yin, int vxIn, int vyIn)
    {
        X = xin;
        Y = yin;
        VectorX = vxIn;
        VectorY = vyIn;
    }

    public Balletje(): this(5,5,1,1)
    {

    }

    //...

We kunnen nu op 2 manieren balletjes aanmaken:

Balletje bal1 = new Balletje();
Balletje bal2 = new Balletje(10,8,-2,1);

Je zou ook kunnen overwegen om in de default constructor het balletje een willekeurige locatie en snelheid te geven:

static Random rng =new Random();
public Balletje()
{
    X = rng.Next(0, Console.WindowWidth);
    Y = rng.Next(0, Console.WindowWidth);
    VectorX = rng.Nex(-2,3);
    VectorY = rng.Nex(-2,3);
}

Object initializer syntax

Het is niet altijd duidelijk hoeveel overloaded constructors je juist nodig hebt. Meestal beperken we het tot de default constructor en 1 of 2 heel veel gebruikte overloaded constructors.

Dankzij object initializer syntax kan je ook parameters tijdens de aanmaak van objecten meegeven zonder dat je hiervoor een specifieke constructor moet schrijven.

Object initializer syntax laat je toe om tijdens (eigenlijk direct er na) creatie van een object, properties beginwaarden te geven.

Object initializer syntax is een eerste glimp in het feit waarom properties zo belangrijk zijn in C#. Je kan object initializer syntax enkel gebruiken om via properties je object extra beginwaarden te geven.

Stel dat we volgende klasse hebben waarin we enkele auto-properties gebruiken. Merk op dat dit evengoed full properties mochten zijn. Voor object initializer syntax maakt dat niet uit, het ziet toch enkel maar het public gedeelte van de klasse:

class Meting
{
    public double Temperatuur {get;set;}
    public bool IsGeconfirmeerd {get;set;}
}

We kunnen deze properties beginwaarden geven via volgende initializer syntax:

Meting meting = new Meting() { Temperatuur = 3.4, IsGeconfirmeerd = true};

Object initializer syntax bestaat er uit dat je een object aanmaakt met de default constructor en dat je dan tussen accolades een lijst van properties en hun beginwaarden kunt meegeven. Object initializer werkt enkel indien het object een default constructor heeft (je hoeft deze niet expliciet te maken indien je klasse geen andere constructors heeft zoals in een eerder hoofdstuk al besproken).

Bovenstaande code mag ook iets korter nog:

Meting meting = new Meting { Temperatuur = 3.4, IsGeconfirmeerd = true};

Zie je het verschil? De ronde haakjes van de default constructor mag je dus achterwege laten.

De volgorde waarin je code wordt uitgevoerd is wel belangrijk. Je ziet het niet duidelijk, maar sowieso wordt eerst nu de default constructor aangeroepen. Pas wanneer die klaar is zullen de properties de waarden krijgen die je meegeeft tussen de accolades. Als je dus zelf een default constructor in Meting had geschreven dan had eerst die code uitgevoerd zijn geweest. Voorgaande voorbeeld zal intern eigenlijk als volgt plaatsvinden:

Meting meting = new Meting();
meting.Temperatuur = 3.4;
meting.IsGeconfirmeerd = true;

Je bent niet verplicht alle properties via deze syntax in te stellen, enkel de zaken die je wilt meegeven tijdens de objectcreatie.

required properties

Object initializer syntax werd ontwikkeld om de wildgroei aan overloaded constructors in te perken. Echter, dit bracht een nieuw probleem met zich mee. Met behulp van overloaded constructors kan je gebruikers van je klasse verplichten om bepaalde begininformatie van het object bij de creatie mee te geven. Object initializer syntax werkt enkel met een default constructor, en dus was een nieuw keyword vereist. Welkom required!

Door required voor een property te plaatsen kan je aangeven dat deze property verplicht moet ingesteld worden wanneer je een object aanmaakt met object initializer syntax:

class Meting
{
    public double Temperatuur {get;set;}
    public required bool IsGeconfirmeerd {get;set;}
}

Wanneer we nu een Meting als volgt aanmaken:

Meting meting = new Meting { Temperatuur = 0.7};

Dan krijgen we een foutboodschap: Required member 'Meting.IsGeconfirmeerd' must be set in the object initializer or attribute constructor. Enkel als we dus minstens IsGeconfirmeerd ook instellen zal onze code werken:

Meting meting = new Meting { IsGeconfirmeerd = true};

Het required keyword werd pas geïntroduceerd in C# 11.0 en zal enkel werken indien je applicaties ontwikkelt in .NET 7 of nieuwer.

Attribute constructors worden niet in dit boek behandeld.

Static

Herinner je dat we bij de definitie van een klasse het volgende schreven: "95% van de tijd zullen we in dit boek de voorgaande definitie van een klasse beschrijven, namelijk de blauwdruk voor de objecten die er op gebaseerd zijn. Je zou kunnen zeggen dat de klasse een fabriekje is dat objecten kan maken. Echter, wanneer we het static keyword zullen bespreken gaan we ontdekken dat heel af en toe een klasse ook als een soort object door het leven kan gaan. " Laten we hier eens dieper op ingaan.

Je hebt het keyword static al een paar keer zien staan aan de start van methodesignaturen. Maar vanaf hoofdstuk 9 werd er dan weer nadrukkelijk verteld géén static voor methoden in klassen te plaatsen. Wat is het nu?

Bij klassen en objecten duidt static aan dat een methode of variabele "gedeeld" wordt over alle objecten van die klasse. Wanneer je het keyword ergens voor plaatst (voor een methode, variabele, property, etc) dan kan je dit element aanroepen zonder dat je een instantie van die klasse nodig hebt.

static kan op verschillende plaatsen in een klasse gebruikt worden:

  1. Bij instantievariabelen om een gedeelde variabele aan te maken, over de objecten heen. We spreken dan niet meer over een instantievariabele maar over een static field.
  2. Bij methoden om zogenaamde methoden-bibliotheken of hulpmethoden aan te maken (denk maar aan Math.Pow() en DateTime.IsLeap()) en spreken dan over een static method.
  3. Bij de klasse zelf om te voorkomen dat er objecten van de klasse aangemaakt kunnen worden (bijvoorbeeld de Console en Math klasse). Je raadt het nooit, maar dit noemt dan een static class. De klasse is dan een uniek object.
  4. Bij properties. We hebben al met 1 static property gewerkt namelijk de readonly property Now van de DateTime klasse (DateTime.Now).

Ook een constructor kan static gemaakt worden, maar dat gaan we in dit boek niet bespreken. Samengevat kan je een static constructor gebruiken indien je een soort oer-constructor wilt hebben die eenmalig wordt aangeroepen wanneer het allereerste object van een klasse wordt aangemaakt. Wanneer een tweede (of derde, enz.) instantie wordt aangemaakt zal de static constructor niet meer aangeroepen worden.

We vereenvoudigen bewust het keyword static wat om verwarring te voorkomen. Het "delen van informatie dankzij static" is een gevolg, niet de reden. Met static geven we eigenlijk aan dat het element (methode, variabele, property, enz.) bij de klasse behoort én niet bij instanties van die klasse.

static fields

Zonder het keyword static heeft ieder object z'n eigen instantievariabelen. Aanpassingen binnen het object aan die variabelen hebben geen invloed op andere objecten van hetzelfde type. We tonen eerst de werking zoals we gewend zijn en vervolgens hoe static werkt.

Zonder static fields

Gegeven volgende klasse:

class Mens
{
    private int geboorteJaar;
    public int Geboortejaar 
    {
        get { return geboorteJaar; }
        private set { geboorteJaar = value; }
    }
    public void Jarig()
    {
        Geboortejaar++;
    }
}

Als we dit doen:

Mens m1 = new Mens();
Mens m2 = new Mens();
m1.Jarig();
m1.Jarig();
m2.Jarig();
Console.WriteLine($"{m1.Geboortejaar}");
Console.WriteLine($"{m2.Geboortejaar}");

Dan zien we volgende uitvoer:

2
1

Ieder object houdt de stand van z'n eigen variabelen bij. Ze kunnen elkaars interne (zowel publieke als private) staat niet rechtstreeks veranderen.

En nu, mét static fields

Laten we eens kijken wat er gebeurt indien we een instantievariabele static maken.

We maken de variabele private int geboorteJaar static als volgt: private static int geboorteJaar = 1;. We krijgen dan:

class Mens
{
    private static int geboorteJaar = 1;
    public int Geboortejaar 
    {
        get { return geboorteJaar; }
        private set { geboorteJaar = value; }
    }
    public void Jarig()
    {
        Geboortejaar++;
    }
}

We hebben er nu voor gezorgd dat ALLE objecten de variabele geboorteJaar delen. Er wordt van deze variabele dus maar één "instantie" in het geheugen aangemaakt.

Voeren we nu terug volgende code uit:

Mens m1 = new Mens();
Mens m2 = new Mens();
m1.Jarig();
m1.Jarig();
m2.Jarig();
Console.WriteLine($"{m1.Geboortejaar}");;
Console.WriteLine($"{m2.Geboortejaar}");;

Dan wordt de uitvoer:

4
4

We zien dat de variabele geboorteJaar dus niet meer per object individueel wordt bewaard, maar dat het één globale variabele als het ware is geworden en géén instantievariabele meer is. static laat je dus toe om informatie over de objecten heen te delen.

Gebruik static niet te pas en te onpas: vaak druist het in tegen de concepten van OO en wordt het vooral misbruikt.

Ga je dit soort static variabelen, ook wel static fields genoemd, vaak nodig hebben? Niet zo vaak. Het volgende concept wel.

static methoden

Heb je er al bij stil gestaan waarom je dit kan doen:

Math.Pow(3,2);

Zonder dat we objecten moeten aanmaken in de trend van:

Math myMath = new Math(); //dit mag niet!
myMath.Pow(3,2)

De reden dat je de Math-bibliotheek kan aanroepen rechtstreeks op de klasse en niet op objecten van die klasse is omdat de methoden in die klasse als static gedefinieerd staan.

De klasse is op de koop toe ook zelf static gemaakt. Zo kan er zeker geen twijfel bestaan: deze klasse kan niét in een object gegoten worden.

De klasse zal er dus zo ongeveer uitzien:

static class Math
{
    public static double Pow(int getal, int macht)
    {
        //enz.

Voorbeeld van static methoden

Stel dat we enkele veelgebruikte methoden willen groeperen en deze gebruiken zonder telkens een object te moeten aanmaken dan doen we dit als volgt:

static class EpicLibrary
{
    static public void ToonInfo()
    {
        Console.WriteLine("Ik ben ik");
    }

    static public int TelOp(int a, int b)
    {
        return a+b;
    }
}

We kunnen deze methoden nu als volgt aanroepen:

EpicLibrary.ToonInfo();
int opgeteld = EpicLibrary.TelOp(3,5);

Mooi toch?!

Dankzij static kunnen we dus eigen bibliotheken van methoden (én properties) aanmaken die we kunnen aanroepen rechtstreeks op de klasse zonder dat we er een object van moeten aanmaken.

Je mag ook hybride klassen maken waarin sommige delen static zijn en andere niet. De DateTime klasse uit het eerste hoofdstuk bijvoorbeeld is zo'n klasse. De meeste dingen gebeurden non-static toch was er ook bijvoorbeeld de static property Now om de huidige tijd terug te krijgen, alsook de IsLeapYear hulpmethode die we rechtstreeks op de klasse DateTime moesten aanroepen:

bool gaIkOpPensioenInEenSchrikkeljaar = DateTime.IsLeapYear(2048);

Intermezzo: Debug.WriteLine

Even een kort intermezzo dat we in de volgende sectie gaan gebruiken, namelijk de werking van de Debug klasse.

De Debug klasse (die in de System.Diagnostics namespace staat) kan je gebruiken om eenvoudig zaken naar het debug output venster te sturen tijdens het debuggen. Dit is handig om te voorkomen dat je debug informatie steeds naar het console-scherm moet sturen . Het zou niet de eerste keer zijn dat iemand vergeet een bepaalde Console.WriteLine te verwijderen uit het finale product en zo mogelijk gevoelige debug-informatie naar de eindgebruikers lekt.

Volgende code toont een voorbeeld (merk lijn 1 op die vereist is):

using System.Diagnostics;

namespace debugdemo
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World! Console");
            Debug.WriteLine("Hello World! Debug");
        }
    }
}

Als je voorgaande code uitvoert in debugger modus, dan zal je enkel de tekst Hello World! Console in je console zien verschijnen. De andere lijn kan je terugvinden in het "Output" venster in Visual Studio:

Het is wat zoeken tussen de andere output die VS genereert, daarom heb ik "onze" output even geel gemarkeerd.

Mooi zo. Nu we dat hebben bekeken kunnen terug keren naar het gebruik van het static keyword. Of zoals mijn grootvader zaliger altijd zei "goto static!".

MILJAAR!

Nog een voorbeeld van het gebruik van static

In het volgende voorbeeld gebruiken we een static variabele om bij te houden hoeveel objecten (via de constructor met behulp van Debug.WriteLine ) er van de klasse reeds zijn aangemaakt. :

class Fiets
{
    private static int aantalFietsen = 0;
    public Fiets()
    {
        aantalFietsen++;
        Debug.WriteLine($"Er zijn nu {aantalFietsen} gemaakt");
    }
    public static void VerminderFiets()
    {
        aantalFietsen--;
        Debug.WriteLine($"STATIC: Er zijn {aantalFietsen} fietsen");
    }
}

Merk op dat we de methoden VerminderFiets enkel via de klasse kunnen aanroepen daar deze static werd gemaakt (zie verder). We kunnen echter nog steeds instanties, Fiets-objecten, aanmaken aangezien de klasse zelf niet static werd gemaakt.

Laten we de uitvoer van volgende code eens bekijken:

Fiets merckx = new Fiets();
Fiets steels = new Fiets();
Fiets evenepoel = new Fiets();
Fiets.VerminderFiets();
Fiets aerts = new Fiets();
Fiets.VerminderFiets();

Dit zal debug uitvoer geven:

Er zijn nu 1 gemaakt 
Er zijn nu 2 gemaakt 
Er zijn nu 3 gemaakt 
STATIC:Er zijn 2 fietsen
Er zijn nu 3 gemaakt
STATIC:Er zijn 2 fietsen

Static vs non-static

Van zodra je een methode hebt die static is dan zal deze methode enkel andere static methoden en variabelen kunnen aanspreken. Dat is logisch: een static methode heeft geen toegang tot de gewone niet-statische variabelen van een individueel object, want welk object zou hij dan moeten benaderen? Het omgekeerde kan nog wel natuurlijk.

Volgende code zal dus een fout geven:

class Mens
{
    private int gewicht = 50;

    private static void VerminderGewicht()
    {
        gewicht--;
    }
}

De error die verschijnt An object reference is required for the non-static field, method, or property 'Program.Mens.gewicht' zal bij lijn 7 staan.

Volgende vereenvoudiging maakt duidelijk wat kan aangeroepen worden en wat niet:

De pijl duidt aan of methoden en variabelen kunnen bereikt worden of niet.

Een eenvoudige regel is te onthouden dat van zodra je in een static omgeving bent (meestal een methode of property), je niet meer naar de niet-static delen van je code zal geraken.

Dit verklaart ook waarom je bij console applicaties in Program.cs steeds alle methoden static moet maken. De Main van een console-applicatie is als volgt beschreven wanneer je deze aanmaakt:public static void Main(){}. Zoals je ziet is de Main methode als static gedefinieerd. Willen we dus vanuit deze methode andere methoden aanroepen dan moeten deze als static aangeduid zijn..

static properties

Beeld je in dat je (weer) een pong-variant moet maken waarbij meerdere balletjes over het scherm moeten botsen. Je wilt echter niet dat de balletjes zelf allemaal apart moeten weten wat de grenzen van het scherm zijn. Mogelijk wil je bijvoorbeeld dat je code ook werkt als het speelveld kleiner is dan het eigenlijke Console-scherm.

We gaan dit oplossen met een static property waarin we de grenzen voor alle balletjes bijhouden. Aan onze klasse Balletje voegen we dan alvast het volgende toe:

static public int Breedte { get; set; }
static public int Hoogte { get; set; }

In ons hoofdprogramma (Main) kunnen we nu de grenzen voor alle balletjes tegelijk vastleggen:

Balletje.Hoogte = Console.WindowHeight;
Balletje.Breedte = Console.WindowWidth;

Maar even goed maken we de grenzen voor alle balletjes gebaseerd op zelf gekozen waarden:

Balletje.Hoogte = 20;
Balletje.Breedte = 10;

We zouden zelfs de grenzen van het veld dynamisch kunnen maken en laten afhangen van het huidige level.

De interne werking van de balletjes hoeft dus geen rekening meer te houden met de grenzen van het scherm. We passen de Update-methode aan, rekening houdend met deze nieuwe kennis:

public void Update()
{
    if (X + VectorX >= Balletje.Breedte || X + VectorX < 0)
    {
        VectorX = -VectorX;
    }
    X = X + VectorX;

    if (Y + VectorY >= Balletje.Hoogte || Y + VectorY < 0)
    {
        VectorY = -VectorY;
    }
    Y = Y + VectorY;
}

En nu kunnen we vlot balletjes laten rond bewegen op bijvoorbeeld een klein deeltje maar van het scherm:

static void Main(string[] args)
{
    Console.CursorVisible = false;
    Balletje.Hoogte = 15;
    Balletje.Breedte = 15;

    Balletje m1 = new Balletje(1,1,1,1);
    Balletje m2 = new Balletje(2,2,-2,1);
    
    while (true)
    {
        m1.Update();
        m1.TekenOpScherm();

        m2.Update();
        m2.TekenOpScherm();
  
        System.Threading.Thread.Sleep(50);
        Console.Clear();
    }
}

Je zal static minder vaak nodig hebben dan non-static zaken. Alhoewel: wanneer je werkt met een klasse waarin je een Random-number generator gebruikt, dan is het een goede gewoonte deze generator static te maken zodat alle objecten deze ene generator gebruiken. Anders bestaat de kans dat je objecten dezelfde random getallen zullen aanmaken wanneer ze toevallig op quasi hetzelfde moment werden geïnstantieerd of methoden in aanroept.

Test maar eens wat er gebeurt als je volgende klasse hebt:

class Dobbelsteen
{
    public int Werp()
    {
        Random gen = new Random();  //SLECHT IDEE!
        return gen.Next(1,7);
    }
}

Wanneer je nu dezelfde dobbelsteen 10 maal snel na elkaar rolt is de kans groot dat je geregeld dezelfde getallen gooit:

Dobbelsteen testDobbel = new Dobbelsteen();
for(int i = 0 ; i < 10; i++)
{
    Console.WriteLine(testDobbel.Werp());
}

De reden? Een nieuw aangemaakt Random-object gebruikt de tijd waarop het wordt aangemaakt als een zogenaamde seed. Een seed zorgt ervoor dat je dezelfde reeks getallen kan genereren wanneer de seed dezelfde is (een concept dat nuttig is in cryptografie waarbij de seed dan de geheime sleutel tussen zender en ontvanger is en zij dus met een gedeelde sleutel dezelfde willekeurige reeks getallen kunnen maken). Uiteraard willen we dat niet bij een dobbelsteen. Het is niet omdat een dobbelsteen snel na elkaar wordt geworpen (of aangemaakt) dat die dobbelsteen dan regelmatig dezelfde getallen na elkaar gooit.

We lossen dit op door de generator static te maken zodat er maar één generator bestaat die alle dobbelstenen en hun methoden delen. Dit is erg eenvoudig opgelost: je verhuist je generator naar buiten de methode en plaatst er static voor:

class Dobbelsteen
{
    static Random gen = new Random();
    public int Werp()
    {
        return gen.Next(1,7);
    }
}

Arrays en klassen

Arrays van value types kwamen we al in hoofdstuk 8 tegen. In dit hoofdstuk tonen we dat ook arrays van objecten perfect mogelijk zijn. We weten reeds dat klassen niets meer dan zijn dan nieuwe datatypes, en dus is het ook logisch dat wat we reeds met arrays konden, we dit gewoon kunnen blijven doen, maar met objecten.

Maar, er is één grote maar: omdat we met objecten werken moeten we rekening houden met het feit dat de individuele objecten in je array reference values hebben en dus mogelijk null zijn. Met andere woorden: het is van essentiëel belang dat je het hoofdstuk rond geheugenmanagement in C# goed begrijpt, want we gaan het geregeld nodig hebben.

Na Exceptions is het weer tijd voor een andere bekentenis: arrays zijn fijn, maar nogal omslachtig qua gebruik. Er zit echter in .NET een soort array on steroids datatype dat ons nooit nog zal doen teruggrijpen naar arrays. Welke dat zijn? Lees verder en ontdek het zelf!

Let's go!

Arrays van objecten aanmaken

Een array van objecten aanmaken doe je als volgt:

Student[] mijnKlas = new Student[20];

De new zorgt er echter enkel voor dat er een referentie naar een nieuwe array wordt teruggegeven, waar ooit 20 studenten-objecten in kunnen komen. Maar: er staan nog géén objecten in deze array. Alle elementen in deze array zijn nu nog null.

De referentie naar een, nu nog, lege array is aangemaakt.

Willen we nu elementen in deze array plaatsen dan moeten we dit ook expliciet doen en moeten we dus objecten aanmaken en hun referentie in de array bewaren:

mijnKlas[0] = new Student();
mijnKlas[2] = new Student();

De situatie in het geheugen nadat 2 objecten werden aangemaakt en in de array werden geplaatst.

Uiteraard kan dit ook in een loop indien relevant voor de opgave. Volgende voorbeeld vult een reeds aangemaakte array met evenveel objecten als de arrays groot is:

for(int i = 0; i < mijnKlas.Length; i++)
{
    mijnKlas[i] = new Student();
}

Individueel object benaderen

Van zodra een object in de array staat kan je deze vanuit de array aanspreken door middel van de index en de dot-operator om de de juiste methode of property op het object aan te roepen:

mijnKlas[3].Name = " Duke Peekaboo";

Uiteraard mag je ook altijd de referentie naar een individueel object in de array kopiëren. Denk er aan dat we de hele tijd met referenties werken en de GC dus niet tussenbeide zal komen zolang er minstens 1 referentie naar het object is. Indien de student op plek 4 in de array aan de start een geboortejaar van 1981 had, dan zal deze op het einde van volgende code als geboortejaar 1983 hebben, daar we op hetzelfde objecten het geboortejaar verhogen in zowel lijn 2 als 3:

Student tijdelijkeStudent = mijnKlas[3];
mijnKlas[3].Geboortejaar++;
tijdelijkeStudent.Geboortejaar++;

Probeer je objecten te benaderen die nog niet bestaan dan zal je uiteraard een NullReferenceException krijgen.

Array initializer syntax

Je kan ook een variant op de object initializer syntax gebruiken waarbij de objecten reeds van bij de start in de array worden aangemaakt. Als bonus zorgt dit er ook voor dat we geen lengte moeten meegeven, de compiler zal deze zelf bepalen.

Volgende voorbeeld maakt een nieuwe array aan die bestaat uit 2 nieuwe studenten, alsook 1 bestaande met de naam jos:

Student jos = new Student();
//...

Student[] mijnKlas = new Student[]
{
    new Student(),
    new Student(),
    jos
};

Let op de puntkomma helemaal achteraan. Die wordt als eens vergeten.

Het kan niet genoeg benadrukt worden dat een goede kennis van de heap, stack en referenties essentieel is om te leren werken met arrays van objecten. Uit voorgaande stukje code zien we duidelijk dat een goed inzicht in referenties je van veel leed beschermen. Bekijk eens de eindsituatie van voorgaande code:

De situatie in het geheugen op het einde.

Zoals je merkt zal nu de student jos niet verwijderd worden indien we op gegeven moment schrijven jos = null daar het object nog steeds bestaat via de array. We kunnen met andere woorden op 2 manieren de student jos momenteel bereiken, via de array of via jos:

jos.Naam = "Joske Vermeulen";
mijnKlas[2].Naam = "Franske Vermeulen"; //we overschrijven "Joske Vermeulen"

Null-check met ?

Ook hier kan je met ? een null-check schrijven:

mijnKlas?[3]?.Name = "Romeo Montague ";

Merk op dat het eerste vraagteken controleert of de array zelf niet null is. Het tweede vraagteken, na de index, is om te controleren of het element op die index niet null is (toegegeven, erg elegant is dit niet).

Object arrays als parameters en return

Ook arrays mag je als parameters en returntype gebruiken in methoden. De werking hiervan is identiek aan die van value-types zoals volgende voorbeeld toont. Eerst maken we een methode die als resultaat een referentie naar een lege array van 10 studenten teruggeeft.

static Student[] CreateEmptyStudentArray()
{
    return new Student[10];
}

Vervolgens kunnen we deze dan aanroepen en het resultaat (de referentie naar de lege array) toewijzen aan een nieuwe variabele (van hetzelfde datatype, namelijk Student[]):

Student[] resultaat = CreateEmptyStudentArray();

List collectie

Een List<>-collectie is de meest standaard collectie die je kan beschouwen als een veiligere variant op een doodnormale array. Een List heeft alle eigenschappen die we al kennen van arrays, maar ze zijn wel krachtiger. Het giet een klasse "rond" het concept van de array, waardoor je toegang krijgt tot een hoop nuttige methoden die het werken met arrays vereenvoudigen.

List aanmaken

De klasse List<> is een zogenaamde generieke klasse (meer hierover in de appendix). Tussen de < >tekens plaatsen we het datatype dat de lijst zal moeten gaan bevatten. Bijvoorbeeld:

List<int> alleGetallen = new List<int>();
List<bool> binaryList = new List<bool>();
List<Pokemon> pokeDex = new List<Pokemon>();
List<string[]> listOfStringarrays = new List<string[]>();

Zoals je ziet hoeven we bij het aanmaken van een List geen begingrootte mee te geven, wat we wel bij arrays moeten doen. Dit is één van de voordelen van List: ze groeien mee.

In dit boek behandelen we het concept generieke klassen niet. Generieke klassen oftewel generic classes zijn een handig concept om je klassen nog multifunctioneler te maken doordat we zullen toelaten dat bepaalde datatypes niet hardcoded in onze klasse moet gezet worden. List<> is zo'n eerste voorbeeld, maar er zijn er tal van anderen én je kan ook zelf dergelijke klassen schrijven. Bekijk zeker de appendix indien je dit interesseert.

De generieke List<> klasse bevindt zich in de System.Collections.Generic namespace. Je dient deze namespace dus als using bovenaan toe te voegen wil je deze klasse kunnen gebruiken in C# 9.0 en ouder.

Elementen toevoegen

Via de Add()-methode kan je elementen toevoegen aan de lijst. Je dient als parameter aan de methode mee te geven wat je aan de lijst wenst toe te voegen. Deze parameter moet uiteraard van het type zijn dat de List verwacht.

In volgende voorbeeld maken we een List aan die objecten van het type string mag bevatten en vervolgens plaatsen we er twee elementen in.

List<string> mijnPersonages = new List<string>();
mijnPersonages.Add("Reinhardt");
mijnPersonages.Add("Mercy");

Ook meer complexe datatypes kan je dus toevoegen:

List<Pokemon> pokedex = new List<Pokemon>();
pokedex.Add(new Pokemon());

Via object syntax initializer kan dit zelfs nog sneller:

List<Pokemon> pokedex = new List<Pokemon>()
    {
        new Pokemon(),
        new Pokemon()
    };

Je kan ook een stap verder gaan en ook binnenin deze initializer syntax dezelfde soort initialize syntax gebruiken om de objecten individueel aan te maken:

List<Pokemon> pokedex = new List<Pokemon>()
    {
        new Pokemon() {Naam = "Pikachu", HP_Base = 5},
        new Pokemon() {Naam = "Bulbasaur", HP_Base = 15}
    };

Elementen indexeren

Het leuke van een List is dat je deze ook kan gebruiken als een gewone array, waarbij je met behulp van de indexer elementen individueel kan aanroepen. Stel bijvoorbeeld dat we een lijst hebben met minstens 4 strings in. Volgende code toont hoe we de string op positie 3 kunnen uitlezen en hoe we die op positie 2 overschrijven, net zoals we reeds kenden van arrays:

Console.WriteLine(mijnPersonages[3]);
mijnPersonages[2] = "Torbjorn";

Ook de klassieke werking met loops blijft gelden. De enige aanpassing is dat List<> niet met Length werkt maar met Count:

for(int i = 0 ; i < mijnPersonages.Count; i++)
{
    Console.WriteLine(mijnPersonages[i])
}

Wat kan een List nog?

Interessante methoden en properties voorts zijn:

  • Clear(): methode die de volledige lijst leegmaakt en de lengte (Count) terug op 0 zet.
  • Insert(): methode om een element op een specifieke plaats in de lijst in te voegen.
  • IndexOf(): geeft de index terug van het element item in de rij. Indien deze niet in de lijst aanwezig is dan wordt -1 teruggegeven.
  • RemoveAt(): verwijdert een element op de index die je als parameter meegeeft.

Let op met het gebruik van IndexOf en objecten. Deze methode zal controleren of de referentie dezelfde is van een bepaald object en daar de index van teruggeven. Je kan deze methode dus wel degelijk met arrays van objecten gebruiken, maar je zal enkel je gewenste object terugvinden indien je reeds een referentie naar het object hebt en dit meegeeft als parameter.

Foreach loops

In het hoofdstuk over loops bespraken we reeds de while, do while en for-loops. Er is echter een vierde soort loop in C# die vooral zijn nut zal bewijzen wanneer we met arrays van objecten werken: de foreach loop.

Wanneer je geen indexering nodig hebt, maar toch snel over alle elementen in een array wenst te gaan, dan is het foreach statement zeer nuttig. Een foreach loop zal ieder element in de array één voor één in een tijdelijke variabele plaatsen (de iteration variable) zodat binnenin de loop met dit ene element kan gewerkt worden. Het voordeel hierbij is dat je geen teller/index nodig hebt en dat de loop zelf de lengte van de array zal bepalen: je code wordt net iets leesbaarder als we dit bijvoorbeeld vergelijken met hoe een for loop geschreven is.

Volgende code toont de werking waarbij we een double-array hebben en alle elementen ervan op het scherm willen tonen:

double[] killDeathRates = {1.2, 0.89, 3.15, 0.1};
foreach (double singleKD in killDeathRates)
{
   Console.WriteLine(singleKD);
}

Het belangrijkste nieuwe concept is de iteration variable die we hier definiëren als singleKD. Deze moet van het type zijn van de individuele elementen in de array (of een compatibel type volgens de regels van polymorfisme in hoofdstuk 16). De naam die je aan de iteration variabele geeft mag je zelf kiezen. Vervolgens schrijven we het nieuwe keyword in gevolgd door de array waar we over wensen te itereren.

De eerste keer dat we in de loop gaan zal het element killDeathRates[0] aan singleKD toegewezen worden voor gebruik in de loop-body, vervolgens wordt killDeathRates[1] toegewezen, enz. De output zal dan zijn:

1.2
0.89
3.15
0.1

Stel dat we een array van Studenten hebben, deKlas, en wensen van deze studenten de naam en geboortejaar op het scherm te tonen, dan kan dat met een foreach erg eenvoudig:

foreach (Student eenStudent in deKlas)
{
   Console.WriteLine($"{eenStudent.Naam}, {eenStudent.Geboortejaar}");
}

Merk op dat al deze voorbeelden ook met een List in plaats van een array werken.

Opgelet bij het gebruik van foreach loops

De foreach loop is weliswaar leesbaarder en eenvoudiger in gebruikt, er zijn ook 3 erg belangrijke nadelen aan:

  • De foreach iteration variabele is read-only: je kan dus geen waarden in de array aanpassen, enkel uitlezen. Dit ogenschijnlijk eenvoudige zinnetje heeft echter veel gevolgen. Je kan met een foreach-loop dus nooit de inhoud van de variabele aanpassen (lees zeker de waarschuwing hieronder). Wens je dat wel te doen, dan dien je de klassieke while, do while of for loops te gebruiken.
  • De foreach loop gebruik je enkel als je alle elementen van een array wenst te benaderen. In alle andere gevallen zal je een ander soort loop moeten gebruiken (daar ik geen fan van break ben).
  • Voorts heb je geen teller (die je gratis bij een for krijgt) om bij te houden hoeveel objecten je al hebt benaderd. Heb je dus een teller nodig dan zal je deze manueel moeten aanmaken zoals je ook bij een while en do while loop moet doen.

Het feit dat de foreach iteration variabele read-only is wil niet zeggen dat we de inhoud van het onderliggend object niet kunnen aanpassen. De iteration variabele krijgt bij een array van objecten telkens een referentie naar het huidige element. Deze referentie kunnen we niet aanpassen, maar we mogen wel de referentie "volgen" om vervolgens iets in het huidige object zelf aan te passen.

Dit mag dus niét:

foreach (Student eenStudent in deKlas)
{
   eenStudent = new Student();
}

Maar dit mag wél:

foreach (Student eenStudent in deKlas)
{
   eenStudent.Geboortejaar++;
}

Met de VS snippet foreach gevolgd door twee maal op de tab-toets te duwen krijg je een kant-en-klare foreach loop.

Het var keyword

C# heeft een var keyword. Je mag dit keyword gebruiken ter vervanging van het datatype (bv. int) op voorwaarde dat de compiler kan achterhalen wat het type (implicit type) moet zijn aan de hand van de expressie rechts van de toekenningsoperator.

var getal = 5; //var zal int zijn
var myArray = new double[20]; //var zal double[] zijn
var tekst = "Hi there handsome"; //var zal string zijn
var ikke = new Leerkracht(); //var zal Leerkracht zijn

Opgelet: het var keyword is gewoon een lazy programmer syntax toevoeging om te voorkomen dat je als programmeur niet constant het type moet schrijven.

Bij JavaScript heeft var een totaal andere functie, daar zegt het eigenlijk: "het type dat je in deze variabele kan steken is...variabel". Met andere woorden het kan de ene keer een string zijn, dan een int, enz.

Bij C# gaat dit niet: eens je een variabele aanmaakt dan zal dat type onveranderbaar zijn en kan je er alleen waarden aan toekennen van dat type.

JavaScript is namelijk een dynamically typed language terwijl C# een statically typed language is (er is één uitzondering bij C# hieromtrent: wanneer je met dynamic leert werken kan je C# ook tijdelijk als een dynamically typed taal gebruiken, maar dat wordt niet besproken in dit boek).

var en foreach

Wanneer je de Visual Studio code snippet voor foreach gebruikt (foreach [tab][tab]) dan zal deze code ook een var gebruiken voor de iteration variabele. De compiler kan aan de te gebruiken array of List zien wat het type van een individueel element in de array moet zijn.

De foreach die we zonet gebruikten kan dus herschreven worden naar:

foreach (var eenStudent in deKlas)
{
   Console.WriteLine($"{eenStudent.Naam}, {eenStudent.Geboortejaar}");
}

Merk op dat dit hoegenaamd geen invloed heeft op je applicatie. Wanneer je code gaat compileren die het keyword var bevatten dan zal de compiler eerst alle vars vervangen door het juiste type, én dan pas beginnen compileren.

Nuttige collectie-klassen

Naast de generieke List collectie, zijn er nog enkele andere nuttige generieke 'collectie-klassen' die je geregeld in je projecten kan gebruiken, namelijk de Dictionary, Queue en Stack-collecties.

Queue<> collectie

Een queue (uitgesproken als kjioe) stelt een "first in, first out"-lijst (FIFO) voor. Een Queue stelt de rijen voor die we in het echte leven ook hebben wanneer we bijvoorbeeld aanschuiven aan een ticketverkoop of in de supermarkt. Met deze klasse kunnen we zo’n rij simuleren en ervoor zorgen dat steeds het eerste/oudste element in de rij als eerste wordt behandeld. Nieuwe elementen worden achteraan de rij toegevoegd.

We gebruiken onder andere volgende 2 methoden om met een Queue-lijst te werken:

  • Enqueue(T item): Voeg een item achteraan de lijst toe.
  • Dequeue(): geeft een referentie naar het eerste element in de queue terug en verwijdert dit element vervolgens uit de lijst.

De Queue: een wachtrij van objecten en een verdomd moeilijk woord om te schrijven.

Voorbeeld:

Queue<string> wachtrij = new Queue<string>();
wachtrij.Enqueue("Ik stond hier eerste.");
wachtrij.Enqueue("Ik tweedes.");
wachtrij.Enqueue("Ik laatste.");
 
Console.WriteLine(wachtrij.Dequeue());
Console.WriteLine(wachtrij.Dequeue());

Dit zal op het scherm tonen:

Ik stond hier eerste.
Ik tweedes.

Een andere interessante methode is de Peek() methode: hiermee kunnen we kijken in de queue wat het eerste element is, zonder het te verwijderen.

Stack<> collectie

Daar waar een queue "first in,first out" is, is een stack "last in,first out" (LIFO). Met andere woorden het recentst toegevoegde element zal steeds vooraan staan en als eerste verwerkt worden. Je kan dit vergelijken met een stapel papieren waar je steeds bovenop een nieuw papier legt.

Ook de klasse Stack heeft verschillende methoden, waarvan volgende 2 methoden het interessantst zijn:

  • Push(T item): plaats een nieuw element bovenop de stapel.
  • Pop(): geeft het bovenste element in de stack terug en verwijdert vervolgens dit element van de stack.

De stack: een toren van objecten

Voorbeeld:

Stack<string> stapel = new Stack<string>();
stapel.Push("Ik was eerste hier.");
stapel.Push("Ik tweede.");
stapel.Push("Ik als laatste.");
 
Console.WriteLine(stapel.Pop());
Console.WriteLine(stapel.Pop());

Dit zal dus het volgende resultaat geven:

Ik als laatste.
Ik tweede.

Dictionary<> collectie

In een dictionary wordt ieder element voorgesteld door een sleutel (key of index) en de waarde (value) van het element.

De sleutel moet een unieke waarde zijn zodat het element kan opgevraagd worden uit de dictionary aan de hand van deze sleutel zonder dat er duplicaten zijn.

Bij de declaratie van de Dictionary dien je op te geven wat het datatype van de key zal zijn, alsook het type van de waarde (value).

De Dictionary-klasse emuleert dus letterlijk de werking van een woordenboek waarbij ieder woord uniek is en een bijhorende uitleg heeft (het woord is de sleutel, de bijhorende uitleg de waarde). Geen enkel woord komt dubbel voor in een woordenboek (als het meerdere definities heeft dan worden deze allemaal bij dat ene woord als waarde geplaatst).

Gebruik Dictionary

In het volgende voorbeeld maken we een Dictionary van klanten aan. Iedere klant heeft een unieke ID (de key is van het type int) alsook een naam (die niet noodzakelijk uniek is en de waarde voorstelt):

Dictionary<int, string> klanten = new Dictionary<int, string>();
klanten.Add(123, "Tim Dams");
klanten.Add(6463, "James Bond");
klanten.Add(666, "The beast");
klanten.Add(700, "James Bond");

Visuele voorstelling van de net aangemaakte Dictionary

Bij de declaratie van klanten plaatsen we dus tussen de < > twee datatypes: het eerste duidt het datatype van de key aan, het tweede dat van de values.

We kunnen een specifiek element opvragen aan de hand van de key. Stel dat we de waarde (naam) van de klant met key (id) gelijk aan 123 willen tonen, dan schrijven we:

Console.WriteLine(klanten[123]);

We kunnen nu met behulp van bijvoorbeeld een foreach-loop alle elementen tonen. Hier kunnen we de key met de .Key-property uitlezen en het achterliggende object of waarde met .Value. Value en Key hebben daarbij ieder het type dat we hebben gedefinieerd toen we het Dictionary-object aanmaakten, in het volgende geval is de Key dus van het type int en Value van het type string:

foreach (var item in klanten)
{
    Console.WriteLine(item.Key+ "\t:"+item.Value);
}

De key werkt dus net als de index bij gewone arrays, alleen heeft de key nu geen relatie meer met de positie van het element in de collectie maar is een unieke identifier van het element in kwestie (vergelijk dit met de nummerplaat van een auto).

Eender welk type voor key en value

De key kan zelfs een string zijn en de waarde een ander type. In het volgende voorbeeld hebben we eerder een klasse Student aangemaakt. We maken nu een student aan en voegen deze toe aan de studentenLijst. Vervolgens willen we het geboortejaar van een bepaalde student tonen op het scherm en vervolgens verwijderen we deze student:

Dictionary<string, Student> studentenLijst = new Dictionary<string, Student>();
Student stud = new Student() { Naam = "Tim", Geboortejaar = 2001 };
studentenLijst.Add("AB12", stud);
Console.WriteLine(studentenLijst["AB12"].Geboortejaar);
studentenLijst.Remove("AB12");

Overerving

Programmeurs zijn luie wezens. Ieder concept dat hen toelaat minder code te schrijven zullen ze dan ook omarmen. Dubbele code wil namelijk ook zeggen dat er dubbel zoveel plekken zijn waar bugs kunnen optreden én die aangepast moeten worden wanneer de specificaties veranderen.

Indien 2 of meer klassen een aantal gelijkaardige stukken code hebben is er mogelijk een verband tussen die twee klassen. Denk maar aan de klassen Monster en Held in een avonturenspel. Beide klassen hebben vermoedelijk bepaalde properties en methoden die identiek, of bijna identiek zijn qua implementatie.

Wat we hier zien is het concept overerving. Beide klassen hebben duidelijk een soort gemeenschappelijke "voorouder". Net zoals in de natuur waar apen en mensen afstammen van een gemeenschappelijke voorouder, kan je dit concept ook in OOP hebben.

De zogenaamde "child-klasse" is de klasse die overerft van een "parent-klasse". Deze child-klasse zal een specialisatie zijn: het zal meer kunnen dan z'n parent. Ook in de natuur zien we dit: de homo sapiens sapiens (wij!) is evolutionair gezien een verbetering tegenover de homo erectus (kleinere hersenen), die op zijn beurt een verbetering is van zijn voorouder, de homo habilis (kon nog niet op 2 ledematen rondwandelen), enz.

Kijken we terug naar Monster en Held dan is het duidelijk dat een gemeenschappelijke parent-klasse misschien wel de klasse Karakter is.

Dankzij overerving kunnen we de gemeenschappelijk code van de child-klassen verhuizen naar deze parent-klasse. In de child-klassen, zullen enkel nog de code bevatten die uniek is voor hen (de zogenaamde specialisatie).

Deze introductie doet uitschijnen dat overerving enkel z'n nut heeft om dubbele code te vermijden, wat niet zo is. Dubbele code vermijden dankzij overerving is eerder een gevolg ervan. Overerving is een erg krachtig concept dat in de komende hoofdstukken telkens zal terugkomen wanneer we gaan praten over polymorfisme, interfaces, enz.

Wat is overerving

Overerving (inheritance) laat ons toe om klassen te specialiseren vanuit een reeds bestaande parent- of basisklasse. Wanneer we een klasse van een andere klasse overerven dan zeggen we dat deze nieuwe klasse een child-klasse of sub-klasse is van de bestaande parent-klasse of super-klasse.

De child-klasse kan alles wat de parent-klasse kan, maar de nieuwe klasse kan nu ook extra specialisatie-code krijgen. Dit is exact hetzelfde in de echte wereld waarin de evolutie van soorten ervoor zorgt dat een bepaalde soort, dankzij evolutie, steeds meer specialisaties bijkrijgt tegenover z'n voorgangers.

Is-een relatie

Wanneer twee klassen met behulp van een "x is een y"-relatie kunnen beschreven worden dan weet je dat overerving mogelijk is.

  • Een paard is een dier (paard = child-klasse, dier = parent-klasse).
  • Een tulp is een plant.
  • Zowel een dier als een plant zijn levende wezens.

Als je dus in een programmeeropdracht het werkwoord zijn tegenkomt, in eender welke vorm (was, is, zijn, zal zijn, zijnde, enz. ), dan is de kans groot dat overerving mogelijk is. We gaan echter dit idee verderop in het boek uitbreiden met interfaces en polymorfisme. Deze twee begrippen hangen nauw samen met overerving en zullen soms een "betere oplossing" zijn dan pure overerving.

Wanneer we "x heeft een y" zeggen gaat het niet over overerving, maar over compositie wat we in het volgende hoofdstuk zullen bekijken.

Het is niet omdat 2 klassen delen gelijkaardige (of dezelfde) code hebben dat hier dus automatisch overerving van toepassing is. Enkel indien er een realistische "is een"-relatie bestaat kan overerving toegepast worden.

Uiteraard is de kans wel groot dat er "een oplossing" voor je dubbele code is, zelfs wanneer er geen "is een"-relatie bestaat. Meestal beland je dan bij generics (zie appendix) of compositie (zie verder) als mogelijke oplossing van je probleem.

Overerving beschrijven

In UML-notatie duiden we een overervings-relatie aan met een pijl van van de child- naar de parentklasse:

Wat zou een parent-klasse van Levend Wezen kunnen zijn?

En als we het voorbeeld van de mens en z'n voorgangers nemen dan zou een vereenvoudigd UML-schema er als volgt uitzien:

Oververing van onze soort.

Overerving in C#

Overving in C# duid je aan met behulp van het dubbele punt(:) bij de klassedefinitie, als volgt:

class Paard : Dier
{
   public bool KanHinnikken{get;set;}
}

class Dier
{
   public void Eet()
   {
    //...
   }
}

We zeggen dus dat Paard overerft van de klasse Dier. Het paard is dus een specialisatie van dier. Objecten van het type Dier kunnen enkel de Eet-methode aanroepen. Objecten van het type Paard kunnen de Eet-methode aanroepen én ze hebben ook een property KanHinnikken. Een paard kan dus alles wat een dier kan en wat het zelf kan. Een dier kan enkel wat het zelf kan:

Dier aDier = new Dier();
Paard bPaard = new Paard();
aDier.Eet();
bPaard.Eet();
bPaard.KanHinnikken = false;
aDier.KanHinnikken = false; //!!! zal niet werken!

Transitief

Overerving in C# is transitief, dit wil zeggen dat de child-klasse ALLES overerft van de parent-klasse: methoden, properties, enz.

Dit kleine, korte zinnetje herbergt aardig wat kracht. Dankzij overerving kunnen we onze klasse dus erg proper (en kort) houden indien er een "is een" relatie bestaat. Er zijn echter ook enkele kanttekeningen aangaande overerving die we in de komende secties uit te doeken zullen doen.

Alhoewel overerving transitief is, wil dat niet zeggen dat private variabelen plots zichtbaar zijn in de child-klasse. De child-klasse erft ALLES over, ook de private instantievariabelen, maar C# houdt zich wel aan de regels en zal voorkomen dat de child-code aan de parent instantievariabelen kan.

Overerving en het geheugen

Tijd om eens te kijken hoe het voorgaande er uitziet in de heap en de stack, met een voorbeeld: een applicatie om aan gebouwbeheer te doen.

Beeld je in dat je volgende klassehiërarchie hebt vastgelegd:

Villa, Huis en Appartement zijn een Gebouw. En Villa is op de koop toe een specialisatie van Huis.

Vervolgens maken we van iedere klasse 1 object aan. De objecten in het geheugen (de heap) zullen er dan als volgt uitzien:

We zien duidelijk dat een Appartement-object bestaat uit 2 delen: het Gebouw en de specialisatie Appartement.

Laten we eens 2 objecten aanmaken en kijken wat er in de heap en stack gebeurt:

Huis eenHuis = new Huis();
Villa groteVilla = new Villa();

Dat ziet er dan als volgt uit

Blik op het geheugen nadat we een Huis en Villa hebben geïnstantieerd.

Ook hier zien we duidelijk dat een Villa object alle "code" in zich heeft die zowel in de klasse Villa staat (de specialisatie) alsook die waarvan wordt overgeërfd, Huis. Bijgevolg heeft groteVilla dus ook de "code" van Gebouw "in zich" dankzij de transitiviteits-eigenschap van overerving.

Echter, de private delen van een klasse blijven beperkt tot dat stuk waar de variabele of methode origineel toe hoort. Als er dus in de klasse Huis een variabele private bool heeftDeurbel was, dan zal de code in de klasse Villa daar niet aan geraken:

Private is echt private. Niets, maar dan ook niets geraakt deze bool, behalve de 'code' binnen de klasse Huis.

protected

Ook al is overerving transitief, hou er rekening mee dat private variabelen en methoden van de parent-klasse NIET rechtstreeks aanroepbaar zijn in de child-klasse. private geeft aan dat het element enkel in de klasse zichtbaar is:

class Paard: Dier
{
   public void MaakOuder()
   {
      geboortejaar++; // !!! dit zal error geven!
   }
}
class Dier
{
   private int geboortejaar;
}

Je kan dit oplossen door de protected access modifier te gebruiken in de plaats van private. Met protected geef je aan dat het element enkel zichtbaar is binnen de klasse én binnen child-klassen:

class Paard: Dier
{
   public void MaakOuder()
   {
      geboortejaar++; // werkt nu wel
   }
}
class Dier
{
   protected int geboortejaar;
}

Alhoewel protected z'n nut heeft, is het meestal veiliger om alles nog steeds via properties te doen. Je kan dus beter van een property met private set er één met protected set van maken, zodat de achterliggende instantievariabele beschermd blijft.

Multiple inheritance

In C# is het niet mogelijk om een klasse van meer dan één parent-klasse te laten overerven (zogenaamde multiple inheritance), wat wel mogelijk is in sommige andere object georiënteerde talen. Het is in C# dus niet mogelijk om een klasse Mens te maken die tegelijkertijd overerft van de klasse Aap en van de klasse Tekening (om maar iets te zeggen).

Als puntje bij paaltje komt zal je trouwens bijna nooit multiple inheritance in de echte wereld tegenkomen (het typische tegenvoorbeeld is het vogelbekdier...maar hoe vaak ga je dat moeten modelleren in een project). Vaker zullen compositie en interfaces de oplossing zijn voor je probleem: 2 essentiële OOP aspecten die we in de hierna volgende hoofdstukken uit de doeken zullen doen.

sealed

Soms wil je niet dat van een klasse nog nieuwe klassen kunnen overgeërfd worden. Je lost dit op door het keyword sealed voor de klasse te zetten:

sealed class DoNotInheritMe
{
   //...
}

Als je later dan dit probeert:

class ChildClass:DoNotInheritMe
{
   //...
}

zal dit resulteren in een foutboodschap, namelijk Cannot derive from sealed type 'DoNotInheritMe'.

Constructors bij overerving

Wanneer je een object instantiëert van een child-klasse dan gebeuren er meerdere zaken na elkaar, in volgende volgorde:

  • Eerst wordt de constructor aangeroepen van de basis-klasse.
  • Gevolgd door de constructors van alle parent-klassen.
  • Finaal de constructor van de klasse zelf.

Dit is logisch: de child-klasse heeft de "fundering" nodig van z'n parent-klasse om te kunnen werken.

Volgende voorbeeld toont dit in actie:

class Soldaat
{
   public Soldaat() 
   {
      Debug.WriteLine("Soldaat is aangemaakt.");
   }
}

class VeldArts : Soldaat
{
   public VeldArts()
   {
      Debug.WriteLine("Veldarts is aangemaakt.");
   }
}

Indien je vervolgens een object aanmaakt van het type VeldArts:

VeldArts RexGregor = new VeldArts();

Dan zien we de volgorde van constructor-aanroep in het debug output venster:

Soldaat is aangemaakt.
Veldarts is aangemaakt.

Er wordt dus verondersteld in dit geval dat er een default constructor in de basis-klasse aanwezig is.

Overloaded constructors en base()

Indien je klasse Soldaat een overloaded constructor heeft, dan wisten we al dat deze niet automatisch een default constructor heeft. Volgende code zou dus een probleem geven indien je een VeldArts wilt aanmaken via new VeldArts():

class Soldaat
{
   public Soldaat(bool kanSchieten) 
   {
      //Doe soldaten dingen
   }
}

class VeldArts:Soldaat
{
   public VeldArts()
   {
      Debug.WriteLine("Veldarts is aangemaakt.");
   }
}

Wat je namelijk niet ziet bij child-klassen en hun constructors is dat er eigenlijk een impliciete aanroep naar de constructor van de parent-klasse wordt gedaan. Bij alle constructors staat er eigenlijk :base() achter, wat je ook zelf kunt schrijven:

class VeldArts:Soldaat
{
   public VeldArts(): base()
   {
      Debug.WriteLine("Veldarts is aangemaakt.");
   }
}

base() achter de constructor zegt eigenlijk "roep de default constructor van de parent-klasse aan". Je mag hier echter ook parameters meegeven en de compiler zal dan zoeken naar een overloaded constructor in de basis-klasse die deze volgorde van parameters kan accepteren.

We zien hier hoe we ervoor moeten zorgen dat we terug via new VeldArts() objecten kunnen aanmaken zonder dat we de constructor(s) van Soldaat moeten aanpassen:

class Soldaat
{
   public Soldaat(bool kanSchieten) 
   {
      //Doe soldaten dingen
   }
}
class VeldArts:Soldaat
{
   public VeldArts():base(true)
   {
      Debug.WriteLine("Veldarts is aangemaakt.");
   }
}

De default constructor van VeldArts zal de actuele parameter kanSchieten steeds op true zetten.

Uiteraard wil je misschien kunnen meegeven bij het aanmaken van een VeldArts wat de startwaarde van kanSchieten moet zijn. Dit vereist dat je een overloaded constructor in VeldArts aanmaakt, die op zijn beurt de overloaded constructor van Soldaat aanroept.

Je schrijft dan een overloaded constructor in VeldArts bij:

class Soldaat
{
   public Soldaat(bool kanSchieten) 
   {
      //Doe soldaten dingen
   }
}

class VeldArts:Soldaat
{
   public VeldArts(bool kanSchieten): base(kanSchieten)
   {} 

   public VeldArts():base(true) //Default
   {
      Debug.WriteLine("Veldarts is aangemaakt.");
   }
}

Merk op hoe we de formele parameter kanSchieten doorgeven als actuele parameter aan base-aanroep.

Uiteraard mag je ook de default constructor aanroepen vanuit de child-constructor, alle combinaties zijn mogelijk (zolang de constructor in kwestie maar bestaat in de parent-klasse).

Een hybride aanpak is ook mogelijk. Volgend voorbeeld toont 2 klassen, Huis en Gebouw waarbij we de constructor van Huis zodanig beschrijven dat deze bepaalde parameters "voor zich houdt" en andere als het ware doorsluist naar de aanroep van z'n parent-klasse:

class Gebouw
{
   public int AantalVerdiepingen { get; private set; }
   public Gebouw(int verdiepingenIn)
   {
      AantalVerdiepingen = verdiepingenIn;
   }
}
class Huis: Gebouw
{
   public bool HeeftTuintje { get; private set; };
   public Huis(bool tuintjeIn, int verdiepingenIn): base(verdiepingenIn)
   {
      HeeftTuintje = tuintjeIn;
   }
}

Vanaf nu kan ik een huis als volgt bouwen:

Huis peperkoekenHuis = new Huis(true, 1);

Volgorde van constructors

De volgorde waarin alles gebeurt in voorgaande voorbeeld is belangrijk om te begrijpen. Er wordt een hele machine in gang gezet wanneer we volgende korte stukje code schrijven:

Huis eenEigenHuis = new Huis(true,5);

Achter de schermen gebeurt er aardig wat bij overerving wanneer we een object aanmaken.

Start: overloaded constructor van Huis wordt opgeroepen.

  1. Nog voor dat deze echter iets kan doen, wordt de formele parameter verdiepingenIn (die de waarde 5 heeft gekregen) doorgegeven als actuele parameter om de constructor van de basis-klasse aan te roepen.
  2. De overloaded constructor van Gebouw wordt dus aangeroepen.
  3. De code van deze constructor wordt uitgevoerd: het aantal verdiepingen van het gebouw/huis wordt ingesteld.
  4. Wanneer het einde van de constructor wordt bereikt, zal er teruggegaan worden naar de constructor van Huis.
  5. Nu wordt de code van de Huis constructor uitgevoerd: HeeftTuintje krijgt de waarde true.

Einde: Finaal keren we terug en staat er nu een gloednieuw object in de heap, wiens geheugenlocatie we kunnen toewijzen aan eenEigenHuis.

Virtual en Override

Het is fijn dat onze child-klasse alles kan dat onze parent-klasse doet. Maar soms is dat beperkend:

  • Mogelijk wil je een bestaande methode van de parent-klasse uitbreiden/aanvullen met extra functionaliteit.
  • Soms wil je gewoon de volledige implementatie van een methode of property herschrijven in je child-klasse.

De keywords virtual en override gaan je hiermee kunnen helpen.

De werking van child-klassen aanpassen

Om te voorkomen dat child-klassen zomaar eender welke methode of property van de parent-klasse kunnen aanpassen gaan we de hulp van het virtual keyword inroepen. Standaard is het geen goede gewoonte om de bestaande werking van een klasse in de child-klasse aan te passen: beeld je in dat je een essentieel stuk code aanpast waardoor je hele klasse plots niet meer werkt!

Soms willen we echter kunnen aangeven dat de implementatie (code) van een property of methode in een parent-klasse door child-klassen mag aangepast worden. Dit geven we aan met het virtual keyword. En we zeggen hiermee aan zij die willen overerven van deze klasse: "de werking van deze methode of property (waar virtual voor staat) mag je in je child-klasse uitbreiden of aanpassen."

Vervolgens dient de child-klasse het keyword override te gebruiken om expliciet aan te geven dat er een methode of property komt wiens werking die van de parent-klasse zal wijzigen.

Enkel indien een element met virtual werd aangeduid, kan je deze dus met override aanpassen. Uiteraard ben je niet verplicht om elke virtueel element ook effectief te overriden. virtual geeft enkel aan dat dit een mogelijkheid is, geen verplichting.

Een voorbeeld met vliegende objecten

Stel je voor dat je een applicatie hebt met 2 klassen, Vliegtuig en Raket. Een raket is een vliegtuig, maar kan veel hoger vliegen dan een vliegtuig. Omdat we weten dat potentiële childklassen op een andere manier zullen willen vliegen, zullen we de methode Vlieg virtual zetten:

class Vliegtuig
{
   public virtual void Vlieg()
   {
      Console.WriteLine("Het vliegtuig vliegt rustig door de wolken.");
   }
}
class Raket: Vliegtuig
{ }

Merk op dat we het keyword virtual mee opnemen in de methodesignatuur op lijn 3, en dat deze dus niets te maken heeft met het returntype en de zichtbaarheid van de methode. Dit zou bijvoorbeeld een perfect legale methodesignatuur kunnen zijn: protected virtual int SayWhatNow().

Terzijde: static methoden kunnen niet virtual gezet worden.

Stel dat we 2 objecten aanmaken en laten vliegen:

Vliegtuig topGun = new Vliegtuig();
Raket spaceX1 = new Raket();
topGun.Vlieg();
spaceX1.Vlieg();

De uitvoer zal dan zijn twee maal dezelfde zin tonen: Het vliegtuig vliegt rustig door de wolken.

Enkel public methoden (en properties) kan je virtual instellen!

Momenteel doet het virtual keyword niets. Het is enkel een signaal aan mede-programmeurs: "hey, als je wilt mag je de werking van deze methode aanpassen als je van deze klasse overerft."

Een raket is een vliegtuig, toch vliegt het anders. We willen dus de methode Vlieg anders uitvoeren voor een raket. Daar hebben we override voor nodig. Door override voor een methode in de child-klasse te plaatsen zeggen we "gebruik deze implementatie en niet die van de parent klasse."

class Raket:Vliegtuig
{
   public override void Vlieg()
   {
      Console.WriteLine("De raket verdwijnt in de ruimte.");
   }     
}

De uitvoer van volgende code zal nu anders zijn:

Vliegtuig topGun = new Vliegtuig();
Raket spaceX1 = new Raket();
topGun.Vlieg();
spaceX1.Vlieg();

Uitvoer:

Het vliegtuig vliegt rustig door de wolken.
De raket verdwijnt in de ruimte.

Indien je iets override moet de signatuur van je methode (of property) uiteraard identiek zijn aan deze van de parent-klasse. Het enige verschil is dat je het keyword virtual vervangt door override.

Het base keyword

Het base keyword laat ons toe om bij override van een methode of property in de child-klasse toch te verplichten om de parent-implementatie toe te passen. Dit kan handig zijn wanneer je in je child-klasse de bestaande implementatie wenst uit te breiden.

Stel dat we volgende 2 klassen hebben:

class Restaurant
{
     protected int kosten = 0;
     public virtual void PoetsAlles()
     {
           kosten += 1000;
     }
}
class Frituur:Restaurant
{
     public override void PoetsAlles()
     {
           kosten += (1000 + 500);  //SLECHT IDEE! Wat als de basiskosten in het restaurant veranderen?
     } 
}

Het poetsen van een Frituur is duurder (1000 basis + 500 voor ontsmetting) dan een gewoon Restaurant. Als we echter later beslissen dat de basisprijs (in Restaurant) moet veranderen dan moet je ook in alle child-klassen doen, wat natuurlijk geen goede programmeerstijl is.

base lost dit voor ons op. De Frituur-klasse herschrijven we naar:

class Frituur:Restaurant
{
     public override void PoetsAlles()
     {
           base.PoetsAlles(); //eerste basiskost wordt opgeteld
           kosten += 500; //kosten eigen aan frituur worden bijgeteld
     }
}

Het base keyword laat ons toe om in onze code expliciet een methode of property van de parent-klasse aan te roepen. Ook al overschrijven we de implementatie van PoetsAlles toch kan de originele versie van de parent-klasse nog steeds gebruikt worden.

We hebben een soortgelijke werking ook reeds gezien bij de constructors van overgeërfde klassen.

Je kan zelf beslissen waar in je code je base aanroept. Soms doe je dat aan de start van de methode, soms op het einde, soms halverwege. Alles hangt er van af wat je juist nodig hebt.

"Ik denk dat ik een extra voorbeeldje nodig ga hebben."

Laten we eens kijken. Beeld je in dat je volgende basisklasse hebt:

class Oermens
{
      public virtual int VoorzieVoedsel()
      {
            return 15; //kg
      }
}

Wanneer 1 van mijn dorpsgenoten voedsel zoekt (door te jagen) zal hij 15 kg vlees verzamelen.

De moderne mens, die overerft van de oermens, is natuurlijk al iets beter in het maken van voedsel en kan dagelijks standaard 100 kg voedsel maken.

Echter, er bestaan ook hipsters die houden van de klassieke manier van voedsel verzamelen (maar ze zijn wel gewoon moderne mensen, dus geen klasse apart hier). Uiteraard hebben zij de technieken van de oermens verbeterd en zullen sowieso toch iets meer voedsel nog kunnen verzamelen met de traditionele methoden, namelijk 20 kg bovenop de basishoeveelheid van 15 kg.

class ModerneMens: Oermens
{
      public bool IsHipster {get;  set;}

      public override int VoorzieVoedsel()
      {
            if (IsHipster)
                  return base.VoorzieVoedsel() + 20;
            return 100;
      }
}

Gevorderde overervingsconcepten

C# houdt van objecten. De hele taal is letterlijk opgebouwd om maximaal het object georiënteerd programmeren te omarmen. Van zodra je een nieuw project aanmaakt kan je niet naast de internal class Program zien. Hoe meer je C# en de bestaande bibliotheken bekijkt, hoe duidelijker dit wordt. Alles is een klasse.

Maar dan stelt zich natuurlijk de vraag: staat er nog iets boven alle klassen die wij aan het maken zijn? Is er misschien een soort oer-klasse waar alle klassen van overerven?

De vraag stellen is ze beantwoorden! Er is effectief een oer-klasse, genaamd de System.Object-klasse, waar alles en iedereen in C# van moet overerven.

System.Object

Alle klassen in C# zijn afstammelingen van de System.Object klasse. Zowel de bestaande, ingebouwde klassen zoals Random en Console, alsook klassen die je zelf maakt. En ja, zelfs de bestaande valuetype datatypes zoals int en bool zijn afstammelingen van System.Object (er zit wel nog één klasse tussen in hun geval, de ValueType klasse).

Enkele voorbeelden. Merk op dat er véél meer ingebouwde klassen in .NET zitten dan degene die we hier tonen.

Indien je een klasse schrijft zonder een expliciete parent dan zal deze steeds System.Object als rechtstreekse parent hebben. Ook afgeleide klassen stammen dus uiteindelijk af van System.Object. Concreet wil dit zeggen dat alle klassen System.Object-klassen zijn en dus ook de bijhorende functionaliteit ervan hebben.

Om de klasse Object niet te verwarren met het concept "object" zullen we hier steeds praten over System.Object.

Impliciete overerving

Wanneer je een klasse Student aanmaakt als volgt: class Student{ }. Dan gebeurt er een zogenaamde impliciete overerving van System.Object. Er staat dus eigenlijk:

class Student: System.Object
{  }

Wat je trouwens ook expliciet zelf mag schrijven, dat maakt niet uit. Maar van zodra je een klasse schrijft die nergens expliciet van overerft, dan zal deze automatisch van System.Object overerven.

Hoe ziet System.Object er uit?

Wanneer je een lege klasse maakt dan zal je misschien al gezien hebben dat instanties van deze nieuwe klasse reeds 4 methoden ingebouwd hebben, dit zijn uiteraard de methoden die in de System.Object klasse staan gedefiniëerd:

MethodeBeschrijving
Equals()Gebruikt om te ontdekken of twee instanties gelijk zijn.
GetHashCode()Geeft een unieke hash terug van het object; nuttig om o.a. te sorteren.
GetType()Geeft het datatype (de klasse) van het object terug.
ToString()Geeft een string terug die het object voorstelt.

Deze methoden zijn redelijk nutteloos in het begin. Enkel door ze zelf te overriden zullen ze hun nut bewijzen. Uiteraard kan je de de methoden testen om te zien wat er gebeurt.

GetType()

Stel dat je een klasse Student hebt gemaakt in je project. Je kan dan op een object van deze klasse de GetType()-methode aanroepen om te weten wat het type van dit object is:

Student stud1 = new Student();
Console.WriteLine(stud1.GetType());

Dit zal als uitvoer de namespace gevolgd door het type van het object op het scherm geven . Als je project bijvoorbeeld StudentManager heet (en je namespace dus vermoedelijk ook) dan zal er op het scherm verschijnen: StudentManager.Student.

Wil je enkel het type zonder namespace dan is het nuttig te beseffen dat GetType() eigenlijk een object teruggeeft van het type Type met meerdere eigenschappen, waaronder Name. Volgende code zal enkel Student op het scherm tonen:

Student stud1 = new Student();
Console.WriteLine(stud1.GetType().Name);

Je kan in de .NET documentatie altijd opzoeken waar een klasse van overerft. De Type klasse bijvoorbeeld erft, je raadt het nooit, finaal ook van System.Object over. Eerst erft Type over van de MemberInfo klasse, die op zijn beurt overerft van de oer-klasse.

Merk op dat de pijltjes hier wijzen van parent naar child-klasse, wat omgekeerd is aan de UML-notatie die we in dit boek prefereren.

ToString(): het werkpaardje van System.Object

Deze methode is de nuttigste, waar je al direct leuke dingen mee kan doen die je programmeursleven, hopelijk, wat gaat vereenvoudigen.

Wanneer je schrijft:

Console.WriteLine(stud1);

Wordt er eigenlijk een impliciete aanroep naar ToString gedaan. Er staat dus eigenlijk altijd:

Console.WriteLine(stud1.ToString());

Op het scherm verschijnt dan StudentManager.Student. Waarom? Wel, de methode ToString() wordt in System.Object() ongeveer als volgt beschreven:

public virtual string ToString()
{ 
    return GetType(); 
}

Merk twee zaken op:

  1. GetType() wordt aangeroepen en die output krijg je dus terug.
  2. De methode is virtual gedefinieerd.

Alle 4 methoden in System.Object zijn virtual, en je kan deze dus override'n!

Nu komen we tot het hart van deze methoden. Aangezien ze alle 4 virtual zijn, kunnen we de werking ervan naar onze hand zetten in onze eigen klassen. Aardig wat .NET bibliotheken rekenen er namelijk op dat je deze methoden op de juiste manier hebt aangepast, zodat ook jouw nieuwe klassen perfect kunnen samenwerken met deze bibliotheken. Een eerste voorbeeld hiervan toonden we net: de Console.WriteLine methode gebruikt van iedere parameter dat je er aan meegeeft de ToString-methode om de parameter op het scherm als string te tonen.

ToString() overriden

Het zou natuurlijk fijner zijn dat de ToString()-methode van onze student nuttigere info teruggeeft, zoals bijvoorbeeld de Voornaam die we als autoprop in de klassen hebben geplaatst, gevolgd door de Geboortejaar (ook een autoprop).

We kunnen dat eenvoudig verkrijgen door ToString() te overriden:

class Student
{
  public int Geboortejaar {get;set;}
  public string Voornaam {get;set;}
  public override string ToString()
  {
      return $"{Voornaam} ({Geboortejaar})";
  }
}

Wanneer je nu Console.WriteLine(stud1); (gelet dat hij de properties Voornaam en Geboortejaar heeft) zou schrijven dan wordt je output: Tim Dams (1981).

Een extra handigheidje van ToString is dat deze methode wordt gebruikt tijdens het debuggen om je objecten samen te vatten in het watch-venster.

De Equals() methode

Ook deze methode kan je overriden om twee objecten met elkaar te vergelijken:

if(stud1.Equals(stud2))

De Equals()-methode heeft als signatuur: public virtual bool Equals(Object o) Twee objecten zijn gelijk voor .NET als aan volgende afspraken wordt voldaan:

  • Het moet false teruggeven indien de parameter o null is.
  • Het moet true teruggeven indien je het object met zichzelf vergelijkt (bv. stud1.Equals(stud1)).
  • Het mag enkel true teruggeven als zowel stud1.Equals(stud2); als stud2.Equals(stud1); waar zijn.
  • Indien stud1.Equals(stud2) true teruggeeft en stud1.Equals(stud3) ook true is, dan moet stud2.Equals(stud3) ook true zijn.

Equals() overriden

Het is echter aan de maker van de klasse om te beslissen wanneer 2 objecten van een zelfde type gelijk zijn. Het is dus niet zo dat iedere waarde van een instantievariabele bijvoorbeeld gelijk moet zijn opdat 2 objecten gelijk zijn. Alles hangt af van de wijze waarop de klasse dienst moet doen.

Stel dat we vinden dat een student gelijk is aan een andere student indien z'n Voornaam en Geboortejaar dezelfde is, we kunnen dan de Equals-methode overriden als volgt in de Student klasse:

public override bool Equals(Object o)
{  
    Student temp = (Student)o; //Zie opmerking na code!
    return (Geboortejaar == temp.Geboortejaar && Voornaam == temp.Voornaam);
}

De lijn Student temp = (Student)o; zal het object o casten naar een Student. Doe je dit niet dan kan je niet aan de interne Student-variabelen van het object o. Dit concept, polymorfisme (zie nog steeds hoofdstuk 16....We komen dichter!).

GetHashcode() overriden

Indien je Equals override dan moet je eigenlijk ook GetHashCode overriden, daar er wordt verondersteld dat twee gelijke objecten ook dezelfde unieke hashcode teruggeven. Wil je dit dus implementeren dan zal je dus een (bestaand) algoritme moeten schrijven dat een uniek nummer genereert voor ieder niet-gelijke object. Algoritmes bespreken om zelf een hash te genereren liggen niet in de scope van dit boek.

"Ik ben nog niet helemaal mee..."

Niet getreurd, je bent niet de enige: het is allemaal een hoop nieuwe kennis om te verwerken. En ik vermoed dat je nu niet bepaald overweldigd bent van de nieuwe kennis. Mogelijk heb je nu zoiets van? "Ok..wow?! Wat krijg ik nu juist extra wetende dat al mijn klassen overerven van een oer-klasse? 4 methoden en wat beloofde compatibiliteit met andere .NET bibliotheken? Call me ...unimpressed".Begrijpelijke reactie. Hou vol, we zijn een hoop puzzelstukjes aan het opnemen die finaal zullen samenkomen om een gigantisch knappe OOPuzzel te maken (see what I did there?) waarin polymorfisme onze sterspeler zal worden en zal toelaten erg krachtige code te schrijven. Polymorfisme wordt onze doelpuntenmaker, maar System.Object zal steeds de perfecte voorzet geven!

Abstracte klassen

Aan de start van hoofdstuk 9 beschreven we volgende 2 duidelijke definities:

  • Een klasse is als een blauwdruk (of prototype) dat het gedrag en toestand beschrijft van alle objecten van deze klasse.
  • Een individueel object is een instantie van een klasse en heeft een eigen toestand, gedrag en identiteit.

Niemand die zich hier vragen bij stelde? Als ik in het echte leven zeg: "Geef mij eens de blauwdruk van een object van het type meubel." Wat voor soort meubel zie je voor je bij het lezen van deze zin? Een tafel? Een kast? Een zetel? Een bed?

En wat zie je voor je als ik vraag om een "geometrische figuur" in te beelden. Een cirkel? Een rechthoek? Een kubus? Een buckyball? Kortom, er zijn in het leven ook soms eerder abstracte dingen die niet op zich in objecten kunnen gegoten worden zonder meer informatie. Toch is het concept "geometrische figuur" een belangrijk concept: we weten dat alle geometrische figuren een gemeenschappelijke definitie hebben, namelijk (met dank aan Encyclo.nl) dat het twee- of meerdimensionale grafische elementen zijn waarvan de vorm wiskundig te berekenen valt. En dus is er ook een bestaansreden voor een klasse GeometrischeFiguur. Objecten van deze, abstracte, klasse maken daarentegen lijkt ons uit ten boze.

Het is dit concept, abstracte klasse dat we in dit hoofdstuk uit te doeken gaan doen. Het laat ons toe klassen te definiëren die niet niet kunnen geïnstantieerd worden, maar die wel dienst kunnen doen als parentklasse voor andere klassen.

Abstracte klassen in C#

Laten we voorgaande eens praktisch binnen C# bekijken. Soms maken we een parent-klasse waarvan geen instanties kunnen gemaakt worden: denk aan de parent-klasse Dier. Voorbeelden van subklassen van Dier zijn Paard en Wolf. Van Paard en Wolf is het logisch dat je instanties kan maken (echte paardjes en wolfjes) maar van 'een dier'? Hoe zou dat er uit zien? Maar toch willen we bepaalde delen gemeenschappelijk maken (alle dieren hebben bijvoorbeeld zuurstof nodig).

Met behulp van het keyword abstract kunnen we aangeven dat een klasse abstract is: je kan overerven van deze klasse, maar je kan er geen instanties van aanmaken.

We plaatsen abstract voor de klasse definitie om dit aan te duiden.

Een voorbeeld:

abstract class Dier
{
    public string Naam {get;set;}
}

We kunnen nu geen objecten meer van het type Dier aanmaken. Volgende code zal een foutboodschap geven: Dier hetDier = new Dier();

Maar, we mogen dus wel klassen overerven van deze klasse en instanties van deze nieuwe klasse aanmaken:

class Paard: Dier
{
    //...
}

class Wolf: Dier
{
    //...
}

En dan zal dit wel werken: Wolf wolfje = new Wolf();

En als we polymorfisme gebruiken (soon!) dan mag dit ook: Dier paardje = new Paard();

In het begin lijkt abstract een beperkende factor: je kan minder dan ervoor. Maar het heeft dus één heel duidelijke functie: je kan een parent-klasse maken waarin de gedeelde functionaliteit van je child-klassen in zit, zonder dat je deze parent-klasse op zich kunt gebruiken.

Abstracte methoden

Het is logisch dat we mogelijk ook bepaalde zaken in de abstracte klasse als abstract kunnen aanduiden. Beeld je in dat je een methode MaakGeluid hebt in je klasse Dier. Wat voor een geluid maakt 'een dier'? We kunnen dus ook geen implementatie (code) geven in de abstracte parent klasse, maar willen wel zeker ervoor zorgen dat alle child-klassen van Dier geluid kunnen maken, op wat voor manier dan ook.

Via abstracte methoden geven we dit aan: we hoeven enkel de methode signatuur te geven, met ervoor abstract:

abstract class Dier
{
    public abstract string MaakGeluid();
}

Door het keyword abstract zijn child-klassen verplicht deze abstracte methoden te overriden!

Merk op dat er geen codeblock-accolades na de signatuur van abstracte methodes komt.

De Paard-klasse wordt dan:

class Paard: Dier
{
  public bool HeeftTetanus {get;set;}

  public override string MaakGeluid()
  { 
      return "Hinnikhinnik";
  }
}

(en idem voor de Wolf-klasse uiteraard, maar hopelijk met een dreigender geluid)

Dit is dus niet hetzelfde als virtual waar een override MAG. Bij abstract MOET je override'n. We komen dan ook bij het hart van het abstracte klasse concept: ze laten ons toe om, als het ware, klassen te maken waar nog gaten in zitten qua implementatie. Een soort klasse-template die de child-klassen nog verder moeten inkleuren.

Alhoewel de code voor MaakGeluid staat beschreven in de klasse Paard, zal deze als het ware ingevuld worden op de plek ervoor in de klasse Dier.

Abstracte methoden enkel in abstracte klassen

Van zodra een klasse een abstracte methode of property heeft dan ben je, logischerwijs, verplicht om de klasse ook abstract te maken.

Het zou heel vreemd zijn om objecten in het leven te kunnen roepen die letterlijk stukken ontbrekende code hebben...

Abstracte properties

Properties kunnen virtual gemaakt worden, en dus ook abstract. Net zoals bij abstracte methoden, kunnen we met abstracte properties de overgeërfde klassen verplichten een eigen implementatie van de property te schrijven.

Volgend voorbeeld toont hoe dit werkt:

abstract class Dier
{
    abstract public int MaxLeeftijd { get;}
}

class Olifant : Dier
{
    public override int MaxLeeftijd 
    {
        get 
        { 
            return 100; 
        }
    }
}

Wanneer je een abstracte property maakt dien je ogenblikkelijk aan te geven of het om een readonly, writeonly, of property met get én set gaat:

  • public abstract int Oppervlakte {get;}
  • public abstract int GeheimeCode {set;}
  • public abstract int GeboorteDatum {get;set;}

Eigen exceptions maken dankzij overerving

We zijn ondertussen al gewend aan het opvangen van uitzonderingen met behulp van try en catch. Ook bij exception handling wordt overerving toegepast. De uitzonderingen die we opvangen zijn steeds objecten van het type Exception of van een afgeleide klasse. Denk maar aan de NullReferenceException klasse die werd overgeërfd van Exception.

Dat wil dus zeggen dat Exceptions ook maar "gewone klassen" zijn en dus ook aan alle andere regels binnen C# moeten voldoen. Zo ondersteunen ze polymorfisme (sooooon!), kan je ze in arrays plaatsen, enz.

Het is dus ook logisch dat je in je code (al dan niet zelfgemaakte) uitzonderingen kunt maken en opwerpen, zodat deze elders opgevangen worden. Je kan hierbij zelf exceptions maken (zie volgende sectie) of gewoon gebruik maken van een bestaande Exception-klasse.

Een voorbeeld van een bestaand Exception type gebruiken:

static int ResultaatBerekening(int getal)
{
    if (getal != 0)
        return 100 / getal;
    else
        throw new DivideByZeroException("BOEM. ZWART GAT!");
}
 
 
static void Main(string[] args)
{
    try
    {
        Console.WriteLine(ResultaatBerekening(0));
    }
    catch(DivideByZeroException e)
    {
        Console.WriteLine(e.Message);
    }
}

De uitvoer zal zijn:

BOEM. ZWART GAT!

De lijn throw new DivideByZeroException("BOEM. ZWART GAT!"); zorgt er dus voor dat we een eigen foutboodschap verpakken en opwerpen.

Een eigen exception ontwerpen

Je kan ook eigen klassen overerven van Exception zodat je eigen uitzonderingen kan maken. Je maakt hiervoor gewoon een nieuwe klasse aan die je laat overerven van de Exception-klasse. Een voorbeeld:

class Timception: Exception
{
    public override string ToString()
    {
        string extrainfo = "Exception Generated by Tim Dams:\n";
        return $"{extrainfo}. {base.ToString()}";
    }
}

Merk op dat we hier met base.ToString() ervoor zorgen dat ook de foutboodschap van het parent-gedeelte van de uitzondering wordt weergegeven.

Om deze exception nu zelf op te werpen gebruiken we het keyword throw gevolgd door een object van het type uitzondering dat je wenst op te werpen.

In volgende voorbeeld gooien we onze eigen exception op een bepaald punt in de code op en vangen deze dan op:

static void Main(string[] args)
{
    try
    {
        TimsMethode();
    }
 
    catch (Timception e)
    {
       Console.WriteLine(e.ToString());
    }     
}
static public void TimsMethode()
{
    //doe dingen
    //...
    //"when suddenly: a wild exception appears"
    throw new Timception();
}

Compositie en aggregatie

Dit hoofdstuk is kort maar krachtig. We gaan niets nieuws uitleggen, maar eerder zaken benoemen die je waarschijnlijk al toepaste zonder te weten dat er daar ook een naam voor was.

We spreken over compositie (compositie) en aggregatie (aggregation) wanneer we een object in een ander object gebruiken. Denk bijvoorbeeld aan een object van het type Motor dat je gebruikt in een object van het type Auto. Afhankelijk of het interne object kan bestaan zonder het omliggende object bepaalt of het gaat om aggregatie of compositie:

  • Compositie: Het interne object heeft geen bestaansreden zonder het omliggende object. Denk bijvoorbeeld aan een kamer in een huis. Als het huis verdwijnt, verdwijnt ook de kamer.
  • Aggregatie: Beide objecten kunnen onafhankelijk van elkaar bestaan. Denk hierbij aan de motor in een auto. Wanneer de auto vernietigd wordt kan de motor gered worden en elders gebruikt worden. Een ander voorbeeld zijn de harde schijven in een computer.

Het lijdende voorwerp zal steeds het object zijn dat binnen het onderwerp zal geplaatst worden (motor in auto, schijf in computer).

Heeft een-relatie

Overerving konden we detecteren door de "is een"-relatie. Compositie (en aggregatie) daarentegen detecteren we met behulp van de "heeft een"-relatie tussen 2 klassen. Een mango heeft een pit. Een vliegtuig heeft een cockpit. enz.

Je hoort ook ogenblikkelijk of het om een "heeft één" of "heeft meerdere"-relatie gaat. In het tweede geval, heeft meerdere, wil dit zeggen dat het omliggende object een array van het interne object in zich heeft. Wederom het voorbeeld van het boek: een boek heeft meerdere pagina's. Dus in de klasse Boek zullen we vermoedelijk een object van het type Pagina[] of List<Pagina> tegenkomen.

Een klassieke fout is overerving gebruiken wanneer je bijvoorbeeld de relatie tussen een boek en z'n pagina's wilt aanduiden. Een boek is géén pagina, ook niet omgekeerd. Een boek HEEFT een pagina (of meerdere).

Compositie en aggregatie beschrijven

Compositie duiden we aan met een lijn die begint met een volle ruit aan de kant van de klasse die de objecten in zich heeft:

Compositie: een huis heeft een slaapkamer en heeft een badkamer.

Aggregatie duiden we op exact dezelfde manier aan, maar de ruiten zijn niet gevuld. Optioneel duidt een getal aan iedere kant van de lijn de verhouding aan (zowel bij aggregatie als compositie), zodat we kunnen aangeven hoeveel (of geen) objecten het omliggende object kan hebben:

Aggregatie: een computer heeft minstens 1 processor nodig, maar kan er tot 8 hebben. Ieder element kan echter ook op zichzelf bestaan.

Uiteraard zijn ook combinaties mogelijk. Stel je voor dat je een applicatie moet ontwerpen waarin je een reeks huizen moet bouwen, waarbij er in de slaapkamer steeds een computer moet gezet worden:

Een computer kan je uit een brandend huis redden. De kamers van het huis zelf helaas niet.

Herinner je: overerving duiden we aan met een pijl die wijst naar de parent-klasse en duidt een "is een"-relatie aan.

Compositie en aggregatie in de praktijk

Het verschil tussen aggregatie en compositie is vooral van filosofische aard. In de praktijk zijn er weinig verschillen.

We bekijken het voorbeeld van de computer en de harde schijf. We hebben twee klassen:

class PC
{
}
class HardeSchijf
{
}

Een PC heeft een HardeSchijf, dit wil zeggen dat we in de klasse PC een object (instantievariabele) van het type HardeSchijf zullen definiëren:

class PC
{
    private HardeSchijf cHardeSchijf;
}

In principe kunnen we nu zeggen dat we aggregatie hebben toegepast. Uiteraard moeten we nu deze HardeSchijf nog instantiëren anders zal deze de hele levensduur van ieder PC-object null zijn.

De instantie van een geaggregeerd object kan op verschillende manieren aangemaakt worden en is afhankelijk van wat je nodig hebt in je applicatie.

Compositie is, net zoals overerving, een onderdeel van het OOP paradigma. Er is geen exacte oplossingsstrategie om compositie toe te passen: deze zal afhankelijk zijn van je specifieke probleem (en oplossing). Staar je dus niet blind op deze voorbeelden, het is maar een greep uit de vele manieren waarmee je compositie kunt gebruiken.

Manier 1: Rechtstreeks de instantievariabele instellen

Wanneer we wensen dat iedere nieuwe PC ogenblikkelijk een interne harde schijf heeft dan kunnen we dit doen door ogenblikkelijk de instantievariabele een object te geven:

class PC
{
    private HardeSchijf cHardeSchijf = new HardeSchijf();
}

Het moge duidelijk zijn: compositie/aggregatie en referenties horen samen. Maar hoe ziet dit er allemaal uit in het geheugen? Blij dat je het vraagt!

Wanneer we van voorgaande klasse een object aanmaken als volgt:

PC mijnSuperPC = new PC();

Dan zien we volgende "beeld":

Het is een erg nuttige skill indien je altijd dit soort tekening mentaal voorstelt, zodat je goed beseft waar welke informatie (en referenties) leven en wanneer die mogelijk door de GC gaan opgepeuzeld worden.

Compositie wil dus niet zeggen dat je in het geheugen grote monolithische objecten gaat hebben die het samengestelde object voorstellen. Neen, we blijven, dankzij de kracht van referenties, de boel apart houden.

Zoals je ziet is het belangrijk te beseffen dat bij compositie én aggregatie het inner object op zichzelf in de heap ergens zal gezet worden en dus niet in het parent-object komt. Alles dat we dus al wisten in verband met het doorgeven van referenties, de GC, enz. blijft dus nog steeds gelden.

Of zoals het hoofdstuk al begon: eigenlijk niets nieuws onder de zon!

Manier 2: Via de constructor(s)

Willen we echter bij het aanmaken van een nieuwe pc ook iets meer controle over wat voor harde schijf er wordt geïnstalleerd, dan kan dit ook via de constructors. We zouden dan bijvoorbeeld afhankelijk van bepaalde parameters in de (overloaded) constructors de schijf andere eigenschappen kunnen geven:

class PC
{
    private HardeSchijf cHardeSchijf;
    
    public PC(bool preinstallHD)
    {
        //enkel interne harde schijf indien klant voorinstallatie wenst
        if(preinstallHD) 
            cHardeSchijf = new HardeSchijf();
        else 
            cHardeSchijf == null; 
    } 
}

De lijn cHardeSchijf == null is niet noodzakelijk, daar cHardeSchijf sowieso null zal zijn indien we niet in de if gaan. Ik raad je toch aan dit altijd expliciet te doen. Hiermee zeg je nadrukkelijk: "als we via de overloaded constructor een PC aanmaken en er is geen preinstallatie vereist dan zit er geen harde schijf in de pc". Het kan namelijk gebeuren dat voor we aan deze code komen er ondertussen iets voor heeft gezorgd dat cHardeSchijf alsnog een objectreferentie bevat. Door deze nu expliciet op null te zetten verwijderen we zeker de harde schijf als die er toch nog had ingezeten.

Heb je gezien hoe ik praat over deze preinstallatie alsof het om iets gaat dat in het echte leven gebeurt? Dit is bewust: het OOP paradigma draait om het feit dat het ons toelaat de realiteit zo dicht mogelijk te benaderen. Het helpt dan ook om je code (en probleemanalyse) steeds vanuit de context van de "echte wereld" te benaderen. Bijna ieder concept uit de echte wereld heeft een equivalent binnen C# als OOP-taal.

Het is een goede OOP oefening om af en toe in je omgeving eens rond te kijken, en wat je ziet vervolgens te vertalen naar een structuur van klassen, objecten en verbanden tussen die dingen (overerving, compositie, arrays en later ook nog polymorfisme en interfaces).

Manier 3: Properties

De vorige 2 voorbeelden waren eigenlijk voorbeelden van compositie. Wanneer de PC-objecten vernietigd worden (door de GC) zullen ook de interne harde schijven verdwijnen.

Willen we echter via aggregatie de pc's bouwen, dan is het logischer dat we op een andere, externe plaats de HardeSchijf objecten aanmaken en deze vervolgens, nadat de PC werd aangemaakt, in de PC plaatsen. We gebruiken hierbij properties om toegang tot het interne (geaggregeerde) object te verschaffen:

class PC
{
    public HardeSchijf CHardeSchijf {get;set;}
}

Vervolgens kunnen we nu van buiten het object benaderen en er, als het ware, een nieuwe harde schijf in steken:

HardeSchijf mijnHardeSchijf = new HardeSchijf() 
PC mijnPC = new PC();
mijnPC.CHardeSchijf = mijnHardeSchijf ;

Op deze manier hebben we nog steeds een referentie naar mijnHardeSchijf en zal de GC dit object dus niet verwijderen wanneer, om welke reden ook, mijnPC wordt opgekuist.

Kortom, nog steeds niets nieuws onder de zon. Alle manieren die ja al kende om met bestaande types objecten aan te maken gelden nog steeds. Compositie deed je al de hele tijd wanneer je bijvoorbeeld zei "een student heeft een geboortejaar" en dan een instantievariabele int geboortejaar aanmaakte. Het grote verschil is echter dat objecten moeten geïnstantieerd worden, wat niet moest met value-types en je dus iets vaker op null zal moeten controleren.

Compositie en aggregatie objecten gebruiken

Stel je voor dat de klasse HardeSchijf ook een auto-property MaxCapacity heeft. De klasse PC kan dankzij compositie dus nu ook die property gebruiken, zoals volgende voorbeeld toont:

class PC
{
    private HardeSchijf cHardeSchijf = new HardeSchijf();
    public override string ToString()
    {
        return $"Intel i9 Capaciteit HD: {cHardeSchijf.MaxCapacity} Gb";
    }
}

NullReferenceException is een klassieke fout

Een veelvoorkomende fout bij compositie en aggregatie van objecten is dat je een intern object aanspreekt dat nooit werd aangemaakt. Je krijgt dan een NullReferenceException.

Het is dus zeker bij compositie en aggregatie een goede gewoonte om zoveel mogelijk te controleren op null telkens je het object gaat gebruiken:

public override string ToString()
{
    string result= "Dit is een Intel i9.";
    if(cHardeSchijf != null)
        result += $"Capaciteit HD: {cHardeSchijf.MaxCapacity} Gb";
    else
        result += "Er is geen harde schijf aanwezig";
    return result:
}

En uiteraard kan het ook nooit kwaad om alles in try-catch blokken te zetten, alleen is dat op detail-niveau niet werkbaar: je werkt met objecten en zal dus bijna de hele tijd code hebben waar NullReferenceException een potentieel gevaar is. Het is dus beter om vanaf de start je code zodanig te schrijven (met controles op null) dat er quasi geen uitzonderingen op null kunnen optreden.

"Heeft meerdere"- relatie

Wanneer een object meerdere objecten van een specifiek type heeft (denk maar aan "een boek heeft meerdere pagina's" of "een boom heeft bladeren") dan zullen we een array of een List als compositie-object gebruiken.

Een voorbeeld:

class Pagina{}

class Boek
{
   public Pagina[] AllePaginas {get;set;} = new Pagina[100];
}

Indien je nu een pagina wenst toe te voegen dan moet je ook deze individuele array-elementen nog instantiëren.

class Boek
{
    public Pagina[] AllePaginas {get;set;} = new Pagina[100];
    public void InsertPagina(Pagina paginaIn, int positie)
    {
        AllePaginas[positie] = paginaIn;
    }
}

Een voorbeeld waarbij men vervolgens van buiten het object bestaande pagina's kan toevoegen:

Boek zieScherper = new Boek();
Pagina mijnDerdePagina = new Pagina();
zieScherper.InsertPagina(mijnDerdePagina, 2);

Of een voorbeeld met List:

class Boek
{
    public List<Pagina> AllePaginas {get;set;} = new List<Pagina>(); //SLECHT IDEE!
}

Dit heeft als voordeel dat we de Insert methode van de List-klasse kunnen gebruiken en niet zelf nog moeten schrijven:

zieScherper.AllePaginas.Insert(new Pagina(), 5);   

Dit voorbeeld met List is vanuit OOP-standpunt geen goede oplossing. Het vereist namelijk dat programmeurs, die jouw klasse Boek gebruiken, weten dat intern met een List wordt gewerkt.

We willen echter zo goed mogelijk een blackbox creëren, conform het abstractie-principe, die van buiten duidelijk en eenvoudig in gebruik is. Het is daarom beter om alsnog aan je Boek klasse een Insert methode toe te voegen. Dit geeft als extra verbetering dat we daarmee de set van onze lijst van pagina's private kunnen houden:

class Boek
{
    public List<Pagina> AllePaginas {get; private set;} = new List<Pagina>();
    
    public void InsertPagina(Pagina paginaIn, int positie)
    {
        allPaginas.Insert(paginaIn, positie)
    }
}

Pagina's voegen we nu als volgt toe:

zieScherper.InsertPagina(new Pagina(), 5); 

Begrijp je nu waarom het geen goed idee is om een interne lijst gewoonweg via een property naar buiten beschikbaar te maken? Stel je voor dat het essentiëel is dat de AllePaginas lijst NOOIT leeggemaakt wordt. Jij als ontwikkelaar weet dit. Maar andere gebruikers van je klasse misschien niet. Zij kunnen echter zonder problemen .Clear() via de property kunnen aanroepen, wat dus nefaste gevolgen kan hebben!

Compositie of overerving?

We vertelden in het begin van dit hoofdstuk dat compositie en aggregatie een "heeft een"-relatie aanduiden, terwijl overerving een "is een"-relatie behelst. In de praktijk zal je véél vaker compositie en aggregatie moeten gebruiken dan overerving. Compositie en aggregatie laat ons toe om 2 (of meer) totaal verschillende soorten zaken met elkaar te laten samenwerken, iets wat met overerving enkel kan indien beide zaken een "is een"-relatie hebben. Dit zien we ook in de echte wereld: de zaken rondom ons zullen vaker een compositie/aggregatie-relatie hebben dan een overervings-relatie.

Zoals je hopelijk beseft kan dus alles een compositieobject zijn in een ander object. Denk maar aan een Dictionary van klanten die je gebruikt in een klasse Winkel. Of wat te denken van de klasse Mens die uit een hele boel organen bestaat. Ieder orgaan is compositie-object in de klasse Mens, zoals 2 Nier-objecten, een Hersenen instantie, 1 Hart instantie enz. Iemand die in jouw Mens-simulator een nieuw hart nodig heeft kan dat dan dankzij manier 3, via een property ingeplant krijgen:

Mens patient = new Mens();
Mens donor = new Mens();
//Donor heeft een tragisch ongeluk en sterft
//Operatie start
patient.Hart = null; //vorig hart wordt "verwijderd"
patient.Hart = donor.Hart; 
donor = null //donor wordt begraven

Let er wel op dat je niet overal compositie begint toe te passen alsof je de Dokter Frankenstein van C# bent. Hoe meer compositie (of aggregatie) je toepast in een klasse, hoe specifieker die soms wordt, en daardoor mogelijk minder herbruikbaar. Het is om die reden dat we verderop interfaces gaan ontdekken om ervoor te zorgen dat 2 of meerdere klassen minder "op/in elkaar gelijmd" zitten ten gevolge van bijvoorbeeld een nogal hechte compositie.

Het this keyword

Je zult in je zoektocht naar online antwoorden mogelijk al een paar keer het this keyword zijn tegengekomen. Dit keyword kan je aanroepen in een object om de referentie van het object terug te krijgen. Met andere woorden: het laat toe dat een object "zichzelf" kan aanroepen. Dat klinkt vreemd, maar heeft 3 duidelijke gebruiken:

  • Het laat toe dat een object zichzelf kan meegeven als actuele parameter aan een methode.
  • Het laat toe instantievariabelen en properties aan te roepen van het object die mogelijk dezelfde naam hebben als een lokale variabele.
  • We kunnen een andere constructor vanuit een constructor aanroepen zoals reeds gezien (in hoofdstuk 11).

Aanroepen van instantievariabelen met zelfde naam

Wanneer je this gebruikt binnen een klasse, dan zal je zien dat bij het schrijven van de dot-operator je ogenblikkelijk de volledige interne structuur van de klasse kunt bereiken:

Met this zien we letterlijk alles dat de klasse heeft aan te bieden, ongeacht de access modifiers.

Enerzijds ben je vrij om altijd this te gebruiken wanneer je eender wat van de klasse zelf wilt bereiken. Vooral in oudere code-voorbeelden zal je dat nog vaak zien gebeuren.

Anderzijds laat this ook toe om properties, methoden en instantievariabelen aan te roepen wanneer die mogelijk op de huidige plek niet aanroepbaar zijn omdat hun naam conflicteert met een lokale variabele dat dezelfde naam heeft:

Bij conflicterende namen binnen dezelfde scope zal this ons helpen om toch buiten de huidige methode aan een gelijknamig element te geraken.

De lijn Levens = 5; in de constructor zal de parameter zelf van waarde aanpassen (wat niet wordt aangeraden). Terwijl door this te gebruiken geraak je aan de property met dezelfde naam.

Merk op dat qua naamgeving de keuze van de formele parameter Levens in de constructor sowieso een ongelukkige keuze is in dit voorbeeld.

Object geeft zichzelf mee als parameter

Beeld je in dat je volgende Management klasse hebt die toelaat om Werknemer objecten te controleren of ze promoveerbaar zijn of niet. Het management van de firma heeft beslist dat werknemers enkel kunnen promoveren als hun huidige Rang lager is dan 10:

class Management
{
    private const int MAXRANG = 10;
    public static bool MagPromoveren(Werknemer toCheck)
    {
        return toCheck.Rang < MAXRANG;
    }
}

Dankzij het this keyword kan je nu vanuit de klasse Werknemer deze externe methode aanroepen om zo te kijken of een object al dan niet kan promoveren:

class Werknemer
{
    public int Rang { get; set; }
    public bool IsPromoveerbaar()
    {
        return Management.MagPromoveren(this);
    }
}

Op deze manier geeft het object waarop je IsPromoveerbaar op aanroept zichzelf mee als actuele parameter aan Management.MagPromoveren(). Dit laat dus toe dat een werknemer zelf kan weten of hij of zij al dan niet kan promoveren:

Werknemer francis = new Werknemer();
if(francis.IsPromoveerbaar())
{
    Console.WriteLine("Jeuj!");
}

Polymorfisme

Naast het blackbox-, abstractie- en overervingsprincipe, is polymorfisme (polymorphism) de vierde grote peiler van object georiënteerd programmeren.

De latijnse naam polymorfisme bestaat uit 2 delen: poly en morfisme, letterlijk dus "meerdere vormen". En geloof het of niet, deze naam dekt de lading ongelooflijk goed.

Polymorfisme laat ons toe dat objecten kunnen behandeld worden als objecten van de klasse waar ze van overerven. Dit klinkt logisch, maar zoals je zo meteen zal zien zal je hierdoor erg krachtige code kunnen schrijven. Anderzijds zorgt polymorfisme er ook voor dat het virtual en override concept bij methoden en properties ook effectief werkt. Het is echter vooral de eerste eigenschap waar we in dit hoofdstuk dieper op in zullen gaan.

De "is een"-relatie in actie

Dankzij overerving kunnen we "is een"-relaties beschrijven. Soms is het echter handig dat we alle child-objecten als hetzelfde type, dat van hun parent, kunnen beschouwen. Beeld je in dat je een gigantische klasse-hiërarchie hebt gemaakt, maar finaal wil je wel dat alle objecten een bepaalde property aanpassen die ze gemeenschappelijk hebben. Zonder polymorfisme is dat een probleem.

Stel dat we een een aantal van Dier afgeleide klassen hebben die allemaal op hun eigen manier een geluid voortbrengen:

abstract class Dier
{
   public abstract string MaakGeluid();
}
class Paard: Dier
{
  public override string MaakGeluid()
  { 
      return "Hinnikhinnik";
  }
}
class Varken: Dier
{
  public override string MaakGeluid()
  { 
      return "Oinkoink";
  }
}

Dankzij polymorfisme kunnen we nu elders objecten van Paard en Varken in een variabele van het type Dier bewaren, maar toch hun eigen geluid laten reproduceren:

Dier someAnimal = new Varken();
Dier anotherAnimal = new Paard();
Console.WriteLine(someAnimal.MaakGeluid()); //Oinkoink
Console.WriteLine(anotherAnimal.MaakGeluid()); //Hinnikhinnik

Alhoewel er een volledig Varken en Paard object in de heap wordt aangemaakt (en blijft bestaan), zullen variabelen van het type Dier enkel die dingen kunnen aanroepen die in de klasse Dier gekend zijn. Dankzij override zorgen we er echter voor dat MaakGeluid wel die code uitvoert die specifiek bij het child-type hoort.

Het gearceerde deel is  niet bereikbaar voor de 2 variabelen in de stack daar deze van het type Dier zijn.

Het is belangrijk te beseffen dat someAnimal en anotherAnimal van het type Dier zijn en dus enkel die dingen kunnen die in Dier beschreven staan. Enkel zaken die override zijn in de child-klasse zullen met de specialisatie-code werken.

Objecten en polymorfisme

Kortom, polymorfisme laat ons toe om referenties naar objecten van een child-type, toe te wijzen aan een variabele van het parent-type (upcasting).

Dit wil ook zeggen dat dit mag (daar alles overerft van System.Object):

System.Object mijnObject = new Varken();

Alhoewel mijnObject effectief een Varken is (in het geheugen), kunnen we enkel aan dat gedeelte dat in de klasse System.Object staat beschreven, zijnde de klassieke 4 methoden (ToString, Equals enz.). Als we het varken toch geluid willen laten maken, dan zal dat niet werken!

Arrays en polymorfisme

Arrays en lijsten laten heel krachtige code toe dankzij polymorfisme. Je kan een lijst van de basis-klasse maken en deze vullen met allerlei objecten van de basis-klasse én de child-klassen.

Een voorbeeld:

List<Dier> zoo = new List<Dier>();
zoo.Add(new Varken());
zoo.Add(new Paard());
foreach(var dier in zoo)
{
  Console.WriteLine(dier.MaakGeluid());
}

We hebben nu een manier gevonden om onze objecten op de juiste momenten even als één geheel te gebruiken, zonder dat we verplicht zijn dat ze allemaal van hetzelfde type zijn!

Polymorfisme is een heel krachtig concept. Door een referentie naar objecten te bewaren in een variabele van hun basistype en, wanneer nodig, ze als 'zichzelf' te gebruiken wordt je code een pak eenvoudiger. Vaak weet je niet op voorhand wat voor elementen je in je lijst wilt plaatsen. Via polymorfisme lossen we dit op. Stel bijvoorbeeld dat je een lijst van Personen hebt (List<Person>) waar echter elementen van subklassen (Bakker, Student, etc.) in terecht kunnen komen, dan laat polymorfisme dit gewoon toe om ook deze elementen in die lijst te plaatsen.

Polymorfisme in de praktijk

Beeld je in dat je een klasse EersteMinister hebt met een methode Regeer en je wilt een eenvoudig land simuleren.

De EersteMinister heeft toegang tot tal van ministers die hem kunnen helpen (inzake milieu, binnenlandse zaken (BZ) en economie). Zonder de voordelen van polymorfisme zou de klasse EersteMinister er zo kunnen uitzien (slechte manier!):

public class EersteMinister
{
    public MinisterVanMilieu Jansens {get;set;} = new MinisterVanMilieu();
    public MinisterBZ Ganzeweel {get;set;} = new MinisterBZ();
    public MinisterVanEconomie VanCent {get;set;} = new MinisterVanEconomie();

    public void Regeer()
    {
        // ministers stappen binnen en zeggen wat er moet gebeuren

        // Jansens: Problematiek aangaande bos dat gekapt wordt
        Jansens.VerhoogBosSubsidies();
        Jansens.OpenOnderzoek();
        Jansens.ContacteerGreenpeace();

        // Ganzeweel advies omtrent rel aan grens met Nederland
        Ganzeweel.VervangAmbassadeur();
        Ganzeweel.RoepTroepenmachtTerug();
        Ganzeweel.VerhoogRisicoZoneAanGrens();

        // Van Cent geeft advies omtrent nakende beurscrash
        VanCent.InjecteerGeldInMarkt();
        VanCent.VerlaagWerkloosheidsPremie();
    }
}

Dit voorbeeld is gebaseerd op een briljante StackOverflow post waarin de vraag "What is polymorphism, what is it for, and how is it used?" wordt behandeld (https://stackoverflow.com/questions/1031273/what-is-polymorphism-what-is-it-for-and-how-is-it-used).

De MinisterVanMilieu zou er zo kunnen uitzien (de methodenimplementatie mag je zelf verzinnen):

class MinisterVanMilieu
{
  public void VerhoogBosSubsidies(){}
  public void OpenOnderzoek(){}
}

De MinisterVanEconomie-klasse heeft dan weer heel andere publieke methoden. En de MinisterBZ ook weer totaal andere.

Je merkt dat de EersteMinister (of de programmeur van deze klasse) aardig wat specifieke kennis moet hebben van de vele verschillende departementen van het land. Bovenstaande code is dus zeer slecht en vloekt tegen het abstractie-principe van OOP: onze klasse moeten veel te veel weten van andere klassen, wat vermeden moet worden. Telkens er zaken binnen een specifieke ministerklasse wijzigen moet dit ook in de EersteMinister aangepast worden. Dankzij polymorfisme en overerving kunnen we dit alles veel mooier oplossen!

Ten eerste: We verplichten alle ministers dat ze overerven van de abstracte klasse Minister die maar 1 abstracte methode heeft Adviseer:

abstract class Minister
{
  abstract public void Adviseer();
}

class MinisterVanMilieu:Minister
{
  public override void Adviseer()
  {
       VerhoogBosSubsidies();
       OpenOnderzoek();
       ContacteerGreenpeace();
  }
  private void VerhoogBosSubsidies(){ ... }
  private void OpenOnderzoek(){ ... }
  private void ContacteerGreenpeace(){ ... }
  }
}

class MinisterBZ:Minister {}
class MinisterVanEconomie:Minister {}

Ten tweede: Het leven van de EersteMinister wordt plots véél makkelijker. Hij kan gewoon de Adviseer methode aanroepen van iedere minister:

public class EersteMinister
{
  public MinisterVanMilieu Jansens {get;set;} = new MinisterVanMilieu();
  public MinisterBZ Ganzeweel {get;set;} = new MinisterBZ();
  public MinisterVanEconomie VanCent {get;set;} = new MinisterVanEconomie();
    
  public void Regeer()
  {
      Jansens.Adviseer(); 
      Ganzeweel.Adviseer(); 
      VanCent.Adviseer();
  }
}

En ten derde: En we kunnen hem nog helpen door met een array of List<Minister> te werken zodat hij ook niet steeds de "namen" van z'n ministers moet kennen. Dankzij polymorfisme mag dit:

public class EersteMinister
{
  public List<Minister> AlleMinisters {get;set;}= new List<Minister>();
  public EersteMinister()
  {
      AlleMinisters.Add(new MinisterVanMilieu());
      AlleMinisters.Add(new MinisterBZ());
      AlleMinisters.Add(new MinisterVanEconomie());
  }
  public void Regeer()
  {  
      foreach (Minister minister in AlleMinisters)
      {
          minister.Adviseer();
      }
  }
}

En wie zei dat het regeren moeilijk was?!

Merk op dat dit voorbeeld ook goed gebruik maakt van compositie.

De is en as keywords

Dankzij polymorfisme kunnen we dus child en parent-objecten door elkaar gebruiken. De keywords is en as gaan ons helpen om door het bos van objecten het bos nog te zien.

Het is keyword

Het is keyword is een operator die je kan gebruiken om te weten te komen of:

  • Een object van een bepaalde datatype is.
  • Een object een bepaalde interface bevat (zie volgende hoofdstuk).

De is operator heeft twee operanden nodig en geeft een bool terug als resultaat. De linkse operator moet een variabele zijn, de rechtse een datatype. Bijvoorbeeld:

bool ditIsEenStudent = mijnStudent is Student;

is voorbeeld

Stel dat we volgende drie klassen hebben:

class Voertuig {}

class Auto: Voertuig{}

class Persoon {}

Een Auto is een Voertuig. Een Persoon is géén Voertuig.

Stel dat we enkele variabelen hebben als volgt:

Auto mijnAuto = new Auto();
Persoon rambo = new Persoon();

We kunnen nu de objecten met is bevragen of ze van een bepaalde type zijn:

if(mijnAuto is Voertuig)
{
    Console.WriteLine("mijnAuto is een Voertuig");
}
if(rambo is Voertuig)
{
    Console.WriteLine("rambo is een Voertuig");
}

De uitvoer zal worden: mijnAuto is een Voertuig.

Met polymorfisme wordt dit voorbeeld echter interessanter. Wat als we een hoop objecten in een lijst van voertuigen plaatsen en nu enkel met de auto's iets willen doen, dan kan dat:

List<Voertuig> alleMiddelen = new List<Voertuig>();
alleMiddelen.Add(new Voertuig());
alleMiddelen.Add(new Auto());
alleMiddelen.Add(new Voertuig());

foreach (var middel in alleMiddelen)
{
    if(middel is Auto)
    {
        //Doe iets met het huidige voertuig
    }
}

as keyword met voorbeeld

Wanneer we objecten van het ene naar het andere type willen omzetten dan doen we dit vaak met behulp van casting:

Student fritz = new Student();
Mens jos = (Mens)fritz;

Het probleem bij casting is dat dit niet altijd lukt. Indien de conversie niet mogelijk is zal een uitzondering gegenereerd worden en je programma zal crashen als je niet aan exception handling doet.

Het as keyword lost dit op. Het keyword zegt aan de compiler "probeer dit object te converteren. Als het niet lukt, zet het dan op null in plaats van een uitzondering op te werpen."

De code van daarnet herschrijven we dan naar:

Student fritz = new Student();
Mens jos = fritz as Mens;

Indien nu de casting niet lukt (omdat Student misschien geen childklasse van Mens blijkt te zijn) dan zal jos de waarde null hebben gekregen.

We kunnen dan vervolgens schrijven:

Student fritz = new Student();
Mens jos = fritz as Mens;
if(jos != null)
{
   //Doe Mens-zaken 
}

Is, as en polymorfisme: een krachtige bende

Dankzij polymorfisme hebben we nu met de is en as keywords handige hulpmiddelen om meer "generieke" methoden te schrijven. Herinner je je nog de Equals methode die we schreven om 2 studenten te vergelijken toen we leerden dat alle klassen van System.Object overerfden? Laten we deze code er nog eens bijnemen en verbeteren:

//In de Student class
public override bool Equals(Object o)
{  
    Student temp = (Student)o; 
    return (Geboortejaar == temp.Geboortejaar && Voornaam == temp.Voornaam);
}

De eerste lijn waarin we o casten naar een student kan natuurlijk mislukken. Het is dan ook veiliger om eerst te controleren of we wel mogen casten, voor we het effectief doen. Hierdoor schrijven we een minder foutgevoelige methode:

//In de Student class
public override bool Equals(Object o)
{  
    if(o is Student)
    { 
        Student temp = o as Student; 
        return (Geboortejaar == temp.Geboortejaar && Voornaam == temp.Voornaam);
    }
    return false;
}

Of we kunnen ook het volgende doen:

//In de Student class
public override bool Equals(Object o)
{  
    Student temp = o as Student; 
    if(temp != null)
    { 
        return (Geboortejaar == temp.Geboortejaar && Voornaam == temp.Voornaam);
    }
    return false;
}

Beide zijn geldige oplossingen.

De is en as keywords laten toe om meer dynamische code te schrijven. Mogelijk weet je niet op voorhand wat voor datatype je code zal moeten verwerken en wordt polymorfisme je oplossing. Maar dan? Dan komen is en as to the rescue!

Je met polymorfisme gevulde lijst van objecten van allerhande typen wordt nu beheersbaarder. Je kan nu met is een element bevragen of het van een bepaald type is. Vervolgens kan je met as het element even 'omzetten' naar z'n effectieve type (en dus meer doen dan wat hij kan in de vermomming van z'n eigen basistype).

Let's be honest. Als je aan dit punt en geen flauw benul hebt waarom je in godsnaam je hier iets van moet aantrekken, wel dan wordt het dringend tijd om dit boek van voor naar achter, links naar rechts en onder tot boven terug door te nemen én vervolgens te gaan fietsen...ik bedoel programmeren.

Interfaces

Interfaces in de echte wereld

De naam interface kan je letterlijk vertalen als "tussen vlakken". Een interface is de verbinding tussen 2 systemen, van welke vorm ook. In de echte wereld gebruik je constant interfaces. Telkens je met de auto rijdt gebruik je een interface: namelijk een handvol handelingen om de auto te laten rijden (pedalen, stuur, enz.). Bijna alle auto's hanteren deze zelfde interface. Van zodra je de interface kent en begrijpt kan je die overal gebruiken, zonder dat je moet weten wat er in het systeem intern juist gebeurt (als ik de gaspedaal induw boeit het niet of ik op gas of elektrisch rijd, zolang de auto maar voortbeweegt).

Aan de achterkant van je computer (en ook in de pc zelf) zijn tal van hardware-interfaces. Afgesproken manieren om 2 systemen met elkaar te laten communiceren. Zo heb je de USB-aansluiting die toelaat dat een extern systeem met een usb-aansluiting met de computer kan communiceren. Maar ook de HDMI, audio, en andere aansluitingen hanteren interfaces. Zouden er rond deze zaken geen wereldwijde interfaces zijn afgesproken, dan zou je mogelijk telkens op een andere manier je externe harde schijf aan een computer moeten hangen.

Voor je dolenthousiast wordt, denkende dat je eindelijk grafische applicaties (GUI oftewel Graphical User Interface applicaties ) gaat maken, moet ik je helaas teleurstellen. Dit hoofdstuk behandelt het programmeer-concept interfaces wat eigenlijk niets te maken heeft met User Interfaces. U weze gewaarschuwd.

Interfaces in OOP

Dit concept van interfaces uit de echte wereld heeft ook een OOP variant. Namelijk de interface tussen 2 (of meer) klassen. Door te beloven dat een klasse aan een bepaalde interface voldoet kunnen alle klassen die deze interface "kennen" met elkaar praten. Een interface in OOP is een beschrijving van publieke methoden en properties die de klasse belooft te hebben. Net zoals je een fotocamera kunt kopen die de HDMI en USB-interface heeft, zo ook kan je nu een klasse maken die bijvoorbeeld de interfaces ISecure en IStreamableheeft.

Interfaces zijn als het ware stempels die we op een klasse kunnen plakken om zo te zeggen "deze klasse gebruikt interface xyz". Gebruikers van de klasse hoeven dan niet de hele klasse uit te spitten en weten dat alle klassen met interface xyz dezelfde publieke properties en methoden hebben.

Een interface is niet meer dan een belofte: het zegt enkel welke publieke methoden en properties de klassen bezit. Het zegt echter niets over de effectieve code/implementatie van deze methoden en properties.

Interfaces in C#

Een interface is dus eigenlijk als het ware een klein stukje papier waar je op zet "om aan deze interface te voldoen moet je zeker volgende methoden en properties hebben". Kortom, een interface-bestand meestal een vrij klein bestand. Het is letterlijk de "Dit apparaat is USB 3.0 compatibel"-sticker.

Stel dat we deze interface kunnen we gebruiken in een spel vechtspel tussen karakters, waarin sommige van de klassen ook aan de Superhelden-interface moeten voldoen. Volgende code toont hoe we een interface definiëren in C#:

interface ISuperHeld
{
    void SchietLasers();
    int VerlaagKracht(bool isZwak);
    int Power{get;set;}
}

Enkele opmerkingen hierbij zijn op z'n plaats:

  • Het woord class wordt niet gebruikt, in de plaats daarvan gebruiken we interface.
  • Het is een goede gewoonte om interfaces met een I te laten starten in hun naamgeving.
  • Methoden en properties gaan niet vooraf van public: interfaces zijn van nature al publiek, dus alle methoden en properties van de interface zijn dat bijgevolg ook (uiteraard geldt dit niet voor andere methoden in de klassen, deze mogen nog steeds private zijn als dat nodig is).
  • Er wordt geen code/implementatie gegeven: iedere methode eindigt ogenblikkelijk met een puntkomma.

Het is in de klassen waar we deze interface "aanhangen" dat we nu vervolgens verplicht zijn deze methode en properties te implementeren.

Ook abstracte klassen kunnen één of meerdere interfaces hebben. In het geval van een abstracte klasse is deze niet verplicht de interface ook al te implementeren, en mag (delen van) de interface ook als abstract aangeduid worden.

Een interface is een beschrijving hoe een component een andere component kan gebruiken, zonder te zeggen hoe dit moet gebeuren. De interface is met andere woorden 100% scheiding tussen de methode/property-signatuur en de eigenlijke implementatie ervan.

Interface regels

Interfaces zijn als het ware standaarden waaraan een klasse moet voldoen, wil het kunnen zeggen dat het een bepaalde interface heeft. Standaarden impliceert dat er duidelijke afspraken nodig zijn. Bij C# interfaces zijn er enkele belangrijke regels:

  • Je kan geen instantievariabelen declareren in een interface (dat hoort bij de implementatie).
  • Je kan geen constructors declareren.
  • Je kan geen access modifiers specificeren (public, protected, etc): alles is public.
  • Je kan nieuwe types (bv. enum) in een interface declareren.
  • Een interface kan niet overerven van een klasse, wel van één of meerdere interfaces.

Interfaces en klassen

We kunnen nu aan klassen de stempel ISuperHeld geven zodat programmeurs weten dat die klasse gegarandeerd de methoden SchietLasers, VerlaagKracht en de property Power zal hebben.

Volgende code toont dit. We plaatsen de interface (of interfaces) die de klasse beloofd te hebben achter het dubbele punt bovenaan.

class Zorro: ISuperHeld
{
    public void RoepPaard(){...}
    public bool HeeftSnor{get;set;}
    public void SchietLasers() //interface ISuperHeld
    {
        Console.WriteLine("pewpew");
    }
    public int VerlaagKracht(bool isZwak)//interface ISuperHeld
    {
        if(isZwak) 
            return 5;
        return 10;
    }
    public int Power {get;set;} //interface ISuperHeld
}

Zolang de klasse Zorro niet exact de interface inhoud implementeert zal deze klasse niet gecompileerd kunnen worden.

De klasse in dit voorbeeld blijft wel overerven van System.Object. Het is ook perfect mogelijk om een klasse te hebben die én overerft van een specifieke klasse én meerdere interfaces heeft:

class DarthVader: StarWarsCharacter, IForceUser, IPilot

Een "lolly" op een klasse geeft aan dat deze klasse een bepaalde interface heeft in UML notatie. In volgende tekening hebben we een klasse WerkStudent en een interface IVerkortTraject. We gebruiken de UML notatie voor een interface om aan te geven dat de Student klasse de IVerkortTraject interface heeft:

Interface UML notatie.

Een visuele manier om interfaces voor te stellen is de volgende. Eigenlijk is een interface als het ware een blad papier dat je bovenop je klasse kunt houden. Op het blad staan de methoden en properties beschreven die de interface moet hebben. Als je het blad mooi bovenop een klasse plaatst die de interface belooft te doen, dan zouden de gaten in het blad mooi bovenop de respectievelijke methoden en properties van de klasse passen.

Het UML "lolly'tje" kan je als een haakje beschouwen waaraan de interface bengelt.

Vervolgens kunnen we de interface met het haakje aan de klasse hangen.

Je zei net: "Volgende interface kunnen we gebruiken in een spel waarin sommige klassen superhelden zijn." Die zin impliceert toch overerving "sommige klassen zijn superhelden"?

Dat klopt, maar zoals we weten kan je maar van 1 klasse overerven. Beeld je in dat je een uitgebreide klasse-hiërarchie hebt gemaakt bestaande uit monsters, mensen, huizen en voertuigen. Deze 4 groepen hebben mogelijk geen gemeenschappelijke parent, maar toch willen we dat sommige monsters superhelden kunnen worden, net zoals sommige mensen EN zelfs enkele voertuigen (Transformers!).

Dankzij interface kunnen we als het ware een stukje de beperking dat je maar van 1 klasse kunt overerven opvangen. Sommige klassen ZIJN een voertuig MAAR OOK een Superheld. Met andere woorden, klassen kunnen meerdere interfaces implementeren.

Merk wel op dat de interface NIET de implementatie bevat van wat een superheld juist doet. Het gaat enkel beloven dat de klasse bepaalde methoden en properties heeft.

Lampje wederom to the rescue

Wanneer je in VS een klasse schrijft die een bepaalde interface moet hebben, dan kan je die snel implementeren. Je schrijft de klasse-signatuur en klikt er dan op: links verschijnt het lampje waar je vervolgens op kunt klikken en kiezen voor "Implement interface". En presto!

Als het lampje niet ogenblikkelijk verschijnt kan je ook altijd rechterklikken op het rood onderstreepte woord en kiezen voor "Quick Actions and Refactorings...".

Merk op dat VS de nieuwere EBM syntax hier hanteert bij properties. Meer informatie hierover vind je in de appendix.

Het is keyword met interfaces

We kunnen is gebruiken om te weten of een klasse een specifieke interface heeft. Dit laat ons toe om code te schrijven die weer een beetje meer polyvalent wordt.

Stel dat we volgende klassen hebben waarbij de Boek klasse de IVerwijderbaar interface implementeert:

interface IVerwijderbaar{ ... };
class Boek: IVerwijderbaar { ... };
class Persoon { ... };

We kunnen nu met is objecten bevragen of ze de interface in kwestie hebben:

Persoon tim = new Persoon();
Boek gameOfThrones = new Boek();

if(gameOfThrones is IVerwijderbaar)
{
    Console.WriteLine("Ik kan Game of Thrones verwijderen");
}
if(tim is IVerwijderbaar)
{
    Console.WriteLine("Ik kan Tim verwijderen");
}

De output zal worden: Ik kan Game of Thrones verwijderen.

Net zoals bij onze voorbeelden over polymorfisme en is zal de kracht van interfaces pas zichtbaar worden wanneer we met arrays of lijsten van objecten werken. Indien deze lijst een bont allegaartje objecten bevat, allemaal met specifieke parents én interfaces, dan kunnen we weer met is bijvoorbeeld alle objecten benaderen die een bepaalde interface hebben:

foreach(var persoon in WerkNemers)
{
    if(persoon is IManager)
    {
        //...
    }
}

Meerder interfaces

Een nadeel van overerving is dat een klasse maar van 1 klasse kan overerven. Een klasse mag echter wel meerdere interfaces met zich meedragen:

interface ISuperHeld{...}
interface ICoureur{...} 
class Man {...}

class Zorro:Man, ISuperHeld
{...}

class Batman:Man, ISuperHeld, ICoureur 
{...}

Merk op dat de volgorde belangrijk is: eerst plaats je de klasse waarvan wordt overgeërfd, dan pas de interface(s).

Ook mogen interfaces van elkaar overerven:

interface IGod:ISuperHeld
{ }

In kleine projecten lijken interfaces wat overkill, en dat zijn ze vaak wel. Van zodra je een iets complexer project krijgt met meerdere klassen die onderling met elkaar allerlei zaken moeten doen, dan zijn interfaces je dikke vrienden! Je hebt misschien al over de SOLID programmeerprincipes gehoord?

And if not, niet erg. Samengevat zegt SOLID dat we een bepaalde hoeveelheid abstractie inbouwen enerzijds (zodat we niet de gore details van klassen moeten kennen om er mee te programmeren) anderzijds dat er een zogenaamde 'separation of concerns' (SoC) moet zijn (ieder deel/klasse/module van je code heeft een specifieke opdracht).

Met interfaces kunnen we volgens de SOLID principes programmeren: het boeit ons niet meer wat er in de klasse zit, we kunnen gewoon aan de interfaces van een klasse zien wat hij kan doen. Handig toch!

Interfaces in de praktijk

In het vorige hoofdstuk bespraken we een voorbeeld van een klasse EersteMinister die enkele Minister-klassen gebruikte om hem of haar te helpen.

Een nadeel van die voorgaande aanpak is dat al onze Ministers maar 1 "job" kunnen hebben: ze erven allemaal over van Minister en kunnen nergens anders van overerven (geen multiple inheritance is toegestaan in C#). Je wordt uiteraard niet geboren als Minister, en het zou dus handig zijn dat ook andere mensen Minister kunnen worden, zonder dat ze hun bestaande expertise moeten wegdoen.

Via interfaces kunnen we dit oplossen. Een Minister gaan we dan eerder als een "bij-job" beschouwen en niet de hoofdreden van een klasse.

We definiëren daarom eerst een nieuwe interface IMinister:

interface IMinister
{
    void Adviseer();
}

Vanaf nu kan eender wie die deze interface implementeert de EersteMinister advies geven. Hoera! En daarnaast kan die klasse echter ook nog tal van andere zaken doen. Beeld je in dat een CEO van een bedrijf ook minister bij de EersteMinister wilt zijn, zoals deze:

class Ceo
{
    public void MaakJaarlijkseOmzet()
    { 
       Console.WriteLine("Geld!!!");
    }
    public void OntslaDepartement()
     { 
       Console.WriteLine("You're all fired!");
    }
}

Nu we de interface IMinister hebben kunnen we deze klasse aanvullen met deze interface zonder dat de bestaande werking van de klasse moet aangepast worden:

class Ceo: IMinister
{ 
    public void Adviseer()
    { 
        Console.WriteLine("Vrijhandel is essentieel!");
    }
    //gevolgd door de reeds bestaande methoden

De CEO kan dus z'n bestaande job blijven uitoefenen maar ook als Minister optreden.

Ook de EersteMinister moet aangepast worden om nu met een lijst van IMinister ipv Minister te werken. Dankzij, wederom, polymorfisme is dat erg eenvoudig!

public class MisterEersteMinister
{
    public void Regeer()
    {
        List<IMinister> AlleMinisters = new List<IMinister>();
        AlleMinisters.Add(new Ceo); 
        foreach (IMinister minister in AlleMinisters)
        {
            minister.Adviseer();
        }
    }
}

De eerder beschreven MinisterVanMilieu, MinisterBZ en MinisterVanEconomie dienen ook niet meer van de abstracte klasse Minister (deze zou je kunnen verwijderen) over te erven en kunnen gewoon de interface implementeren. Enkel lijn 1 moet hierbij aangepast worden:

class MinisterVanMilieu:IMinister
{
    public void Adviseer()
    {
        VerhoogBosSubsidies();
        OpenOnderzoek();
        ContacteerGreenpeace();
    }

    private void VerhoogBosSubsidies(){ ... }
    private void OpenOnderzoek(){ ... }
    private void ContacteerGreenpeace(){ ... }
    }
}

En bij deze hebben we, dankzij interfaces, compositie en polymorfisme, ervoor gezorgd dat eender wie Minister kan worden zonder dat dat hij of zij daarvoor z'n bestaande beroep moet teniet doen. OOP laat ons echt toe de realiteit zo dicht mogelijk te benaderen!

Bestaande interfaces in .NET

De bestaande .NET klassen gebruiken vaak interfaces om bepaalde zaken uit te voeren. Zo heeft .NET tal van interfaces gedefiniëerd (bv. IEnumerable, IDisposable, IList, IQueryable enz.) waar je zelfgemaakte klassen mogelijk aan moeten voldoen indien ze bepaalde bestaande methoden wensen te gebruiken. Een typisch voorbeeld is het gebruik van de Array.Sort methode. Hier wordt het echte nut van interfaces erg duidelijk: de ontwikkelaars van .NET kunnen niet voorspellen hoe andere ontwikkelaars hun bibliotheken gaan gebruiken. Via interfaces geven ze als het ware krijtlijnen en vanaf dan moeten de ontwikkelaars zelf maar bepalen hoe hun nieuwe klassen zullen samenwerken met die van .NET.

Sorteren met Array.Sort en de IComparable interface

Een veelgebruikte .NET interface is de IComparable interface. Deze wordt gebruikt indien .NET bijvoorbeeld een array van objecten wil sorteren. Bij wijze van demonstratie zullen we tonen waarom deze interface erg nuttig kan zijn.

Stap 1: Het probleem

Indien je een array van objecten hebt en je wenst deze te sorteren via Array.Sort dan dienen de objecten de IComparable interface te hebben.

We willen een array van landen kunnen sorteren op grootte van oppervlakte.

Stel dat we de klasse Land hebben:

class Land
{
    public string Naam {get;set;}
    public int Oppervlakte {get;set;}
    public int Inwoners {get;set;}
}

We plaatsen 3 landen in een array:

Land[] eurolanden = new Land[3];
eurolanden[0] = new Land() {Naam = "België", Oppervlakte = 5, Inwoners = 2000};
eurolanden[1] = new Land() {Naam = "Frankrijk", Oppervlakte = 7, Inwoners = 2500};
eurolanden[2] = new Land() {Naam = "Nederland", Oppervlakte = 6, Inwoners = 1800};

Wanneer we nu zouden proberen de landen te sorteren:

Array.Sort(eurolanden);

Dan treedt er een uitzondering op:InvalidOperationException: Failed to compare two elements in the array. Dit is erg logisch: .NET heeft geen flauw benul hoe objecten van het type Land moeten gesorteerd worden. Moet dit alfabetisch volgens de Naam property, of van groot naar klein op aantal Inwoners? Enkel jij als ontwikkelaar weet momenteel hoe er gesorteerd moet worden.

Stap 2: IComparable onderzoeken

We kunnen dit oplossen door de IComparable interface in de klasse Land te implementeren. We bekijken daarom eerst de documentatie van deze interface (op msdn.microsoft.com/system.icomparable). De interface is beschreven als:

interface IComparable
{
    int CompareTo(Object obj);
}

OPGELET: Deze interface bestaat al in .NET en mag je dus niet opnieuw in code schrijven!

Daarbij moet de methode een int teruggeven als volgt:

WaardeBetekenis
Getal kleiner dan 0Huidig object komt voor het obj dat werd meegegeven.
0Huidig object komt op dezelfde positie als obj.
Getal groter dan 0Huidig object komt na obj.

De Array.Sort methode zal werken tegen deze IComparable interface om juist te kunnen sorteren. Het verwacht dat de klasse in kwestie een int teruggeeft volgens de afspraken van de tabel hierboven.

Stap 3: IComparable in Land implementeren

We zorgen er nu voor dat Land deze interface implementeert. Daarbij willen we dat de landen volgens oppervlakte worden gesorteerd :

class Land: IComparable
{
    public int CompareTo(object obj)
    {
        Land temp =  obj as Land;
        if(temp != null)
        {  
            if(Oppervlakte > temp.Oppervlakte) 
                return 1;
            if(Oppervlakte < temp.Oppervlakte) 
                return -1;
            return 0;
        }
        else
            throw new NotImplementedException("Object is not a Land"); 
    }
}

Nu zal de Sort werken:

Array.Sort(eurolanden);

De Sort()-methode kan nu ieder object bevragen via de CompareTo()-methode en zo volgens een eigen interne sorteeralgoritme de landen in de juiste volgorde plaatsen.

Stel dat vervolgens nog beter willen sorteren: we willen dat landen met een gelijke oppervlakte, op hun aantal inwoners gesorteerd worden:

public int CompareTo(object obj)
{

    Land temp = obj as Land;
    if(temp != null)
    { 
        if(Oppervlakte > temp.Oppervlakte) return 1;
        if(Oppervlakte < temp.Oppervlakte) return -1;
        if(this.Inwoners > temp.Inwoners) return 1;
        if(this.Inwoners < temp.Inwoners) return -1;
    }
    else
        throw new ArgumentException("Object is not a Land"); 
    
}

Ik laat jou de code schrijven wat er moet gebeuren indien het aantal inwoners én de oppervlakte dezelfde is. Misschien kan je dan sorteren volgens de Naam van het land

De bestaande datatypes in .NET hebben allemaal de IComparable interface ingebakken. Zo ook dus de gekende primitieve datatypes. string dus ook en laat dus toe om bijvoorbeeld snel te weten welke van 2 string alfabetisch eerst komt, als volgt:

return this.Naam.CompareTo(temp.Naam);

Kortom, voeg dit achteraan de eerder geschreven vergelijkingen in je Land-klasse om finaal de Naam te gebruiken als sorteer-element.

Alles samen : Polymorfisme, interfaces en is/as

De eigenschappen van polymorfisme en interfaces combineren kan tot zeer krachtige code resulteren. Wanneer we dan ook nog eens de is en as keywords gebruiken zoals we ook al even toonden in de vorige sectie is het hek helemaal van de dam. Als afsluiter van deze lange reis in OOP-land zullen we daarom een voorbeeld tonen waarin de verschillende OOP-concepten samenkomen om, je raadt het nooit, vloekende mensen op het scherm te tonen.

Vloekende mensen: Opstart

Het idee is het volgende: mensen kunnen spreken. Leraren, Studenten, Politieker, en ja zelfs Advocaten zijn mensen. Echter, enkel Politiekers en Advocaten hebben ook de interface IVloeker die hen toelaat eens goed te vloeken. Brave leerkrachten en studenten doen dat niet (kuch). We willen een programma dat lijsten van mensen bevat waarbij we de vloekers kunnen doen vloeken zonder complexe code te moeten schrijven.

We hebben volgende klasse-structuur:

Klasse-schema van de vloekende mensen.

Als basis klasse Mens hebben we:

public class Mens
{
    public void Spreek()
    {
        Console.WriteLine("Hoi!");
    }
}

Voorts definiëren we de interface IVloeker als volgt:

interface IVloeker
{
    void Vloek();
}

We kunnen nu de nodige child-klassen maken:

  1. De niet-vloekers: Leraar en Student
  2. De vloekers: Advocaat en Politieker
class Leraar:Mens {} //moet niets speciaal doen
class Student:Mens{} //ook studenten doen niets speciaal
class Politieker: Mens, IVloeker
{
    public void Vloek()
    {
        Console.WriteLine("Godvermiljaardedju, zei de politieker");
    }
}
class Advocaat: Mens, IVloeker
{
    public void Vloek()
    {
        Console.WriteLine("SHIIIIT, zei de advocaat");
    }
}

Vloekende mensen: Het probleem

We maken een array van mensen aan waarin we van iedere type een vertegenwoordiger plaatsen (uiteraard had dit ook in een List<Mens> kunnen gebeuren):

Mens[] mensjes = new Mens[4];
mensjes[0] = new Leraar();
mensjes[1] = new Politieker();
mensjes[2] = new Student();
mensjes[3] = new Advocaat();
for(int i = 0; i < mensjes.Length; i++)
{
    //NOW WHAT?

Het probleem: hoe kan ik in de array van mensen (bestaande uit een mix van studenten, leraren, advocaten en politiekers) enkel de vloekende mensen laten vloeken?

Oplossing 1: is to the rescue

De eerste oplossing is door gebruik te maken van het is keyword. We zullen de array doorlopen en steeds aan het huidige object vragen of dit object de IVloeker interface bezit, als volgt:

for(int i = 0; i<mensjes.Length; i++)
{
    if(mensjes[i] is IVloeker)
    {
        //NOW WHAT?
    }
    else
    {
        mensjes[i].Spreek();
    }
}

Vervolgens kunnen we binnen deze if het huidige object tijdelijk omzetten (casten) naar een IVloeker object en laten vloeken:

if(mensjes[i] is IVloeker)
{
    IVloeker tijdelijk = (IVloeker)mensjes[i];
    tijdelijk.Vloek();
}

Oplossing 2: as to the rescue

Het as keyword kan ook een toffe oplossing geven. Hierbij zullen we het object proberen om te zetten via as naar een IVloeker. Als dit lukt (het object is verschillend van null) dan kunnen we het object laten vloeken:

for(int i = 0; i<mensjes.Length; i++)
{
    IVloeker tijdelijk = mensjes[i] as IVloeker;
    if(tijdelijk != null)
    {
        tijdelijk.Vloek();
    }
    else
    {
        mensjes[i].Spreek();
    }
}

Hopelijk hebben voorgaande voorbeelden je een beetje hebben kunnen doen proeven van de kracht van interfaces. Gedaan met ons druk te maken wat er allemaal in een klasse gebeurt. Werk gewoon 'tegen' de interfaces van een klasse en we krijgen de ultieme black-box revelatie! See what I did there?

Conclusie

Je hebt het gehaald! Volgens m'n statistieken zal je nu in 1 van volgende 2 staten zijn:

  1. Het scheelt niet veel of je droomt in klassen en objecten. Overal waar je kijkt zie je toepassingen van polymorfisme, interfaces en overerving. Je begrijpt nu waarom zoveel mensen graag software ontwikkelen. Je hebt de smaak te pakken en er ligt een ongelooflijk scala aan mogelijkheden voor je klaar. Bekijk zeker enkele aanbevelingen op de volgende pagina die je na dit boek kan ontdekken. Ook in de appendix zal je nog enkele interessante, gevorderde concepten kunnen ontdekken.
  2. Je pinkt een traantje weg. Je had zo gehoopt nu alles van OOP te kunnen, maar het is alleen maar verwarrender geworden. Dat is jammer, maar niets aan te doen. Bij sommigen komt de klik niet altijd direct. Hopelijk heb je toch iets geleerd uit dit boek en begrijp je waarom zoveel mensen, zoals ik, zo enthousiast over OOP zijn. Blijven oefenen is de boodschap!

En moest je dit boek nu ongelooflijk nuttig, slecht of briljant vinden: iedere review helpt. Je doet me er een ongelooflijke dienst mee als je een review plaatst op de website waar je dit boek kocht!

Ik wens je alvast veel succes met de verdere ontwikkeling van je programmeer-expertise en denk er aan: gebruik nooit goto!

Tim Dams

Zomer 2022

PS In welke staat je ook bent: houd sowieso ziescherp.be in het oog. Momenteel ben ik aan een podcast bezig van dit boek waar je de prille eerst stappen reeds van kan beluisteren op anchor.fm/tim-dams/. Voorts zal je er tal van oefeningen én oplossingen vinden!

En nu? Ken ik nu alles van C#/.NET ?

Helaas niet. Maar je hebt wel een erg goede basis gelegd. Vanaf dit punt kan je tal van richtingen uitgaan, afhankelijk van je interesses:

  • Geavanceerde C# concepten: je zou je verder kunnen verdiepen in "de taal C#". Denk maar aan leren werken met async en events. Maar ook het wonderlijke Linq is iets dat je in bijna alle .NET geledingen zal kunnen gebruiken.
  • Desktop-applicaties: Totnogtoe hebben we enkel oersaaie Console-applicaties gemaakt. Uiteraard kan je ook heel eenvoudig, met de kennis die je nu hebt, zogenaamde bureaublad-applicaties maken. Neem zeker eens een kijkje wat WPF en UWP je te bieden heeft. Je zal je even moeten inwerken in eventgebaseerd-programmeren en XAML en vanaf dan ben je vlot vertrokken!
  • Mobiele applicaties: Zogenaamde native Android of iPhone applicaties ontwikkelen gaat niet met C# (merk wel op dat dankzij je nieuwe C# kennis je vlot de native programmeertalen van Android (Java) en iOS (Swift) kan leren). Binnen de .NET-familie bestaat er echter wel het nieuwe .NET MAUI-framework. Dit krachtige framework (de opvolger van Xamarin) laat je toe om in C# crossplatform-apps te ontwikkelen. Je zal met 1 codebase kunnen compileren naar zowel Windows, Android, iPhone, enz. Bekijk zeker ook eens de Comet toolkit om erg modern-ogende apps te maken met .NET MAUI.
  • Web-ontwikkeling: Ook .NET heeft een zogenaamde back-end stack waar aardig wat grote bedrijven op draaien. Deze technologie-stack bevat tal van belangrijke technologieën zoals APS.NET, Entity Framework. En als je genoeg hebt van altijd maar in Javascript te werken, dan moet je zeker eens een kijkje nemen in de jongste .NET-telg Blazor, die je toelaat om C# te schrijven in je HTML!
  • Game development: Wil je eerder de Sid Meiers, John Romeros en Gabe Newells van deze wereld achterna gaan en games beginnen ontwikkelen? Steeds meer games, zeker in de indie-wereld, worden nu ontwikkeld in Unity, een op C# gebaseerde game-engine. Maar bekijk zeker ook eens Monogame, een C# bibliotheek waar onder andere Stardew Valley in is ontwikkeld (Monogame is een zogenaamde crossplatform bibliotheek en kan je games compileren naar Mac, Windows, Linux, Android, Nintendo Switch, Playstation 4, XboxOne, etc). Godot is een andere, laagdrempelige, manier om in C# games te ontwikkelen.
  • Azure en de cloud: en wil je echt ontdekken dat je nog niet veel kent van .NET, dan moet je eens kijken naar wat er allemaal onder de Azure-tak van Microsoft te vinden is. Azure is de verzamelnaam voor alle cloud-gebaseerde technologieën & services van Microsoft, waarin .NET (en dus ook C#) een belangrijk onderdeel is.
  • Gevorderde programmeerconcepten: Design Patterns, Dependency Injection, SOLID programming, enz. zijn allemaal taal-agnostische programmeerconcepten. Wat wil zeggen dat je ze kan toepassen op je programmeerproblemen, onafhankelijk van de programmeertaal die je hanteert. Je zal namelijk ontdekken dat bepaalde problemen vaak herleid kunnen worden tot een specifieke groep van problemen, waar slimmere mensen dan ik als het ware "oplossings-recepten" (design patterns) voor hebben uitgedokterd.

Kennisclips

Op volgende pagina vind je alle kennisclips en andere opnames samen die doorheen dit boek verspreid staan bij de relevante hoofdstukken.

Hoofdstuk 1 - De eerste stappen

Hoofdstuk 2 - De basiconcepten van C#

Hoofdstuk 3 - Tekst gebruiken in code

Hoofdstuk 4 - Werken met data

Hoofdstuk 5 - Beslissingen

Hoofdstuk 6 - Herhalingen, herhalingen, herhalingen

Hoofdstuk 7 - Methoden

Hoofdstuk 8 - Arrays

Kennisclips

Op volgende pagina vind je alle kennisclips en andere opnames samen die doorheen dit boek verspreid staan bij de relevante hoofdstukken.

Hoofdstuk 9 - Object Oriented Programming

Hoofdstuk 10 - Geheugenmanagement, uitzonderingen en namespaces

Hoofdstuk 11 - Gevorderde klasseconcepten

Hoofdstuk 12 - Arrays en klassen

Hoofdstuk 13 - Overerving

Hoofdstuk 14 - Gevorderde overervingsconcepten

Hoofdstuk 15 - Compositie en this

Hoofdstuk 16 - Polymorfisme

Hoofdstuk 17 - Interfaces

Coding guidelines

Naamgeving

  • Duidelijke naam: de identifier moet duidelijk maken waarvoor de identifier dient. Schrijf dus liever gewicht of leeftijd in plaats van a of meuh.
  • Camel casing: gebruik camel casing indien je meerdere woorden in je identfier wenst te gebruiken. Camel casing wil zeggen dat ieder nieuw woord terug met een hoofdletter begint. Een goed voorbeeld kan dus zijn leeftijdTimDams of aantalLeerlingenKlas1EA . Merk op dat we liefst het eerste woord met kleine letter starten.
  • Constanten: deze zogenaamde magic numbers zijn steeds volledig in hoofdletters (bv const int MAXTEMP = 45)
  • Prefereer cijfers en letters: gebruik geen liggende streepjes of andere karakters die geen cijfers of letters zijn.
  • Private kleine letter, public hoofdletter: private variabelen starten met een kleine letter (bv pagesBook), public variabelen (zie volgende boek) met een grote letter (bv. SizeBook).
  • Methoden met hoofdletter: methoden starten steeds met een hoofdletter (bv. OpenDataBase).
  • Geen afkortingen: Schrijf GetWindowsSize in plaats van GetWinSz.
  • Enum: zowel de naam van het enum-type als de afzonderlijke waarden starten met een hoofdletter
  • Solution/Project: je VS solution en project-namen begin je steeds met een hoofdletter en vervolgens volg je de afspraken van identifiers: enkel liggende strepen, getallen en letters, inclusief camelCasing.

Handige Visual Studio code snippets

Bepaalde code zal je vaak opnieuw schrijven. Er zitten in VS tal van shortcuts om deze typische lijnen code sneller te schrijven. Schrijf een van volgende stukken code en druk dan 2x op de [tab]-toets:

  • cw : schrijft Console.WriteLine();
  • for
  • while
  • dowhile
  • switch
  • ///: automatisch methode commentaar blok
  • propfull: full property (semester 2)
  • prop: auto-property (semester 2)

Regions

Je kan delen van je code in handige inklapbare secties zetten door deze als regions aan te duiden, als volgt:

#region My Epic code
Console.WriteLine("I am the greatest!");
Console.WriteLine("Echt waar!");
#endregion

Je zal vanaf dan in Visual Studio rechts van de start van de region een minnetje zien waar je op kunt klikken om de hele region tot aan #endregion in te klappen. De code zal nog steeds gecompileerd worden, maar je bladspiegel is weer wat ordelijker geworden.

Bereik in code weten (Pro-kennis)

Het bereik van datatypen is weliswaar opgegeven. Maar het is belangrijk om weten dat deze ook in de compiler gekend is. Het volgende voorbeeld toont dit aan:

string zinnetje = "Het bereik van het type double is:";
Console.WriteLine(zinnetje + double.MinValue + " en " + double.MaxValue);

Je kan met andere woorden met int.MaxValue en int.MinValue het minimum- en maximumbereik van het type int verkrijgen. Wil je dit van een double, dan gebruik je double.MaxValue enz.

out en ref keywords

Zoals verteld kun je parameters aan een methode doorgeven by value (de waarde) of by reference (het geheugenadres) afhankelijk van het datatype dat je meegeeft (de primitieve datatypes zoals int en double worden by value meegegeven, arrays by reference). Je kan echter de primitieve datatypes ook by reference meegeven zodat de methode rechtstreeks toegang tot de meegegeven variabele heeft en niet met een kopie moet werken. Dit kan soms handig zijn, maar zorgt ook voor ongewenste bugs. Opletten dus.

Parameters by reference doorgeven

Je kan parameters op 2 manieren by reference doorgeven aan een methode:

  • Indien de actuele parameter reeds een waarde heeft dan kan je het ref keyword gebruiken. Dit gebruik je dus voor in/out-parameters.
  • Indien de actuele parameter pas in de methode een waarde krijgt toegekend dan wordt het out keyword gebruikt. Dit gebruik je dus voor out-parameters.

ref

Je plaatst het ref keyword in de methode signatuur voor de formele parameter dat by reference moet meegegeven worden. Vanaf dan heeft de methode toegang tot de originele parameter en dus niet tot de kopie. Je dient ook expliciet het keyword voor de actuele parameter bij de aanroep van de methode te plaatsen:

static void VerhoogWaarde(ref int getal)
{
    getal++;
}
 
static void Main(string[] args)
{
    int eerste = 1;
    Console.WriteLine(eerste); //er verschijnt 1 op het scherm
    VerhoogWaarde(ref eerste); //let op het ref keyword!
    Console.WriteLine(eerste); //er verschijnt 2 op het scherm
}

out

Door het out keyword te gebruiken geven we expliciet aan dat we beseffen dat de parameter in kwestie pas binnen de methode een waarde zal toegekend krijgen. Wat we hier tonen:

static void GeefWaarde(out int getal)
{
    getal = 5;
}
 
static void Main(string[] args)
{
    int eerste;
    GeefWaarde(out eerste);
    Console.WriteLine(eerste); //er verschijnt 5 op het scherm
}

Foute invoer van de gebruiker opvangen m.b.v. TryParse

Vaak wil je de invoer van de gebruiker verwerken/omzetten naar een getal. Denk maar aan volgende applicatie:

Console.WriteLine("Geef je leeftijd");
string invoer = Console.ReadLine();
int leeftijd = int.Parse(invoer);
leeftijd += 10;
Console.WriteLine($"Over 10 jaar ben je {leeftijd} jaar oud");

Deze applicatie zal falen indien de gebruiker iets invoert dat niet kan geconverteerd worden naar een int. We lossen dit op met behulp van TryParse.

Werking TryParse

De primitieve datatypes int, double, float enz. hebben allemaal een TryParse methode. Je kan deze gebruiken om de invoer van een gebruiker te proberen om te zetten, als deze niet lukt dan kan je dit ook weten zonder dat je programma crasht door een exception op te werpen.

De werking van TryParse is als volgt:

bool gelukt = int.TryParse(invoer,out int leeftijd);

De methode TryParse zal de string in de eerste parameter (invoer in dit voorbeeld) trachten naar een int te converteren. Als dit lukt dan zal het resultaat in de variabele int leeftijd geplaatst worden. Merk op dat we out voor de parameter moeten zetten zoals besproken in de vorige sectie van de appendix.

Het return resultaat van de methode is bool: indien de conversie gelukt is dan zal deze true teruggeven, anders false.

We kunnen nu onze applicatie herschrijven en minder foutgevoelig maken voor slechte invoer van de gebruiker:

Console.WriteLine("Geef je leeftijd");
string invoer = Console.ReadLine();
bool gelukt = int.TryParse(invoer,out int leeftijd);
if (gelukt)
{
    leeftijd += 10;
    Console.WriteLine($"Over 10 jaar ben je {leeftijd} jaar oud");
}
else
{
    Console.WriteLine("Geen geldige invoer gegeven!");
}

TryParse en loops

Daar TryParse een bool teruggeeft kunnen we deze ook gebruiken in loops als logische expressie. Volgende applicatie zal aan de gebruiker een komma getal vragen en pas verder gaan indien de gebruiker een geldige invoer heeft gegeven:

double temperatuur;
string invoer = "";
do
{
    Console.WriteLine("Geef temperatuur");
    invoer = Console.ReadLine();
} while (! double.TryParse(invoer, out temperatuur));

//enkel verdergaan van zodra temperatuur een geldige waarde heeft gekregen

Let er op dat de scope hier van belang is: invoer en temperatuur moet gekend zijn buiten de loop waar technisch gezien ook de TryParse zal gebeuren.

Operator overloading

Stel, je hebt volgende klasse:

class Kassa
{
    public int Totaal {get;set;}
    public int Bouwjaar {get;set;}
}

Je maakt even later twee kassa's aan met de nodige informatie:

Kassa benedenKassa = new Kassa(){Totaal = 50, Bouwjaar = 1981};
Kassa bovenKassa = new Kassa(){Totaal = 40, Bouwjaar = 2000};

Even later wordt besloten dat beide kassa's moeten samengevoegd worden tot een gloednieuwe kassa voor beide verdiepingen samen. Bedoeling is dat het totale geld in beide kassa's opgeteld in de nieuwe kassa moet gezet worden. Het bouwjaar van de nieuwe kassa moet het bouwjaar van de oudste van de 2 originele kassa's zijn. Je zou willen schrijven:

Kassa nieuw = benedenKassa + bovenKassa;

Uiteraard heeft C# geen flauw benul hoe de + operator moet toegepast worden op objecten van klassen die je zelf geschreven hebt.

Operator overloading to the rescue

Je kan in een klasse bestaande operators (+,-,*, enz.) overloaden, wat wil zeggen: aan C# vertellen hoe deze operator moet toegepast worden wanneer je die nodig hebt voor instanties van de klasse.

Stel dat je de + wilt overloaden in je klasse dan voeg je volgende methode toe:

class Kassa
{
    public int Totaal {get;set;}
    public int Bouwjaar {get;set;}

    public static Kassa operator+ (Kassa a, Kassa b)
    {
        //Zie verder
    }
}

Laten we deze syntax even bekijken:

  • Operator overloading methoden zijn altijd static.
  • Het returntype is idealiter het type van de klasse zelf (logisch: twee kassa's optellen geeft een nieuwe kassa).
  • operator+ geeft aan welke operator je wenst te overloaden. Zie verderop met een link naar alle operators die je kan overloaden.
  • Indien je een operator hebt met twee operanden (zoals de +) dan vereist de methode ook twee parameters, van het type van de klasse zelf: dit zijn de twee elementen (operanden) die je wenst op te tellen via de operator.

Bekijk zeker de lijst docs.microsoft.com/dotnet/csharp/programming-guide/statements-expressions-operators/overloadable-operators om te zien welke operators je allemaal kan overloaden. Tip: het zijn er veel!

De operator beschrijven

Vervolgens moeten we nu beschrijven hoe de operator moet werken. Finaal zal de methode een nieuw object moeten teruggeven waarin het resultaat van de operatie zit.

In het voorbeeld dat we maken willen we dus het volgende:

public static Kassa operator+ (Kassa a, Kassa b)
{
    Kassa resultaat = new Kassa()
        {
            Totaal = a.Totaal+b.Totaal,
            Bouwjaar = a.Bouwjaar
        };

    if(a.Bouwjaar <  b.Bouwjaar)
    {
        resultaat.Bouwjaar = b.Bouwjaar;
    }
    return resultaat;
}

Zoals je ziet maken we een nieuw object resultaat waarin we de som van de twee meegegeven kassa's hun totalen plaatsen, alsook het bouwjaar van de oudste van de 2 kassa's.

Expression bodied members

Wanneer je methoden, constructors of properties schrijft waar exact 1 expressie (1 lijn code die een resultaat teruggeeft) nodig is dan kan je gebruik maken van de expression bodied member syntax (EBM). Deze is van de vorm:

member => expression

Dankzij EBM kan je veel kortere code schrijven.

We tonen telkens een voorbeeld hoe deze origineel is en hoe deze naar EBM syntax kan omgezet worden.

Methoden en EBM

Origineel:

public void ToonGeboortejaar(int geboortejaarIn)
{
    Console.WriteLine(geboortejaarIn);
}

Met EBM:

public void ToonGeboortejaar(int geboortejaarIn)
                     => Console.WriteLine(geboortejaarIn);

Nog een voorbeeld, nu met een return. Merk op dat we return niet moeten schrijven:

public int GeefGewicht()
{
    return 4 * 34;
}

Met EBM:

public int GeefGewicht() => 4 * 34;

Constructors en EBM

Ook constructors die maar 1 expressie bevatten kunnen korter nu. Origineel:

class Student
{
    public int Geboortejaar {get;set;}
    public Student(int geboorteJaarIn)
    {
        Geboortejaar = geboorteJaarIn;
    }
}

Met EBM wordt dit:

class Student
{
    public int Geboortejaar {get;set;}
    public Student(int geboorteJaarIn) => Geboortejaar = geboorteJaarIn;
}

Full Properties met EBM

Properties worden een soort mengeling tussen full en auto-properties:

private int name;
public int Name
{
    get => name;
    set => name = value;
}

Read-only properties met EBM

Bij read-only properties hoeft het get keyword zelfs niet meer getypt te worden bij EBM:

private int name;
public int Name => name;

Uiteraard had voorgaande zelfs nog korter geweest met behulp van een auto-property.

Generics

Generieke methoden

Vaak schrijf je methoden die hetzelfde doen, maar waarvan enkel het type van de parameters en/of het returntype verschilt. Stel dat je een methode hebt die de elementen in een array onder elkaar toont. Je wil dit werkende hebben voor arays van het type int, string, enz. Zonder generics moeten we dan per type een methode moeten schrijven:

public static void ToonArray(int[] array)
{
    foreach (var i in array)
    {
        Console.WriteLine(i);
    }
}
 
public static void ToonArray(string[] array)
{
    foreach (var i in array)
    {
        Console.WriteLine(i);
    }
}

Dankzij generics kunnen we nu het deel dat generiek moet zijn aanduiden (in dit geval met T) en onze methode eenmalig definiëren. We gebruiken hierbij de < > aanduiding die aan de compiler vertelt "dit stuk is een generiek type":

public static void ToonArray<T>(T[] array)
{
    foreach (T item in array)
    {
        Console.WriteLine(item);
    }
}

Vanaf nu kun je eender welk soort array aan deze ene methode geven en de array zal naar het scherm afgedrukt worden:

int[] getallen= {1,2,4};
string[] namen = {"tim", "ali", "marie", "fons"};
ToonArray(getallen);
ToonArray(namen);

Generic types

We kunnen niet alleen generieke methoden schrijven, maar ook eigen klassen én interfaces definiëren die generiek zijn. In het volgende codevoorbeeld is te zien hoe een eigen generic class in C# gedefinieerd en gebruikt kan worden. Merk het gebruik van de aanduiding T, deze geeft weer aan dat hier een type (zoals int, double, Student, enz.) zal worden ingevuld tijdens het compileren.

<T>

De typeparameter T wordt pas voor de specifieke instantie van de generieke klasse of type ingevuld bij het compileren. Hierdoor kan de compiler per instantie controleren of alle parameters en variabelen die in samenhang met het generieke type gebruikt worden wel kloppen.

De afspraak is om .NET een T te gebruiken indien het type nog dient bepaald te worden (dit is niet verplicht maar wordt aanbevolen als je maar 1 generieke type nodig hebt).

We wensen een klasse te maken die de locatie in X,Y,Z richting kan bewaren. We willen echter zowel float, double als int gebruiken om deze X,Y,Z coördinaten in bij te houden:

class Locatie<T>
{
    public T X {get;set;}
    public T Y {get;set;}
    public T Z {get;set;}
}

We kunnen deze klasse nu als volgt gebruiken:

var plaats = new Locatie<int>();
plaats.X = 34;
plaats.Y = 22;
plaats.Z = 56;

var plaats2 = new Locatie<double>();
plaats2.X = 34.5;
plaats2.Y = 22.2;
plaats2.Z = 56.7;

var plaats3 = new Locatie<string>();
plaats3.X = "naast de kerk";
plaats3.Y = "links van de bakker";
plaats3.Z = "onder het hotel";

Merk op dat het keyword var hier handig is: het verkort de ellenlange stukken code waarin we toch maar gewoon het datatype herhalen dat ook al rechts van de toekenningsoperator staat.

Een complexere generieke klasse

Voorgaand voorbeeld is natuurlijk maar de tip van de ijsberg. We kunnen bijvoorbeeld volgende klasse maken die we kunnen gebruiken met eender welk type om de meetwaarde van een meting in op te slaan. Merk op hoe we op verschillende plaatsen in de klasse het element T gebruiken als een datatype:

public class Meting<T>
{
    public T Waarde {get;set;}
    public Meting(T waardein)
    {
        Waarde = waardein;
    }
}

Een voorbeeldgebruik van dit nieuwe type kan zijn:

var m1 = new Meting<int>(44);
Console.WriteLine(m1.Waarde);
var m2 = new Meting<string>("slechte meting");
Console.WriteLine(m2.Waarde);

Meerdere types in generics

Zoals reeds eerder vermeld is de T aanduiding enkel maar een afspraak. Je kan echter zoveel T-parameters meegeven als je wenst. Stel dat je bijvoorbeeld een klasse wenst te maken waarbij 2 verschillende types kunnen gebruikt worden. De klassedefinitie zou er dan als volgt uit zien:

class DataBewaarder<Type1, Type2>
{
    public Type1 Waarde1 {get;set;}
    public Type2 Waarde2 {get;set;}
    public DataBewaarder(Type1 w1, Type2 w2)
    {
        Waarde1 = w1;
        Waarde2 = w2;
    }
}

Een object aanmaken zal nu als volgt gaan:

DataBewaarder<int, string> d1 = new DataBewaarder<int, string>(4, "Ok");

Constraints

We willen soms voorkomen dat bepaalde types wel of niet gebruikt kunnen worden in je zelfgemaakte generieke klasse. Stel bijvoorbeeld dat je een klasse schrijft waarbij je de CompareTo() methode wenst te gebruiken. Dit gaat enkel indien het type in kwestie de IComparable interface implementeert. We kunnen als constraint (beperking) dan opgeven dat de volgende klasse enkel kan gebruikt worden door klassen die ook effectief die interface implementeren (en dus de CompareTo()-methoden hebben). We doen dit in de klasse-definitie met het nieuwe where keyword. We zeggen dus letterlijk: "waar T overerft van IComparable":

public class Wijziging<T> where T : IComparable
{
    public T VorigeWaarde {get;set;}
    public T Huidigewaarde {get;set;}
    public Wijziging(T vorig, T huidig)
    {
        VorigeWaarde = vorig;
        Huidigewaarde = huidig;
    }
 
    public bool IsGestegen()
    {
        return Huidigewaarde.CompareTo(VorigeWaarde) > 0;
    }
}

Volgende gebruik van deze klasse zou dan True op het scherm tonen:

Wijziging<double> w = new Wijziging<double>(3.4, 3.65);
Console.WriteLine(w.IsGestegen());

Mogelijke constraints

Verschillende zaken kunnen als constraint optreden. Naast de verplichting dat een bepaalde interface moet worden geïmplementeerd kunnen ook volgende constraints gelden (bekijk de online documentatie voor meer informatie hierover):

  • Enkel value types.
  • Enkel klassen.
  • Moet default constructor hebben.
  • Moet overerven van een bepaalde klasse.

Records & structs

Records

Sinds C# 9.0 is het ook mogelijk om zogenaamde record-klassen te maken. Erg vaak schrijf je klassen die niet meer moeten doen dan wat data eenmalig wegschrijven en onthouden, dat je dan vervolgens via readonly getters kunt uitlezen, zoals:

public class Student
{
    public Student(string naam, int geboorteJaarIn, bool isIngeschreven)
    {
        Naam = naam;
        Geboortejaar = geboorteJaarIn;
        IsIngeschreven = isIngeschreven;
    }

    public string Naam {get;}
    public int Geboortejaar {get;}
    public bool IsIngeschreven {get;}
}

Wanneer je een dergelijke klasse nodig hebt kan dit sinds C# 9.0 vereenvoudigd geschreven worden als een record:

public record Student
{
    public string Naam { get; init; }
    public int Geboortejaar { get; init; }
    public bool IsIngeschreven { get; init; }
}

Het init keyword geeft aan dat deze auto-property eenmalig kunnen geset worden bij het aanmaken van het record via de object initializer syntax:

Student eenNieuweStudent = new Student 
            {   Naam = "Tim", 
                Geboortejaar = 1981,
                IsIngeschreven = false
            };

Er zijn nog tal van extra's die je krijgt met records (o.a. eenvoudig objecten vergelijken) maar die gaan we niet bespreken.