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
- Exercism
- Code Wars
- Coding game zeer vet
- Code Combat
- Pex For Fun (specifiek voor C#!)
- Code Academy
- RPG Game in C# (behandelt leerstof van volledig eerste jaar en meer)
- Advent of code Pittige programmeeroefeningen die jaarlijks in december verschijnen.
- Free Programming Book Handig vorm gegeven gratis ebooks met tal van onderwerpen waaronder ook C# en het .NET Framework.
- Tutorials teacher: De uitgebreidere, Engelstalige variant van dit boek zeg maar.
Tutorials
- Freecodecamp.org C# tutorial Zeer erge aanrader!
- Microsoft C# tutorial Zeer interactief én in het Nederlands. Aanrader.
- Johnny does Dotnet
- Dotnet beginning
- C# Getting started interactive quickstart tutorials: Aanrader.
- Online video c# cursus: Zeer aan te raden indien je een bepaald concept uit de les niet begrijpt.
- C-sharp.be : Nederlandstalige cursus met veel toffe oefeningen waarvan je sommige zelfs in dit boek zal terugvinden.
- Microsoft Virtual Academy: Microsoft heeft een virtual academy cursus "C# fundamentals" uitgebracht. Ik kan dit boek zeer erg aanbevelen.
- Rob Miles's The C# Programming Yellow book: Zeer vermakelijk, vlot geschreven C# boek(je)
- Open Source Game Clones: "This site tries to gather open-source remakes of great old games in one place." Je vindt er ook tal van C# projecten terug zoals GTA 2.Klik bovenaan op "languages" en filter maar eens op C#.
- 2,000 Things You Should Know About C#: De auteur is helaas aan "maar" 1219 tips geraakt. De moeite.
Oefenvragen
- Een lijst met oude oefenvragen uit 2010: nog steeds relevant.
- Veel kleine, fijne oefeningen
- Een dagelijkse programmeeruitdaging op reddit
- Pittige vragen van de jaarlijkse Vlaamse Programmeerwedstrijd:
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):
- Handmade Hero: deze programmeur heeft een volledige RPG gemaakt en het hele proces gestreamd.
- CSharpFrits
- DevChatter
- Visual Studio Twitch
- NoopKat
- The Coding train
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?".
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.
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.
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 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".
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.
Een nieuw project aanmaken
We zullen nu een nieuw project aanmaken, kies hiervoor "Create a new project".
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.
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.
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.
- 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:
Als alles goed gaat krijg je nu "Hello World!" te zien en wat extra informatie omtrent het programma dat net werd uitgevoerd:
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.
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 genaamdMain
. Je programma kan meerdere methoden (of functies) bevatten, maar enkel degene genaamdMain
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 deWriteLine
-methode aanroept van deConsole
-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.
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.
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:
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.
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 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):
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:
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 letterr
totaal verschillende zaken zijn voor C#.Reinhardt
enreinhardt
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:
abstract | as | base | bool |
break | byte | case | catch |
char | checked | class | const |
continue | decimal | default | delegate |
do | double | else | enum |
event | explicit | extern | false |
finally | fixed | float | for |
foreach | goto | if | implicit |
in | int | interface | internal |
is | lock | long | namespace |
new | null | object | operator |
out | override | params | private |
protected | public | readonly | ref |
return | sbyte | sealed | short |
sizeof | stackalloc | static | string |
struct | switch | this | throw |
true | try | typeof | uint |
ulong | unchecked | unsafe | ushort |
using | using static | virtual | void |
volatile | while |
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
enTim
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
enstRINg
mogen dus wel, maar nietgoto
ofstring
daar beide een gereserveerd keyword zijn maar dankzij de hoofdlettergevoelig-regel is dit dus toegelaten. Een ander voorbeeldINT
mag bijvoorbeeld wel, maarint
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 met9
)
- 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:
Enkele voorbeelden
Enkele voorbeelden van toegelaten en niet toegelaten identifiers:
identifier | toegelaten? | uitleg indien niet toegelaten |
---|---|---|
werknemer | ja | |
kerst2018 | ja | |
pippo de clown | neen | geen spaties toegestaan |
4dPlaats | neen | mag niet starten met een cijfer |
_ILOVE2022 | ja | |
Tor+Bjorn | neen | enkel cijfers, letters en liggende streepjes toegestaan |
ALLCAPSMAN | ja | |
B_A_L | ja | |
class | neen | gereserveerd keyword |
WriteLine | ja | |
______ | 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
ofleeftijd
in plaats vana
ofmeuh
. - 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
ofaantalLeerlingenKlas1EA
. 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:
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
enchar
. - 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:
Type | Geheugen | Bereik (waardenverzameling) |
---|---|---|
sbyte | 8 bits | -128 tot 127 |
byte | 8 bits | 0 tot 255 |
short | 16 bits | -32 768 tot 32 767 |
ushort | 16 bits | 0 tot 65535 |
int | 32 bits | -2 147 483 648 tot 2 147 483 647 |
uint | 32 bits | 0 tot 4 294 967 295 |
long | 64 bits | -9 223 372 036 854 775 808 tot 9 223 372 036 854 775 807 |
ulong | 64 bits | 0 tot 18 446 744 073 709 551 615 |
char | 16 bits | 0 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
vooraansbyte
staat voorsigned
: m.a.w. 1 bit wordt gebruikt om het + of - teken te bewaren. - De
u
vooraanushort
,uint
enulong
staat voorunsigned
. 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 staatchar
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:
Type | Geheugen | Bereik | Precisie |
---|---|---|---|
float | 32 bits | gemiddeld | ~6-9 digits |
double | 64 bits | meeste | ~15-17 digits |
decimal | 128 bits | minste | 28-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:
- Het datatype (bv
int
,double
). - Een identifier zodat de variabele uniek kan geïdentificeerd worden volgens de naamgevingsregel van C#.
- (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:
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 alsdouble
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
ofu
vooruint
, vb:125U
(dus bijvoorbeelduint aantalSchapen = 27u;
)L
ofl
voorlong
, vb:125L
.UL
oful
voorulong
, vb:125ul
.F
off
voorfloat
, vb:12.5f
.M
ofm
voordecimal
, 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
bijint
,0.0
bijdouble
enzovoort). - Bij variabelen van het type
bool
is datfalse
. - 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 ookString.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:
- Haakjes
- Vermenigvuldigen, delen en modulo:
*
(vermenigvuldigen),/
(delen) en%
(rest na deling, ook modulo genoemd) - 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 notatie | Lange notatie | Beschrijving |
---|---|---|
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 eenint
delen en dus terug eenint
als resultaat krijgen. - Vervolgens zullen we deze
0
vermenigvuldigen met10000.0
waarvan ik zo slim was om deze indouble
te zetten. Niet dus. We vermenigvuldigen weliswaar eendouble
(het salaris) met eenint
maar dieint
is reeds0
en we krijgen dus0.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:
- Jij (of mede-ontwikkelaars) zullen niet per ongeluk deze variabele aanpassen waardoor andere stukken code plots vreemde bugs lijken te krijgen.
- 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:
- Een solution is een folder waarbinnen één of meerdere projecten bestaan.
- 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:
- Naam van het project = Opdracht1
- 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.
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).
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 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!
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
enstring 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
wordt00123
) (werkt uiteraard enkel op gehele getallen!) - E2: wetenschappelijke notatie met 2 cijfers precisie (
12000000
wordt1,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 oftewel65
. In het geheugen staat dus het geheel getal65
. B
wordt voorgesteld door66
.- Als we dus de variabelen
letter1
enletter2
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.
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:
- Zoek het teken(s) dat je nodig hebt in een UNICODE-tabel, bijvoorbeeld op UNICODE-table.com.
- Plaats bovenaan je Main:
Console.OutputEncoding = System.Text.Encoding.UTF8;
- 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;
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:
- Eerst tellen we twee integers op, wat dus een nieuwe integer geeft, die we vervolgens delen door een
double
, wat dus eendouble
als resultaat geeft. - Eerst tellen we twee integers op, wat weer een integer geeft, vervolgens zetten we dit resultaat om naar een
double
en delen dit door eenint
wat dus eendouble
geeft. - Eerst zetten
tempGisteren
om naar eendouble
. Vervolgens tellen we eendouble
met eenint
op, wat een double als tussenresultaat geeft. Dit delen we dan door eenint
wat eendouble
finaal geeft. - Hetzelfde als de vorige stap, maar nu zetten we eerst
tempVandaag
om naar eendouble
.
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:
- een
string
hebt waarvan je weet dat deze altijd van een specifieke vorm zal zijn die omgezet kan worden naar een ander datatype, bv. eenint
, dan kan jeInt32.Parse()
gebruiken. - input van de gebruiker vraagt (bv. via
Console.ReadLine
) en niet 100% zeker bent dat deze een getal zal bevatten, gebruik danInt32.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:
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:
- Input uitlezen met
Console.ReadLine()
. - Input bewaren in een
string
variabele. - 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:
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:
- Schrijf de Methode zonder parameters. Bijvoorbeeld
Math.Pow()
(je mag de error negeren). - Plaats je cursor op
Pow
. - Druk op
F1
op je toetsenbord. - Je krijgt nu de help-files te zien van deze methode.
- 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:
- Maak eenmalig een Random-generator object aan.
- 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);
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":
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:
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:
Door je code steppen
Wanneer je gepauzeerd bent kan je de nieuw verschenen debug-knoppen bovenaan VS gebruiken om het verdere verloop te bepalen:
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:
Operator | Betekenis |
---|---|
> | 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 enkeltrue
als beide operandentrue
zijn||
(Of) : Geefttrue
indien minstens 1 operandtrue
is!
(Niet) : Inverteert de waarde van de expressie (true
wordtfalse
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.
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");
}
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}!");
}
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:
- Met een
int
die een getal van 1 tot en met 7 kan bevatten, afhankelijk van de dag (bv. 1 voor maandag, enz.) - 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? Was2
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 :
- Leesbaardere code.
- Minder foutgevoelige code, en dus minder potentiële bugs.
- 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:
- Het nieuwe datatype en de mogelijke waarden definiëren.
- 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 genaamdswitch_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:
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
:
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
).
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.
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:
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();
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:
- Wanneer een parameter by value wordt meegegeven aan een methode, dan wordt een kopie gemaakt van de huidige waarde die wordt meegegeven.
- 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)}" );
}
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();
}
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).
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);
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:
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)
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.
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:
- 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.
- 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.
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.
Parametertype | Voorkeur van meeste voorkeur naar minste |
---|---|
byte | short, ushort, int, uint, long, ulong, float, double, decimal |
sbyte | short, int long, float, double, decimal |
short | int, long, float, double, decimal |
ushort | int, uint, long, ulong, float, double, decimal |
int | long, float, double, decimal |
uint | long, ulong, float, double, decimal |
long | float, double, decimal |
ulong | float, double, decimal |
float | double |
char | ushort, 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:
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.
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};
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.
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:
- Maak een nieuw console-project aan genaamd
argstest
. - Voeg volgende code toe in je
Main
:
for (int i = 0; i < args.Length; i++)
{
Console.WriteLine(args[i]);
}
- 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!
- 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).
- Open de
bin
folder, en open daarin dan dedebug
folder, gevolgd door denet6.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. - 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. - Cool he. Je zit nu in een shell in de juiste folder.
- 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 getal5
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.
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:
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:
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:
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:
- de lezer van de code sneller kan zien wat het programma juist doet
- 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 }
};
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:
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:
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".
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.
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
enTekenOpScherm
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..." .
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
enbool
. - 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 eenint
. - 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 metnew
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 dePraat
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 privateget
, zal je zelden tegenkomen). - Read-only property die data transformeert: om interne data in een andere vorm uit je object te krijgen.
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 ResetLord
schrijven 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
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:
- Door aan de klasse de huidige datum en tijd te vragen via
DateTime.Now
. - 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:
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:
- 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.
- Vervolgens namen we de mammoet bij de horens en bekeken we de theorie van OO, die ons vooral verwarde.
- Gelukkig gingen we dan ogenblikkelijk naar de praktijk over en zagen we dat methoden en properties de kern van iedere klasse blijkt te zijn.
- 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 voorenum
types. - Reference types: deze variabelen bevatten als inhoud een geheugenadres naar een andere plek in het geheugen waar de effectieve waarde van deze variabele stond. We zagen dat dit voorlopig enkel bij arrays gebeurde.
Ook objecten zijn reference types. 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:
- Het kleine, maar snelle stack geheugen.
- Het grote, maar tragere heap geheugen.
Afhankelijk van het soort variabele wordt ofwel de stack, ofwel de heap gebruikt. Het is uitermate belangrijk dat je weet in welk geheugen de variabele zal bewaard worden! Je hebt hier geen controle over, maar het beïnvloedt wel de manier waarop je code zal werken.
Volgende tabel vat samen welke type in welk geheugen wordt bewaard:
Value types | Reference types | |
---|---|---|
Inhoud van de variabele | Eigenlijke data | Referentie naar de eigenlijke data |
Locatie | Stack | Heap |
Beginwaarde | 0 ,0.0 , "" ,false , enz. | null |
Effect = operator | Kopieert actuele waarde | Kopieert adres naar actuele waarde |
Waarom twee geheugens?
Waarom plaatsen we niet alles in de stack? De reden hiervoor is dat bij het compileren van je applicatie er reeds zal berekend worden hoeveel geheugen de stack zal nodig hebben. Wanneer je programma dus later wordt uitgevoerd weet het OS perfect hoeveel geheugen het minstens moet reserveren bij het besturingssysteem.
Er is echter een probleem: de compiler kan niet alles perfect berekenen of voorspellen. Van een variabele van het type int
is perfect geweten hoe groot die zal zijn (32 bit). Maar wat met een string
die je aan de gebruiker vraagt? Of wat met een array waarvan we pas tijdens de uitvoer de lengte gaan berekenen gebaseerd op runtime informatie?
Het zou nutteloos (en zonde) zijn om reeds bij aanvang een bepaalde hoeveelheid stackgeheugen voor een array te reserveren als we niet weten hoe groot die zal worden. Beeld je in dat alle applicaties op je computer voor alle zekerheid een halve gigabyte aan geheugen zouden vragen. Je computer zou enkele terabyte aan geheugen nodig hebben. Het is dus veel realistischer om enkel het geheugen te reserveren waar de compiler 100% zeker van is dat deze zal nodig zijn.
De heap laat ons toe om geheugen op een wat minder gestructureerde manier in te palmen. Tijdens de uitvoer van het programma 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?
new Student()
:new
roept de constructor vanStudent
aan. Deze zal met behulp van een constructor een object in de heap aanmaken en vervolgens de geheugenlocatie ervan teruggeven.- Een variabele
stud
wordt in de stack aangemaakt en mag enkel een referentie naar een object van het typeStudent
bewaren. - De geheugenlocatie uit de eerste stap wordt vervolgens in
stud
opgeslagen in de stack.
Laten we eens inzoomen op voorgaande door de code even in 2 delen op te splitsen:
Student stud;
stud = new Student();
Het geheugen na lijn 1 ziet er zo uit:
Merk op dat de variabele stud
eigenlijk de waarde null
heeft. We leggen later uit wat dit juist wil zeggen.
Lijn 2 gaan we nog trager bekijken: Eerst zal het gedeelte rechts van de =
-operator uitgevoerd worden. Er wordt dus in de heap een nieuw Student
-object aangemaakt:
Vervolgens wordt de toekenning toegepast en wordt het geheugenadres van het object in de variabele stud
geplaatst:
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
Hetzelfde gedrag zien we bij objecten:
Student a = new Student("Abba");
Student b = new Student("Queen");
Geeft volgende situatie in het geheugen:
Schrijven we dan het volgende:
b = a;
Console.WriteLine(a.Naam);
Dan zullen we in dit geval dus Abba
op het scherm zien omdat zowel b
als a
naar hetzelfde object in de heap verwijzen. Het originele "Queen"-object zijn we kwijt en zal verdwijnen (zie Garbage collector verderop).
De meeste klassen zullen met value type-properties en instantievariabelen werken in zich, toch worden deze ook samen met het gehele object in de heap bewaard en niet in de stack. Kortom het hele object ongeacht de vorm (datatypes) van z'n inhoud wordt in de heap bewaard.
De Garbage Collector
Een stille held van .NET is de zogenaamde GC, de Garbage Collector. Dit is een geautomatiseerd onderdeel van ieder C# programma dat ervoor zorgt dat we geen geheugen nodeloos gereserveerd houden. De GC zal geregeld het geheugen doorlopen en kijken of er in de heap objecten staat waar geen referenties naar verwijzen. Indien er geen referenties naar wijzen zal dit object verwijderd worden.
In dit voorbeeld zien we dit in actie:
Held supermand = new Held();
Held batmand = new Held();
batmand = supermand;
Vanaf de laatste lijn zal er geen referentie meer naar het originele object zijn waar batmand
naar verwees in de heap, daar we deze hebben overschreven met een referentie naar het eerste Held
object in supermand
. De GC zal dus dat tweede aangemaakte Held
object verwijderen. Wil je dat niet dan zal je minstens 1 variabele moeten hebben die naar de data verwijst. Volgend voorbeeld toont dit:
Held supermand = new Held();
Held batmand = new Held();
Held bewaarEersteHeld = batmand;
batmand = supermand;
De variabele bewaarEersteHeld
houdt dus een referentie naar die in batmand
bij en we kunnen dus later via deze variabele alsnog aan de originele data.
De GC werkt niet continue daar dit te veel overhead van je computer zou vereisen. De GC zal gewoon om de zoveel tijd alle gereserveerde geheugenplekken van de applicatie controleren en die delen verwijderen die niet meer nodig zijn.
Je kan de GC manueel de opdracht geven om een opkuisbeurt te starten met GC.Collect()
maar dit is ten stelligste af te raden! De GC weet meestal beter dan ons wanneer er gekuist moet worden.
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:
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).
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.
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:
Klasse | Omschrijving |
---|---|
Exception | Basisklasse |
SystemException | Klasse voor uitzonderingen die niet al te belangrijk zijn en die mogelijk verholpen kunnen worden. |
IndexOutOfRangeException | De index is te groot of te klein voor de benadering van een array |
NullReferenceException | Benadering 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.
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:
Element | Omschrijving |
---|---|
Message | Foutmelding in relatief eenvoudige taal. |
StackTrace | Lijst van methoden die de exception hebben doorgegeven. |
TargetSite | Methode 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 denew
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:
- De overloaded constructor
Microfoon(bool isUitverkochtIn)
wordt aangeroepen. - Ogenblikkelijk wordt de meegegeven actuele parameter
isUitverkochtIn
doorgegeven om de overloaded constructorMicrofoon(string merkIn, bool isUitverkochtIn)
te benaderen. - Deze constructor zal het
Merk
opBovarc
zetten enIsUitverkocht
optrue
(daar we die parameter doorgeven). - We keren nu terug naar de contructor
Microfoon(bool isUitverkochtIn)
en voeren de code hiervan uit. Bijgevolg wordt de waarde inMerk
overschreven metWit 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:
- 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.
- Bij methoden om zogenaamde methoden-bibliotheken of hulpmethoden aan te maken (denk maar aan
Math.Pow()
enDateTime.IsLeap()
) en spreken dan over een static method. - Bij de klasse zelf om te voorkomen dat er objecten van de klasse aangemaakt kunnen worden (bijvoorbeeld de
Console
enMath
klasse). Je raadt het nooit, maar dit noemt dan een static class. De klasse is dan een uniek object. - Bij properties. We hebben al met 1 static property gewerkt namelijk de readonly property
Now
van deDateTime
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:
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:
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
.
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();
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:
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 klassiekewhile
,do while
offor
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 eenwhile
endo 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.
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.
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");
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:
En als we het voorbeeld van de mens en z'n voorgangers nemen dan zou een vereenvoudigd UML-schema er als volgt uitzien:
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:
Vervolgens maken we van iedere klasse 1 object aan. De objecten in het geheugen (de heap) zullen er dan als volgt uitzien:
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
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:
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);
Start: overloaded constructor van Huis
wordt opgeroepen.
- Nog voor dat deze echter iets kan doen, wordt de formele parameter
verdiepingenIn
(die de waarde5
heeft gekregen) doorgegeven als actuele parameter om de constructor van de basis-klasse aan te roepen. - De overloaded constructor van
Gebouw
wordt dus aangeroepen. - De code van deze constructor wordt uitgevoerd: het aantal verdiepingen van het gebouw/huis wordt ingesteld.
- Wanneer het einde van de constructor wordt bereikt, zal er teruggegaan worden naar de constructor van
Huis
. - Nu wordt de code van de
Huis
constructor uitgevoerd:HeeftTuintje
krijgt de waardetrue
.
Einde: Finaal keren we terug en staat er nu een gloednieuw object in de heap, wiens geheugenlocatie we kunnen toewijzen aan eenEigenHuis
.