In dit opleidingsonderdeel (SES) leer je de nodige skills om een softwareproject op de wereld te brengen volgens de regels van de kunst en met het aandacht voor de courante industriële praktijk.
We besteden aandacht aan software development tools en de moderne software development lifecycle, alsook programmeer- en ontwerpvaardigheden.
Het eerste deel van dit vak (software development tools and practices) wordt ook gevolgd door de studenten elektronica (EA) als deel van het vak Electronic Engineering Skills (EES).
Cursusmateriaal
We trachten al het cursusmateriaal voor deze cursus te bundelen op deze website.
Deze wordt doorheen het jaar aangevuld.
Voor sommige delen vind je ook nog slides op Toledo.
Deel 1: Software Development Tools and Practices (SES+EES)
In dit deel leer je werken met enkele moderne tools en onmisbare praktijken voor software-ontwikkeling:
Terminal en IDE (Linux/WSL, VS Code)
Versie- en issuebeheer (Git en GitHub)
Dependency Management en build tools (gradle, makefiles)
Test-Driven Development (TDD) en debugging
Continuous integration en continuous deployment (CI/CD)
Processen (SCRUM)
Deel 2: Programmer- en ontwerpvaardigheden (SES)
In dit deel scherpen we je programmeervaardigheden verder aan, verderbouwend op Software-ontwerp in Java.
Advanced Java
Hier behandelen we enkele geavanceerde (en recent toegevoegde) concepten uit Java.
Gelijkaardige concepten bestaan vaak ook in andere programmeertalen.
Records
Generics
Gegevensstructuren (collections)
Multithreading
Streams
Recursie en backtracking
Recursie en backtracking zijn krachtige tools om complexe problemen op te lossen.
We leren hoe na te denken over recursie, alsook templates voor typische backtracking-problemen.
Design Patterns
Design patterns zijn ontwerpoplossingen voor vaak terugkerende problemen.
Wanneer je zo’n probleem tegenkomt, biedt het patroon een goed startpunt voor je ontwerp.
Er bestaan erg veel patronen; we bekijken er enkele zeer gekende en vaak voorkomende.
Syllabus
Lesgevers:
Coördinerend Verantwoordelijke: Prof. dr. ir. Koen Yskout
Onderwijsassistent: ing. Arne Duyver
Voor EES: Prof. dr. ing. Kris Myny
Kantoor: Technologiecentrum Diepenbeek, Groep ACRO.
Het materiaal op deze website bouwt voort op materiaal van Dr. ing. Wouter Groeneveld en Prof. dr. Kris Aerts.
Vereiste voorkennis
Het vak ‘Software Ontwerp in Java’ (INF1) dient eerst gevolgd te worden. We gaan uit van een basiskennis Java en object-georiënteerd programmeren. Een snelle opfrissing van Java kan je vinden in de bijlagen.
Beoordeling en evaluatie
Schriftelijke evaluatie tijdens onderwijsperiode: 50%.
Schriftelijk examen (gesloten boek): 50%.
Een uitgebreide beschrijving is beschikbaar in de studiegids.
Meer leermiddelen
Zie elke sectie ‘meer leermateriaal’ voor extra materiaal per thema.
Dit extra materiaal wordt aangeboden ter illustratie of verdieping voor de geïnteresseerde student, en is geen deel van de leerstof.
In deze cursus gebruiken we Windows Subsystem for Linux (WSL) als besturingssysteem en Visual Studio Code (VSCode) als code-editor. Dit zorgt ervoor dat iedereen met dezelfde tools werkt. Al het lesmateriaal en alle opdrachten zijn gebaseerd op deze omgeving.
Subsections of 1. Windows Subsystem for Linux en VSCode
Windows Subsystem for Linux (WSL)
Besturingssystemen: introductie
Voor we met de echte materie kunnen starten moeten we eerst wat meer kennis vergaren over hoe computers juist werken. Je zal dit nog met meer diepgang bespreken in het vak ‘Besturingssystemen en C’, maar we geven toch al een korte intro zodat je weet waar we mee bezig zijn.
Wat is een besturingssysteem? (Besturingssysteem = Operating System = OS)
Een besturingssysteem (OS) is een essentieel onderdeel van de computerarchitectuur dat fungeert als een brug tussen de hardware van een computer en de gebruiker. Het beheert de hardwarebronnen van de computer en biedt een omgeving waarin applicaties kunnen draaien. Zonder een besturingssysteem zou een computer niet functioneel zijn voor de meeste gebruikers.
Het besturingssysteem voert verschillende cruciale taken uit:
een platform voorzien waar software/applicaties op kunnen draaien;
een gebruikersinterface voorzien;
beheren van processen;
input en output apparaten beheren;
applicaties beheren;
veiligheid beheren.
Dit betekent dat het OS bijvoorbeeld bepaalt welke processen toegang hebben tot welke bronnen en wanneer. Dit beheer is essentieel om ervoor te zorgen dat de computer efficiënt en effectief werkt. We gaan vooral even dieper in hoe het OS applicaties beheert en kan starten/stoppen.
Daarnaast biedt het besturingssysteem een gebruikersinterface, die kan variëren van een command-line interface (CLI) tot een grafische gebruikersinterface (GUI). Deze interface stelt gebruikers in staat om met de computer te interageren. Je bent waarschijnlijk al zeer bekend met de GUI. Tijdens de lessen SES gaan we ons echter focussen op het interageren met de computer via de CLI.
Nog ander belangrijk aspect van een besturingssysteem is het beheer van bestanden. Het OS organiseert en bewaart bestanden op een manier die gemakkelijk toegankelijk en veilig is. Dit omvat het beheren van lees- en schrijfrechten, het organiseren van bestanden in mappen en het zorgen voor gegevensintegriteit. Dit komt later nog aan bod wanneer we het hebben over het beheer van applicaties en het commando chmod.
Zoals hierboven al vermeld, biedt het besturingssysteem een platform voor het uitvoeren van applicaties. Het zorgt ervoor dat applicaties de benodigde bronnen krijgen en dat ze geïsoleerd zijn van elkaar om conflicten en beveiligingsproblemen te voorkomen. Dit maakt het mogelijk om meerdere applicaties tegelijkertijd te draaien zonder dat ze elkaar storen.
In samenvatting, een besturingssysteem is een complex maar essentieel onderdeel dat de functionaliteit en bruikbaarheid van computers mogelijk maakt.
Voorbeelden van besturingssystemen:
Windows
Linux
Mac OS
FreeRTOS
Van de voorbeelden hierboven kunnen Linux en Mac OS nog gegroepeerd worden tot de zogenaamde UNIX-besturingssystemen omdat ze beide gebaseerd zijn op de principes en architectuur van het oorspronkelijke UNIX-systeem. De CLI werkt bij zowel Linux als Mac OS gelijkaardig, en dit is grotendeels te danken aan hun gemeenschappelijke UNIX-basis.
De CLI is een tekstgebaseerde interface waarmee gebruikers commando’s kunnen invoeren om taken uit te voeren op een computer. In tegenstelling tot GUI’s, waarbij gebruikers met muis en vensters werken, vereist de CLI dat gebruikers tekstcommando’s typen.
Hoewel GUI’s tegenwoordig veel gebruiksvriendelijker zijn voor de meeste gebruikers, blijft de CLI een krachtig hulpmiddel voor specifieke taken zoals taken die efficiëntie, automatisering en flexibiliteit vereisen.
Info
Daarenboven bestonden in de beginjaren van de computertechnologie nog geen GUI’s. Computers werden voornamelijk bediend via de CLI. Dit was de standaard manier om met computers te communiceren, omdat de hardware en software van die tijd niet krachtig genoeg waren om grafische interfaces te ondersteunen. Dus zeker in low-end devices blijft de CLI een essentiële tool.
Volgende termen houden verband met de CLI en worden soms (incorrect) door elkaar gebruikt:
Terminal: Een terminal is een apparaat of softwaretoepassing die communicatie tussen de gebruiker en het besturingssysteem mogelijk maakt via de CLI. In de context van moderne computers is een terminal meestal een softwaretoepassing die een CLI-omgeving biedt.
Terminal Emulator: Een terminal emulator is een softwaretoepassing die de functionaliteit van een traditionele hardwareterminal nabootst. Het stelt gebruikers in staat om een terminalsessie te openen binnen een grafische omgeving en zorgt voor een omgeving waarin een shell kan draaien. Voorbeelden van terminal emulators zijn GNOME Terminal, Konsole en Terminator.
Shell: Een shell is een programma dat de CLI biedt en de interpretatie van de ingevoerde commando’s verzorgt. Het fungeert als een interface tussen de gebruiker en het besturingssysteem. De shell ontvangt commando’s van de gebruiker, voert ze uit en geeft de output terug.
Bash: Bash (Bourne Again Shell) is een veelgebruikte UNIX-shell die vaak standaard wordt geleverd met Linux-distributies. Bash biedt krachtige scriptingmogelijkheden en een breed scala aan ingebouwde commando’s, waardoor het een populaire keuze is voor zowel systeembeheer als ontwikkeling.
Zsh: Zsh (Z Shell) is een andere UNIX-shell die bekend staat om zijn uitgebreide functies en configuratiemogelijkheden. Het biedt verbeterde autocompletion, betere scriptingmogelijkheden en een meer aanpasbare omgeving in vergelijking met Bash. Veel gebruikers kiezen voor Zsh vanwege de extra functionaliteit en flexibiliteit.
PowerShell: PowerShell is een shell ontwikkeld door Microsoft voor Windows. Het combineert de traditionele CLI met een krachtige scriptingtaal gebaseerd op .NET. PowerShell is ontworpen voor systeembeheer en automatisering, en biedt uitgebreide mogelijkheden voor het beheren van Windows-systemen.
De verschillende shells gebruiken vaak ook verschillende commando’s voor een gelijkaardige functionaliteit. Zo kan je met het commando pwd de inhoud van een folder/directory weergeven in Bash, terwijl in vroegere versies van PowerShell het commando dir voor gebruikt werd.
Verschillende terminal emulators hebben bijvoorbeeld verschillende manieren om tekst te copy en pasten in de terminal.
Software programma’s / applicaties
Enkele belangrijke termen:
Hardware: Hardware is de fysieke machine.
Software: Software is een programma dat op hardware draait.
Programma: Een programma is een reeks instructies (in alle vormen en maten). Een software programma is dus een reeks computer instructies.
Proces: Een proces is een programma dat in het geheugen is geladen van het computersysteem en wordt beheert door het besturingssysteem.
Applicatie: Een programma dat is ontworpen voor de eindgebruiker voor een specifiek doel. Sommige programma’s zijn algemeen van aard, zoals een besturingssysteem, anderen hebben een specifiek doel zoals tekstverwerking (Word) of draaien niet voor de eindgebruiker maar op de achtergrond (zoals een applicatie dat de hardware monitort).
In het dagelijkse leven is een gebruiker dus vooral aan het interageren met verschillende applicaties op de computer. Je kan applicaties installeren, updaten of deïnstalleren.
Als ingenieur of software ontwikkelaar zal je zelf programma’s schrijven of zelfs volledige applicaties en kan het soms nodig zijn om dieper in te gaan in de processen die bezig zijn op je computer.
Hoe een applicatie installeren?
Ten eerste zal je voor de meeste applicaties administrator rechten nodig hebben om een applicatie te installeren. Via een GUI krijg je dan vaak een pop-up, met behulp van de CLI kan je in Windows bijvoorbeeld simpelweg PowerShell met administrator rechten starten. In Linux kan je een commando uitvoeren als “super user” met het commando sudo wat staat voor “super user do”.
1. Executables downloaden
De meeste onder jullie hebben ooit al wel een applicatie gedownload en geïnstalleerd op je computer. Hiervoor heb je waarschijnlijk een .exe file gedownload van het internet op Windows. Dubbelklikken op die file start een process dat alle nodige bestanden installeert met het belangrijkste bestand dat op zichzelf weer een .exe file is. EXE staat dan ook voor “executable” of “iets dat uitgevoerd kan worden”. Door op die laatste file te klikken start je dan meestal je applicatie. Menu iconen en Desktop iconen zijn dan meestal gewoon een link naar die specifieke .exe file die op een speciale plaats op je computer is opgeslagen.
2. Een package manager gebruiken
In een CLI omgeving gaan we echter nergens op kunnen klikken. We gaan hier dan vaak gebruik maken van een package manager die in een online repository op zoek gaat naar de gewilde applicatie en hier dan automatisch de correcte bestanden van gaat downloaden op de juiste plaats. Een package manager is een softwaretool die helpt bij het installeren, updaten, configureren en verwijderen van softwarepakketten op een computer. Voordelen van een package manager zijn:
Efficiëntie: Bespaart tijd en moeite bij het installeren en beheren van software.
Betrouwbaarheid: Zorgt ervoor dat alle benodigde afhankelijkheden correct worden geïnstalleerd.
Beveiliging: Helpt bij het up-to-date houden van software, wat belangrijk is voor beveiligingspatches en bugfixes.
Gemak: Maakt het eenvoudig om software te vinden, installeren en verwijderen met eenvoudige commando’s. Je kan namelijk met één commando alle applicaties op je computer updaten naar de nieuwste versie.
Populaire package managers zijn:
Apt voor Ubuntu (Linux)
Homebrew voor Mac OS
Chocolatey voor Windows
3. Een fully contained package gebruiken
Er bestaan ook volledig voorverpakte software bestanden. In dat geval zit alles wat nodig is om de applicatie te runnen in één bestand. Dit worden ook wel mobiele applicaties genoemd omdat je ze makkelijk op een usb stick kan zetten en op verschillende computers kan gebruiken zonder dat een volledige installatie nodig is. Een voorbeeld hiervan is “Balena Etcher”. Snap packages en flatpacks zijn hiervan voorbeelden voor Linux.
4. Een applicatie bouwen vanuit de bronbestanden
Je kan ook de bronbestanden/broncode van sommige software downloaden en dan de applicatie zelf bouwen. Hiervoor heb je echter de juiste build tools voor nodig, de juiste dependecies, moet je de bestanden op de juiste plaats zetten … Dit kost veel moeite voor de gebruiker en leidt vaak tot errors waardoor we deze manier liefst niet gebruiken. Soms is er echter geen andere mogelijkheid.
Waar worden de nodige bestanden voor de applicatie bewaard?
Een applicatie bestaat vaak uit meerdere onderdelen/bestanden die tijdens het installeren op je computer gezet worden (behalve in fully contained packages, want daar zitten juist alle nodige bestanden in één bestand), maar waar worden deze dan gezet zodat je deze later kan gebruiken?
In Windows heb je waarschijnlijk al eens gekeken in C:\\Program Files, wat meestal de default locatie is voor applicaties. In deze directory wordt er per applicatie meestal een map aangemaakt waar de nodige bestanden inkomen. In Linux worden de meeste executables geplaatst in /usr/bin of /usr/local/bin. In Mac OS worden applicaties meestal geïnstalleerd in de Applications-map, die zich in de root van het bestandssysteem bevindt.
Hoe een applicatie starten?
Je kan natuurlijk gewoon dubbelklikken op het icoontje of de executable file zoals je waarschijnlijk altijd al gedaan hebt in een GUI. Dit kan wel weer niet in een CLI omgeving. Hiervoor gebruiken we de naam van de executable file. Als ik in mijn huidige directory een executable heb staan met de naam myprogram, dan kan ik deze applicatie/dit commando simpelweg uitvoeren door de naam in te typen in de CLI. Ik kan echter ook applicaties starten met hun commandonaam die niet in deze specifieke folder zitten. Het OS heeft namelijk een lijst met alle folders waar die moet gaan zoeken naar commando’s. Die lijst staat opgeslagen in de PATH-variabele.
Virtual Machines en WSL
Een virtual machine (VM) is een software-emulatie van een fysieke computer die een besturingssysteem en applicaties kan draaien alsof het een echte machine is. VM’s maken gebruik van hypervisors om de hardwarebronnen van een fysieke hostmachine te verdelen en te isoleren, waardoor meerdere virtuele machines tegelijkertijd op dezelfde fysieke hardware kunnen draaien.
Normal computer (left) VS VM's on host (right)
Het principe van virtual machines bestaat al lange tijd en kan soms ingewikkeld zijn om op te zetten, vooral op Windows. Daarom heeft Windows onlangs een ingebouwde oplossing geïntroduceerd genaamd het Windows Subsystem voor Linux (WSL). WSL biedt een Linux-omgeving binnen Windows, waarbij WSL 2 gebruikmaakt van een lichte virtuele machine met een volledige Linux-kernel, wat zorgt voor betere prestaties en integratie dan traditionele VM’s.
Wij gaan de Linux distributie Ubuntu versie 24.04 gebruiken in deze cursus. Een Linux distributie is een complete verzameling van software die een Linux-kernel bevat, samen met een reeks tools, bibliotheken en applicaties die nodig zijn om een volledig functioneel besturingssysteem te vormen. Elke distributie is samengesteld en geoptimaliseerd voor verschillende doeleinden en gebruikersgroepen. Linux distributies verschillen van elkaar op gebied van verschillende aspecten, waaronder: package manager, desktop omgeving, vooraf geïnstalleerde software en tools … Populaire Linux distributies zijn: Ubuntu, Debian, Pop OS, Fedora, Debian, Linux Mint, Arch Linux …
Wij kiezen voor Ubuntu voor de gebruiksvriendelijkheid, community en ondersteuning, softwarebeschikbaarheid, regelmatige update.
Voor Windows 10 version 2004 en hoger is het commando wsl normaal al geïnstalleerd. Je kan dit testen door Terminal of PowerShell te openen en het commando wsl --version in te geven. Krijg je een antwoord terug zonder error dan werk WSL. Je kan nu het volgende commando gebruiken om Ubuntu 24.04 te installeren: wsl --install -d Ubuntu-24.04. Na de installatie wordt je meteen in de VM gegooid en moet je een username en password meegeven. Zorg ervoor dat je deze onthoudt! Je kan via WSL meerdere WMs tegelijkertijd installeren op je computer, daarom zetten we Ubuntu even als de default via het commando wsl --set-default Ubuntu-24.04. Wanneer je nu de Windows applicatie WSL opent zal je rechtstreeks in de CLI omgeving van Ubuntu terecht komen. Hier gaan we voorlopig het grootste deel van onze tijd doorbrengen.
Klik hier om voorbeeld output te zien/verbergen🔽
PS C:\Users\u0158802> wsl --install -d Ubuntu-24.04
Installing: Ubuntu 24.04 LTS
Failed to install Ubuntu-24.04 from the Microsoft Store: The Windows Subsystem for Linux instance has terminated.
Attempting web download...
Downloading: Ubuntu 24.04 LTS
Installing: Ubuntu 24.04 LTS
Ubuntu 24.04 LTS has been installed.
Launching Ubuntu 24.04 LTS...
Installing, this may take a few minutes...
Please create a default UNIX user account. The username does not need to match your Windows username.
For more information visit: https://aka.ms/wslusers
Enter new UNIX username: arne
New password:
Retype new password:
passwd: password updated successfully
Installation successful!
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root"for details.
Welcome to Ubuntu 24.04 LTS (GNU/Linux 5.15.167.4-microsoft-standard-WSL2 x86_64) * Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
System information as of Mon Feb 10 17:20:37 CET 2025 System load: 0.62 Processes: 53 Usage of /: 0.1% of 1006.85GB Users logged in: 0 Memory usage: 12% IPv4 address for eth0: 172.29.161.86
Swap usage: 0%
This message is shown once a day. To disable it please create the
/home/arne/.hushlogin file.
arne@LT3210121:~$
WSL starten, stoppen
Je kan WSL ook starten vanuit een Windows CLI tool zoals Terminal of Powershell met het commando wsl. Om dan uit je WSL te raken en terug in de host versie van de CLI gebruik je het commando exit.
Overzicht commando’s:
>wsl--version>wsl--install-d[distributie naam]>wsl--set-default[distributie naam]# binnenin WSL gebruik je `exit` om terug naar de host te keren$exit
Connectie tussen host en VM
Eén van de handige functies van het WSL is de naadloze integratie tussen de WSL-omgeving en de Windows-host. Dit betekent dat je eenvoudig bestanden kunt bekijken en bewerken die zich op je Windows-bestandssysteem bevinden vanuit de WSL-omgeving, en omgekeerd.
filesystem Windows vs Linux
Er is nog een vrij groot verschil tussen het bestandssysteem van Windows en Linux en kan invloed hebben op hoe je met bestanden en mappen werkt.
De root/hoofd directory in Windows is meestal een specifieke schijf, zoals C:\. Elke schijf of partitie heeft zijn eigen root, bijvoorbeeld D:\ voor een tweede schijf.In Linux is er één enkele root directory aangeduid met /.
Windows gebruikt backslashes ‘' om directories te scheiden, bijvoorbeeld C:\Users\Gebruiker\Documents. Linux gebruikt forward slashes ‘/’ voor hetzelfde doel, bijvoorbeeld /home/gebruiker/documents.
In Windows zijn bestandsnamen niet hoofdlettergevoelig. Document.txt en document.txt worden als hetzelfde bestand beschouwd. Linux is wel hoofdlettergevoelig.
In Windows worden schijven en partities aangeduid met letters zoals C:\, D:\. In Linux worden schijven en partities gekoppeld aan directories binnen het filesystem, zoals /mnt/c voor de C-schijf.
Info
Een directory is een locatie op een filesystem waar bestanden (= files) en andere directories (subdirectories) kunnen worden opgeslagen. Het fungeert als een container die helpt bij het organiseren en structureren van bestanden op een computer.
Hoewel er kleine verschillen kunnen zijn tussen de volgende termen, in de context waarin deze termen worden gebruikt, verwijzen ze allemaal naar hetzelfde concept: een locatie op een bestandssysteem waar bestanden en andere mappen kunnen worden georganiseerd: directory, folder, map.
files van VM op host en files van de host op de VM
Binnenin File Explorer (Verkenner) in Windows kan je de files van de WSL terugvinden op de locatie \\wsl$\Ubuntu-24.04 zoals op onderstaande afbeelding te zien is.
WSL bestandslocatie in File Explorer
In WSL vind je de bestanden van de Windows host in de directory /mnt/c.
De CLI gebruiken: essentiële UNIX commands
Dan kunnen we nu eindelijk aan het praktische deel beginnen. Dingen doen binnen in je WSL Ubuntu met behulp can CLI-commando’s. We gaan een reeks van veelgebruikte commando’s overlopen en bekijken. We gaan echter niet alle commando’s zien omdat er gewoon teveel zijn en ook niet voor elk commando elke optie die mogelijk is. Maar met alles wat we gaan overlopen zou je het grootste deel van je taken als een ingenieur in de CLI moeten kunnen uitvoeren.
We gebruiken een bash shell.
pwd - ‘print working directory’: Met dit commando output je je huidige directory in de terminal.
$ pwd# Voorbeeldarne@LT3210121:~$ pwd/home/arne
Je huidige directory is ook steeds terug te vinden tussen de : en de $ in de terminal. In het voorbeeld hierboven zie je dat de directory ~ is aangegeven. Dit is een synoniem voor de /home/$USER directory. (Voor de @ staat de ingelogde usernaam en tussen de @ en de : staat de naam van het apparaat)
echo: Met dit commando toon je de opgegeven tekst in de terminal.
Je kan op twee manieren een directory opgeven, namelijk met een relatief pad of een absoluut pad:
Een absoluut pad geeft de volledige locatie van een directory of bestand vanaf de root directory van het bestandssysteem. Het begint altijd met een / en specificeert de exacte locatie, ongeacht de huidige werkdirectory. (bijvoorbeeld: /home/arne/test)
Een relatief pad geeft de locatie van een directory of bestand ten opzichte van de huidige werkdirectory. Het begint niet met een /, maar met de naam van de directory of bestand. Relatieve paden zijn handig voor het navigeren binnen de huidige directorystructuur zonder de volledige padnaam te hoeven opgeven. (bijvoorbeeld: arne/test als je je in de /home directory bevindt) Bovendien zijn er nog twee speciale symbolen die je in padnamen kan gebruiken . en ..:
. verwijst naar de huidige directory. (bijvoorbeeld: ./arne/test als je je in de /home directory bevindt)
.. verwijst naar de bovenliggende directory (= parent directory). (bijvoorbeeld: ./arne/../arne/test als je je in de /home directory bevindt, waarbij je met .. terug naar de /home verwijst, de parent directory van /home/arne)
Voor de meeste commando’s kan je de tab-toets gebruiken om automatisch je text of commando’s te laten vervolledigen. Bijvoorbeeld voor directory of file namen … Als er meerdere mogelijkheden voor autocomplete zijn moet je tweemaal op de tab-toets drukken en dan krijg je een lijst te zien met alle mogelijkheden.
ls - ’list files’: Met dit commando lijst je de bestanden en directories in de huidige directory op.
$ ls
# Voorbeeldarne@LT3210121:~$ ls
test
Je kan meestal je commando meer specificeren met behulp van options en flags om extra functionaliteit toe te voegen of de uitvoer aan te passen. Bijvoorbeeld, het ls-commando in Linux wordt gebruikt om de inhoud van een directory weer te geven. Door opties en flags toe te voegen, kun je de uitvoer van ls aanpassen aan je behoeften. Zo geeft ls -l een gedetailleerde lijst weer met extra informatie zoals bestandsrechten, eigenaar en grootte, terwijl ls -a ook verborgen bestanden toont die normaal gesproken niet zichtbaar zijn. Je kunt ook meerdere opties combineren, zoals ls -la, om zowel een gedetailleerde lijst als verborgen bestanden weer te geven. Deze flexibiliteit maakt het mogelijk om commando’s nauwkeurig af te stemmen op specifieke taken en vereisten. Je hebt verder ook de optie om aan het ls commando een specifiek directory mee te geven waardoor het de inhoud van de meegegeven directory toont.
Klik hier om voorbeeld output te zien/verbergen🔽
# Voorbeeldarne@LT3210121:~$ ls -l
total 4drwxr-xr-x 2 arne arne 4096 Feb 10 23:01 test# Voorbeeldarne@LT3210121:~$ ls -a
. .aws .bash_history .bashrc .docker .profile .sudo_as_admin_successful
.. .azure .bash_logout .cache .motd_shown .skip-cloud-init-warning test# Voorbeeldarne@LT3210121:~$ ls -la
total 36drwxr-x--- 5 arne arne 4096 Feb 11 14:26 .
drwxr-xr-x 4 root root 4096 Feb 10 17:21 ..
lrwxrwxrwx 1 arne arne 26 Feb 11 14:26 .aws -> /mnt/c/Users/u0158802/.aws
lrwxrwxrwx 1 arne arne 28 Feb 11 14:26 .azure -> /mnt/c/Users/u0158802/.azure
-rw------- 1 arne arne 9 Feb 10 17:27 .bash_history
-rw-r--r-- 1 arne arne 220 Feb 10 17:21 .bash_logout
-rw-r--r-- 1 arne arne 3771 Feb 10 17:21 .bashrc
drwx------ 2 arne arne 4096 Feb 10 17:21 .cache
drwxr-xr-x 5 arne arne 4096 Feb 11 14:26 .docker
-rw-r--r-- 1 arne arne 0 Feb 11 14:26 .motd_shown
-rw-r--r-- 1 arne arne 807 Feb 10 17:21 .profile
-rw-r--r-- 1 arne arne 0 Feb 11 14:26 .skip-cloud-init-warning
-rw-r--r-- 1 arne arne 0 Feb 10 22:20 .sudo_as_admin_successful
drwxr-xr-x 2 arne arne 4096 Feb 10 23:01 test# Voorbeeldarne@LT3210121:~$ ls /mnt
c wsl wslg
In de output bovenaan van het commando ls -l krijg je meer info te zien over het bestandstype (d = directory, - = file …) en de eigenaarsrechten (r = can read, w = can write, x = can execute). De output bevat verschillende kolommen met belangrijke informatie.
De eerste kolom toont de bestandsrechten, die aangeven wie lees-, schrijf- en uitvoerrechten heeft voor het bestand (bijvoorbeeld -rw-r--r--). De rechten komen in 3 groeperingen van 3 waarvan de meest linkse de rechten van de eigenaar zijn, de middelste groepering de rechten van alle users in dezelfde groep als de eigenaar en de meest rechtse groepering de rechten van alle gebruikers op het systeem.
De tweede kolom geeft het aantal harde links naar het bestand weer.
De derde en vierde kolommen tonen respectievelijk de eigenaar en de groep waartoe het bestand behoort.
De vijfde kolom geeft de bestandsgrootte in bytes weer. De zesde kolom toont de datum en tijd van de laatste wijziging.
De laatste kolom geeft de naam van het bestand of de directory weer.
man - ‘manual’: Dit commando is een krachtig hulpmiddel in Linux om gedetailleerde informatie over andere commando’s en hun opties te vinden.
$ man [commando]# Voorbeeldarne@LT3210121:~$ man ls
LS(1) User Commands LS(1)NAME
ls - list directory contents
SYNOPSIS
ls [OPTION]... [FILE]...
DESCRIPTION
List information about the FILEs (the current directory by default). Sort entries alphabetically if none of
-cftuvSUX nor --sort is specified.
Mandatory arguments to long options are mandatory for short options too.
-a, --all
do not ignore entries starting with .
-A, --almost-all
do not list implied . and ..
--author
with -l, print the author of each file
-b, --escape
print C-style escapes for nongraphic characters
--block-size=SIZE
with -l, scale sizes by SIZE when printing them; e.g., '--block-size=M'; see SIZE format below
Manual page ls(1) line 1(press h forhelp or q to quit)
Door man te gebruiken gevolgd door de naam van een commando, zoals man ls, krijg je toegang tot de handleidingspagina voor dat commando (gebruik de pijltjes toetsen om te navigeren en ‘q’ om dit menu te sluiten). Deze pagina bevat uitgebreide informatie over wat het commando doet, welke opties en flags beschikbaar zijn, en hoe je ze kunt gebruiken. Bijvoorbeeld, door man ls in te voeren, zie je een lijst van alle beschikbare opties voor het ls-commando, zoals -l voor een gedetailleerde lijstweergave en -a om verborgen bestanden te tonen. Dit maakt het man-commando een onmisbaar hulpmiddel voor zowel beginners als gevorderde gebruikers om de volledige functionaliteit van Linux-commando’s te ontdekken en effectief te benutten.
touch: Met dit commando maak je een nieuw, leeg bestand aan of update je de timestamp van een bestaand bestand.
$ touch [bestandsnaam]# Voorbeeldarne@LT3210121:~$ touch test.txt
arne@LT3210121:~$ ls
test test.txt
rm - ‘rm [bestand]’: Met dit commando verwijder je het opgegeven bestand. Gebruik de -R (recursive) flag to delete entire directories.
$ rm [bestandsnaam]$ rm -R [directorynaam]# Voorbeeldarne@LT3210121:~$ ls
test test.txt
arne@LT3210121:~$ rm test.txt
arne@LT3210121:~$ ls
testarne@LT3210121:~$ rm -R testarne@LT3210121:~$ ls
arne@LT3210121:~$
nano: Met dit commando open je het opgegeven bestand in de nano-teksteditor. Aangezien nano een CLI teksteditor is, kan je niet gewoon klikken om de cursor te verplaatsen, op te slaan of te exitten. Gebruik hiervoor de pijltjestoetsen, Ctrl+o (= save, write out), Ctrl+x (= exit). Indien het bestand bestaat kan je het aanpassen en indien het bestand niet bestaat wordt het on save aangemaakt.
vim: Met dit commando open je het opgegeven bestand in de vim-teksteditor. Dit is een zeer speciale editor. Het belangrijkste dat je moet weten is dat je kan exiten met :q!+enter. Soms moet je eerst tweemaal op esc drukken. Meer info vind je hier.
$ vim [bestandsnaam]# Voorbeeldarne@LT3210121:~$ vim test.txt
...
cat - ‘cat [bestand]’: Met dit commando toon je de inhoud van het opgegeven bestand in de terminal.
$ cat [bestandsnaam]# Voorbeeldarne@LT3210121:~$ cat test.txt
Dit is een test file.
copy en paste: Dit zal voor verschillende terminal emulators anders zijn. In Windows terminal kan je simpelweg Ctrl+c en Ctrl+v gebruiken, in WSL kan je Ctrl+c gebruiken voor copy en rechtermuisklik voor paste en een andere veel gebruikte toetsencombinatie is Ctrl+Shift+c en Ctrl+Shift+v.
chown - ‘change ownership’: Met dit commando verander je de eigenaar van het opgegeven bestand.
$ sudo chown [gebruiker]:[groep][bestandsnaam]# Voorbeeldarne@LT3210121:~$ chown root:root test.txt
chown: changing ownership of 'test.txt': Operation not permitted
arne@LT3210121:~$ sudo chown root:root test.txt
[sudo] password for arne:
arne@LT3210121:~$ ls -l
total 8drwxr-xr-x 2 arne arne 4096 Feb 11 14:55 test-rw-r--r-- 1 root root 22 Feb 11 14:52 test.txt
Important
sudo - ‘sudo [commando]’: Met dit commando voer je een ander commando uit met verhoogde (superuser) rechten.
export: Met dit commando kun je omgevingsvariabelen instellen die beschikbaar zijn voor de huidige shell-sessie en alle sub-processen die vanuit deze shell worden gestart.
$ export[VARIABELENAAM]=[waarde]# Voorbeeldarne@LT3210121:~$ exportTEST=testwaarde
arne@LT3210121:~$ echo$TESTtestwaarde
# Er zijn ook al voorgedefinieerde variabelen zoals de huidige user ($USER)arne@LT3210121:~$ echo$USERarne
chmod - ‘change mode’: Met dit commando verander je de bestandsrechten van het opgegeven bestand. (+r,+w,+x,-r,-w,-x)
$ chmod [rechten][bestand]# Voorbeeldarne@LT3210121:~$ ls -l
total 8drwxr-xr-x 2 arne arne 4096 Feb 11 14:55 test-r--r--r-- 1 arne arne 22 Feb 11 14:52 test.txt
arne@LT3210121:~$ chmod -w test.txt
arne@LT3210121:~$ ls -l
total 8drwxr-xr-x 2 arne arne 4096 Feb 11 14:55 test-r--r--r-- 1 arne arne 22 Feb 11 14:52 test.txt
arne@LT3210121:~$ chmod +x test.txt
arne@LT3210121:~$ ls -l
total 8drwxr-xr-x 2 arne arne 4096 Feb 11 14:55 test-r-xr-xr-x 1 arne arne 22 Feb 11 14:52 test.txt
mv - ‘move’: Met dit commando verplaats of hernoem je een bestand of directory.
$ mv [bron][doel]# Voorbeeldarne@LT3210121:~$ mv ./test.txt ./test/test.txt
arne@LT3210121:~$ ls -l ./test
total 4-rw-r--r-- 1 arne arne 22 Feb 11 14:52 test.txt
cp - ‘cp [bron] [doel]’: Met dit commando kopieer je een bestand of directory naar een nieuwe locatie.
$ mv [bron][doel]# Voorbeeldarne@LT3210121:~$ cp ./test/test.txt ./test.txt
arne@LT3210121:~$ ls -l ./test
total 4-rw-r--r-- 1 arne arne 22 Feb 11 14:52 test.txt
arne@LT3210121:~$ ls -l
total 8drwxr-xr-x 2 arne arne 4096 Feb 11 16:27 test-rw-r--r-- 1 arne arne 22 Feb 11 16:29 test.txt
sudo apt update - APT staat voor Advanced Packaging Tool: Met dit commando vernieuw je de lijst van beschikbare applicatie packages en hun versies, maar installeert of verwijdert geen packages. sudo apt upgrade: Met dit commando installeer je de nieuwste versies van alle geïnstalleerde applicatie packages die kunnen worden bijgewerkt, zonder nieuwe packages te verwijderen. apt install: Met dit commando installeer je het opgegeven pakket op een Debian-gebaseerd systeem.
$ sudo apt install [packagenaam]# Voorbeeldarne@LT3210121:~$ sudo apt install curl
[sudo] password for arne:
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
curl is already the newest version (8.5.0-2ubuntu10.6).
curl set to manually installed.
The following package was automatically installed and is no longer required:
libllvm17t64
Use 'sudo apt autoremove' to remove it.
0 upgraded, 0 newly installed, 0 to remove and 2 not upgraded.
apt search - ‘apt search [pakket]’: Met dit commando zoek je naar een pakket in de pakketbronnen.
$ sudo apt search [zoekterm]# Voorbeeldarne@LT3210121:~$ sudo apt search curl
Sorting... Done
Full Text Search... Done
ario/noble 1.6-1.2build4 amd64
GTK+ client for the Music Player Daemon (MPD)ario-common/noble 1.6-1.2build4 all
GTK+ client for the Music Player Daemon (MPD)(Common files)cht.sh/noble 0.0~git20220418.571377f-2 all
Cht is the only cheat sheet you need
cl-curry-compose-reader-macros/noble 20171227-1.1 all
...
apt remove: Met dit commando verwijder je het opgegeven pakket van het systeem.
$ sudo apt remove [packagenaam]# Voorbeeldarne@LT3210121:~$ sudo apt remove curl
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following packages were automatically installed and are no longer required:
libcurl4t64 libllvm17t64
Use 'sudo apt autoremove' to remove them.
The following packages will be REMOVED:
curl ubuntu-wsl
0 upgraded, 0 newly installed, 2 to remove and 2 not upgraded.
After this operation, 551 kB disk space will be freed.
Do you want to continue? [Y/n] y
(Reading database ... 40775 files and directories currently installed.)Removing ubuntu-wsl (1.539.2) ...
Removing curl (8.5.0-2ubuntu10.6) ...
Processing triggers for man-db (2.12.0-4build2) ...
sleep: Met dit commando pauzeer je de uitvoering van commando’s voor het opgegeven aantal seconden.
commando’s aaneenschakelen met ; of &&: je kan ze gebruiken om meerdere commando’s aan elkaar te schakelen, maar ze werken op verschillende manieren. De ; operator voert de commando’s sequentieel uit, ongeacht of het vorige commando succesvol was of niet. De && voert operator het tweede commando alleen uit als het eerste commando succesvol is (d.w.z. een exitstatus van 0 heeft). Dit maakt && nuttig voor het uitvoeren van afhankelijkheden, waar het tweede commando alleen zinvol is als het eerste commando slaagt.
# Voorbeeld ;arne@LT3210121:~$ cat test.txt;echo"two"Dit is een test file.
two
arne@LT3210121:~$ cat onbestaand.txt;echo"two"cat: onbestaand.txt: No such file or directory
two
# Voorbeeld &&arne@LT3210121:~$ cat test.txt &&echo"two"Dit is een test file.
two
arne@LT3210121:~$ cat onbestaand.txt &&echo"two"cat: onbestaand.txt: No such file or directory
»: Met deze operator voeg je de output van een commando toe aan het einde van een bestand.
$ [commando met output] >> [bestand]# Voorbeeldarne@LT3210121:~$ cat test.txt
Dit is een test file.
arne@LT3210121:~$ echo"Hello" >> test.txt
arne@LT3210121:~$ cat test.txt
Dit is een test file.
Hello
>: Met deze operator operator kun je de output van een commando naar een bestand sturen, waarbij de inhoud van het bestand wordt overschreven als het bestand al bestaat.
$ [commando met output] >> [bestand]# Voorbeeldarne@LT3210121:~$ cat test.txt
Dit is een test file.
arne@LT3210121:~$ echo"Hello" > test.txt
arne@LT3210121:~$ cat test.txt
Hello
Wildcards (*): Met de wildcard operator kun je patronen specificeren die overeenkomen met meerdere bestanden of directories in één keer. Dit is vooral handig bij het uitvoeren van bewerkingen op groepen bestanden zonder dat je elk bestand afzonderlijk hoeft te specificeren. Bijvoorbeeld kopieer alle bestanden met een bepaalde extensie naar een map: cp *.txt doelmap/
EXTRA: hier nog een lijst van nuttige commando’s en principes die we voorlopig niet verder in diepgang gaan bespreken, maar wel handig kunnen zijn in je ingenieurs carrière:
grep
het principe van piping |
ssh
het principe van background processes met & of Ctrl+z
fg
curl
ping
het principe van de bashrc-file
source
fancy printouts
het principe van signal interrupts
regular expressions
Shell scripts
Een shell script is een tekstbestand dat een reeks commando’s bevat die door een Unix-shell worden uitgevoerd. Shell scripts worden vaak gebruikt om taken te automatiseren, zoals systeembeheer, batchverwerking en het uitvoeren van complexe commando’s. Een belangrijk onderdeel van een shell script is de shebang (#!), die aangeeft welke interpreter moet worden gebruikt om het script uit te voeren. Voor een Bash-script wordt vaak de volgende shebang gebruikt: #!/bin/bash. Dit vertelt het systeem dat het script moet worden uitgevoerd met de Bash-shell.
Voorbeeld van een eenvoudig shell script: test.sh
#!/bin/bash
# Dit is een eenvoudig shell script dat een begroeting weergeeft en een lijst van bestanden in de huidige directory toont.echo"Hallo, wereld!"echo"Hier is een lijst van bestanden in de huidige directory:"ls -l
Om een shell script uitvoerbaar te maken, moet je het bestand de juiste permissies geven met het chmod-commando. Dit doe je door de uitvoerbare permissie toe te voegen met chmod +x. Dan kan je het shell script uitvoeren met de naam van het .sh-bestand.
Variabelen in een Shell Script
In een shell script kun je variabelen gebruiken om gegevens op te slaan en te manipuleren. Variabelen worden zonder spaties gedefinieerd en kunnen later in het script worden opgeroepen door een $-teken voor de variabelenaam te plaatsen.
Voorbeeld van een shell script met variabelen:
#!/bin/bash
# Dit script gebruikt variabelen om een begroeting weer te geven.NAAM="Alice"echo"Hallo, $NAAM! Welkom bij het shell scripting."
Opties meegeven en/of input uitlezen
Je kunt een shell script zo schrijven dat het invoer van de gebruiker accepteert via de commandline of tijdens de uitvoering van het script. Hier is een voorbeeld van beide methoden:
Voorbeeld: Invoer via de commandline
#!/bin/bash
# Dit script accepteert een naam als argument en geeft een begroeting weer.NAAM=$1echo"Hallo, $NAAM! Welkom bij het shell scripting."
Om dit script uit te voeren, geef je de naam op als argument:
./script.sh Alice
Merk op dat $0 het commando zelf is!
Voorbeeld: Invoer tijdens de uitvoering
#!/bin/bash
# Dit script vraagt de gebruiker om een naam in te voeren en geeft een begroeting weer.echo"Voer je naam in:"read NAAM
echo"Hallo, $NAAM! Welkom bij het shell scripting."
Voorbeeld: conditional statements
#!/bin/bash
# Dit script controleert de waarde van een variabele en geeft een bericht weer op basis van de waarde.echo"Voer een getal in:"read getal
if[$getal -lt 10];thenecho"Het getal is kleiner dan 10."elif[$getal -eq 10];thenecho"Het getal is precies 10."elseecho"Het getal is groter dan 10."fi
#!/bin/bash
# Dit script gebruikt een for-loop om een reeks bestanden te maken met oplopende getallen in de bestandsnamen.# Beginwaarde van het getalstart=1# Eindwaarde van het getaleind=5# Gebruik een for-loop om door de reeks getallen te itererenfor((i=start; i<=eind; i++));dobestandsnaam="bestand_$i.txt" touch "$bestandsnaam"echo"Bestand aangemaakt: $bestandsnaam"done
Merk op dat je eindigt met done
Voorbeeld: while-loop
#!/bin/bash
# Dit script gebruikt een while-loop om een getal te verhogen en weer te geven totdat het een bepaalde waarde bereikt.# Beginwaarde van het getalgetal=1# Eindwaarde van het getaleind=5# Gebruik een while-loop om het getal te verhogen en weer te gevenwhile[$getal -le $eind];doecho"Huidig getal: $getal"getal=$((getal +1))done
Het doel van deze informatie is dat je vlot je weg kan vinden in een OS met behulp van enkel een CLI. Je hoeft geen theorie te kennen, maar je moet wel simpele commando’s kunnen gebruiken en kennen zoals: Met welk commando navigeer je naar de directory /home/arne/test door gebruik te maken van een relatief pad wanneer je je in de directory /home/arne bevindt? OPLOSSING: $ cd test of $ cd ./test.
Heb je nog meer vragen over hoe de commando’s werken, gebruik dan het man-commando of zoek de documentatie op op het internet.
Oefeningen op de CLI
De gegeven oplossingen zijn EEN mogelijke oplossing, soms zijn meerdere mogelijkheden juist. Is het gewenste gedrag bereikt, dan is je oplossing correct!
Oefeningenreeks 1
Toon het pad van de huidige werkdirectory.
Solution:$ pwd
Maak een nieuw leeg bestand genaamd nieuwbestand.txt.
Solution:$ touch nieuwbestand.txt
Maak een nieuwe directory genaamd testmap.
Solution:$ mkdir testmap
Verwijder een bestand genaamd nieuwbestand.txt.
Solution:$ rm nieuwbestand.txt
Voeg de tekst “Hallo, wereld!” toe aan de terminaloutput.
Solution:$ echo "Hallo, wereld!"
Navigeer naar je home directory.
Solution:$ cd ~
Wis de output van je terminal.
Solution:$ clear
Bekijk de handleiding voor het commando dat bestanden en directories weergeeft.
Solution:$ man cd
Toon de inhoud van de huidige directory.
Solution:$ ls
Open het bestand nieuwbestand.txt in een teksteditor en voeg de tekst “Dit is een test.” toe. Sla het bestand op en sluit de editor.
Solution:
$ nano nieuwbestand.txt
# save met Ctrl+o en Enter. Exit met Ctrl+x
Toon de inhoud van nieuwbestand.txt in de terminal.
Solution:$ cat nieuwbestand.txt
Maak een nieuw directory genaamd project, navigeer naar deze directory, en maak een nieuw bestand genaamd README.md.
Solution:
$ mkdir project
$ cd ./project
$ touch ./README.md
Maak een nieuw bestand genaamd info.txt, voeg de tekst “Dit is een informatief bestand.” toe, en toon de inhoud van het bestand.
Solution:
$ nano info.txt
$ cat info.txt
Maak een nieuw directory genaamd backup, kopieer het bestand info.txt naar de backup-directory, en verwijder vervolgens het originele info.txt-bestand.
Zoek naar een softwarepakket met de naam ’neofetch'.
Solution:$ sudo apt search neofetch
Installeer een softwarepakket genaamd ’neofetch'.
Solution:$ sudo apt install neofetch
Verwijder een geïnstalleerd softwarepakket genaamd ’neofetch'.
Solution:$ $ sudo apt remove neofetch
Wijzig de permissies van een bestand genaamd nieuwbestand.txt zodat de eigenaar lees-, schrijf- en uitvoerrechten heeft, en de groep en anderen alleen lees- en uitvoerrechten hebben.
Solution:$ sudo chmod 755 nieuwbestand.txt
Voer twee commando’s na elkaar uit, ongeacht of het eerste commando succesvol is.
Solution:$ cat nieuwbestand.txt; echo "De file bestaat of niet"
Voer een tweede commando alleen uit als het eerste commando succesvol is.
Solution:$ cat nieuwbestand.txt && echo "De file bestaat"
Schrijf de uitvoer van een commando naar een bestand genaamd output.txt, waarbij de bestaande inhoud van het bestand wordt overschreven.
Solution:$ ls > output.txt
Voeg de uitvoer van een commando toe aan het einde van een bestand genaamd output.txt, zonder de bestaande inhoud te verwijderen.
Solution:$ echo "Einde bestand" >> output.txt
Zoek naar een softwarepakket genaamd curl, installeer het pakket.
Solution:
$ sudo apt search curl
$ sudo apt install curl
Verwijder alle bestanden in je map met de extensie .txt.
Solution:$ rm *.txt
Maak een bestand genaamd config.txt en voeg wat tekst toe. Maak een kopie van een bestand genaamd config.txt naar een nieuwe locatie met de naam backup_config.txt, wijzig de eigenaar van backup_config.txt naar de gebruiker root, en voeg de tekst “Backup voltooid” toe aan een logbestand genaamd log.txt.
Maak een directorystructuur aan met de volgende paden: project/src, project/bin, en project/docs.
Navigeer naar de src-directory.
Maak een nieuw bestand genaamd main.c in de src-directory.
Kopieer het bestand main.c naar de bin-directory.
Toon de inhoud van de bin-directory.
Solution:
$ mkdir -p project/src project/bin project/docs
$ cd project/src
$ touch main.c
$ cp main.c ../bin/
$ ls ../bin/
Oefening 2:
Maak een nieuwe directory genaamd backup in je thuismap.
Maak een subdirectory genaamd 2025 in de backup-directory.
Maak een nieuw bestand genaamd data.txt in de 2025-directory.
Voeg de tekst “Backup data voor 2025” toe aan data.txt.
Toon de inhoud van data.txt in de terminal.
Solution:
$ mkdir ~/backup
$ mkdir ~/backup/2025
$ touch ~/backup/2025/data.txt
$ echo"Backup data voor 2025" > ~/backup/2025/data.txt
$ cat ~/backup/2025/data.txt
Oefening 3:
Zoek naar een softwarepakket genaamd htop.
Installeer het htop-pakket.
Maak een directorystructuur aan met de volgende paden: tools/monitoring.
Start het programma htop via het absolute pad naar de htop executable file.
Solution:
# vergeet voor het installeren van software packages geen update te doen...$ sudo apt-get update
$ sudo apt-get install -y htop
$ mkdir -p tools/monitoring
# Een kleine zoektocht toont met dat de `htop` executable file zich bevindt in de `/bin` folder$ /bin/htop
Oefening 4:
Maak een directorystructuur aan met de volgende paden: website/css, website/js, en website/images.
Navigeer naar de css-directory.
Maak een nieuw bestand genaamd styles.css in de css-directory.
Voeg de tekst “body { background-color: #f0f0f0; }” toe aan styles.css.
Oefening 1: Maak een shell script dat aan de gebruiker een absoluut pad van een directory vraagt en het aantal .txt bestanden in die directory teruggeeft.
Solution:
#!/bin/bash
# Vraag de gebruiker om een absoluut pad van een directoryread -p "Voer het absolute pad van de directory in: " DIR_PATH
# Controleer of de directory bestaatif[ -d "$DIR_PATH"];thenCOUNT=0# Gebruik wildcards om alle .txt files in de directory op te vragenfor FILE in "$DIR_PATH"/*.txt;do# Controleer of de file bestaatif[ -f "$FILE"];thenCOUNT=$((COUNT +1))# vergeet de `fi` nietfi# vergeet de `done` niet voor de for loopdoneecho"Aantal .txt bestanden in $DIR_PATH: $COUNT"elseecho"De directory $DIR_PATH bestaat niet."fi
Oefening 2: Maak een shell script dat het ls commando nadoet met de opties -l en -a in de huidige directory. Je kan enkel de opties apart meegeven of als combinatie -la. Je hebt dus maximum 1 flag die je meegeeft aan je shell script waaruit je afleidt hoe je het ls commando moet uitvoeren.
Solution:
#!/bin/bash
if["$1"=="-l"];then ls -l
elif["$1"=="-a"];then ls -a
elif["$1"=="-la"];then ls -la
else ls
fi
Oefening 3: Maak een shell script genaamd make.sh dat 4 mogelijke opties kan meekrijgen:
Als je de optie start meegeeft vraagt het script de gebruiker naar een projectnaam en maakt dan volgende directories aan: ./projectnaam/src en ./projectnaam/build.
Als je de optie build meegeeft worden alle bestanden in de ./projectnaam/src directory gekopieerd naar de ./projectnaam/build directory.
Als je de optie clean meegeeft worden alle bestanden in de ./projectnaam/build directory gewist.
Als je de optie run meegeeft worden alle bestanden in ./projectnaam/build van alle .txt bestanden een na een getoond.
Solution:
#!/bin/bash
if["$1"=="start"];thenecho"creating project directory ..."read -p "Geef een naam voor je project: " PROJECT_NAME
mkdir -p ./$PROJECT_NAME/src ./$PROJECT_NAME/build
elif["$1"=="build"];thenread -p "Geef je projectnaam: " PROJECT_NAME
echo"building files to build directory ..." cp -r ./$PROJECT_NAME/src/* ./$PROJECT_NAME/build
elif["$1"=="clean"];thenread -p "Geef je projectnaam: " PROJECT_NAME
echo"cleaning build directory ..." rm -R ./$PROJECT_NAME/build/*
elif["$1"=="run"];thenread -p "Geef je projectnaam: " PROJECT_NAME
echo"running program ..."for FILE in ./$PROJECT_NAME/build/*.txt ;doif[ -f "$FILE"];then cat $FILEfidoneelseecho"Wrong command, choose: 'start', 'build', 'clean', or 'run'."fiecho"Done"
VSCode
Heb je speciale tools nodig als software engineer?
Je kan in principe code schrijven puur met nano en andere command line tools, maar dat is niet altijd even handig. Hoewel nano en vergelijkbare tools lichtgewicht en eenvoudig te gebruiken zijn, missen ze veel van de geavanceerde functies die moderne ontwikkelaars nodig hebben om efficient aan software engineering te doen. Denk hierbij aan syntax highlighting, code completion, debugging, en geïntegreerde versiebeheer. Deze functies kunnen het programmeren aanzienlijk efficiënter en minder foutgevoelig maken.
Daarom bestaan er Integrated Development Environments (IDE’s) die het programmeren vergemakkelijken op verschillende manieren. IDE’s bieden een uitgebreide set tools en functies binnen één enkele applicatie. Ze ondersteunen vaak meerdere programmeertalen, bieden geavanceerde debugging-mogelijkheden en hebben ingebouwde ondersteuning voor versiebeheer zoals Git. Bovendien bieden ze vaak een visuele interface (GUI) voor het beheren van projecten en dependencies, wat het ontwikkelproces stroomlijnt.
De nadelen van IDE’s zijn echter dat ze vaak zwaar en traag kunnen zijn, vooral op oudere of minder krachtige hardware. Ze kunnen ook een steile leercurve hebben vanwege de vele functies en configuratiemogelijkheden. Dit kan overweldigend zijn voor beginners of voor ontwikkelaars die snel aan de slag willen zonder veel tijd te besteden aan het leren van een nieuwe tool.
Als je dit vergelijkt met een lichtgewicht code/text editor zoals Notepad++ of Visual Studio Code (VSCode), zie je dat deze editors een goede balans bieden tussen functionaliteit en prestaties. Ze zijn sneller en minder resource-intensief dan volledige IDE’s, maar bieden toch veel van de functies die ontwikkelaars nodig hebben.
VSCode biedt een mooie middenweg omdat je via extensies het gedrag van de editor kunt aanpassen aan je eigen wensen. Met duizenden beschikbare extensies kun je functies toevoegen zoals linting, debugging, versiebeheer, en ondersteuning voor vrijwel elke programmeertaal. Dit maakt VSCode zeer flexibel en aanpasbaar, waardoor het een populaire keuze is onder ontwikkelaars van alle niveaus.
VSCode installeren
Om Visual Studio Code (VSCode) op Windows te installeren, volg je deze stappen:
Vink tijdens de installatie de optie aan om VSCode aan je PATH toe te voegen tijdens de installatie en om directories te openen met VSCode.
Na de installatie kun je VSCode starten vanuit het Startmenu of door code in de command line te typen.
Inloggen met je Microsoft- of GitHub-account in VSCode biedt verschillende voordelen. Door in te loggen, kun je je instellingen, thema’s en extensies synchroniseren over meerdere apparaten. Dit betekent dat je dezelfde ontwikkelomgeving hebt, ongeacht waar je werkt. Bovendien kun je eenvoudig samenwerken met anderen via GitHub, waarbij je toegang hebt tot je repositories en pull requests direct vanuit VSCode. Het is echter niet verplicht.
Ingebouwde menus
VSCode komt out-of-the-box met een breed scala aan functionaliteiten die het programmeren en ontwikkelen aanzienlijk vergemakkelijken:
Bestandenviewer: De ingebouwde bestandenviewer biedt een overzichtelijke manier om door je projectbestanden en mappen te navigeren. Je kunt eenvoudig bestanden openen, verplaatsen, hernoemen en verwijderen zonder de editor te verlaten.
Zoekfunctie: De krachtige zoekfunctie in VSCode stelt je in staat om snel door je codebase te zoeken naar specifieke termen of patronen. Je kunt zoeken binnen een enkel bestand of door je hele project, en zelfs gebruik maken van reguliere expressies (regex) voor geavanceerde zoekopdrachten.
Ingebouwd versiebeheer: VSCode heeft ingebouwde ondersteuning voor versiebeheer, zoals Git. Je kunt je code rechtstreeks vanuit de editor beheren, inclusief het maken van commits, het bekijken van de geschiedenis, en het oplossen van conflicten. Dit maakt het samenwerken met anderen en het bijhouden van wijzigingen in je code veel eenvoudiger.
Debugger: De ingebouwde debugger in VSCode ondersteunt verschillende programmeertalen en biedt functies zoals breakpoints, stap-na-stap uitvoering en het inspecteren van variabelen.
Extensies: Daarnaast kun je zoals al eerder vermeld met extensies de functionaliteit van VSCode verder uitbreiden. Of je nu ondersteuning nodig hebt voor een specifieke programmeertaal, linting, formattering, of integratie met andere tools, er is vrijwel altijd een extensie beschikbaar die aan je behoeften voldoet.
Shortcuts: Verder kan je ook zeer gepersonaliseerde shortcuts aanmaken die bij jouw specifieke workflow passen.
Instellingen
Je kunt op verschillende niveaus instellingen aanpassen in Visual Studio Code (VSCode), wat je de flexibiliteit geeft om de editor precies naar jouw wensen te configureren. Hier zijn de belangrijkste niveaus waarop je instellingen kunt aanpassen:
Gebruikersniveau: Instellingen die op gebruikersniveau worden aangepast, gelden voor alle projecten en werkruimten die je opent in VSCode. Deze instellingen worden opgeslagen in een JSON-bestand dat je kunt openen en bewerken via de Command Palette door te zoeken naar “Preferences: Open Settings (JSON)”.
Workspace niveau: Instellingen op workspace niveau gelden alleen voor de specifieke werkruimte of het project dat je hebt geopend. Dit is handig als je verschillende configuraties nodig hebt voor verschillende projecten. Workspace-instellingen worden opgeslagen in een .vscode-map binnen je projectdirectory.
Directory niveau: Binnen een werkruimte kun je ook instellingen aanpassen voor specifieke mappen. Dit kan nuttig zijn als je een project hebt met meerdere mappen die elk hun eigen configuratie vereisen.
Je kan extenties ook op die verschillende niveaus enablen/disablen.
De command pallette
De Command Palette in VSCode is een krachtige tool die je toegang geeft tot vrijwel alle functies en instellingen van de editor via een eenvoudige interface. Je kunt de Command Palette openen door Ctrl+Shift+p (Windows/Linux) of Cmd+Shift+p (Mac) te gebruiken. In de Command Palette kun je commando’s invoeren om taken uit te voeren zoals het openen van bestanden, het wijzigen van instellingen, het installeren van extensies, en nog veel meer. Het biedt een snelle manier om acties uit te voeren zonder door menu’s te hoeven navigeren, wat je workflow aanzienlijk kan versnellen. De Command Palette ondersteunt ook fuzzy search, waardoor je snel kunt vinden wat je zoekt, zelfs als je de exacte naam van het commando niet weet.
Connectie maken met WSL
Om connectie te maken met je WSL in VSCode heb je de extensie WSL nodig. Daarna kan je onderaan links op de blauwe of groene knop met >< pijlen klikken om connectie te maken met je WSL, op die manier kan je (de meeste) van je extensies behouden wanneer je
Enkele nuttige algemene extensies
Prettier: Coder formatter
TODO Tree: Hiervan gaan we in de lessen gebruik maken om oefeningen aan te geven binnen in bronbestanden.
Een ontwikkelomgeving opstellen voor de gewenste programmeertaal/het gewenste framework
Pad naar interpreter/compiler instellen: In de instellingen kan je voor specifieke programmeertalen het pad naar de correcte interpreter of compiler instellen. Op die manier kan je ook met de play-knop code uitvoeren in VSCode
Nuttige extenties voor elk framework/elke programmeertaal:
Syntax highlighter: Het zorgt ervoor dat verschillende elementen van de code, zoals sleutelwoorden, variabelen, strings en opmerkingen, verschillende kleuren krijgen. Dit helpt ontwikkelaars om de structuur en betekenis van de code sneller te begrijpen.
Code suggestions/completion: ook bekend als IntelliSense, biedt intelligente code-aanvullingen terwijl je typt.
Debugger: De ingebouwde debugger in VSCode helpt ontwikkelaars om hun code te testen en fouten op te sporen. Het biedt functies zoals breakpoints, stap-voor-stap uitvoering, en het inspecteren van variabelen. Dit versnelt de cyclus van bewerken, compileren en debuggen, waardoor ontwikkelaars efficiënter kunnen werken.
Linter: analyseert de code op semantische en stilistische problemen. Het helpt bij het identificeren en corrigeren van subtiele programmeerfouten en coding practices die tot fouten kunnen leiden.
Formatter: maakt de broncode gemakkelijker leesbaar door mensen door bepaalde regels en conventies af te dwingen, zoals lijnspatiëring, inspringing en spatiëring rond operators.
Code navigation shortcuts: VSCode biedt verschillende sneltoetsen om efficiënt door je code te navigeren.
(code templates)
Deze zijn meestal programmeertaal specifiek en moet je dus voor elke taal apart instellen. Soms kan je ook pakketten van extensies downloaden
Enkele development environments:
C development environment
Om broncode in C te runnen op je WSL ga je een aantal prerequisites nodig hebben:
gcc: Install via sudo apt install gcc -y. De GNU Compiler Collection is een verzameling van compilers voor verschillende programmeertalen zoals C, C++, Objective-C, Fortran, Ada, en meer.
make: Install via sudo apt install make -y. Dit is een tool die de bouw van softwareprojecten automatiseert door gebruik te maken van een Makefile om rules en dependencies te definiëren.
Verder kunnen volgende VSCode extensies handig zijn om je development proces te optimaliseren:
C C++ Extension Pack: dit bevat volgende extensies
C/C++: The C/C++ extension adds language support for C/C++ to Visual Studio Code, including editing (IntelliSense) and debugging features.
C/C++ Themes
CMake Tools: CMake Tools provides the native developer a full-featured, convenient, and powerful workflow for CMake-based projects in Visual Studio Code.
Java development environment
Om Javacode te runnen en te compileren op je WSL ga je een aantal prerequisites nodig hebben:
java: Install via sudo apt install default-jre -y. De Java Runtime Environment (JRE) is een softwarelaag die nodig is om Java-applicaties uit te voeren. Het bevat de Java Virtual Machine (JVM), kernbibliotheken en andere componenten die nodig zijn om Java-programma’s te draaien.
javac: Install via sudo apt install default-jdk -y. De Java Development Kit (JDK) is een softwaredeveloperskit die de tools en bibliotheken bevat die nodig zijn om Java-applicaties te ontwikkelen en te compileren. Het omvat de Java Runtime Environment, een compiler (javac), en andere hulpmiddelen zoals een debugger en documentatiegenerator.
gradle: Install via sudo snap install gradle --classic. Gradle is een open-source build automation tool die wordt gebruikt voor het ontwikkelen van softwareprojecten. Het ondersteunt het bouwen, testen, en implementeren van applicaties en is vooral populair in Java- en Android-ontwikkeling vanwege zijn flexibiliteit en krachtige configuratiemogelijkheden.
create gradle project in directory: gradle init
update gradle version per project: gradle wrapper --gradle-version x.x.x.
Verder kunnen volgende VSCode extensies handig zijn om je development proces te optimaliseren:
Extension Pack for Java: dit bevat volgende extensies
Language Support for Java(TM) by Red Hat: Java Linting, Intellisense, formatting, refactoring, Maven/Gradle support and more… Java Linting, Intellisense, formatting, refactoring, Maven/Gradle support and more… Provides Java ™ language support via Eclipse ™ JDT Language Server, which utilizes Eclipse ™ JDT, M2Eclipse and Buildship.
Debugger for Java: A lightweight Java Debugger based on Java Debug Server which extends the Language Support for Java by Red Hat. It allows users to debug Java code using Visual Studio Code (VS Code).
Test Runner for Java: A lightweight extension to run and debug Java test cases in Visual Studio Code.
Maven for Java: Maven extension for VS Code. It provides a project explorer and shortcuts to execute Maven commands, improving user experience for Java developers who use Maven.
Gradle for Java: This VS Code extension provides a visual interface for your Gradle build. You can use this interface to view Gradle Tasks and Project dependencies, or run Gradle Tasks as VS Code Task. The extension also offers better Gradle file (e.g. build.gradle) authoring experience including syntax highlighting, error reporting and auto completion.
Project Manager for Java: A lightweight extension to provide additional Java project explorer features. It works with Language Support for Java by Red Hat.
IntelliCode: The Visual Studio IntelliCode extension provides AI-assisted development features for Python, TypeScript/JavaScript and Java developers in Visual Studio Code, with insights based on understanding your code context combined with machine learning.
Python development environment
Om Pythoncode te runnen (en te compileren) op je WSL ga je een aantal prerequisites nodig hebben:
python3: Install via sudo apt install python3 -y. Het package python3 heb je nodig om Python 3 scripts en programma’s uit te voeren. Het bevat de interpreter en de standard libraries die essentieel zijn voor het draaien van Python 3 code.
pip: Install via sudo apt-get install python3-pip. Pip is een package manager voor Python die wordt gebruikt om Python-packages te installeren en te beheren.
update pip via pip install --upgrade pip
pyinstaller: Install via pip install pyinstaller. PyInstaller is een tool dat Python-scripts bundelt tot stand-alone executables voor Windows, macOS en Linux. Het maakt het mogelijk om Python-applicaties te distribueren zonder dat gebruikers een Python-omgeving hoeven te installeren.
Verder kunnen volgende VSCode extensies handig zijn om je development proces te optimaliseren:
Python: A Visual Studio Code extension with rich support for the Python language (for all actively supported Python versions), providing access points for extensions to seamlessly integrate and offer support for IntelliSense (Pylance), debugging (Python Debugger), formatting, linting, code navigation, refactoring, variable explorer, test explorer, and more!
Python Debugger: A Visual Studio Code extension that supports Python debugging with debugpy. Python Debugger provides a seamless debugging experience by allowing you to set breakpoints, step through code, inspect variables, and perform other essential debugging tasks. The debugpy extension offers debugging support for various types of Python applications including scripts, web applications, remote processes, and multi-threaded processes.
Pylance: Pylance is an extension that works alongside Python in Visual Studio Code to provide performant language support. Under the hood, Pylance is powered by Pyright, Microsoft’s static type checking tool. Using Pyright, Pylance has the ability to supercharge your Python IntelliSense experience with rich type information, helping you write better code faster.
2. Versie- en issuebeheer
Wat is de beste manier om source code te bewaren?
Wat is versiebeheer of source control?
Source Control is een sleutelbegrip voor ontwikkelteams. Het stelt iedereen in staat om aan dezelfde source file te werken zonder bestanden op- en neer te sturen, voorziet backups, maakt het mogelijk om releases en branches uit te rollen, …
Een versiebeheer systeem bewaart alle wijzigingen aan (tekst)bestanden. Dat betekent dat eender welke wijziging, door wie dan ook, teruggedraaid kan worden. Zonder versiebeheer is het onmogelijk om code op één centrale plaats te bewaren als er met meerdere personen aan wordt gewerkt. Zelfs met maar één persoon is het toch nog steeds sterk aan te raden om te werken met versionering. Fouten worden immers snel gemaakt. Een bewaarde wijziging aan een bestand is permanent op je lokale harde schijf: de volgende dag kan je niet het origineel terug boven halen. Er wordt samen met delta’s ook veel metadata bewaard (tijdstippen, commit comments, gebruikers, bestandsgroottes, …)
Zonder versionering stuurt iedereen e-mail attachments door naar elkaar, in de verwachting een aangepaste versie terug te ontvangen. Maar, wat gebeurt er met:
Conflicten? (iemand wijzigt iets in dezelfde cel als jij)
Meerdere bestanden? (je ontvangt verschillende versies, welke is nu relevant?)
Nieuwe bestanden? (je ontvangt aparte bestanden met nieuwe tabbladen)
Bestandstypes? (iemand mailt een .xslx, iemand anders een .xls)
…
Het wordt al snel duidelijk dat het delen van celdata beter wordt overgelaten aan Google Sheets, waar verschillende mensen tegelijkertijd gegevens in kunnen plaatsen. Hetzelfde geldt voor source code: dit wordt beter overgelaten aan een versiebeheer systeem.
What is “version control”, and why should you care? Version control is a system that records changes to a file or set of files over time so that you can recall specific versions later
Pro Git
Subsections of 2. Versie- en issuebeheer
Lokaal versiebeheer: Git
Git installeren
Het versiebeheer systeem dat we in dit opleidingsonderdeel zullen gebruiken is Git. ‘Git’ staat voor Global Information Tracker of met andere woorden Git wordt gebruikt om informatie over bestanden te volgen.
Via volgende link kan je alle up-to-date informatie terugvinden over hoe je Git op je besturingssysteem kan istalleren: Installatie procedure Git
Je kan checken of Git al geïnstalleerd is op je WSL door de versie ervan op te vragen via de CLI met git --version. Indien je geen error krijgt, is Git klaar voor gebruik. Als Git nog niet geïnstalleerd zou zijn kan je dit eenvoudig installeren met sudo apt install git.
Git gui en Git bash
Het is ook mogelijk een GUI voor Git te installeren, maar deze gaan we niet gebruiken. Dit is overigens ook nog niet mogelijk aangezien we enkel een CLI interface hebben voor onze WSL.
De git-gui app is een beginner vriendelijke omgeving om gemakkelijk met versiebeheer aan de slag te gaan. In de SES-lessen willen we je echter de ‘correcte’ manier aanleren om als software ontwikkelaar overweg te kunnen met verschillende handige tools zoals Git. Ook daarom gaan we de grafische interface van Git niet gebruiken.
Info
We gaan gebruik maken van de Git commands in een CLI om aan versiebeheer te doen. Windows gebruikers kunnen hiervoor best gebruik maken van det Git Bash applicatie die gelijktijdig met git geïnstalleerd werd. Mac OS en Linux gebruikers kunnen gebruik maken van hun eigen terminal applicatie.
De git workflow
Note
SVN, RCS, MS SourceSafe, CVS, … zijn allemaal version control systemen (VCS). Merk op dat Git géén klassieke “version control” is maar eerder een collaboratieve tool om met meerdere personen tegelijkertijd aan verschillende versies van een project te werken. Er is geen revisienummer dat op elkaar volgt zoals in CVS of SVN (v1, v2, v3, v’), en er is geen logische timestamp. (Zie git is not revision control).
Ook, in tegenstelling tot bovenstaande tools, kan je Git ook compleet lokaal gebruiken, zonder ooit te pushen naar een upstream server. Het is dus “self-sufficient”: er is geen “server” nodig: dat is je PC zelf.
In Git doorloopt een bestand verschillende statussen tijdens zijn levenscyclus. Wanneer een bestand voor het eerst wordt aangemaakt, is het untracked, wat betekent dat Git het nog niet volgt. Zodra je het bestand toevoegt aan de repository met git add, wordt het staged. Dit betekent dat het klaar is om gecommit te worden. Als je het bestand daarna bewerkt, wordt het modified. Om deze wijzigingen op te nemen in de volgende commit, moet je het bestand opnieuw stagen met git add. Wanneer je tevreden bent met de wijzigingen, gebruik je git commit om de wijzigingen definitief vast te leggen (commit) in de repository. Als je een bestand niet langer nodig hebt, kun je het verwijderen met git rm, waardoor het bestand uit de repository wordt verwijderd en de status van het bestand verandert. Gedurende deze cyclus kan een bestand ook de status unmodified hebben, wat betekent dat er geen wijzigingen zijn aangebracht sinds de laatste commit.
Git correct configureren
Om van start te kunnen gaan, moeten we Git eerst nog correct configureren. Dit doen we via de volgende commando’s:
We gaan in de volgende les een account aanmaken op een cloudplatform waarmee we ons versiebeheer gaan kunnen uitbreiden met cloud opslag en de mogelijkheid om samen te werken aan hetzelfde project. Hiervoor gaan we een gratis account moeten aanmaken met een emailadres en een username. Dit moeten dezelfde worden als de gegevens die je zojuist hebt geconfigureerd. Je kan deze gegevens later nog aanpassen.
Soms ga je een text editor moeten gebruiken om bijvoorbeeld een boodschap mee te geven bij elke nieuwe ‘versie’ die we gaan opslaan. Standaard wordt hier Vim voor gebruikt, maar aangezien dit niet zo beginnersvriendelijk is, veranderen we de default liever naar iets anders zoals Nano. Dit doe je via het volgende commando:
$ git config --global core.editor nano
Een directory initialiseren als een Git directory
Om van een directory een git directory te maken en alle veranderingen beginnen tracken gebruiken we het commando:
$ git init
Er verschijnt een verborgen folder in je directory genaamd .git. In die folder zal git alles bewaren om de veranderingen te tracken en een geschiendenis van alle versies bij te houden.
Checking, staging and committing changes
Met het commando $ git status kan je controleren in welke staat alle files/directories zich bevinden. Een file/folder kan zich in één van drie toestanden bevinden:
Modified: de file is aangepast maar nog niet gestaged.
Staged: de file is gestaged en klaar om gecommit te worden.
Committed: de file is sinds zijn laatste commit niet meer aangepast.
(Untracked: dit geldt enkel voor files/folders die juist aangemaakt zijn en nog nooit gecommit werden)
Een voorbeeld output ziet er als volgt uit:
arne@LT3210121:~/gittest$ git status
On branch master
No commits yet
Changes to be committed:
(use "git rm --cached ..." to unstage)
new file: eenfile.txt
Changes not staged for commit:
(use "git add ..." to update what will be committed)
(use "git restore ..." to discard changes in working directory)
modified: eenfile.txt
Untracked files:
(use "git add ..." to include in what will be committed)
filetwee.txt
Met het commando $ git diff kan je de verschillen zien tussen de huidige staat van je files/folders versus de staat van de files/folders in de laatste commit.
Met het commando $ git add <filenaam> kan je afzonderlijk modified files naar de staging area ‘verplaatsen’. Volgens een goede version control strategy, is dit de manier om wijzigingen te stagen. Op die manier kan je gerichte commits maken die over een bepaald onderwerp gaan. In de praktijk wordt er soms echter voor snelheid gekozen. Zo kan je met $ git add . alle wijzigingen en nieuwe files in een keer stagen.
Met het commando $ git commit kan je alle files committen die gestaged zijn. Je teksteditor opent en je wordt gevraagd een message mee te geven met de commit. Probeer steeds een concrete boodschap mee te geven met elke commit bv. “Bug opgelost waarbij alles vetgedrukt stond”. Probeer elke commit message zo zinvol mogelijk te maken. Dit maakt het later makkelijk om terug te keren naar een belangrijke ‘versie’ van je broncode. Elke commit krijgt ook een specifieke hash zodat je gemakkelijk naar een specifieke ‘versie’ (commit) kan refereren.
Alle modified files stagen met 1 commando: $ git add .
Onmiddellijk een commit message meegeven binnenin het commit commando: $ git commit -m "mijn commit message"
combinatie van de twee bovenstaande commando’s: $ git commit -a -m "mijn commit message"
Opgelet Met $ git commit -a worden nieuwe files niet toegevoegd. Gebruik hier dus nog $ git add voor.
Undo changes
git status geeft je ook informatie over hoe je files/folders kan unstagen en zelfs de veranderingen van files kan terugbrengen naar hoe ze eruit zagen tijdens de laatste commit.
Gebruik: git rm --cached <file>... om te unstagen en git restore <file>... om wijzigingen terug te draaien (te discarden).
Bekijk de version tree (log)
Het commando $ git log wordt gebruikt om een lijst te zien van all je commits in chronologische volgorde, startend bij de laatste commit. Dat commando geeft veel informatie mee voor elke commit. Wil je liever een korter overzicht? Gebruik dan $ git log --oneline. (Met de --graph flag kan je ook de boomstructuur weergeven waar we zo dadelijk op terugkomen)
Met de flag --all kan je ervoor zorgen dat je zeker ben dat je alle branch information en tags te zien krijgt
Teruggaan naar vorige ‘versies’
Er bestaan commando’s die we kunnen gebruiken om naar een vroegere commit terug te keren. Ze werken echter op een verschillende manier en het is belangrijk dat je deze verschillen goed kent.
$ git reset <hash van de commit>: hiermee ga je terug naar een vorige commit en worden alle commits die erna gebeurden verwijderd.
$ git checkout <hash van de commit>: hiermee kan je tijdelijk terugkeren naar een vorige commit. Je zit hier dan in een soort virtuele omgeving waar je ook commits kan aanbrengen. Om dan terug te keren naar de HEAD van de versiegeschiedenis gebruik je:
$ git switch -c <branchname> : om je nieuwe virtuele commits op te slaan in een echte nieuwe branch.
$ git switch - : om terug te keren zonder je nieuwe virtuele commits bij te houden.
Snel een extra wijziging aanbrengen aan een vorige commit
Soms ga je snel te werk en heb je een commit gedaan, maar is er nog een kleine wijziging die eigenlijk ook nog best tot die commit hoort. In de plaats van een volledig nieuwe commit te doen kan je via git commit --amend je nieuwe gestagede files/folders toevoegen aan de laatste commit en de commit message eventueel wijzigen.
Dit is ook een handige manier om incrementeel en regelmatig een commit te doen, ook al denk je dat je werk nog geen volwaardige commit vereist. Je blijft gewoon stap na stap delen toevoegen tot je tevreden bent.
Warning
Het git commit --amend commando kan je remote repository in de war sturen, wanneer een commit bijvoorbeeld gepushed is naar de remote en daarna lokaal nog geamend wordt.
Branching
Het fijne aan Git is dat je parallel aan verschillende versies van je project kan werken door gebruik te maken van branching. Je start letterlijk een nieuwe tak waar je eigen commits kan aan toevoegen. Zo kan elk teamlid bijvoorbeeld zijn eigen branch aanmaken. Of je kan een brach aanmaken om aan een nieuwe feature te werken zonder dat je schrik moet hebben om problemen te creëren op de main branch.
Je maakt een nieuwe branch aan met het commando: $ git branch <branchnaam>
Je kan de verschillende branches oplijsten met: $ git branch
Het * symbool duidt de actieve branch aan.
Je kan naar een bepaalde branch gaan met: $ git checkout <branchnaam>
Je kan een branch deleten met: $ git branch -d <branchnaam>
Om een grafische voorstelling van de braches te laten printen bij het log commando gebruik je de --graph flag.
Merging
Je kan de commits van een zijtak nu terug toevoegen aan de historie van een andere tak. Dit doe je met het commando $ git merge <branchname>. Wil je bijvoorbeeld de wijzigingen van de new_feature branch mergen met de main branch, dan moet je eerst zien dat je je in de main branch bevindt en dan gebruik je het volgende commando: $ git merge new_feature
Merge conflicts
Wanneer je op twee verschillende branches echter commits heb die verschillende aanpassingen maken aan hetzelfde stukje code in je project dan ontstaat er een merge conflict. Je zal dan beide versies voorgeschoteld krijgen en jij moet dan de versie verwijderen die je niet wilt behouden. Dan pas kan de ‘merge’ succesvol afgerond worden.
.gitignore
Soms wil je dat een file niet wordt bijgehouden door git. Hier komen we in het gedeelte over remote repositories op terug. De files/folders die niet getracked mogen worden, moeten we in het bestand .gitignore toevoegen.
Klik hier om een voorbeeld .gitignore file te zien/verbergen🔽
# Negeert het bestand secret.txt in de root directory.
/secret.txt
# Negeert alle bestanden in de node_modules directory.
node_modules/
# Negeert de gehele directory build en alle subdirectories.
/build/
# Negeert alle bestanden met de extensie .log.
*.log
# Negeert alle bestanden in de temp directory, maar niet de subdirectories.
/temp/*
# Negeert alle .tmp bestanden in alle directories en subdirectories.
**/*.tmp:
# Negeert alle bestanden in de logs directory.
/logs/*
# Negeert NIET het bestand debug.log in de logs directory.
!/logs/debug.log:
# Negeert alle bestanden die beginnen met een tilde (~).
~*
Tagging
Om bepaalde belangrijke commits snel terug te vinden, kan je gebruik maken van tags.
Je kan de bestaande tags oplijsten met: $ git tag
Je kan de huidige commit taggen met bijvoorbeeld: $ git tag vX.X
Je kan een specifieke commit taggen met bijvoorbeeld: $ git tag vX.X <hashcode commit>
Je kan tags deleten met bijvoorbeeld: $ git tag -d vX.X
**Eens een commit een tag heeft gekregen kan je er ook naar refereren via de tag.
Indien je een bestaande tag wil verplaatsen, moet je ze eerst deleten en daarna toevoegen aan de nieuwe commit.
Belangrijk
We hebben hier enkel de basis commando’s aangehaald met een paar van de meest gebruikte manieren waarop ze gebruikt worden. Voor meer diepgang en achtergrond van alle git commando’s verwijs ik naar het Pro Git handboek. Vooral hoofdstuk 1 t.e.m. 3 zijn voor jullie interessant.
Oefeningen
Maak een git directory aan
Voeg één of meerdere tekstbestanden toe aan deze directory. Maak eventueel subfolders …
Gebruik $ git status om te zien wat er allemaal gecommit kan worden.
Commit deze veranderingen.
Maak aanpassingen aan de tekstbestanden en commit.
Keer terug naar de eerste commit via $ git reset
Bestudeer de output van $ git log
Maak een nieuwe branch en chechout naar die nieuwe branch
Voeg in deze branch nieuwe textbestanden toe en commit de verandering in die nieuwe branch.
Open File Explorer en doe een checkout terug naar de main branch. Zie je de nieuwe files verdwijnen/verschijnen wanneer je tussen de twee branches wisselt?
Merge je nieuwe branch met de main branch.
Maak veranderingen in de main branch en commit.
Switch terug naar je andere branch en maak op dezelfde plaats veranderingen en commit. Zo kan ga je een mergeconflict opwekken.
Probeer nu weer je nieuwe branch te mergen met je main branch. Je zal eerst het merge conflict moeten oplossen
Tag je eerste commit met v1.0 en tag je laatste commit met v2.0.
Doe een git checkout naar je eerste commit door gebruik te maken van je nieuwe tags.
Probeer van alle bovenstaande stappen een schets te maken om zo na te gaan of je alle commando’s goed begrijpt.
Indien niet afgewerkt in het applicatiecollege, gelieve thuis af te werken tegen volgende les.
Extra
Wildcards en commando’s aaneenschakelen
Wildcards kunnen gebruikt worden om naar meerdere files tegelijk te verwijzen. Zo kan je de wildcard ‘_’ gebruiken om naar alle files die eindigen op ‘.txt’ te verwijzen.Bijvoorbeeld: **$ git add _.txt**
Je kan commando’s ook aaneenschakelen met ‘;’ zodat meerdere commando’s onmiddellijk na elkaar uitgevoerd wordenBijvoorbeeld: $ git add . ; git commit -m "initial commit"
Extra bronnen
Pro Git handboek, vooral hoofdstuk 1 tot en met 3.
Wil je meer inzicht in hoe git achter de schermen precies werkt? Lees dan zeker dit artikel eens! Die geeft je op een toegankelijke manier uitleg over hoe git wijzigingen opslaat, wat de staging area precies is, etc. (Opmerking: het artikel begint met een vergelijking met SVN; dat is een ander/ouder versiecontrolesysteem.)
Remote repositories: Github
Types van versiebeheer systemen
Git is een gedecentraliseerd versiebeheer systeem waarbij de hele repository inclusief historiek lokaal wordt geplaatst zodra een clone commando wordt uitgevoerd. Oudere gecentraliseerde systemen zoals SVN en CVS bewaren (meta-)data op één centrale plaats: de version control server. Voor dit vak wordt resoluut voor git gekozen.
Info
Een repository (of repo) is een opslagplaats waar alle bestanden en hun geschiedenis van een project worden bewaard. In de context van Git is een repository een verzamelplaats voor alle projectbestanden, inclusief hun revisiegeschiedenis. Dit betekent dat elke wijziging die je aanbrengt in de bestanden wordt bijgehouden, zodat je altijd kunt terugkeren naar een eerdere versie als dat nodig is.
Onderstaande Figuur geeft het verschil weer tussen een gecentraliseerd versioneringssysteem, zoals SVN, CVS en MS SourceSafe, en een gedecentraliseerd systeem, zoals Git. Elke gebruiker heeft een kopie van de volledige repository op zijn lokale harde schijf staan. Bij SVN communiceren ‘working copies’ met de server. Bij Git communiceren repositories (inclusief volledige history) met eender welke andere repository. ‘Toevallig’ is dat meestal een centrale server, zoals Github.com, Gitlab.com of BitBucket.com.
Andere populaire platformen zijn Gitlab, Codeberg, Gitea (self-hosted), … Deze software platformen voorzien een extra laag bovenop Git (de web UI) die het makkelijk maakt op via knoppen acties uit te voeren. Het voegt ook een social media esque aspect toe: comments, requests, issues, …
Note
SVN, RCS, MS SourceSafe, CVS, … zijn allemaal version control systemen (VCS). Merk op dat Git géén klassieke “version control” is maar eerder een collaboratieve tool om met meerdere personen tegelijkertijd aan verschillende versies van een project te werken. Er is geen revisienummer dat op elkaar volgt zoals in CVS of SVN (v1, v2, v3, v’), en er is geen logische timestamp. (Zie git is not revision control).
Ook, in tegenstelling tot bovenstaande tools, kan je Git ook compleet lokaal gebruiken, zonder ooit te pushen naar een upstream server. Het is dus “self-sufficient”: er is geen “server” nodig: dat is je PC zelf.
Remote repository met Github
“… GitHub is a website and cloud-based service that helps developers store and manage their code, as well as track and control changes to their code. …”
source
Cloud based storage is zeer populair geworden en voor goede redenen:
Je hebt een backup van al je data.
Je kan op meerdere verschillende pc’s aan hetzelfde project werken zonder met een USB-stick dingen te copy pasten.
Je kan met meerdere personen aan hetzelfde project werken zonder doorsturen van verschillende file versies via mail.
Github is zeer populair en wordt door veel softwareontwikkelaars en bedrijven gebruikt. Wij zullen dit platform dan ook gebruiken in dit opleidingsonderdeel en het is ook aangeraden dit in de toekomst voor je softwareprojecten te gebruiken.
GitHub is nu eigendom van Microsoft en is closed source software. Je kan wel de meeste features volledig gratis gebruiken. Wil je later toch liever een open source alternatief gebruiken? Dan is Gitlab een goed alternatief en werkt zeer gelijkaardig aan Github. Gitea is een goed self-hosted optie
Een account aanmaken, 2FA instellen, SSH key toevoegen
Voordat we van start kunnen gaan moeten we eerst een gratis account aanmaken. Verder moeten we ook nog een aantal belangrijke instellingen veranderen voordat we van de services gebruik kunnen maken.
Aangezien het stappenplan voor het aanmaken van een account en de gewenste instellingen wel eens kan wijzigen refereren we hieronder naar de officiële bronnen die door de ontwikkelaar up-to-date gehouden worden:
Account aanmaken op Github (Let op! gebruik hetzelfde email adres en dezelfde username die je hebt geconfigureerd in je lokale Git om het process zo vlot mogelijk te laten verlopen)
Repositories clonen en veranderingen fetchen, pullen & pushen
Je kan via de webinterface van Github nieuwe repositories aanmaken. Github geeft je dan alle nodige instructies/commando’s om oftewel die remote repo te koppelen aan een bestaande lokale Git directory of een nieuwe lege repo lokaal te clonen.
Vanaf nu bestaat er een link tussen de lokale repository en de remote repository.
Je kan nieuwe aanpassingen van je remote repository pullen naar (≈ synchroniseren met) je lokale repo met:
$ git pull: Dit haalt alle veranderingen binnen en past ze onmiddelijk toe
$ git fetch: Dit haalt alle veranderingen binnen maar zo worden nog niet gemerged met jouw lokale versie. Dit doe je met $ git merge. (git pull is dus eigenlijk een aaneenschakeling van git fetch en git merge)
Je kan nieuwe aanpassingen van je lokale repository pushen naar je remote repo met:
$ git push origin: Dit pusht enkel je huidige branch.
$ git push origin --all: Dit pusht de wijzigingen op alle branches.
$ git push origin --tags: Dit pusht alle tags die nog niet aanwezig zijn op de remote repo.
Warning
Voeg files met gevoelige informatie zoals wachtwoorden onmiddellijk toe aan je .gitignore-file VOORDAT je een push doet naar de remote repository. Eens een bestand eenmaal gepushed is naar de remote zal er namelijk altijd een kopie van op het internet te vinden zijn en dat wil je natuurlijk niet als het gaat over je wachtwoord of api-key bijvoorbeeld.
Collaboration made easy
Door een teamgenoot als collaborator toe te voegen aan je remote repository kan hij/zij de repo lokaal clonen en ook wijzigingen via pushen en pullen. Door dit te combineren met branching waarbij elke collaborator bijvoorbeeld een eigen branch aanmaakt, kan je een zeer efficiënte online workflow creëren om remote te kunnen samen werken aan software projecten.In principe kan iedereen een public remote repository clonen, maar ze gaan geen veranderingen kunnen pushen. (Hiervoor gebruik je fork en pull requests)
Collaborative workflow: to minimize merge conflicts and other problems
Regularly pull all changes into your local directory.
Regularly keep your branch up-to-date by merging master branch into it.
(Merge the changes on your branch to the master branch when nessecary)
Regularly push to remote directory.
Forking en pull requests
Je kan een public repository waar je geen collaborator van bent forken (≈ kopiëren) naar je eigen Github. Zo kan je toch nieuwe aanpassingen pushen naar ‘jouw kopie’ van de repository. Github houd echter bij vanwaar jouw fork afkomstig is. Op die manier kan je een pull request aanvragen aan de eigenaar van de originele repo. Die persoon kan jouw aanpassingen dan nakijken en toevoegen aan zijn/haar repo door de pull request te accepteren en mergen.
Een waarheidsgetrouw voorbeeld ziet er als volgt uit:
Je wil een kleine software developer helpen door een bug op te lossen.
Je forked de repository van de software.
Je fixed de bug, commit en pushed de aanpassing naar jouw fork van de software repo.
Je dient een pull request in bij de originele repo van de software.
De eigenaar vindt jouw oplossing geweldig en merged hem met zijn eigen repo.
Op deze manier kan je zeer gemakkelijk je steentje bijdragen aan de wereld van open source software en dit maakt het principe van open source ook juist mede zo aantrekkelijk.
Oefeningen
Maak in je Github een repository aan voor je Git directory uit de vorige oefenreeks.
Koppel je lokale repo aan je remote repo en push je versiegeschiedenis naar Github.
Push ook je tags mee naar de remote repo en controleer of ze te zien zijn bij versions
Clone op een andere plaats op je computer je remote repository
Maak veranderingen aan de files, commit en push
Fetch of pull nu die veranderingen in de originele directory.
Denk na over het verschil tussen fetch en pull.
Bestudeer de online user interface van Github voor het beheren van je remote repositories
Voeg een medestudent toe als collaborator aan het project.
Laat die student je repo clonen, veranderingen aanbrenge, committen en pushen.
Pull nu de veranderingen van je teamgenoot in je eigen local repository
Laat je als collaborator verwijderen
Fork nu de repository van je medestudent, maak aanpassingen en submit een pull request.
Bestudeer nu grondig de git log van je repository. Kan je alle veranderingen volgen?
(Submit een issue in je eigen remote repository, los de issue op met een commit en gebruik de issuenummer met het sleutelwoord ‘Close’ (Close #<issuenummer>) om de issue automatisch op te lossen)
Extra
De Git workflow
Een typische workflow is als volgt:
git clone [url]: Maakt een lokale repository aan die je op Github hebt gecreëerd. Het commando maakt een subdirectory aan.
Doe je programmeerwerk.
git status en git diff: Bekijk lokale changes voordat ze naar de server gaan.
git add [.]: Geef aan welke changes staged worden voor commit
git commit -m [comment]: commit naar je lokale repository. Wijzingen moeten nu nog naar de Github server worden gesynchroniseerd.
git push: push lokale commits naar de Github server. Vanaf nu kan eender wie die meewerkt aan deze repository de wijzigingen downloaden op zijn lokaal systeem.
git pull: pull remote changes naar je lokale repository. Wijzigingen moeten altijd eerst gepushed worden voordat iemand anders kan pullen.
Ik zie niet alle branches lokaal die wel op Github staan, hoe komt dat?
Een git pull commando zal soms in de console ’new branch [branchnaam]’ afdrukken, maar toch zal je deze niet tot je beschikking hebben met het git branch commando. Dat komt omdat branches dan ook nog (lokaal) getracked moeten worden:
Controleer welke branch je lokaal wilt volgen met git branch -r
Selecteer een bepaalde branch, waarschijnlijk origin/[naam]
Track die branch lokaal met git branch --track [naam] origin/[naam]
Vanaf nu kan je ook switchen naar die branch, controleer met git branch
Merk op dat een branch verwijderen met git branch -d enkel gaat bij lokale branches. Een remote branch verwijderen wordt niet met het branch subcommando gedaan, maar met git push origin --delete [branch1] [branch2] ....
Bug tracking met Github
Enkele ‘kleine probleempjes’ in software worden al snel een hele berg aan grote problemen als er niet op tijd iets aan wordt gedaan. Bedrijven beheren deze problemen (issues) met een bug tracking systeem, waar alle door klant of collega gemeldde fouten van het systeem in worden gelogd en opgevolgd. Op die manier kan een ontwikkelaar op zoek naar werk in deze lijst de hoge prioritaire problemen identificeren, oplossen, en terug koppelen naar de melder.
graph LR;
Bug[Bug discovery]
Report[Bug report]
Backlog[Bug in backlog met issues]
Prio[Bug in behandeling]
Fix[Bug fixed]
Bug --> Report
Report --> Backlog
Backlog --> Prio
Prio --> Fix
De inhoud van deze stappen hangt af van bedrijf tot bedrijf, maar het skelet blijft hetzelfde. Bugs worden typisch gereproduceerd door middel van unit testen op een bepaalde git branch om de bestaande ontwikkeling van nieuwe features niet in de weg te lopen. Wanneer het probleem is opgelost, wordt deze branch gemerged met de master branch:
Github Issues is een minimalistische feature van Github die het mogelijk maakt om zulke bugs op te volgen. Een nieuw issue openen en een beschrijving van het probleem meegeven (samen met stappen om het te reproduceren), maakt een nieuw item aan dat standaard op ‘open’ staat. Dit kan worden toegewezen aan personen, en daarna worden ‘closed’, om aan te geven dat ofwel het probleem is opgelost, ofwel het geen echt probleem was. Issues kunnen worden gecategoriseerd door middel van labels.
Versiebeheer met je favoriete IDE
Sommige IDE’s komen met built-in tools of plug-ins om gemakkelijk gebruik te maken van Version Control en remote repositories. Ik raad je echter aan eerst voldoende te oefenen met de command line tools zodat je altijd weet wat je aan het doen bent. Op die manier ga je beter en vooral sneller problemen kunnen oplossen. Indien je de ‘hard’ way onder de knie hebt, kan je natuurlijk overschakelen naar eender welke tool jij het handigste vindt.
Github Classroom
GitHub Classroom is een hulpmiddel waarmee docenten eenvoudig programmeeropdrachten kunnen beheren en beoordelen via GitHub. Met deze tool kunnen docenten repositories aanmaken voor studenten, opdrachten automatisch toewijzen en versiebeheer integreren in het leerproces. Studenten werken in hun eigen repository, waardoor docenten de voortgang kunnen volgen en feedback kunnen geven op basis van commits en pull requests. Dit vereenvoudigt het beheer van opdrachten en stimuleert het gebruik van professionele ontwikkelpraktijken in het onderwijs.
Opdracht: zie opdrachten
3. Build systems and Makefiles
Hoe ga je van broncode naar een werkende software applicatie
In de wereld van software engineering vormt de compiler een essentieel hulpmiddel dat de kloof overbrugt tussen de door ontwikkelaars geschreven broncode en de uitvoerbare machinecode die door de computer wordt begrepen. Wanneer programmeurs een programma schrijven, doen zij dit vaak in een hoog-niveau taal die leesbaar en begrijpelijk is voor mensen. De compiler vertaalt deze code vervolgens naar een lager-niveau, binaire instructies die specifiek zijn voor de hardware (CPU architecture) waarop het programma draait.
Het compilatieproces bestaat doorgaans uit verschillende stappen die gezamenlijk zorgen voor een correcte en efficiënte omzetting van de broncode.
Allereerst vindt een lexicale analyse plaats, waarbij de broncode wordt opgesplitst in basiselementen, of tokens.
Vervolgens controleert de parser of deze tokens in de juiste volgorde staan volgens de grammaticale regels van de taal, waardoor een abstracte syntaxisboom ontstaat.
Hierna volgt een semantische analyse om te verifiëren dat de code logisch en consistent is.
Optimalisatiefasen kunnen daarna ingrijpen om de prestaties te verbeteren, waarna de codegenerator de uiteindelijke machinecode produceert.
Ten slotte wordt deze code vaak gekoppeld/gelinkt met externe bibliotheken en modules, zodat er een volledig functioneel uitvoerbaar bestand ontstaat, ook wel een executable of binary genoemd.
Naast de technische vertaalslag biedt het gebruik van een compiler ook andere belangrijke voordelen.
Zo kan de compiler programmeerfouten al vroeg in het ontwikkelproces opsporen, zoals syntaxis- of typefouten, waardoor deze sneller gecorrigeerd kunnen worden.
Tevens zorgt de optimalisatie tijdens de compilatie ervoor dat de uiteindelijke applicatie efficiënter draait, wat cruciaal is in productieomgevingen.
Deze scheiding tussen broncode en machinecode maakt het bovendien mogelijk voor ontwikkelaars om zich te richten op de logica en architectuur van hun programma, terwijl de compiler de complexe taak van vertalen en optimaliseren op zich neemt.
Build systems
Naast het compileren en linken van code, kunnen build-systems het manuele werk aanzienlijk vereenvoudigen voor developers. Build-systems automatiseren niet alleen het proces van compileren en linken, maar beheren vaak ook dependencies, voeren tests uit en zorgen voor een consistente en reproduceerbare build-omgeving. Dit betekent dat ontwikkelaars niet langer handmatig complexe commando’s hoeven uit te voeren voor elke stap in het buildproces.
Door alleen de gewijzigde onderdelen opnieuw te compileren, optimaliseren build-systems de efficiëntie en verminderen ze de kans op menselijke fouten. Bovendien dragen ze bij aan een gestandaardiseerde workflow, wat vooral binnen teams zorgt voor een soepelere samenwerking en minder integratieproblemen.
In de volgende onderdelen leer je hoe dit er praktisch uitziet voor verschillende programmeertalen
Subsections of 3. Build systems and Makefiles
Cmdline C Compiling
C programma’s compilen
Source code en header files
De ontwikkeling begint met het schrijven van de broncode in .c-bestanden en het definiëren van functies en variabelen in header files (.h-bestanden). Header files bevatten vaak declaraties van functies en macro’s die in meerdere bronbestanden worden gebruikt.
De preprocessor voert tekstvervangingen uit voordat de daadwerkelijke compilatie begint. Dit omvat het verwerken van #include-directives, het vervangen van macro’s en het uitvoeren van voorwaardelijke compilatie.
De #ifndef-directive staat voor “if not defined” en wordt gebruikt om te voorkomen dat een header file meerdere keren wordt ingeladen, wat kan leiden tot dubbele declaraties en andere problemen. Dit wordt ook wel een include guard genoemd.
Compiler
De compiler vertaalt de preprocessed broncode naar assembly code. Dit is een laag-niveau representatie van de code die specifiek is voor de CPU-architectuur.
gcc -c main.c header.c
Assembler
De assembler vertaalt de assembly code naar objectbestanden (.o-bestanden). Deze objectbestanden bevatten machinecode die door de processor kan worden uitgevoerd, maar zijn nog niet zelfstandig uitvoerbaar.
Linker
De linker neemt de objectbestanden en eventuele bibliotheken en combineert deze tot een enkel uitvoerbaar bestand. De linker lost symbolen op (zoals functie- en variabelenamen) en zorgt ervoor dat alle verwijzingen correct zijn. Bijvoorbeeld:
gcc -o output.bin main.o header.o
Libraries
Libraries bevatten vooraf gecompileerde code die kan worden hergebruikt in verschillende programma’s. Er zijn statische bibliotheken (.a-bestanden) en dynamische bibliotheken (.so-bestanden). Hier komen we later bij dependency management nog op terug.
De Binary
Het eindresultaat is een binary executable die direct door het besturingssysteem kan worden uitgevoerd. Dit bestand bevat de machinecode, evenals alle benodigde symbolen en verwijzingen naar bibliotheken. Je kan je programma runnen met volgende commando:
./output.bin
Door die stappen te doorlopen, wordt de broncode omgezet in een uitvoerbaar programma dat op de doelmachine kan draaien.
C compilation proces
De GNU Compiler Collection: gcc
GCC is een krachtige en veelzijdige compiler die wordt gebruikt voor het compileren van verschillende programmeertalen zoals C, C++, Objective-C, Fortran, Ada en meer. GCC is een essentieel onderdeel van de GNU-toolchain en wordt veel gebruikt in de open-source gemeenschap vanwege zijn flexibiliteit en robuustheid. Het biedt uitgebreide optimalisatiemogelijkheden, foutdetectie en ondersteuning voor verschillende architecturen.
compileren naar .o files: gcc -c ./src/main.c -o ./build/main.o
linken van de .o files: gcc -o program.bin ./build/*.o
Info
Hier enkele veelgebruikte flags voor gcc:
-Wall: Deze flag schakelt de meeste waarschuwingen in, zodat je tijdens het compileren meldingen krijgt over mogelijke problemen in je code. Dit helpt om fouten en onbedoelde gedragingen op te sporen.
-Wextra:Met deze flag worden extra waarschuwingen ingeschakeld die niet door -Wall worden gedekt. Hierdoor krijg je nog meer informatie over potentiële problemen of verbeterpunten in je code.
-std=c11:Deze flag geeft aan dat de compiler de C-standaard uit 2011 (C11) moet gebruiken. Dit zorgt ervoor dat je code voldoet aan de specificaties en functionaliteiten die in deze standaard zijn gedefinieerd.
Automatiseren met een shell script
Het handmatig compileren van bronbestanden kan tijdrovend en foutgevoelig zijn, vooral bij grotere projecten met veel bestanden. Daarom geven we de voorkeur aan het automatiseren van dit proces. Dit kan je eventueel doen met behulp van een Shell script.
Door een script te gebruiken, kunnen we ervoor zorgen dat alle stappen consistent en correct worden uitgevoerd, zonder dat we elke keer dezelfde commando’s hoeven in te typen. Dit vermindert de kans op menselijke fouten, zoals het vergeten van een bestand of het verkeerd typen van een commando. Bovendien maakt automatisering het eenvoudiger om het compilatieproces te herhalen, wat handig is bij het ontwikkelen en testen van software. Het gebruik van scripts verhoogt de efficiëntie en betrouwbaarheid van het ontwikkelproces, waardoor ontwikkelaars zich kunnen concentreren op het schrijven van code in plaats van op het compileren ervan.
Oefening
Extract alle files in dit zip bestand naar een directory naar keuze OF clone de repository. Schrijf een shell script met de naam make.sh dat de volgende dingen kan doen en plaats het in de root van je directory:
compile: Compileert de bronbestanden naar de /build-directory en maakt de binary game.bin in de root directory
clean: Verwijdert de binary en de object files in de build directory
run : Voert de binary uit (bouwt eerst als die nog niet bestaat) en geeft eventuele flags door (bv --hp 12)
Solution: Klik hier om de code te zien/verbergen🔽
#!/bin/sh
# Dit script ondersteunt drie commando's:# compile: Compileert de bronbestanden en maakt de binary# run: Voert de binary uit (bouwt eerst als die nog niet bestaat)# clean: Verwijdert de binary en de build directory# Variabelen (hardcoded voor eenvoud)SRC_DIR="src"BUILD_DIR="build"TARGET="game.bin"CFLAGS="-Wall -Wextra -std=c11"# Zorg dat er minstens één argument is meegegevenif["$#" -lt 1];thenecho"Gebruik: $0 {compile|run|clean} [--hp <waarde>]"exit1fiCOMMAND=$1# Shift the parameters so the second becomes the first etc.shiftif["$COMMAND"="compile"];thenecho"Bouwen van het project..."# Maak de build-directory als deze nog niet bestaatif[ ! -d "$BUILD_DIR"];then mkdir -p "$BUILD_DIR"fi# Compileer main.c en game.c naar objectbestanden in build/echo"Compileren van $SRC_DIR/main.c..." gcc $CFLAGS -c "$SRC_DIR/main.c" -o "$BUILD_DIR/main.o"echo"Compileren van $SRC_DIR/game.c..." gcc $CFLAGS -c "$SRC_DIR/game.c" -o "$BUILD_DIR/game.o"# Link de objectbestanden naar de uiteindelijke binary in de rootecho"Linken naar $TARGET..." gcc $CFLAGS -o "$TARGET""$BUILD_DIR/main.o""$BUILD_DIR/game.o"echo"Build succesvol: $TARGET is aangemaakt."elif["$COMMAND"="run"];then# Bouw de binary als deze niet bestaatif[ ! -f "$TARGET"];thenecho"Binary niet gevonden, eerst bouwen..." sh "$0" build "$@"fiecho"Uitvoeren van $TARGET..." ./"$TARGET""$@"elif["$COMMAND"="clean"];thenecho"Opruimen..."# Verwijder de binary en de build-directory rm -rf "$BUILD_DIR/*" rm -f "$TARGET"echo"Opruimen voltooid."elseecho"Onbekend commando: $COMMAND"echo"Gebruik: $0 {compile|run|clean} [--hp <waarde>]"exit1fi
Moeilijkheden
Bij het handmatig compileren van projecten kunnen er verschillende tekortkomingen optreden:
Een van de grootste uitdagingen is het beheren van afhankelijkheden tussen bestanden. Wanneer een bronbestand wordt gewijzigd, moeten alle gerelateerde bestanden opnieuw worden gecompileerd, wat moeilijk bij te houden is zonder een gestructureerd systeem.
Daarnaast kan het handmatig invoeren van compilatie- en linkcommando’s voor elk bestand tijdrovend en foutgevoelig zijn.
De if-else syntax voor de verschillende opties is ook niet zo een gracieuze oplossing.
Beter, een build system: Makefiles
Makefiles proberen een antwoord te bieden op de tekortkomingen van shell scripts door een gestructureerde en efficiënte manier te bieden om afhankelijkheden en compilatiestappen te beheren. Ze maken gebruik van regels en doelen om automatisch te bepalen welke bestanden opnieuw moeten worden gecompileerd wanneer een bronbestand wordt gewijzigd. Dit voorkomt onnodige hercompilatie en bespaart tijd. Bovendien kunnen Makefiles complexe build-processen eenvoudig beheren door verschillende taken zoals compileren, linken, testen en opruimen te automatiseren. Ze bieden ook flexibiliteit door het gebruik van variabelen en conditionele statements, waardoor dezelfde Makefile kan worden gebruikt voor verschillende configuraties en platformen.
Hoe zijn makefiles opgebouwd?
Makefiles zijn opgebouwd uit een reeks regels die beschrijven hoe verschillende bestanden in een project moeten worden gecompileerd en gelinkt. Elke regel in een makefile bestaat uit drie hoofdonderdelen: doelen, afhankelijkheden en commando’s.
Doelen (Targets): Dit zijn de bestanden die je wilt genereren, zoals objectbestanden of een uitvoerbaar bestand. Een doel kan ook een alias zijn voor een groep commando’s, zoals all of clean.
Afhankelijkheden (Dependencies): Dit zijn de bestanden waarvan het doel afhankelijk is. Als een van deze bestanden wordt gewijzigd, weet make dat het doel opnieuw moet worden gegenereerd.
Commando’s (Commands): Dit zijn de shell-commando’s die worden uitgevoerd om het doel te genereren. Ze MOETEN beginnen met een tab en worden uitgevoerd in de volgorde waarin ze zijn geschreven.
Het doel program hangt af van main.o en header.o. Als een van deze objectbestanden wordt gewijzigd, wordt program opnieuw gegenereerd.
De regel compile specificeert hoe de nodige .c-bestanden moeten worden gecompileerd naar de respectievelijke .o-bestanden.
Het doel clean verwijdert de gecompileerde bestanden, wat handig is voor een schone hercompilatie.
Syntax en Flow
Een ’naïve’ make file zou er kunnen uitzien zoals hieronder, met wat leuke syntax zoals variabelen:
# Declareer variabelen
SRCDIR= ./src
BUILDDIR= ./build
TARGET= program.bin
# Je kan zoals in de commando's simpelweg wildcards gebruiken
compile: gcc -c $(SRCDIR)/header.c -o ./build/header.o
gcc -c ./src/main.c -o ./build/main.o
gcc -o $(TARGET) ./build/*.o
clean: rm -rf program.bin $(BUILDDIR)/*
# @ suppresses outputting the command to the terminal
run: @echo "Running program ..." ./program.bin
Info
In Makefiles, you can use certain symbols as prefixes to control the behavior of commands:
@: Suppresses the command echo, so the command itself won’t be printed to the terminal.
-: Ignores errors from the command, allowing the Makefile to continue even if the command fails.
+: Forces the command to be executed even if make is run with options that normally prevent command execution (like -n, -t, or -q).
Hiermee bereiken we echter niets meer mee dan een gewoon shell script daarom gaan we van de rule en dependecy met wat syntactische suiker om de kracht van Makefiles te unlocken:
# Declareer variabelen kan met `=`, `:=` of `::=`
CC= gcc
CFLAGS= -Wall -Wextra -std=c11
SRCDIR= ./src
BUILDDIR= ./build
# declareer alle .c files
CFILES=$(SRCDIR)/main.c $(SRCDIR)/header.c
# declareer de corresponderende .o files
OBJECTS=$(BUILDDIR)/main.o $(BUILDDIR)/header.o
TARGET= program.bin
# Het is een good practice om altijd een `all` rule te implementeren
# In dit geval is de `all` afhankelijk van onze TARGET
all:$(TARGET)# Maar waar is onze TARGET afhankelijk van ...
# van alle object files, want enkel dan kunnen we linken tot een binary
$(TARGET):$(OBJECTS)$(CC) -o $@ $^
# Hierboven verwijzen we met $@ naar alles links van de `:` en met $^ alle elementen er rechts van
# Maar onze OBJECTS zijn op hun beurt weer afhankelijk van hun corresponderende .c files ...
# we gebruiken hier regular expressions waardoor % een wildcard is
$(BUILDDIR)/%.o:$(SRCDIR)/%.c$(CC)$(CFLAGS) -c -o $@ $<
# Hierboven verwijzen we met $@ naar alles links van de `:` en met $< (het corresponderende element) er rechts van
compile:$(TARGET)clean: rm -rf $(TARGET)$(OBJECTS)run:$(TARGET) ./$(TARGET)
We declareren als eerst de all-rule omdat wanneer je standaard geen command meegeeft aan make, de eerste rule uitgevoerd zal worden.
Met deze structuur zal er wanneer je make run ingeeft enkel gecompileerd worden wat gewijzigt is! Voor mog meer info kan je hier terecht
Maar we kunnen nog beter
Nu moeten we nog manueel alle source-files gaan benoemen, maar hier bestaat echter ook wat makefile ‘magic’ voor om dit te automatiseren: zie voorbeeld hieronder:
CC= gcc
CFLAGS= -Wall -Wextra -std=c11
SRCDIR= ./src
BUILDDIR= ./build
# declareer alle .c files en gebruik de * wildcard om simpelweg alle .c bestanden te selecteren in de SRCDIR
CFILES=$(wildcard $(SRCDIR)/*.c)# declareer de corresponderende .o files via subsititutie en renaming
OBJECTS=$(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(CFILES))TARGET= program.bin
all:$(TARGET)$(TARGET):$(OBJECTS)$(CC) -o $@ $^
$(BUILDDIR)/%.o:$(SRCDIR)/%.c$(CC)$(CFLAGS) -c -o $@ $<
compile:$(TARGET)clean: rm -rf $(TARGET)$(OBJECTS)run:$(TARGET) ./$(TARGET)
Nog steeds niet perfect maar buiten scope van deze cursus
Makefiles blijven een aantal beperkingen hebben waaronder dat wanneer je een CONSTANT in een header file aanpast, make niet noodzakelijk doorheeft dat je de file die gebruik maakt van de constant moet updaten. Dit valt op te lossen met wat ’elbow grease’ aan de make-syntax maar dat valt buiten de scope van deze cursus. Later gaan we toch ook gebruik maken van meer advanced build systems.
Oefening
Maak nu een Makefile voor de game van hierboven die dezelfde functionaliteit biedt als je gemaakte shell script.
Solution: Klik hier om de code te zien/verbergen🔽
# Directories
SRC_DIR= src
BUILD_DIR= build
# Doel binary
TARGET= game.bin
# Compiler en flags
CC= gcc
CFLAGS= -Wall -Wextra -std=c11
# Alle bronbestanden en objectbestanden
SRCS=$(wildcard $(SRC_DIR)/*.c)OBJS=$(patsubst $(SRC_DIR)/%.c,$(BUILD_DIR)/%.o,$(SRCS))# Standaard doel: bouw de binary
all:$(TARGET)# Link de objectbestanden tot de uiteindelijke binary
$(TARGET):$(OBJS)$(CC)$(CFLAGS) -o $(TARGET)$(OBJS)# Compileer .c naar .o en plaats deze in de build map
$(BUILD_DIR)/%.o:$(SRC_DIR)/%.c|$(BUILD_DIR)$(CC)$(CFLAGS) -c $< -o $@# Zorg dat de build map bestaat
$(BUILD_DIR): mkdir -p $(BUILD_DIR)# Geef ook de optie om 'compile' als een commando mee te geven
compile:$(TARGET)# Voer de binary uit. Eventuele argumenten meegegeven met ARGS worden doorgegeven.
run:$(TARGET) ./$(TARGET)$(ARGS)# run as `make run ARGS="--your-flags"`
# Verwijder de binary en de build directory
clean: rm -f $(TARGET) rm -rf $(BUILD_DIR)
Breid de functionaliteit van je spel verder uit, door nieuwe Monsters te spawnen wanneer je een monster verslaat. Hou dan ook bij hoeveel monsters je verslagen hebt. Dat is je uitendelijke score wanneer je sterft.
Zoek op hoe je de library cJSON downloaden, toevoegen aan je applicatie en kan gebruiken in de main.c file.
Laat wanneer je sterft de applicatie de naam van de speler vragen en een JSON object aanmaken van de speler met naam en score en dit toevoegen aan een /resources/highscore.json bestand.
Voeg aan het begin van de game toe dat de huidige highscores van de json file geladen worden en getoond worden aan de speler.
(Geniet hierbij van het feit dat je een gemakkelijke makefile hebt om snel wijzigingen aan de code te testen.)
De cJSON-library is een voorbeeld van een dependency, we gaan hier nog dieper over in in het deel rond ‘Dependency management’
Klik hier om de code te zien/verbergen van een voorbeeld main.c programma dat gebruik maakt van cJSON🔽
#include<stdio.h>#include<stdlib.h>#include<string.h>#include"cJSON.h"// Function to read the JSON file
char*read_file(constchar*filename){FILE*file=fopen(filename,"rb");if(!file){returnNULL;}fseek(file,0,SEEK_END);longlength=ftell(file);fseek(file,0,SEEK_SET);char*data=(char*)malloc(length+1);fread(data,1,length,file);data[length]='\0';fclose(file);returndata;}// Function to write the JSON file
voidwrite_file(constchar*filename,constchar*data){FILE*file=fopen(filename,"wb");if(!file){perror("File opening failed");return;}fwrite(data,1,strlen(data),file);fclose(file);}intmain(){constchar*filename="scores.json";// Read the JSON file
char*json_data=read_file(filename);cJSON*root;if(!json_data){// File does not exist, create a new JSON object
root=cJSON_CreateArray();}else{// Parse the existing JSON data
root=cJSON_Parse(json_data);if(!root){printf("Error parsing JSON data\n");free(json_data);return1;}free(json_data);}// Create a new JSON object to add
cJSON*new_entry=cJSON_CreateObject();cJSON_AddStringToObject(new_entry,"name","Jane Doe");cJSON_AddNumberToObject(new_entry,"score",95);// Add the new entry to the JSON array
cJSON_AddItemToArray(root,new_entry);// Convert JSON object to string
char*updated_json_data=cJSON_Print(root);// Write the updated JSON data back to the file
write_file(filename,updated_json_data);// Clean up
cJSON_Delete(root);free(updated_json_data);return0;}
Dit is in principe iets wat je in het INF1 vak onbewust reeds uitvoerde door op de groene “Compile” knop te drukken van je NetBeans/IntelliJ IDE. Het is belangrijk om te weten welke principes hier achter zitten net als in C. Hieronder volgt dus een kort overzicht over het compileren van Java programma’s zonder een buildtool, later gaan we hier meestal een buildtool voor gebruiken om ons leven gemakkelijk te maken.
de Java Runtime Environment (JRE) om het commando java te gebruiken om gecompileerde Java programma’s te kunnen runnen.
de Java Development Kit (JDK) om het commando javac te gebruiken om Java broncode (.java-files) te compileren (en verzamelen in een jar-file.)
De Java Virtual Machine (JVM)
Waarom heb je nu toch een extra stukje software nodig om Java-applicaties te runnen? In C hebben we echter gezien dat we juist willen compileren naar een binary zodat we dit native kunnen uitvoeren. Java wil namelijk een oplossing bieden voor de ‘flaw’ in hoe C-programma’s werken. Zoals daar aangehaald compileer je een C-programma naar een bepaalde architectuur, daarom kan je een C-programma dat gecompileerd is voor een x86-cpu niet runnen op een arm-cpu bijvoorbeeld. Java lost dit probleem op door te compileren naar een speciale bytecode, die dan door de Java Virtual Machine (JVM) kan worden uitgevoerd op de onderliggende architectuur. De JVM vormt dus een laag tussen je bytecode en de hardware. Op die manier moet de gebruiker enkel één programma specifiek voor zijn/haar architectuur downloaden (de JVM) en kunnen de Java-binaries hetzelfde blijven. Je hoeft dan als developer geen meerdere verschillende binaries meer te voorzien. TOP!
LET OP: de Student klasse leeft in package student—die op zijn beurt wordt geïmporteerd in Main.java. Dat betekent dat we Student.java moeten bewaren in de juiste subfolder ofwel package. Dit zou je moeten herkennen vanuit INF1, waar de structuur ook src/main/java/[pkg1]/[pkg2] is. We hebben nu dus twee bestanden:
Alle files apart compileren levert .class files op in diezelfde folders. java Main zoekt dan nog steeds de student/Student.class file vanwege de import. Dit betekent dat je je programma moeilijker kan delen met anderen: er zijn nu twee bestanden én een subdirectory met juiste naamgeving vereist.
Gelukkig kan je met de juiste argumenten alle .class files in één keer genereren en die in een aparte folder—meestal genaamd build—plaatsen:
$ javac -d ./build *.java
$ cd build
$ ls
Main.class student
$ java Main
Heykes Jos
Java programma’s packagen
Omdat het vervelend is om verschillende bestanden te kopiëren naar andere computers worden Java programma’s typisch verpakt in een .jar bestand: een veredelde .zip met metadata informatie zoals de auteur, de java versie die gebruikt werd om te compileren, welke klasse te starten (die de main() methode bevat), … Indien deze metadata, in de META-INF subfolder, niet bestaat, worden defaults aangemaakt. Zie de JDK Jar file specification voor meer informatie.
We gebruiken een derde commando, jar, om, na het compileren naar de build folder, alles te verpakkken in één kant-en-klaar programma:
‘c’ voor “create” (aanmaken van een nieuwe JAR-file)
‘v’ voor “verbose” (gedetailleerde uitvoer)
‘f’ voor “file” (de naam van de JAR-file die je wilt maken).
Nu kunnen we programma.jar makkelijk delen. De vraag is echter: hoe voeren we dit uit, ook met java? Ja, maar met de juiste parameters, want deze moet nu IN het bestand gaan zoeken naar de juiste .class files om die bytecode uit te kunnen voeren: (In het onderstaande commando staat de -cp-flag voor Classpath. Hier geef je dus aan waar het java commando mag gaan zoeken naar .class-files naar de klasse die je wil uitvoeren)
$ java -cp "programma.jar" Main
Heykes Jos
Warning
Java classpath separators zijn OS-specifiek! Unix: : in plaats van Windows: ;.
Vanaf nu kan je programma.jar ook uploaden naar een Maven repository of gebruiken als dependency in een ander project. Merk opnieuw op dat dit handmatig aanroepen van javac in de praktijk wordt overgelaten aan de gebruikte build tool—in ons geval, gaan we dit eerst automatiseren met een Makefile.
Jar files inspecteren
Mocht je jar ooit niet goed werken kan het handig zijn om te inspecteren wat er juist allemaal in de jar-package zit. Je kan hier het volgende commando voor gebruiken: jar -tf naam.jar
Voorbeeld:
arne@LT3210121:~/ses/java-met-cli/build$ jar -tf programma.jar
META-INF/
META-INF/MANIFEST.MF
Main.class
student/
student/Student.class
Nu je de structuur ziet kan je ook makkelijk de main methode oproepen van de Student.class in de jar(als die methode zou bestaan) met java -cp "programma.jar" student.Student
Main class instellen
Merk op dat als je de jar wil runnen zonder een specifieke klasse mee te geven dan ga je nu een error krijgen in de vorm van no main manifest attribute, in programma.jar. We kunnen er wel voor zorgen dat automatisch de klasse Main gebruikt wordt. Hiervoor moeten we het attribuut Main-Class in de MANIFEST.MF file de waarde van de klassenaam meegeven (eventueel met packages ervoor).
Met het volgende commando kunnen we inspecteren hoe die MANIFEST file er nu uit ziet. unzip -q -c programma.jar META-INF/MANIFEST.MF. Voorlopig bestaat dat attribuut dus nog niet.
Met jar cvfe programma.jar Main * waar de ’e’ nu staat voor “entry point” (de Main-Class die je wilt specificeren).
Nu kan je je jar simpel runnen met java -jar programma.jar:
arne@LT3210121:~/ses/java-met-cli/build$ java -jar programma.jar
Hekyes Jos
Oefening
Extract alle files in dit zip bestand naar een directory naar keuze OF clone de repository. Schrijf een simpele makefile dat de volgende dingen kan doen en plaats het in de root van je directory:
compile: Compileert de bronbestanden naar de /build-directory
jar : packaged alle klassen naar een jar genaamd ‘app.jar’ in de ‘build’-directory met entrypoint de ‘App’-klasse.
run : Voert de jar file uit
clean: Verwijdert de ‘.class’-bestanden en het ‘.jar’-bestand uit de ‘build’-directory
In tegenstelling tot programmeertalen waarvan de broncode eerst gecompileerd moet worden om te runnen (zoals Java en C), is Python een interpreted programming language. Dit betekent dat de broncode van Python direct wordt uitgevoerd door een interpreter, zonder dat er een aparte compilatiestap nodig is. De interpreter (de python software die je geïnstalleerd moet hebben) leest de broncode regel na regel en voert deze direct uit, wat het ontwikkelproces vaak sneller en flexibeler maakt. Dit komt omdat fouten direct tijdens het uitvoeren van de code kunnen worden opgespoord en gecorrigeerd, zonder dat de hele applicatie opnieuw gecompileerd hoeft te worden.
Het verschil met compiled languages is dat bij deze talen de broncode eerst wordt omgezet in machinecode door een compiler voordat de code kan worden uitgevoerd. Dit proces, bekend als compilatie, genereert een uitvoerbaar bestand dat direct door de computer kan worden uitgevoerd. Hoewel dit een extra stap toevoegt aan het ontwikkelproces, kan het resulteren in snellere uitvoeringstijden van de uiteindelijke applicatie, omdat de machinecode direct door de hardware wordt uitgevoerd zonder tussenkomst van een interpreter.
Een tussenoplossing tussen interpreted en compiled languages is Just-In-Time (JIT) compiling. JIT-compiling combineert aspecten van beide benaderingen door de broncode tijdens de uitvoering te compileren naar machinecode. Dit betekent dat de code aanvankelijk wordt geïnterpreteerd, maar dat veelgebruikte delen van de code tijdens de uitvoering worden gecompileerd naar machinecode om de prestaties te verbeteren. Talen zoals Java en C# maken gebruik van JIT-compiling om een balans te vinden tussen de flexibiliteit van interpreted languages en de snelheid van compiled languages.
Compilen naar .bin
Iedereen kan dus in principe door het python-commando te gebruiken je python bronbestanden runnen. We willen het de eindgebruiker echter zo simpel mogelijk maken, daarom gaan we met behulp van pinstaller al onze python files kunnen “compileren” naar een single binary, dat je dan kan runnen.
Je kan pyinstaller installeren met: sudo pip install pyinstaller --break-system-packages
Je kan nu met een simpel command je python applicatie compileren naar een binary: pyinstaller --onefile --name app.bin app.py
Je ziet meteen dat pyinstaller een build/<appname>-directory aanmaakt waar pyinstaller alle files zet die nodig zijn voor de omvorming tot een binary. Een tweede belangrijke file die aangemaakt wordt is de <appname>.spec, hierin kan je verschillende eigenschappen aanpassen zoals de targeted architecture bijvoorbeeld (target_arch)
Test nu eens je binary in de ./dist-directory met ./dist/app.bin.
Oefening
Extract alle files in dit zip bestand in een directory naar keuze OF clone de repository. Schrijf een simpele makefile dat de volgende dingen kan doen en plaats het in de root van je directory:
compile: Compileert de bronbestanden naar de single ‘monstergame.bin’ file
Dependency management is een essentieel onderdeel van softwareontwikkeling. Het verwijst naar het beheren van alle externe libraries, modules, externe broncode … die een project nodig heeft om correct te functioneren. Deze dependencies (= afhankelijkheden) kunnen variëren van kleine hulpprogramma’s tot grote frameworks die een groot deel van de functionaliteit van een applicatie leveren.
Er zijn verschillende uitdagingen verbonden aan dependency management:
Versieconflicten: Wanneer verschillende dependencies verschillende versies van dezelfde bibliotheek vereisen, kunnen er conflicten ontstaan. Dit kan leiden tot bugs of zelfs het failen van de build.
Transitive dependencies: Dit zijn dependencies van dependencies. Het kan moeilijk zijn om bij te houden welke bibliotheken indirect worden geïmporteerd en of ze compatibel zijn met de rest van het project.
Beveiligingsrisico’s: Het gebruik van externe bibliotheken kan beveiligingsrisico’s met zich meebrengen, vooral als deze bibliotheken niet regelmatig worden bijgewerkt.
Compatibiliteit: Het kan een uitdaging zijn om ervoor te zorgen dat alle dependencies compatibel zijn met elkaar en met de gebruikte programmeertaal of runtime.
Tools zoals Gradle helpen bij het automatiseren en vereenvoudigen van dependency management. Hier zijn enkele redenen waarom we deze tools gebruiken:
Automatisering: tools zoals Gradle kunnen het proces van het downloaden en beheren van dependencies automatiseren. Dit betekent dat ontwikkelaars zich kunnen concentreren op het schrijven van code in plaats van het handmatig beheren van bibliotheken.
Versiebeheer: tools zoals Gradle bieden vaak geavanceerde mogelijkheden voor versiebeheer, waardoor het eenvoudiger wordt om versieconflicten op te lossen en ervoor te zorgen dat de juiste versies van bibliotheken worden gebruikt.
Transitive dependency management: vaak worden door deze tools transitieve dependencies automatisch beheerd, wat betekent dat het alle benodigde bibliotheken en hun dependencies downloadt en configureert.
Configuratiebeheer: het stelt ontwikkelaars ook vaak in staat eenvoudig verschillende configuraties in te stellen voor verschillende omgevingen (bijvoorbeeld ontwikkel-, test- en productieomgevingen) en/of verschillende architecturen en operating systems.
Reproduceerbaarheid: door een tool zoals Gradle wordt je software gebouwd met steeds dezelfde versies van bibliotheken, onafhankelijk van wat (en welke versies) op de machines van de ontwikkelaars geïnstalleerd werd. Dat maakt het makkelijker om bestaande software opnieuw te bouwen op een willekeurige machine.
Voorbeeld
Stel je voor dat je een project hebt dat afhankelijk is van een logging-library en een JSON-parser. De logging-library heeft op zijn beurt een specifieke versie van een utility-library nodig.
Met een dependency management tool (bv. Gradle) hoef je alleen maar de logging-library en de JSON-parser te specificeren in je buildscript, en de tool zorgt ervoor dat alle benodigde dependencies, inclusief de transitieve dependency (de utility-library) worden gedownload en correct worden geconfigureerd.
Door zo’n tools te gebruiken, kunnen we de complexiteit van dependency management dus aanzienlijk verminderen en ervoor zorgen dat onze projecten betrouwbaar en veilig blijven.
Tools voor dependency management
Enkele bekende tools voor dependency management zijn:
Gradle, Maven, Ant voor Java
(C/Q)Make (custom config) voor C/C++
npm, yarn, grunt, gulp, (in JS) … voor JS (nodejs)
pip voor Python
nuget (custom config, XML) voor .NET
In de volgende delen gaan we dieper in op deze concepten en er ook praktisch mee aan de slag. We bestuderen meer bepaald Gradle (Java), CMake (C) en pip (Python).
Subsections of 4. Dependency Management
In Java (met Gradle)
Een voorbeeld
Een dependency, of afhankelijkheid, is een externe bibliotheek die wordt gebruikt tijdens de ontwikkeling van een toepassing. Tijdens het vak ‘Software ontwerp in Java’ zijn reeds de volgende externe libraries gebruikt:
Het vertrouwen op zo’n library houdt in dat een extern bestand, zoals een .jar of .war bestand, gedownload en gekoppeld wordt aan de applicatie. In Java koppelen we externe libraries door middel van het CLASSPATH: dat is een lijst van folders en jars die de compiler (bij het compilen) en de Java runtime (bij het uitvoeren) gebruikt om te zoeken naar de implementatie van de gebruikte klassen.
Laten we bijvoorbeeld eens kijken naar het gebruik van de Gson library om Json te genereren vanuit Java.
Serialisatie met behulp van Gson kan op deze manier:
Als we bovenstaande Main.java compileren zonder meer, krijgen we echter de volgende fout:
arne@LT3210121:~/ses/depmanag$ javac Main.java
Main.java:1: error: package com.google.gson does not exist
import com.google.gson.Gson; ^
Main.java:6: error: cannot find symbol
Gson gson= new Gson(); ^
symbol: class Gson
location: class Main
Main.java:6: error: cannot find symbol
Gson gson= new Gson(); ^
symbol: class Gson
location: class Main
3 errors
De klasse Arrays die we importeren is deel van de standaard Java-omgeving. Die zorgt dus niet voor problemen.
Maar de klasse com.google.gson.Gson hebben we niet zelf gemaakt, en willen we importeren uit een library.
We moeten de library daarom eerst downloaden.
(Je kan de jar ook rechtstreeks downloaden met behulp van curl via $ curl https://repo1.maven.org/maven2/com/google/code/gson/gson/2.12.1/gson-2.12.1.jar --output gson-2.12.1.jar. Indien je curl nog niet geïnstalleerd hebt doe dat dan eerst!)
Het programma compileren kan nu met javac -cp gson-2.12.1.jar Main.java. We geven de gedownloade jar mee aan het classpath (via de -cp optie). Dat zorgt ervoor dat de compiler de geïmporteerde klasse Gson nu wel kan terugvinden, en het compileren slaagt.
Om het programma uit te voeren, gebruiken we java -cp "gson-2.12.1.jar:." Main. Merk op dat er nu 2 zaken aan het classpath worden meegegeven: de Google jar, maar ook de huidige directory (.) om Main.class terug te vinden.
Opgelet!
Java classpath separators zijn OS-specifiek! Unix: : in plaats van Windows: ;.
Dit programma kan schematisch worden voorgesteld als volgt:
graph LR;
A["Main klasse"]
C["Gson 2.12.1"]
A -->|depends on| C
De dependency in bovenstaand voorbeeld is gson-2.12.1.jar. Een gemiddelde Java applicatie heeft echter meer dan 10 dependencies! Het beheren van deze bestanden en de verschillende versies (major, minor, revision) geeft vaak conflicten die beter beheerd kunnen worden door tools dan door de typische vergeetachtigheid van mensen. Dit kluwen aan afhankelijkheden, dat erg snel onhandelbaar kan worden, noemt men een Dependency Hell. Er zijn varianten: DLL Hell sinds 16-bit Windows versies, RPM Hell voor Redhat Linux distributies, en JAR Hell voor Java projecten.
De eenvoudigste manier om een library te gebruiken is de volgende procedure te volgen:
Navigeer naar de website van de library en download de jar in een bepaalde map, zoals /lib.
Importeer de juiste klasses met het import statement.
Compileer de code door middel van het -cp lib/dependency1.jar argument.
Voor kleine programma’s met slechts enkele libraries is dit meer dan voldoende. Het kost echter redelijk veel moeite om de juiste versie te downloaden: stap 1 kost meestal meer dan 5 minuten werk.
Note
Merk op dat jar files in een submap steken de syntax van de -cp parameter lichtjes wijzigt: bij compileren wordt het javac -cp "lib/*" Main.java en bij uitvoeren wordt het java -cp "lib/*:." Main. Zonder de toegevoegde punt (.) bij het java commando wordt de main methode in Main zelf niet gevonden. Wildcards zijn toegestaan. Zie ook Understanding the Java Classpath. In de praktijk worden build tools als Gradle gebruikt om projecten automatisch te builden, inclusief het doorgeven van de juiste parameters/dependencies.
Als er een nieuwe versie van de library verschijnt die je wil gebruiken (bv. met een nieuwe feature of een bugfix), moet je dat eerst en vooral zelf nagaan, de jar van de nieuwe versie downloaden, je classpath aanpassen, en alles opnieuw compileren.
Ook bij de uitvoering moet je zorgen dat je de nieuwe versie gebruikt.
Apache Maven
Maven is een build tool van de Apache Foundation om de software te compileren en afhankelijkheden te beheren. Maven is de voorloper van Gradle en bestaat reeds 15 jaar.
Een Maven project heeft een pom.xml bestand (Project Object Model), waarin in XML formaat wordt beschreven hoe de structuur er uit ziet, welke libraries men gebruikt, en zo voort:
Maven is erg populair in de Java wereld, waardoor er verschillende servers zijn die deze pom bestanden samen met hun libraries beheren, zoals de Central Maven Repository en de Google Maven Repository mirrors. De syntax van het configuratiebestand is echter erg onoverzichtelijk, en er zijn ondertussen betere alternatieven beschikbaar, zoals Gradle.
We gaan in deze cursus dus geen gebruik maken van Maven, maar je zal wel verwijzingen naar de Maven repositories tegenkomen bij het gebruik van Gradle.
Gradle
Gradle is, net zoals Maven, een build tool voor de Java wereld (en daarbuiten) die de automatisatie van releasen, builden, testen, configureren, dependencies en libraries managen, … eenvoudiger maakt. Kort gezegd: het maakt het leven van een ontwikkelaar eenvoudiger.
Gradle bouwt verder op de populariteit van Maven door bijvoorbeeld compatibel te zijn met de Repository servers, maar de grootste pijnpunten wegneemt: een moeilijk leesbaar configuratiebestand in XML en complexe command-line scripts.
In een config bestand genaamd build.gradle schrijft men met Groovy, een dynamische taal bovenop de JVM (Java Virtual Machine), op een descriptieve manier hoe Gradle de applicatie moet beheren.
Je build-file is dus een uitvoerbaar (Groovy) script dat gebruik maakt van Gradle-specifieke functies.
Info
Gradle configuratiebestanden kunnen ook in Kotlin geschreven worden (build.gradle.kts) in plaats van Groovy.
Deze optie wint aan populariteit, en is sinds Gradle 8.2 (2023) de default, maar we gebruiken in deze cursus nog de Groovy syntax. De verschillen zijn klein.
Voordelen van Gradle
De grootste voordelen van een build en dependency management tool zoals Gradle zijn onder andere:
Een kleine voetafdruk van de broncode (repository). Het is niet nodig om zelf alle jars van libraries te downloaden en bij te houden in een lib/ folder: Gradle doet dit immers voor jou.
Een project bootstrappen in luttele seconden: download code, voer de Gradle wrapper uit, en alles wordt vanzelf klaargezet (de juiste Gradle versie, de juiste library versies, …)
Een project bootstrappen betekent het opzetten of initialiseren van een project vanaf het begin, vaak met behulp van een tool of framework dat de basisstructuur en configuratie voor je genereert. Dit proces helpt je snel aan de slag te gaan zonder dat je alles handmatig hoeft in te stellen.
Platform- en machine-onafhankelijk projecten bouwen en uitvoeren: een taak uitvoeren via een build tool op mijn PC doet exact hetzelfde als bij jou, dankzij de beschrijving van de stappen in de config file.
Om een library als Gson te kunnen gebruiken, moet je dus niet zelf de jar-bestanden aanleveren; het volstaat simpelweg om twee regels in de gradle-configuratie toe te voegen.
Gradle installeren
Je kan Gradle op je WSL installeren met het commando sudo snap install gradle --classic. (Zie ook Gradle Docs: installing manually) Dit installeert echter niet altijd de nieuwste versie van Gradle, in dit geval v7.2, maar dat is geen probleem (hier komen we zo dadelijk op terug).
Er zijn ook andere manieren om Gradle te installeren, bijvoorbeeld met SDKMAN!. Dit is een tool om allerhande (Java-gebaseerde) Software Development Kits (SDKs) op je systeem te installeren en beheren, en eenvoudig te wisselen tussen versies. Eens SDKMAN! geïnstalleerd is, kan je eenvoudig de laatste versie van gradle installeren met sdk install gradle.
Gradle in VSCode
Je kan ondersteuning toevoegen voor Gradle in VSCode met de juiste extensie, zie Java development environment in VSCode.
In het bijzonder voor Gradle is de Gradle for Java plugin van Microsoft aangewezen.
Deze is deel van het Extension Pack for Java, waarmee je ineens ook andere nuttige extensies voor Java-ontwikkeling installeert.
Deze extensie geeft je extra ondersteuning voor het editeren van je gradle.build bestand (syntax highlighting, code completion).
Bovendien kan je voor je projecten ook de verschillende Gradle-taken bekijken en uitvoeren, alsook nagaan welke dependencies er in welke configuratie (compile, runtime, test) geactiveerd zijn — wat dat precies inhoudt, komt zodadelijk aan bod.
# Gradle vraagt eerst welk soort project je wil aanmaken, we kiezen voor 2. applicationSelect type of project to generate:
1: basic
2: application
3: library
4: Gradle plugin
Enter selection (default: basic)[1..4]2# Nu vraagt Gradle welke programmeertaal we willen gebruiken, we kiezen voor 3. JavaSelect implementation language:
1: C++
2: Groovy
3: Java
4: Kotlin
5: Scala
6: Swift
Enter selection (default: Java)[1..6]3# Nu vraagt Gradle te kiezen tussen een het soort project, we kiezen voor 1. only one application projectSplit functionality across multiple subprojects?:
1: no - only one application project
2: yes - application and library projects
Enter selection (default: no - only one application project)[1..2]1# Nu vraagt Gradle te kiezen tussen programmeertalen voor ons build script (de taal waarmee we onze build.gradle file gaan programmeren), we kiezen voor 1. GroovySelect build script DSL:
1: Groovy
2: Kotlin
Enter selection (default: Groovy)[1..2]1# Nu vraagt Gradle te kiezen tussen een testframework, we kiezen voor 1. JUnit 4Select test framework:
1: JUnit 4 2: TestNG
3: Spock
4: JUnit Jupiter
Enter selection (default: JUnit Jupiter)[1..4]4# Nu vraagt Gradle een projectnaam, default is dit de directorynaam. Vul niets in en druk op enter om de default te gebruiken.Project name (default: gradletest):
# Als laatste vraagt Gradle je de naam van de source package te kiezen. Gelijkaardig als in INF 1 kiezen we voor be.ses.<app_name>Source package (default: gradletest): be.ses.my_application
Gradle vraagt ons tijdens de init een aantal opties te kiezen. Alhoewel we in deze lessen 90% van de tijd de opties kiezen zoals hierboven getoond in het voorbeeld, kan je hieronder toch een overzicht terugvinden met enkele andere opties:
Select type of build to generate: opties - Application - Library (gaan we niet verder op in) - Gradle Plugin (gaan we niet verder op in) - Basic
Application: Dit type is geconfigureerd om een volwaardige uitvoerbare applicatie te bouwen. Het bevat extra configuraties en plugins, zoals de application plugin, die helpt bij het definiëren van de hoofdmethode en het maken van uitvoerbare JAR-bestanden. Geschikt voor het ontwikkelen van volledige applicaties die je kunt uitvoeren en distribueren. (99% van de tijd gaan we dit type gebruiken)
Basic: Dit is een eenvoudig project zonder specifieke plugins of configuraties. Het bevat alleen de minimale structuur en bestanden die nodig zijn om een Gradle-project te starten. Je kan dan enkel nog de projectnaam kiezen en de programmeertaal voor Gradle.
Select implementation language: Je ziet dat Gradle dus ook voor andere programmeertalen gebruikt kan worden. Wij kiezen hier echter voor Java.
Select application structure: opties - 1. only one application project - application and library projects
Only one application project: Dit type project is gericht op het bouwen van één enkele applicatie. De projectstructuur is eenvoudig en bevat meestal alleen de bronbestanden en configuratiebestanden die nodig zijn om de applicatie te bouwen en uit te voeren. Geschikt voor kleinere projecten of wanneer er geen behoefte is aan herbruikbare componenten.
Application and library project: Dit type project is opgesplitst in meerdere modules, waaronder een applicatiemodule en een of meer bibliotheekmodules. De bibliotheekmodules bevatten herbruikbare code die door de applicatiemodule kan worden gebruikt. Deze structuur bevordert modulariteit en hergebruik van code, wat vooral nuttig is voor grotere projecten of wanneer je van plan bent om delen van je code in andere projecten te gebruiken.
Bij het aanmaken van je project maakt Gradle een aantal folders en bestanden aan. We overlopen hieronder de functionaliteit van de belangrijkste onderdelen.
Ontleding van een Gradle project mappenstructuur
Als we kijken naar de bestanden- en mappenstructuur van een voorbeeld Gradle project, vinden we dit terug:
Broncode (.java bestanden) van het app subproject in src/main/java en src/test/java, met productie- en testcode gescheiden.
Eventueel resources (bv. afbeeldingen, html en css voor webapplicaties …)
Gecompileerde code (.class bestanden) in de build/ (of ook wel out) folder.
Een gradle map, met daarin de Gradle-wrapper en bijhorende configuratie (hier komen we zodadelijk op terug). Deze folder hoor je toe te voegen aan je versiebeheersysteem (git).
Twee executables (gradlew.bat voor Windows en een gradlew-shell script voor Linux/Unix). Ook deze twee executables voeg je toe aan git.
Twee Gradle configuratie-bestanden:
settings.gradle voor het project als geheel. Hierin kan je de projectnaam aanpassen: rootProject.name = 'new_name'. Je lijst hier ook de verschillende subprojecten op: include('app').
build.gradle: eentje per subproject (hier enkel app). Hierin specifieer je de dependencies en andere instellingen om dat specifieke subproject te bouwen.
Later kan er ook een verborgen .gradle map verschijnen. De inhoud hiervan hoort niet thuis in je versiebeheersysteem!
Gradle wrapper (gradlew)
Wanneer je een Gradle project aanmaakt, creëert Gradle vanzelf ook een wrapper. Dat is een soort lokale executable in de vorm van een ./gradlew-executable (of gradlew.bat voor op Windows).
Dit wrapper-script zal, wanneer het uitgevoerd wordt, een specifieke versie van Gradle downloaden in de gradle-map.
Dit heeft enkele voordelen:
Consistentie: Het zorgt ervoor dat iedereen die aan het project werkt dezelfde versie van Gradle gebruikt, ongeacht de versie die lokaal is geïnstalleerd. (Door steeds de gradlew in de projectdirectory te gebruiken)
Gemak: Gebruikers hoeven Gradle niet apart te installeren, omdat de wrapper automatisch de juiste versie downloadt en gebruikt. Enkel om initieel een project aan te maken heb je een lokale versie van Gradle nodig.
Automatisering: Het maakt het eenvoudiger om build-scripts en CI/CD-pijplijnen te configureren (zie een later hoofdstuk), omdat de wrapper de benodigde Gradle-versie beheert.
De specifieke versie van Gradle die je gebruikt hangt (onder andere) af van de Java-versie die je gebruikt in je project.
Hier vind je een overzicht van de minimum-versies van Gradle die compatibel zijn met welke Java-versies (JVM provided by JRE or JDK).
Eens je weet welke versie van Gradle je wil gebruiken, kan je de lokale gradlew updaten met het volgende commando: ./gradlew wrapper --gradle-version x.x (bijvoorbeeld 8.13 voor Java versies 23 of ouder).
Kijk na of je de gewenste versie gebruikt met ./gradlew --version.
Tip
Soms is je Java-versie te nieuw om een oude Gradle-wrapper uit te voeren en de Gradle-versie te updaten (bijvoorbeeld JDK 17 met Gradle 6.7 in plaats van 7.0 of nieuwer). Je krijgt dan een foutmelding zoals General error during conversion: Unsupported class file major version 65 of Could not initialize class `org.codehaus.groovy.reflection.ReflectionCache` .
Om dit op te lossen, kan je de te gebruiken versie van Gradle ook rechtstreeks specifiëren in gradle/wrapper/gradle-wrapper.properties, door het versienummer in de distributionUrl aan te passen.
Gradle laat je soms zelf ook wel weten naar welke versie je moet updaten aan de hand van je gebruikte Java code.
Bij het uitvoeren van gradlew gebeurt hetvolgende:
De wrapper downloadt de juiste versie van Gradle zelf (dus installatie van Gradle via snap/SDKMAN!/… is niet nodig voor een bestaand Gradle-project), afhankelijk van de specificaties in de properties file.
Vervolgens downloadt Gradle de juiste libraries om het project te kunnen compilen en uitvoeren.
Aan deze wrapper kan je commando’s meegeven (gradle tasks, zie later). Bijvoorbeeld, ./gradlew run om je programma (te compileren en) uit te voeren:
Dit is exact hetzelfde als in een IDE zoals IntelliJ het project runnen met de knop ‘Run’ (play-knop):
Ontleding van build.gradle
De belangrijkste file voor Gradle is de build.gradle file die zich in de ./app directory bevindt. Die ziet er als volgt:
/* This file was generated by the Gradle 'init' task.
*
* This generated file contains a sample Java application project to get you started.
* For more details take a look at the 'Building Java & JVM projects' chapter in the Gradle
* User Manual available at https://docs.gradle.org/7.2/userguide/building_java_projects.html
*/plugins{// Apply the application plugin to add support for building a CLI application in Java.
id'application'}repositories{// Use Maven Central for resolving dependencies.
mavenCentral()}dependencies{// Use JUnit test framework.
testImplementation'junit:junit:4.13.2'// This dependency is used by the application.
implementation'com.google.guava:guava:30.1.1-jre'}application{// Define the main class for the application.
mainClass='be.ses.my_application.App'}
Met de Groovy syntax definiëren we verschillende configuratie-blokken als bloknaam { ... }. We onderscheiden volgende blokken:
plugins: hier kan je plugins voor Gradle toevoegen, je voegt ze toe op basis van id
id 'application': de plugin voor Java (stand-alone) applicaties. Dit voegt taken toe zoals build en test voor je applicatie.
repositories: hiermee specificeer je waar de dependencies in de dependencies functie gezocht en gedownload moeten worden. Meestal gebruik je hier de default, namelijk de standaard maven central repository (ingebouwde URL).
dependencies: hiermee specificeer je de dependencies voor je project. Dependencies worden toegevoegd met een zogenaamde dependency configuration, die aangeeft wanneer ze nodig zijn. De meest voorkomende zijn:
implementation (productie dependencies): deze dependencies zijn beschikbaar bij het compileren en uitvoeren van alle code.
testImplementation (test dependencies): deze dependencies zijn enkel beschikbaar bij het compileren en uitvoeren van test-code.
runtimeOnly: deze dependencies worden enkel gebruikt bij het uitvoeren van de applicatie.
Als argument moet je de dependency opgeven in de vorm groep:naam:versie. Je vindt deze meestal makkelijk terug op de gebruikte repository.
application: met mainClass = 'be.ses.my_application.App' geef je aan welke main-methode van welke klasse moet gerund worden wanneer je je applicatie wil runnen. (Dit is dus het entrypoint van je applicatie)
Dependencies toevoegen
In het kort volg je volgende procedure als je Gradle je dependencies laat beheren:
Bij het uitvoeren van gradle downloadt Gradle automatisch de juiste opgegeven versie van de Gson library en gebruikt die om je applicatie te compileren en uit te voeren.
De download komt niet terecht in je project maar in een gedeelde cache-folder, zodat elke versie van een library slechts éénmaal gedownload wordt op je systeem.
Die cache-folder staan in een submap van je home folder: ~/.gradle. Dit kan je controleren door ls ~/.gradle/caches/modules-2/files-2.1/, waar je nu dus ook de com.google.code.gson-directory terug vindt. Met tree ~/.gradle/caches/modules-2/files-2.1/com.google.code.gson kan je eens inspecteren hoe die directory er juist uitziet. (Indien je tree nog niet geïnstalleerd hebt doe dat dan eerst!)
Deze cache-folder kan groeien naarmate je meer met Gradle werkt en meer versies van libraries downloadt. Je mag deze cache-folder gerust verwijderen van je systeem; de volgende keer zullen de nodige dependencies gewoon opnieuw gedownload worden.
De voordelen van het gebruik van Gradle voor dependencies zijn dus:
Het zoeken van libraries beperkt zich tot één centrale (Maven Repository) website, waar alle verschillende versies duidelijk worden vermeld.
Het downloaden van libraries beperkt zich tot één centrale locatie op je harde schijf (~/.gradle/caches/modules-2/files-2.1/): 10 verschillende Java projecten die gebruik maken van Gson vereisen linken naar dezelfde gradle bestanden. Je hebt dus geen 10 kopieën nodig van de Gson.jar.
Het beheren van dependencies en versies beperkt zich tot één centraal configuratiebestand: build.gradle. Dit is (terecht) een integraal deel van het project!
Er bestaan twee types van dependencies: directe (soms ook harde dependency genoemd) en transitieve (zachte dependency).
Een directe dependency is een afhankelijkheid die het project nodig heeft, zoals het gebruik van Gson, waarbij dit in de dependencies {} config zit.
Een transitieve of indirecte dependency is een dependency van een dependency. In een van de oefeningen hieronder maken we een project (1) aan, dat een project (2) gebruikt, dat Gson gebruikt. In project 1 is project 2 een directe dependency, en Gson een transitieve. In Project 2 is Gson een directe dependency (en komt project 1 niet voor):
graph LR;
A[Project 1]
B[Project 2]
C[Gson]
A --> B
B --> C
A -.-> C
Deze transitieve dependencies worden afgehandeld door Groovy. Het is niet nodig om ze toe te voegen aan je dependencies {} configuratie.
Meer zelfs, als je fouten krijgt gerelateerd aan dependencies, is het geen goed idee om de zachte (transitieve) dependency (stippellijn) te veranderen in een harde, door die als directe dependency toe te voegen in de configuratie. Gradle biedt hier betere alternatieven voor.
Gradle tasks
./gradlew tasks --all voorziet een overzicht van alle beschikbare taken voor een bepaald Gradle project, opgesplitst per fase (build tasks, build setup tasks, documentation tasks, help tasks, verification tasks). Gradle plugins voorzien vaak extra tasks, zoals bijvoorbeeld de maven plugin om te publiceren naar repositories.
Belangrijke taken zijn onder andere:
test: voer alle unit testen uit. Een rapport hiervan is beschikbaar op build/reports/tests/test/index.html. (Hier komen we zeker nog op terug in het hoofdstuk rond test-driven development)
clean: verwijder alle binaries en metadata.
build: compile en test het project.
publish: (maven plugin) publiceer de gebouwde versie naar een repository.
jar: compile en package in een jar bestand
javadoc: (plugin) genereert HTML javadoc. Een rapport hiervan is beschikbaar op build/docs/javadoc/index.html.
Onderstaande screenshot is een voorbeeld van een Unit Test HTML rapport dat gegenereerd wordt elke keer de test task uitgevoerd wordt:
Compile to JAR
Zoals we in het hoofdstuk rond build systems in Java gezien hebben kan je alle bestanden groeperen in een .jar bestand om er een ’executable’ van te maken die door iemand anders uitgevoerd kan worden op een JVM. Om zo’n jar te maken gebruik je het commando ./gradlew jar. Er wordt dan een main_classname.jar aangemaakt in je /app/build/libs directory.
Zonder extra opties in je build.gradle wordt er echter geen 'Main-Class'-attribuut toegevoegd aan de MANIFEST file van je jar. Daarom kan je volgende optie instellen in je build.gradle waarbij application.mainClass een referentie is naar de value die je daar hebt ingesteld:
jar{manifest{attributes('Main-Class':application.mainClass// dit maakt een verwijzing naar de mainClass eigenschap die je in het application {} blok hebt ingesteld
)}archiveBaseName='myJarName'// enkel als je de naam van de gegenereerde jar wil wijzigen
}
Weetje
We kunnen ook zelf een kleine task coderen in Groovy die simpelweg de ‘group’ van ons project uitprint:
taskexample{printlnproject.group}
We gebruiken deze dan via ./gradlew example.
Fat jars (shadow jars)
Merk op dat een typisch project dat gebouwd of uitgevoerd wordt via Gradle geen jars van de dependencies bevat. Die worden immers automatisch door Gradle gedownload en in de juiste map (de Gradle cache) geplaatst.
Bij het maken van een jar van je applicatie zal Gradle standaard de dependencies dus niet mee in jouw jar file steken. Dat is logisch: Gradle geeft de voorkeur aan het scheiden van de applicatiecode en de dependencies. Hierdoor blijft de jar file kleiner en wordt het eenvoudiger om dependencies te beheren en bij te werken.
Wil je nu toch dat een (eind)gebruiker bijvoorbeeld niet zelf aan dependency management via Gradle moet doen, dan is het mogelijk om ook toch de applicatie en alle dependencies te bundelen in één grote jar file. Dit wordt een uber jar, fat jar, of shadow jar genoemd. De gebruiker heeft dan alles om je code uit te voeren zolang hij/zij over de juiste versie van de JVM beschikt.
Je kan Gradle zo een shadowJar laten aanmaken op deze manier:
Voeg de Shadow plugin toe aan je build.gradle bestand:
Bouw je project met de shadow jar taak: ./gradlew shadowJar
Hieronder zie je het verschil tussen compileren tot een shadowJar of een gewone jar:
arne@LT3210121:~/ses/depgradle/app/build/libs$ ls
app-1.0-SNAPSHOT-shadow.jar app-1.0-SNAPSHOT.jar
# Trying to run normal jar via cli manuallyarne@LT3210121:~/ses/depgradle/app/build/libs$ java -jar app-1.0-SNAPSHOT.jar
Exception in thread "main" java.lang.NoClassDefFoundError: com/google/gson/Gson
at be.ses.depgradle.App.main(App.java:10)Caused by: java.lang.ClassNotFoundException: com.google.gson.Gson
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641) at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188) at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526) ... 1 more
# Trying to run shadowJar via cli manuallyarne@LT3210121:~/ses/depgradle/app/build/libs$ java -jar app-1.0-SNAPSHOT-shadow.jar
1
Aangezien we toch meestal gebruik gaan maken van build tools zoals Gradle die steeds zelf de nodige dependecies download en toevoegt is het gebruik van de shadowJar in deze cursus echter beperkt
Meer output
De standaard output geeft enkel weer of er iets gelukt is of niet:
Je kan meer informatie krijgen met de volgende parameters:
--info, output LogLevel INFO. Veel irrelevante info wordt ook getoond.
--warning-mode all, toont detail informatie van warning messages
--stacktrace, toont de detail stacktrace bij exceptions
Repositories
Zoals eerder vermeld gebruikt Gradle Maven-repositories om de bestanden (denk: de .jar) van alle opgelijste dependencies op te halen.
We bekijken hier hoe je extra repositories kan toevoegen, wat in zo’n repository zit, en hoe je zelf kan publiceren naar een repository.
Extra repositories toevoegen
Veelgebruikte libraries zijn eenvoudig te vinden via de Central Maven Repository. Wanneer echter een eigen library werd gecompileerd, die dan in andere projecten worden gebruikt, schiet deze methode tekort: interne libraries zijn uiteraard niet op een publieke server gepubliceerd.
mavenCentral(), jcenter(), en google() zijn ingebouwde repositories. Eigen Maven folders en URLs toevoegen kan ook.
Tenslotte kan je ook een lokale folder toevoegen:
repositories{flatDir{dirs'lib'}}
Door nu de nodige .jar files toe te voegen aan de folder ./app/lib kunnen de juiste dependencies ook gevonden worden. Indien de ./app/lib directory nog niet bestaat, ga je die eerst moeten toevoegen.
Deze laatste manier is vooral handig wanneer je een library (.jar) wil gebruiken die niet via een repository beschikbaar zijn. Zo kan je deze dependencies toch nog via Gradle beheren.
Een jar uit zo’n flatDir repository als dependency toevoegen doe je als volgt:
Klik op ‘View All’ bij de Gson module op de MVN Central Repo om te inspecteren welke bestanden typisch worden aangeleverd in een Maven repository:
De library zelf, in een bepaalde versie (gson-2.12.1.jar).
Eventueel de javadoc en/of sources als aparte jars (gson-2.12.1-javadoc.jar, gson-2.12.1-sources.jar).
Een .pom XML bestand (gson-2.12.1.pom).
metadata over het build-proces(gson-2.12.1.buildinfo)
checksums (md5 en sha1) en digitale handtekeningen (asc) op alle vorige bestanden
Het .pom XML bestand beschrijft welke afhankelijkheden deze module op zich heeft. Zo kan een hele dependency tree worden opgebouwd! Het beheren van alle afhankelijkheden is complexer dan op het eerste zicht lijkt, en laat je dus beter over aan deze gespecialiseerde tools. Google heeft voor Gson enkel Junit als test dependency aangeduid:
Grote projecten kunnen makkelijk afhankelijk zijn van tientallen libraries, die op hun beurt weer afhankelijk zijn van meerdere andere libraries.
Hieronder een voorbeeld van een dependency tree voor een typische grote webapplicatie geschreven in Java:
Je kan deze opvragen via Gradle met de de task dependencies: ./gradlew dependencies. Detailinformatie voor specifieke dependencies kunnen worden opgevraagd met de dependencyInsight task. Zie ook: Viewing and debugging dependencies in de Gradle docs.
Publiceren naar een Maven Repository
Gradle voorziet een plugin genaamd ‘maven-publish’ die bovenvermelde bestanden automatisch aanmaakt, zodat je jouw project kan uploaden naar een online repository, zoals Maven central, of een lokale maven repository. Op die manier kunnen andere projecten jouw project dan weer als dependency gaan gebruiken. Activeer de plugin en voeg een publishing block toe met de volgende eigenschappen:
plugins{id'java'id'maven-publish'// toevoegen!
}group='be.ses'version='1.0-SNAPSHOT'publishing{publications{maven(MavenPublication){groupId=project.group.toString()version=versionartifactId='projectnaam'fromcomponents.java}}repositories{maven{url="/home/arne/local-maven-repo"// gebruik je eigen home-folder!
}}}
Indien die directory nog niet bestaat wordt deze aangemaakt!
Windows-gebruikers dienen in de url value te werken met dubbele backslashes (\\) in plaats van forward slashes (/) om naar het juiste pad te navigeren.
Deze uitbreiding voegt de target publish toe aan Gradle. Dus: ./gradlew publish publiceert de nodige bestanden in de aangegeven folder.
De aangemaakte lokale Maven repository ziet er nu zo uit:
Een Gradle project dat nu gebruik wilt maken van de libraries in die lokale Maven repository dient enkel een tweede Maven repository plaats te definiëren:
repositories{mavenCentral()maven{url="/home/arne/local-maven-repo"// gebruik je eigen home-folder!
}}
In de praktijk ga je natuurlijk eerder een gedeelde (al dan niet interne) locatie gebruiken als repository, en geen map op je eigen harde schijf.
Dat laatste doet immers veel van de voordelen van het gebruik van Gradle teniet.
Oefeningen
Oefening 1: Hoger lager
Maak een nieuw Gradle project aan met de naam: higher_lower en gebruik de packagenaam be.ses.higher_lower.
Hint
Om een packagenaam in te stellen in recentere versies van gradle, moet je het --package argument meegeven:
gradle init --package be.ses.higher_lower.
Delete de test in app/src/test/java/be/ses/higher_lower/App.java. Anders zal je je project niet kunnen runnen.
Aangezien we input aan de gebruiker gaan vragen moeten we een extra optie instellen in de app/build.gradle:
run{standardInput=System.in}
Kopieer onderstaande code voor de klasse app/src/main/java/be/ses/higher_lower/App.java:
App.java (klik om te tonen)
packagebe.ses.higher_lower;importjava.util.Random;importjava.util.Scanner;publicclassApp{publicstaticvoidmain(String[]args){Scannerscanner=newScanner(System.in);Randomrandom=newRandom();System.out.println("Welcome to the Higher or Lower game!");System.out.println("Guess if the next number will be higher or lower.");intcurrentNumber=random.nextInt(100)+1;booleanplaying=true;intscore=0;while(playing){System.out.println("Current number: "+currentNumber);System.out.print("Will the next number be higher or lower? (h/l): ");Stringguess=scanner.next().toLowerCase();intnextNumber=random.nextInt(100)+1;if((guess.equals("h")&&nextNumber>currentNumber)||(guess.equals("l")&&nextNumber<currentNumber)){System.out.println("Correct! The next number was: "+nextNumber);score++;}else{System.out.println("Wrong! The next number was: "+nextNumber);playing=false;}currentNumber=nextNumber;}System.out.println("Game over! Your final score: "+score);scanner.close();}}
Test je programma: Aangezien we input vragen via de scanner moeten we ook op een speciale manier ons project runnen: gebruik ./gradlew --console plain run (of ./gradlew -q --console plain run om helemaal geen output van gradle tasks ertussen te zien.)
Maak een jar via Gradle.
Copy de app/build/libs/app.jar-file naar een andere directory en hernoem naar higherLower.jar.
Run de jar via de terminal: java -jar higherLower.jar. Je krijgt een foutmelding gerelateerd aan het niet vinden van een main-klasse. Los die op (zie de uitleg hierboven) en probeer opnieuw.
Oefening 2: Scorebord-library
Ontwerp een eenvoudige library genaamd ‘scorebord’ die scores kan bijhouden voor bordspelletjes. Deze library kan dan gebruikt worden door toekomstige digitale Java bordspellen (en zal gebruikt worden door onze hoger-lager app).
In een Scorebord kan je spelers toevoegen door middel van een naam en een score. Er is een mogelijkheid om de huidige score van een speler op te vragen, en de winnende speler. Deze gegevens worden met behulp van Gson in een json bestand bewaard, zodat bij het heropstarten van een spel de scores behouden blijven.
packagebe.ses.scorebord;importjava.io.IOException;importjava.nio.file.Files;importjava.nio.file.Path;importjava.util.ArrayList;importjava.util.Comparator;importjava.util.List;importjava.util.NoSuchElementException;importcom.google.gson.Gson;publicclassScorebord{privateList<SpelerScore>spelerScores=newArrayList<>();publicvoidsetScore(Stringname,intcurrentScore){spelerScores.add(newSpelerScore(name,currentScore));spelerScores.sort(Comparator.comparing(ss->ss.getScore()));}publicintgetScoreOf(Stringname){for(vars:spelerScores){if(s.getName().equals(name)){returns.getScore();}}thrownewNoSuchElementException("No player with that name");}publicStringgetWinner(){SpelerScorewinner=null;for(varss:spelerScores){if(winner==null||ss.getScore()>winner.getScore()){winner=ss;}}returnwinner.getName();}@OverridepublicStringtoString(){Stringresult="";for(varss:spelerScores){result+=ss.getName()+": "+ss.getScore()+"\n";}returnresult;}publicvoidsaveToJson(Stringfilename)throwsIOException{vargson=newGson();varjson=gson.toJson(this);Files.write(Path.of("scorebord.json"),json.getBytes());}publicstaticScorebordgetScorebordFromJson(Stringfilename)throwsIOException{if(!Files.exists(Path.of(filename))){returnnewScorebord();}vargson=newGson();varjson=Files.readString(Path.of("scorebord.json"));varscorebord=gson.fromJson(json,Scorebord.class);returnscorebord;}}
De klasse SpelerScore is een intern hulpmiddel om te serialiseren van/naar JSON. Deze klasse wordt gebruikt in de implementatie van Scorebord.
Maak via de CLI een nieuw Gradle - Java project. Groupid: be.ses. Geef je project de naam: scorebord.
Configureer Gradle zodat het commando gradlew jar een bestand scorebord-1.0.jar creëert in de build/libs folder.
Tip: je update best je gradle wrappen naar versie 8.13 of hoger.
Oefening 3: Hoger lager met scorebord
Voeg dat bovenstaand scorebord project als een library toe aan higher_lower-applicatie uit de eerste oefening. Kopieer de scorebord-1.0.jar-jarfile lokaal in een ./app/lib folder in je higher_lower project en instrueer Gradle zo dat dit als flatDir repository wordt opgenomen (zie boven). Update nu ook de App klasse in de higher_lower-applicatie:
Nieuwe App.java
packagebe.ses.higher_lower;importjava.io.IOException;importjava.util.Random;importjava.util.Scanner;importbe.ses.scorebord.Scorebord;publicclassApp{publicstaticvoidmain(String[]args)throwsIOException{Scannerscanner=newScanner(System.in);Randomrandom=newRandom();Scorebordscorebord=Scorebord.getScorebordFromJson("highscores.json");System.out.println("Welcome to the Higher or Lower game!");System.out.println("This is the current leaderboard: \n"+scorebord+"\n\n");System.out.println("Guess if the next number will be higher or lower.");intcurrentNumber=random.nextInt(100)+1;booleanplaying=true;intscore=0;while(playing){System.out.println("Current number: "+currentNumber);System.out.print("Will the next number be higher or lower? (h/l): ");Stringguess=scanner.next().toLowerCase();intnextNumber=random.nextInt(100)+1;if((guess.equals("h")&&nextNumber>currentNumber)||(guess.equals("l")&&nextNumber<currentNumber)){System.out.println("Correct! The next number was: "+nextNumber);score++;}else{System.out.println("Wrong! The next number was: "+nextNumber);playing=false;}currentNumber=nextNumber;}System.out.println("Game over! Your final score: "+score);System.out.println("What is your name?");Stringname=scanner.next();scorebord.setScore(name,score);System.out.println("This is the new leaderboard: \n"+scorebord+"\n\n");scorebord.saveToJson("highscores.json");scanner.close();}}
Als de dependencies goed liggen, kan je een nieuw Scorebord aanmaken, en herkent VSCode dit met CTRL+Space. Hieronder een voorbeeld van Gson:
Gradle extention for VSCode Ctrl+space
Als we het project uitvoeren, werkt dit echter niet: we krijgen een foutmelding bij het opslaan.
Dat komt omdat we een library gebruiken (scorebord), die op zijn beurt een library gebruikt (Gson).
Maar de Gson dependency is niet in onze huidige Gradle file gedefinieerd.
Om dit op te lossen, hebben we 3 opties:
zelf een dependency naar Gson toevoegen. Dat is echter ten zeerste af te raden, omdat we zo het automatische dependencybeheer via Gradle omzeilen.
een shadow jar/fat jar maken van scorebord. Dat is echter ook niet de meest aangewezen manier, opnieuw omdat we ingaan tegen de bedoeling van Gradle.
overschakelen naar een lokale Maven repository waarnaar we onze scorebord-library publiceren (zie eerder). Dan worden ook transitieve dependencies automatisch ingeladen. Verwijder de flatDir en voeg een lokale maven URL toe. Publiceer het scorebord project naar diezelfde URL volgens de instructies van de maven-publish plugin.
Denkvragen
Hoe zou je transitieve dependencies handmatig kunnen beheren? Wat zijn de voor- en nadelen?
Wat gebeurt er als project1-1.0 afhankelijk is van lib1-1.0 en lib1-2.0, en lib1-1.0 van lib2-1.0 - een oudere versie dus?
Als ik publiceer naar een lokale folder, welke bestanden zijn dan absoluut noodzakelijk voor iemand om mijn library te kunnen gebruiken?
In C (met CMake)
CMake
CMake is een open-source tool dat wordt gebruikt voor het beheren van de build processen van softwareprojecten in C en C++. Het biedt een platformonafhankelijke manier om build configuraties te genereren, wat betekent dat het kan worden gebruikt op verschillende besturingssystemen zoals Windows, macOS en Linux. CMake maakt gebruik van eenvoudige configuratiebestanden, genaamd CMakeLists.txt (soms meerdere van deze files in je project, je kan namelijk in meerdere directories zo een CMakeLists.txt file aanmaken), waarin de projectstructuur en de dependencies worden gedefinieerd. Deze configuratiebestanden worden vervolgens gebruikt om platformspecifieke buildsystemen te genereren, zoals Makefiles op Unix-achtige systemen (of Visual Studio-projectbestanden op Windows).
Een van de belangrijkste voordelen van CMake ten opzichte van het gebruik van alleen Makefiles is de verbeterde ondersteuning voor dependency management. Met Makefiles moet je handmatig de afhankelijkheden tussen bestanden en bibliotheken beheren, wat foutgevoelig en tijdrovend kan zijn. CMake automatiseert dit proces door dependencies automatisch te detecteren en te beheren. Dit zorgt ervoor dat alleen de noodzakelijke onderdelen opnieuw worden opgebouwd wanneer er wijzigingen worden aangebracht, wat de build tijd aanzienlijk kan verkorten.
Daarnaast biedt CMake een aantal geavanceerde functies die het beheren van dependencies verder vereenvoudigen. Zo ondersteunt CMake het gebruik van externe packages en libraries via de find_package-functie, waarmee je eenvoudig externe afhankelijkheden kunt integreren in je project. Ook biedt CMake ondersteuning voor moderne C++-functionaliteiten zoals target-gebaseerde builds, waarbij je specifieke build opties en dependencies kunt toewijzen aan individuele targets in plaats van aan het hele project. Hierdoor wordt het beheer van complexe projecten met meerdere componenten een stuk eenvoudiger en overzichtelijker. Deze functionaliteiten vallen echter buiten de scope van deze cursus.
Kortom, CMake biedt een krachtig en flexibel alternatief voor traditionele Makefiles, met name op het gebied van dependency management. Het automatiseert en vereenvoudigt het beheer van dependencies.
Je kan CMake simpel installeren op je WSL met behulp van volgend commando: sudo apt install cmake -y.
‘CMakeLists.txt’ en project structure
CMake werkt op basis van een CMakeLists.txt. Daarin specificeer je bepaalde instellingen over de structuur van je project en hoe je project juist gebuild en gelinkt moet worden. Verder kan je net als in Makefiles ook gebruik maken van variabelen om het proces nog meer te automatiseren. Je maakt volgens conventie ook een ./build directory aan waarin dan alle build-files terecht zullen komen. Een van die build-files in een door CMake gegenereerde make file waarmee je dan simpelweg via het commando make je project effectief kan builden naar een binary die je kan uitvoeren. Hieronder bekijken we hoe zo een ‘CMakeLists.txt’ eruit ziet in de root van je project en welke functionaliteiten je hebt.
We starten met een zeer simpel project met een main.c-file, een helloworld.c-file en een helloworld.h-file:
Klik hier om de code te zien/verbergen voor het kleine voorbeeld🔽
Een van de eenvoudigste CMakeLists.txt ziet er dan als volgt uit:
# Specify the minimum required version of CMake you need to use with this project. (Simply use your own version of CMake)cmake_minimum_required(VERSION 3.28)# Specify the project nameproject(myproject)# Specify: the name of the binary to build, the source files needed to build the binaryadd_executable(myproject.bin main.c helloworld.c)
Nu voeren we volgens conventie het cmake commando uit in de build-directory zodat alle nodige build files, waaronder de Makefile aangemaakt worden in die directory: cd ./build && cmake ..
Dat resulteert in volgende files en folders in de build-directory waarvan we eigenlijk enkel geinteresserd zijn in de Makefile:
Klik hier om de code te zien/verbergen voor de contents in de Makefile. Het fijne is nu dat je niet perfect moet weten wat er gebeurt omdat we die verantwoordelijkheid nu bij CMake gelegd hebben🔽
# CMAKE generated file: DO NOT EDIT!
# Generated by "Unix Makefiles" Generator, CMake Version 3.28
# Default target executed when no arguments are given to make.
default_target:all.PHONY :default_target# Allow only one "make -f Makefile2" at a time, but pass parallelism.
.NOTPARALLEL:#=============================================================================
# Special targets provided by cmake.
# Disable implicit rules so canonical targets will work.
.SUFFIXES:# Disable VCS-based implicit rules.
% : %,v# Disable VCS-based implicit rules.
% :RCS/%
# Disable VCS-based implicit rules.
% :RCS/%,v# Disable VCS-based implicit rules.
% :SCCS/s.%
# Disable VCS-based implicit rules.
% :s.%
.SUFFIXES: .hpux_make_needs_suffix_list# Command-line flag to silence nested $(MAKE).
$(VERBOSE)MAKESILENT= -s
#Suppress display of executed commands.
$(VERBOSE).SILENT:# A target that is always out of date.
cmake_force:.PHONY :cmake_force#=============================================================================
# Set environment variables for the build.
# The shell in which to execute make rules.
SHELL= /bin/sh
# The CMake executable.
CMAKE_COMMAND= /usr/bin/cmake
# The command to remove a file.
RM= /usr/bin/cmake -E rm -f
# Escaping for special characters.
EQUALS==# The top-level source directory on which CMake was run.
CMAKE_SOURCE_DIR= /home/arne/ses/cmake_example
# The top-level build directory on which CMake was run.
CMAKE_BINARY_DIR= /home/arne/ses/cmake_example/build
#=============================================================================
# Targets provided globally by CMake.
# Special rule for the target edit_cache
edit_cache: @$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "No interactive CMake dialog available..." /usr/bin/cmake -E echo No\ interactive\ CMake\ dialog\ available.
.PHONY :edit_cache# Special rule for the target edit_cache
edit_cache/fast:edit_cache.PHONY :edit_cache/fast# Special rule for the target rebuild_cache
rebuild_cache: @$(CMAKE_COMMAND) -E cmake_echo_color "--switch=$(COLOR)" --cyan "Running CMake to regenerate build system..." /usr/bin/cmake --regenerate-during-build -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR).PHONY :rebuild_cache# Special rule for the target rebuild_cache
rebuild_cache/fast:rebuild_cache.PHONY :rebuild_cache/fast# The main all target
all:cmake_check_build_system$(CMAKE_COMMAND) -E cmake_progress_start /home/arne/ses/cmake_example/build/CMakeFiles /home/arne/ses/cmake_example/build//CMakeFiles/progress.marks
$(MAKE)$(MAKESILENT) -f CMakeFiles/Makefile2 all
$(CMAKE_COMMAND) -E cmake_progress_start /home/arne/ses/cmake_example/build/CMakeFiles 0.PHONY :all# The main clean target
clean:$(MAKE)$(MAKESILENT) -f CMakeFiles/Makefile2 clean
.PHONY :clean# The main clean target
clean/fast:clean.PHONY :clean/fast# Prepare targets for installation.
preinstall:all$(MAKE)$(MAKESILENT) -f CMakeFiles/Makefile2 preinstall
.PHONY :preinstall# Prepare targets for installation.
preinstall/fast:$(MAKE)$(MAKESILENT) -f CMakeFiles/Makefile2 preinstall
.PHONY :preinstall/fast# clear depends
depend:$(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 1.PHONY :depend#=============================================================================
# Target rules for targets named myproject.bin
# Build rule for target.
myproject.bin:cmake_check_build_system$(MAKE)$(MAKESILENT) -f CMakeFiles/Makefile2 myproject.bin
.PHONY :myproject.bin# fast build rule for target.
myproject.bin/fast:$(MAKE)$(MAKESILENT) -f CMakeFiles/myproject.bin.dir/build.make CMakeFiles/myproject.bin.dir/build
.PHONY :myproject.bin/fasthelloworld.o:helloworld.c.o.PHONY :helloworld.o# target to build an object file
helloworld.c.o:$(MAKE)$(MAKESILENT) -f CMakeFiles/myproject.bin.dir/build.make CMakeFiles/myproject.bin.dir/helloworld.c.o
.PHONY :helloworld.c.ohelloworld.i:helloworld.c.i.PHONY :helloworld.i# target to preprocess a source file
helloworld.c.i:$(MAKE)$(MAKESILENT) -f CMakeFiles/myproject.bin.dir/build.make CMakeFiles/myproject.bin.dir/helloworld.c.i
.PHONY :helloworld.c.ihelloworld.s:helloworld.c.s.PHONY :helloworld.s# target to generate assembly for a file
helloworld.c.s:$(MAKE)$(MAKESILENT) -f CMakeFiles/myproject.bin.dir/build.make CMakeFiles/myproject.bin.dir/helloworld.c.s
.PHONY :helloworld.c.smain.o:main.c.o.PHONY :main.o# target to build an object file
main.c.o:$(MAKE)$(MAKESILENT) -f CMakeFiles/myproject.bin.dir/build.make CMakeFiles/myproject.bin.dir/main.c.o
.PHONY :main.c.omain.i:main.c.i.PHONY :main.i# target to preprocess a source file
main.c.i:$(MAKE)$(MAKESILENT) -f CMakeFiles/myproject.bin.dir/build.make CMakeFiles/myproject.bin.dir/main.c.i
.PHONY :main.c.imain.s:main.c.s.PHONY :main.s# target to generate assembly for a file
main.c.s:$(MAKE)$(MAKESILENT) -f CMakeFiles/myproject.bin.dir/build.make CMakeFiles/myproject.bin.dir/main.c.s
.PHONY :main.c.s# Help Target
help: @echo "The following are some of the valid targets for this Makefile:" @echo "... all (the default if no target is provided)" @echo "... clean" @echo "... depend" @echo "... edit_cache" @echo "... rebuild_cache" @echo "... myproject.bin" @echo "... helloworld.o" @echo "... helloworld.i" @echo "... helloworld.s" @echo "... main.o" @echo "... main.i" @echo "... main.s".PHONY :help#=============================================================================
# Special targets to cleanup operation of make.
# Special rule to run CMake to check the build system integrity.
# No rule that depends on this can have commands that come from listfiles
# because they might be regenerated.
cmake_check_build_system:$(CMAKE_COMMAND) -S$(CMAKE_SOURCE_DIR) -B$(CMAKE_BINARY_DIR) --check-build-system CMakeFiles/Makefile.cmake 0.PHONY :cmake_check_build_system
Nu gaan we van die Makefile gebruik maken om met het make-commando de binary effectief te builden: make. (zorg er natuurlijk wel voor dat je in de ./build-directory zit!)
Er is nu een binary verschenen in de ./build-directory met de naam zoals gespecificeerd in de CMakeLists.txt-file, die je onmiddellijk kan uitvoeren.
Dependency management met CMake en variables
We kunnen nu de CMake file aanpassen zodat de binary automatisch de naam van het project gebruikt door standaard variabelen te gebruiken:
Je kan CMake ook wat zelf wat extra info text laten printen met
message("Hallo vanuit CMake je CMAKE_PROJECT_NAME is '${CMAKE_PROJECT_NAME}'")
Verder kunnen we de helloworld.c en helloworld.h files beschouwen als een kleine internal dependency en kunnen we deze dus best in een soort ./libs/helloworld-directory steken. Dan moeten we ook wel onze CMakeLists.txt-file aanpassen en BELANGRIJK in die ./libs/helloworld-directory moeten we ook een eigen CMakeLists.txt-file aanmaken waarin je definieert hoe die dependency juist gebuild en gelinkt moet worden. Onze project folder ziet er nu dan als volgt uit:
Klik hier om de code te zien/verbergen voor ./CMakeLists.txt🔽
# Specify the minimum required version of CMake you need to use with this project. (Simply use your own version of CMake)cmake_minimum_required(VERSION 3.28)# Specify the project nameproject(myproject)# Add the helloworld directory to the projectadd_subdirectory(libs/helloworld)# Specify: the name of the binary to build, the source files needed to build the binary# You don't need to specify the helloworld.c file here anymore, see belowadd_executable(${CMAKE_PROJECT_NAME}.bin main.c)message("Hallo vanuit CMake je CMAKE_PROJECT_NAME is '${CMAKE_PROJECT_NAME}'")# Link internal library 'helloworld' to the main binarytarget_link_libraries(${CMAKE_PROJECT_NAME}.bin PRIVATE helloworld)
Belangrijk: Merk op dat we de directory naar de library moeten toevoegen via add_subdirectory(), we niet langer de helloworld.c moeten vermelden bij de add_executable()-functie omdat we dit nu aangeven met de functie target_link_libraries():
target_link_libraries: Dit is een CMake-commando dat wordt gebruikt om libraries te koppelen aan een specifieke target. Een target kan een executable of een library zijn die je wilt builden.
${CMAKE_PROJECT_NAME}.bin: Dit is de naam van de target waaraan je de library wilt koppelen. ${CMAKE_PROJECT_NAME} is een variabele die de naam van het project bevat, en .bin is een extension die aangeeft dat het om een executable/binary bestand gaat.
PRIVATE: Dit is een eigenschap die aangeeft hoe de gekoppelde library zichtbaar is voor andere targets. In dit geval betekent PRIVATE dat de library alleen zichtbaar is voor de target ${CMAKE_PROJECT_NAME}.bin en niet voor andere targets die afhankelijk zijn van deze target. Dit helpt om de dependencies te beperken en de build configuratie overzichtelijk te houden. Andere opties buiten ‘PRIVATE’ zijn: ‘PUBLIC’ en ‘INTERFACE’.
PUBLIC: Wanneer je een library met de PUBLIC-eigenschap koppelt, betekent dit dat de library zichtbaar is voor zowel de target zelf als voor alle targets die afhankelijk zijn van deze target. Dit is handig wanneer je wilt dat de dependency doorgegeven wordt aan andere targets die deze target gebruiken.
INTERFACE: Deze eigenschap geeft aan dat de library alleen zichtbaar is voor targets die afhankelijk zijn van de target, maar niet voor de target zelf. Dit is nuttig voor het definiëren van interface-afhankelijkheden die alleen relevant zijn voor afhankelijke targets.
PRIVATE: Alleen zichtbaar voor de target zelf.
helloworld: Dit is de naam van de library die je wilt koppelen aan de target. In dit geval is helloworld waarschijnlijk een library die je hebt gedefinieerd of geïmporteerd in je project.
./libs/helloworld/CMakeLists.txt
Klik hier om de code te zien/verbergen voor ./libs/helloworld/CMakeLists.txt🔽
# Specify the minimum required version of CMake you need to use with this project. (Simply use your own version of CMake)cmake_minimum_required(VERSION 3.28)# Create a sort of temp project that compiles so it can be linked to the main project AND specify it is a C projectproject(Helloworld C)# Now add the compiled project binary as a libraryadd_library(helloworld helloworld.c)# Include this directory's binary as a binary that can be used by the main project to link withtarget_include_directories(helloworld PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
Belangrijk: Deze CMakeLists.txt file bevat essentiële instructies die samen zorgen dat de interne dependency correct wordt opgebouwd en bruikbaar is voor het main project. Allereerst definieert de project(Helloworld C) regel een nieuw project met de naam “Helloworld” en specificeert dat het in de programmeertaal C is geschreven, waardoor CMake de juiste compiler en instellingen selecteert. Met add_library(helloworld helloworld.c) wordt een statische of dynamische bibliotheek aangemaakt op basis van de broncode in “helloworld.c”, zodat deze gecompileerde code later in het hoofdproject gelinkt kan worden. Tenslotte zorgt target_include_directories(helloworld PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) ervoor dat de directory waarin deze CMakeLists.txt en de headers staan, toegevoegd wordt aan de include-paden van alle targets die de “helloworld” library gebruiken. Hierdoor weet het main project waar de benodigde headers te vinden zijn, wat essentieel is voor een correcte compilatie en koppeling tussen de modules.
Voorbeeld met externe library
Voor dit hoofdstuk is er geen oefening voorzien, maar wel een voorbeeld analoog aan de oefening rond dependency management in Java. In dit geval hebben we een “Higher Lower C” project waarbij we de volgende structuur hebben. De entrypoint van onze applicatie is de main.c-file, de scorebord.c en scorebord.h kunnen gezien worden als een interne library en cJSON is een externe library/dependency. Hieronder vind je dan een mogelijke structuur voor ons project terug. Je kan dit project en bijhorende files ook downloaden met volgende github link.
Wil je de content van de source files bekijken, kan je dat hieronder. Het is in dit hoofdstuk vooral belangrijk dat je begrijpt wat de regels in de verschillende CMakeLists.txt betekenen en wat het voordeel is om het op deze manier te doen.
source code: files in root
Klik hier om de code te zien/verbergen voor CMakeLists.txt🔽
cmake_minimum_required(VERSION 3.28)project(HogerLagerC)# Gebruik de C99-standaardset(CMAKE_C_STANDARD 99)set(CMAKE_C_STANDARD_REQUIRED ON)add_subdirectory(external/cJSON)# Voeg de scorebord library toeadd_subdirectory(internal/scorebord)# Maak de executable van het speladd_executable(hoger_lager main.c)# Link de externe JSON-librarytarget_link_libraries(hoger_lager PRIVATE scorebord cjson)
Klik hier om de code te zien/verbergen voor main.c🔽
/* main.c - Hoger Lager spel */#include<stdio.h>#include<stdlib.h>#include<time.h>#include"scorebord.h"intmain(void){srand((unsigned)time(NULL));Scorebord*scorebord=load_scorebord("highscores.json");printf("Welkom bij het Hoger Lager spel!\n");printf("Dit is de huidige ranglijst:\n");print_scorebord(scorebord);printf("\nRaad of het volgende getal hoger of lager zal zijn.\n");intcurrentNumber=rand()%100+1;intscore=0;charchoice;while(1){printf("Huidig getal: %d\n",currentNumber);printf("Zal het volgende hoger (h) of lager (l) zijn? ");scanf(" %c",&choice);intnextNumber=rand()%100+1;if((choice=='h'&&nextNumber>currentNumber)||(choice=='l'&&nextNumber<currentNumber)){printf("Correct! Het volgende getal was: %d\n",nextNumber);score++;}else{printf("Fout! Het volgende getal was: %d\n",nextNumber);break;}currentNumber=nextNumber;}printf("Game over! Jouw eindscore: %d\n",score);printf("Wat is je naam? ");charname[50];scanf("%49s",name);add_score(scorebord,name,score);save_scorebord("highscores.json",scorebord);free_scorebord(scorebord);return0;}
source code: files in ./internal/scorebord
Klik hier om de code te zien/verbergen voor ./internal/scorebord/CMakeLists.txt🔽
cmake_minimum_required(VERSION 3.28)project(Scorebord C)include(FetchContent)FetchContent_Declare( cjson
GIT_REPOSITORY https://github.com/DaveGamble/cJSON.git
GIT_TAG v1.7.14 # Specify the version you want to use)FetchContent_MakeAvailable(cjson)add_library(scorebord scorebord.c)# Ensure cJSON include directories are correctly settarget_include_directories(scorebord PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}${cjson_SOURCE_DIR}${cjson_BINARY_DIR})# Link cJSON library to scorebordtarget_link_libraries(scorebord PRIVATE cjson)
Klik hier om de code te zien/verbergen voor ./internal/scorebord/scorebord.h🔽
#ifndef SCOREBORD_H
#define SCOREBORD_H
#include"player.h"typedefstruct{Player*players;intcount;intcapacity;}Scorebord;// Laadt de scorebordgegevens vanuit een JSON-bestand.
Scorebord*load_scorebord(constchar*filename);// Slaat de scorebordgegevens op in een JSON-bestand.
voidsave_scorebord(constchar*filename,Scorebord*sb);// Voegt een nieuwe score toe aan het scorebord.
voidadd_score(Scorebord*sb,constchar*name,intscore);// Print het scorebord naar de standaard output.
voidprint_scorebord(Scorebord*sb);// Geeft het geheugen van het scorebord vrij.
voidfree_scorebord(Scorebord*sb);#endif // SCOREBORD_H
Klik hier om de code te zien/verbergen voor ./internal/scorebord/scorebord.c🔽
#include<stdio.h>#include<stdlib.h>#include<string.h>#include"scorebord.h"#include"cJSON.h" // Externe JSON-library: cJSON// Hulpfunctie: maakt een nieuw Scorebord aan
staticScorebord*create_scorebord(){Scorebord*sb=malloc(sizeof(Scorebord));if(sb){sb->count=0;sb->capacity=10;// begin met ruimte voor 10 spelers
sb->players=malloc(sb->capacity*sizeof(Player));}returnsb;}Scorebord*load_scorebord(constchar*filename){FILE*fp=fopen(filename,"r");if(!fp){// Bestaat niet? Geef een nieuw scorebord terug.
returncreate_scorebord();}// Lees het hele bestand in een buffer
fseek(fp,0,SEEK_END);longfsize=ftell(fp);fseek(fp,0,SEEK_SET);char*data=malloc(fsize+1);fread(data,1,fsize,fp);fclose(fp);data[fsize]='\0';cJSON*json=cJSON_Parse(data);free(data);Scorebord*sb=create_scorebord();if(json){intarraySize=cJSON_GetArraySize(json);for(inti=0;i<arraySize;i++){cJSON*item=cJSON_GetArrayItem(json,i);cJSON*name=cJSON_GetObjectItem(item,"name");cJSON*score=cJSON_GetObjectItem(item,"score");if(cJSON_IsString(name)&&cJSON_IsNumber(score)){add_score(sb,name->valuestring,score->valueint);}}cJSON_Delete(json);}returnsb;}voidsave_scorebord(constchar*filename,Scorebord*sb){cJSON*json=cJSON_CreateArray();for(inti=0;i<sb->count;i++){cJSON*item=cJSON_CreateObject();cJSON_AddStringToObject(item,"name",sb->players[i].name);cJSON_AddNumberToObject(item,"score",sb->players[i].score);cJSON_AddItemToArray(json,item);}char*rendered=cJSON_Print(json);FILE*fp=fopen(filename,"w");if(fp){fputs(rendered,fp);fclose(fp);}free(rendered);cJSON_Delete(json);}voidadd_score(Scorebord*sb,constchar*name,intscore){if(sb->count>=sb->capacity){sb->capacity*=2;sb->players=realloc(sb->players,sb->capacity*sizeof(Player));}// Kopieer de naam (maximaal 49 karakters) en voeg de score toe
strncpy(sb->players[sb->count].name,name,sizeof(sb->players[sb->count].name)-1);sb->players[sb->count].name[sizeof(sb->players[sb->count].name)-1]='\0';sb->players[sb->count].score=score;sb->count++;}voidprint_scorebord(Scorebord*sb){// Eenvoudige weergave; je kunt hier sorteren op score toevoegen indien gewenst.
for(inti=0;i<sb->count;i++){printf("%s: %d\n",sb->players[i].name,sb->players[i].score);}}voidfree_scorebord(Scorebord*sb){if(sb){free(sb->players);free(sb);}}
Klik hier om de code te zien/verbergen voor ./internal/scorebord/player.h🔽
Dependency management in Python kan efficiënt worden afgehandeld met pip en venv. Deze tools helpen ervoor te zorgen dat je projecten de benodigde libraries hebben en dat verschillende projecten elkaar niet verstoren door conflicting dependencies.
pip is de package manager voor Python. Hiermee kun je extra libraries en dependencies installeren en beheren die niet zijn opgenomen in de standaardbibliotheek. Om een pakket te installeren met pip, kun je de volgende command prompt gebruiken:
pip install <package_name>
Je kunt ook de versie van het pakket specificeren die je wilt installeren:
pip install package_name==1.2.3
Om de dependencies van je project bij te houden, kun je een requirements.txt-bestand maken waarin alle packages en hun versies worden vermeld. Dit bestand kan worden gegenereerd met:
pip freeze > requirements.txt
Om de dependencies in requirements.txt dan ergens te installeren, kun je het volgende commando gebruiken:
pip install -r requirements.txt
venv is een module die ondersteuning biedt voor het maken van lichte, geïsoleerde Python-omgevingen. Je installeer de module opt je WSL via sudo apt install python3.12-venv -y. Venv is vooral handig wanneer je meerdere projecten hebt met verschillende dependencies en zeker bij meerdere projecten met verschillende versies van dezelfde dependencies. Om een virtuele omgeving (virtual environment = venv) te maken, kun je de volgende opdracht gebruiken (dit hoeft niet specifiek in de directory van je prject te zijn):
python3 -m venv <myenv_name>
Hiermee wordt een directory genaamd myenv_name gemaakt die een kopie van de Python-interpreter en een pip-executable bestand bevat. Om de virtuele omgeving te activeren, gebruik je:
source <myenv_name>/bin/activate
Info
Je kan venv ook op Windows gebruiken maar dan gebruik je het commando: <myenv_name>\Scripts\activate om de virtual environment te activeren.
Zodra de virtuele omgeving is geactiveerd, worden alle packages die met pip worden geïnstalleerd beperkt tot deze omgeving, zodat de dependencies van je project geïsoleerd blijven van andere projecten. Om de virtuele omgeving te deactiveren, voer je eenvoudigweg volgend commando uit:
deactivate
Je zal dan zien dat je CLI je dan ook toont in welke ‘venv’ je je bevindt:
(<myenv_name>) arne@LT3210121:~/myproject$
Door pip en venv te combineren, kun je effectief dependencies beheren, meerdere projectomgevingen simpel onderhouden en conflicten tussen verschillende projecten vermijden. Deze aanpak zorgt ervoor dat je projecten reproduceerbaar blijven en eenvoudig op verschillende systemen kunnen worden opgezet.
Voorbeeld ‘hoger lager’
Hieronder staat een voorbeeld van hoe je het ‘hoger lager’-project kunt inrichten als een Python-project met een virtual environment, een requirements.txt-file voor de externe JSON-library (via simplejson) en met de scorebord.py als interne dependency in een libs-directory. De projectstructuur ziet er dan als volgt uit:
Requirements.txt bevat nu enkel volgende regel: simplejson==3.20.1 en kan je installeren in de ‘venv’ met pip install -r requirements.txt.
Je main.py ziet er dan als volgt uit, waarbij de if __name__ == '__main__': een indicatie geeft dat deze file de entrypoint tot onze applicatie is:
Klik hier om de code te zien/verbergen voor main.py🔽
importrandomfromlibs.scorebordimportScoreboarddefmain():# Laad het scorebord vanuit het JSON-bestand (als het bestaat)sb=Scoreboard.load_from_json('highscores.json')print("Welcome to the Higher or Lower game!")print("This is the current leaderboard:\n",sb,"\n")print("Guess if the next number will be higher or lower.")current_number=random.randint(1,100)score=0playing=Truewhileplaying:print("Current number:",current_number)guess=input("Will the next number be higher or lower? (h/l): ").lower().strip()next_number=random.randint(1,100)if(guess=='h'andnext_number>current_number)or(guess=='l'andnext_number<current_number):print("Correct! The next number was:",next_number)score+=1else:print("Wrong! The next number was:",next_number)playing=Falsecurrent_number=next_numberprint("Game over! Your final score:",score)name=input("What is your name? ")sb.add(name,score)print("This is the new leaderboard:\n",sb,"\n")Scoreboard.save_to_json('highscores.json',sb)if__name__=='__main__':main()
Bestand: libs/init.py
Dit bestand kan leeg zijn, maar het zorgt ervoor dat Python de map libs als package herkent.
Info
Wil je meer info over de conventies over het werken met interne libraries in python projecten, bekijk dan deze video.
Interne dependency: scorebord.py
Dit is de interne dependency die het scorebord beheert. Hierin gebruiken we de externe JSON-library (simplejson) om de gegevens op te slaan en te laden.
Klik hier om de code te zien/verbergen voor ./libs/scorebord.py🔽
importsimplejsonasjsonclassScoreboard:def__init__(self):self.scores=[]defadd(self,name,score):self.scores.append({'name':name,'score':score})deftotal_score(self,name):returnsum(player['score']forplayerinself.scoresifplayer['name']==name)defget_winner(self):ifnotself.scores:return"No players yet"returnmax(self.scores,key=lambdaplayer:player['score'])['name']def__str__(self):# Sorteer de scores aflopend en formatteer de outputsorted_scores=sorted(self.scores,key=lambdaplayer:player['score'],reverse=True)return"\n".join(f"{player['name']}: {player['score']}"forplayerinsorted_scores)@staticmethoddefsave_to_json(filename,scoreboard):withopen(filename,'w')asf:json.dump(scoreboard.scores,f,indent=4)@staticmethoddefload_from_json(filename):sb=Scoreboard()try:withopen(filename,'r')asf:scores=json.load(f)ifscoresisnotNone:sb.scores=scoresexcept(FileNotFoundError,json.JSONDecodeError):# Bestand bestaat niet of is ongeldig, geef een nieuw scorebord terug.passreturnsb
Info
Meer info over het gebruik van classes in Python vind je hier.
Builden/runnen
Je zou dit project nu kunnen laten compileren tot een binary met bijvoorbeeld pyinstaller zoals we gezien hebben in build tools voor python, maar we kunnen het project ook simpelweg uitvoeren met python via:
python3 ./app.py
De bronbestanden voor dit voorbeeld kan je ook in deze github repo terugvinden. Merk op dat in dit project de ‘venv’ niet is opgenomen in het versiebeheer via de higher_lower_venv/ in de ./.gitignore-file wat de conventie is.
5. Test Driven Development
Wat is de beste manier om het aantal bugs in code te reduceren?
Test-Driven Development
TDD (Test-Driven Development) is een hulpmiddel bij softwareontwikkeling om minder fouten te maken en sneller fouten te vinden, door éérst een test te schrijven en dan pas de implementatie. Die (unit) test zal dus eerst falen (ROOD), want er is nog helemaal geen code, en na de correcte implementatie uiteindelijk slagen (GROEN).
graph LR;
T{"Write FAILING<br/> test"}
D{"Write<br/> IMPLEMENTATION"}
C{"Run test<br/> SUCCEEDS"}
S["Start Hier"]
S --> T
T --> D
D --> C
C --> T
Testen worden opgenomen in een build omgeving, waardoor alle testen automatisch worden gecontroleerd bij bijvoorbeeld het compileren, starten, of packagen van de applicatie. Op deze manier krijgt men onmiddellijk feedback van modules die door bepaalde wijzigingen niet meer werken zoals beschreven in de test.
Soorten van Testen
Er zijn drie grote types van testen:
1. Unit Testing (GROEN)
Een unit test test zaken op individueel niveau, per klasse dus. De meeste testen zijn unit testen. Hoe kleiner het blokje op bovenstaande figuur, hoe beter de F.I.R.S.T. principes kunnen nageleefd worden. Immers, hoe meer systemen opgezet moeten worden voordat het assertion framework zijn ding kan doen, hoe meer tijd verloren, en hoe meer tijd de test in totaal nodig heeft om zijn resultaat te verwerken.
2. Integration Testing (ORANJE)
Een integratie test test het integratie pad tussen twee verschillende klasses. Hier ligt de nadruk op interactie in plaats van op individuele functionaliteit, zoals bij de unit test. We willen bijvoorbeeld controleren of een bepaalde service wel iets wegschrijft naar de database, maar het schrijven zelf is op unit niveau bij de database reeds getest. Waar wij nu interesse in hebben, is de interactie tussen service en database, niet de functionaliteit van de database.
Typische eigenschappen van integration testen:
Test geïntegreerd met externen. (db, webservice, …)
Test integratie twee verschillende lagen.
Trager dan unit tests.
Minder test cases.
3. End-To-End Testing (ROOD)
Een laatste groep van testen genaamd end-to-end testen, ofwel scenario testen, testen de héle applicatie, van UI tot DB. Voor een GUI applicatie bijvoorbeeld betekent dit het simuleren van de acties van de gebruiker, door op knoppen te klikken en te navigeren doorheen de applicatie, waarbij bepaalde verwachtingen worden afgetoetst. Bijvoorbeeld, klik op ‘voeg toe aan winkelmandje’, ga naar ‘winkelmandje’, controleer of het item effectief is toegevoegd.
Typische eigenschappen van end-to-end testen:
Test hele applicatie!
Niet alle limieten.
Traag, moeilijker onderhoudbaar.
Test integratie van alle lagen.
In plaats van dit in (Java) code te schrijven, is het ook mogelijk om de Selenium IDE extentie voor Google Chrome of Mozilla Firefox te gebruiken. Deze browser extentie laat recorden in de browser zelf toe, en vergemakkelijkt het gebruik (er is geen nood meer aan het vanbuiten kennen van zulke commando’s). Dit wordt in de praktijk vaak gebruikt door software analisten of testers die niet de technische kennis hebben om te programmeren, maar toch deel zijn van het ontwikkelteam.
Recente versies van de Selenium IDE plugin bewaren scenario’s in .side bestanden, wat een JSON-notatie is. Oudere versies bewaren commando’s in het .html formaat. deze bestanden bevatten een lijst van je opgenomen records:
Gebruik Selenium IDE om een test scenario op te nemen van het volgende scenarios op de website https://www.saucedemo.com/. :
Log in met “locked_out_user” en wachtwoord “secret_sauce” en verifieer dat je een error boodschap krijgt.
Log in met “standard_user” en wachtwoord “secret_sauce”, klik op het eerste item, voeg toe aan je winkelmandje, ga naar je winkelmandje. Verifieer dat er een product inzit.
Log in met “standard_user” en wachtwoord “secret_sauce” en test of de afbeeldingen van de producten verschillend zijn.
Log in met “problem_user” en wachtwoord “secret_sauce” en test of de afbeeldingen van de producten verschillend zijn. (Deze test moet nu falen omdat je je voordoet als een user die een bug ervaart.)
Je zal voor deze opgave dus de Selenium (Chromium/Firefox) plugin moeten installeren: zie hierboven.
Important
In de volgende delen wordt er dieper ingegaan op de verschillende concepten die komen kijken bij Test Driven Development. We gaan de theoretische concepten echter enkel aanhalen in de tekst rond TDD in Java, maar die zijn natuurlijk wel van toepassing op alle talen zoals Python en C. We halen later bij de pagina’s rond TDD in Python en C die theoretische kant niet zo zwaar meer boven en houden ons daar meer bezig met de praktische kant.
Subsections of 5. Test Driven Development
Debugger tools
De wie, wat en waarom van debugger tools
Een debugger in een geïntegreerde ontwikkelomgeving (IDE) is een tool waarmee je stap voor stap door je code kan gaan, je kan code uitvoeren en de toestand van variabelen en van het programma als geheel bekijken tijdens het uitvoeren van je programma. Hier zijn de voordelen van het gebruik van een debugger ten opzichte van het handmatig uitprinten van waarden:
Interactieve stapsgewijze uitvoering: Met een debugger kunnen ontwikkelaars hun code uitvoeren en pauzeren bij specifieke punten, zoals breakpoints of fouten. Ze kunnen de code stap voor stap doorlopen om te zien hoe de waarden van variabelen veranderen en om eventuele fouten te identificeren.
Breakpoints: Ontwikkelaars kunnen breakpoints instellen op specifieke regels in hun code, waardoor het programma pauzeert wanneer het deze regels bereikt. Dit stelt ontwikkelaars in staat om de staat van het programma te onderzoeken op een specifiek punt in de uitvoering, wat handig is voor het debuggen van specifieke problemen.
Variabele inspectie: Ontwikkelaars kunnen variabelen inspecteren terwijl ze door de code stappen. Dit stelt hen in staat om de waarden van variabelen te bekijken en te begrijpen hoe deze veranderen tijdens de uitvoering van het programma, wat nuttig is bij het debuggen van complexe problemen.
Dynamische wijziging van variabelen: Sommige debuggers bieden de mogelijkheid om variabelen dynamisch te wijzigen tijdens het debuggen. Dit kan handig zijn om te testen hoe verschillende waarden van variabelen van invloed zijn op de uitvoering van het programma, zonder de code opnieuw te hoeven compileren en uitvoeren.
Call stack en uitvoeringspad: Een debugger biedt informatie over de call stack en het uitvoeringspad van het programma. Dit helpt ontwikkelaars om te begrijpen welke functies worden aangeroepen en in welke volgorde, wat kan helpen bij het opsporen van bugs die zich voordoen als gevolg van onverwachte uitvoeringspaden.
Over het algemeen biedt een debugger een krachtige set van tools voor het effectief debuggen van code, waardoor ontwikkelaars efficiënter problemen kunnen oplossen en complexe codebases beter kunnen begrijpen. Het is een essentiële tool voor elke software developper.
Debuggen met VSCode
VSCode biedt voor verschillende talen (eventueel met behulp van extensies) de mogelijkheid om programma’s te debuggen met een geïntegreerde debugger.
TDD in Java
Een TDD Scenario
Stel dat een programma een notie van periodes nodig heeft, waarvan elke periode een start- en einddatum heeft, die al dan niet ingevuld kunnen zijn. Een contract bijvoorbeeld geldt voor een periode van bepaalde duur, waarvan beide data ingevuld zijn, of voor gelukkige werknemers voor een periode van onbepaalde duur, waarvan de einddatum ontbreekt:
We wensen aan de Periode klasse een methode toe te voegen om te controleren of periodes overlappen, zodat de volgende statement mogelijk is: periode1.overlaptMet(periode2).
1. Schrijf Falende Testen
Voordat de methode wordt opgevuld met een implementatie dienen we na te denken over de mogelijke gevallen van een periode. Wat kan overlappen met wat? Wanneer geeft de methode true terug, en wanneer false? Wat met lege waardes?
Het standaard geval: beide periodes hebben start- en einddatum ingevuld, en de periodes overlappen.
Merk op dat de namen van de testen zeer descriptief zijn. Op die manier wordt in één opslag duidelijk waar er problemen opduiken in je code.
… Er zijn nog tal van mogelijkheden, waarvan voornamelijk de extreme gevallen belangrijk zijn om de kans op bugs te minimaliseren. Immers, gebruikers van onze Periode klasse kunnen onbewust null mee doorgeven, waardoor de methode onverwachte waardes teruggeeft.
De testen compileren niet, omdat de methode overlaptMet() nog niet bestaat. Voordat we overschakelen naar het schrijven van de implementatie willen we eerst de testen zien ROOD kleuren, waarbij wel de bestaande code nog compileert:
De aanwezigheid van het skelet van de methode zorgt er voor dat de testen compileren. De inhoud, die een UnsupportedOperationException gooit, dient nog aangevuld te worden in stap 2. Op dit punt falen alle testen (met hopelijk als oorzaak de voorgaande exception).
2. Schrijf Implementatie
Pas nadat er minstens 4 verschillende testen werden voorzien (standaard gevallen, edge cases, null cases, …), kan met een gerust hart aan de implementatie worden gewerkt:
Deze eerste aanzet verandert de deprimerende rode kleur van minstens één test naar GROEN. Echter, lang niet alle testen zijn in orde. Voer de testen uit na elke wijziging in implementatie totdat alles in orde is. Het is mogelijk om terug naar stap 1 te gaan en extra test gevallen toe te voegen.
4. Pas code aan (en herbegin)
De cyclus is compleet: red, green, refactor, red, green, refactor, …
Wat is ‘refactoring’?
Structuur veranderen, zonder de inhoud te wijzigen.
Als de overlaptMet() methode veel conditionele checks bevat is de kans groot dat bij elke groene test de inhoud stelselmatig ingewikkelder wordt, door bijvoorbeeld het veelvuldig gebruik van if statements. In dat geval is het verbeteren van de code, zonder de functionaliteit te wijzigen, een refactor stap. Na elke refactor stap verifiëer je de wijziging door middel van de testen.
Voel jij je veilig genoeg om grote wijzigingen door te voeren zonder te kunnen vertrouwen op een vangnet van testen? Wij als docenten alvast niet.
Naamgeving van testen en projectstructuur
Om testen op een correcte manier uit te voeren wordt aan een bepaalde structuur vastgehouden.
Note
We maken gebruik van CamelCase en snake_case om alle testen de vorm te geven van gegevenDit_wanneerDezeMethodeEropToegepastWordt_danMoetDitDeUitkomstZijn.
Unit Tests
Wat is Unit Testing
Unit testen zijn stukjes code die productie code verifiëren op verschillende niveau’s. Het resultaat van een test is GROEN (geslaagd) of ROOD (gefaald met een bepaalde reden). Een collectie van testen geeft ontwikkelaars het zelfvertrouwen om stukken van de applicatie te wijzigen met de zekerheid dat de aanwezige testen rapporteren wat nog werkt, en wat niet. Het uitvoeren van deze testen gebeurt meestal in een IDE zoals IntelliJ voor Java, of Visual Studio voor C#, zoals deze screenshot:
Elke validatieregel wordt apart opgelijst in één test. Als de validate() methode 4 regels test, zijn er minstens 4 testen geimplementeerd. In de praktijk is dat meestal meer omdat edge cases - uitzonderingsgevallen zoals null checks - ook aanzien worden als een apart testgeval.
Eigenschappen van een goede test
Elke unit test is F.I.R.S.T.:
Fast. Elk nieuw stukje functionaliteit vereist nieuwe testen, waarbij de bestaande testen ook worden uitgevoerd. In de praktijk zijn er duizenden testen die per compile worden overlopen. Als elke test één seconde duurt, wordt dit wel erg lang wachten…
Isolated. Elke test bevat zijn eigen test scenario dat géén invloed heeft op een andere test. Vermijd ten allen tijden het gebruik van het keyword static, en kuis tijdelijk aangemaakte data op, om te vermijden dat andere testen worden beïnvloed.
Repeatable. Elke test dient hetzelfde resultaat te tonen, of die nu éénmalig wordt uitgevoerd, of honderden keren achter elkaar. State kan hier typisch roet in het eten gooien.
Self-Validating. Geen manuele inspectie is vereist om te controleren wat de status van de test is. Een falende foutboodschap is een duidelijke foutboodschap.
Thorough. Testen moeten alle scenarios dekken: denk terug aan edge cases, randgevallen, het gebruik van null, het opvangen van mogelijke Exceptions, …
Het Raamwerk van een test
Bij het aanmaken van het project met Gradle, heeft Gradle je al een heel stuk geholpen om het testraamwerk op te stellen. Testen over een bepaalde klasse bundel je namelijk in een file onder de test-directory. Zorg er ook voor dat de testfile zich in dezelfde package onder de testdiretory bevindt. De conventie is dat we de testfile dezelfde naam geven als de klasse die we willen testen met Test achter. In principe is dit ook gewoon een javaklasse die we op een speciale manier gaan gebruiken. Bij het aanmaken van je project voorziet Gradle zelfs al een test. De structuur van de app/src-directory van je project ziet er dus als volgt uit:
./app/src
├── main
│ ├── java
│ │ └── be
│ │ └── ses
│ │ └── App.java
│ └── resources
└── test
├── java
│ └── be
│ └── ses
│ └── AppTest.java
└── resources
Test Libraries bestaande uit twee componenten
Een test framework, zoals JUnit voor Java, MSUnit/NUnit voor C#, of Jasmine voor Javascript, bevat twee delen:
1. Het Test Harnas
Een ‘harnas’ is het concept waar alle testen aan worden opgehangen. Het harnas identificeert en verzamelt testen, en het harnas stelt het mogelijk om bepaalde of alle testen uit te voeren. De ingebouwde Test UI in VSCode fungeert hier als visueel harnas. Elke test methode, een public void methode geannoteerd met @Test, wordt herkent als mogelijke test.
Gradle en het JUnit harnas verzamelen data van testen in de vorm van HTML rapporten.
Hiervoor dient dus de dependency testImplementation libs.junit in onze gradle.build-file.
2. Het Assertion Framework
Naast het harnas, dat zorgt voor het uitvoeren van testen, hebben we ook een verificatie framework nodig, dat fouten genereert wanneer nodig, om te bepalen of een test al dan niet geslaagd is. Dit gebeurt typisch met assertions, die vereisten dat een argument een bepaalde waarde heeft. Is dit niet het geval, wordt er een AssertionError exception gegooid, die door het harnas herkent wordt, met als resultaat een falende test.
Assertions zijn er in alle kleuren en gewichten, waarbij in de oefeningen de statische methode assertThat() wordt gebruikt, die leeft in org.assertj.core.api.Assertions. AssertJ is een plugin library die ons in staat stelt om een fluent API te gebruiken in plaats van moeilijk leesbare assertions:
Het tweede voorbeeld leest als een vloeiende zin, terwijl de eerste AssertEquals() vereist dat als eerste argument de expected value wordt meegegeven - dit is vaak het omgekeerde van wat wij verwachten!
Je kan simpelweg de dependency testImplementation "org.assertj:assertj-core:3.11.1" in de gradle.build-file toevoegen. En de import van import static org.junit.Assert.*; in de test file veranderen naar import static org.assertj.core.api.Assertions.*;
Een populair alternatief voor AssertJ is bijvoorbeeld Hamcrest. De keuze is aan jou: alle frameworks bieden ongeveer dezelfde fluent API aan met ongeveer dezelfde features.
Messages meegeven aan Testresultaten
Je kan ook extra messages meegeven aan testresultaten die afhankelijk kunnen zijn van het resultaat, bekijk hiervoor de documentatie van AssertJ.
Testen op Exceptions
Info
Je kan testen met AssertJ op exceptions op de volgende manier:
@TestpublicvoidmyTest(){// whenThrowablethrown=catchThrowable(()->{// ...});// thenassertThat(thrown).isInstanceOf(Exception.class).hasMessageContaining("/ by zero");}@TestpublicvoidmyTest(){assertThatExceptionOfType(Exception.class).isThrownBy(()->{// ...}).withMessageContaining("Substring in message");}
Arrange, Act, Assert
De body van een test bestaat typisch uit drie delen:
Arrange. Het klaarzetten van data, nodig om te testen, zoals een instantie van een klasse die wordt getest, met nodige parameters/DB waardes/…
Act. Het uitvoeren van de methode die wordt getest, en opvangen van het resultaat.
Assert. Het verifiëren van het resultaat van de methode.
Setup, Execute, Teardown
Wanneer de Arrange stap dezelfde is voor een serie van testen, kunnen we dit veralgemenen naar een @Before methode, die voor het uitvoeren van bepaalde of alle testen wordt uitgevoerd. Op dezelfde manier kan data worden opgekuist na elke test met een @After methode - dit noemt men de teardown stap.
JUnit 4 en JUnit 5 verschillen hierin op niveau van gebruik. Vanaf JUnit 5 werkt men met @BeforeEach/@BeforeAll. Raadpleeg de documentatie voor meer informatie over het verschil tussen each/all en tussen v4/v5.
Demo: Calculator app unit testen
Om testen op de correcte manier uit te kunnen voeren gaan we starten met de juiste dependencies in te stellen in onze build.gradle file:
dependencies{// Use JUnit 4 for testing.
testImplementationlibs.junittestImplementation"org.assertj:assertj-core:3.11.1"// This dependency is used by the application.
implementationlibs.guava}
Vervolgens maken we een Calculator klasse aan in ons gradle project in het package be.ses.
packagebe.ses;publicclassCalculator{}
Omdat we goede developers zijn maken we ook meteen een CalculatorTest klasse aan in de overeenkomstige package in de app/src/test directory. En voegen hier al de nodige imports aan toe. hier zullen al onze testen in geschreven worden als public void methodes:
We willen aan onze calculator een divide-methode toevoegen die 2 parameters meekrijgt teller en noemer.
We gaan volgens de correcte principes dus EERST testen schrijven die de onze method gaan testen voordat we de implementatie ervan gaan uitschrijven. (En aangezien het kan zijn dat er door 0 gedeeld wordt, gaan we dit ook testen):
Nu kunnen we aan onze implementatie werken en proberen alle testen te laten slagen:
packagebe.ses;publicclassCalculator{publicCalculator(){}publicfloatdivide(floatteller,floatnoemer){if(noemer==0){thrownewArithmeticException("/ by zero");}returnteller/noemer;}}
Oefening
Breid je Calculator klasse uit om ook add, subtract en multiply te doen. En schrijf natuurlijk eerst enkel tests.
Voeg eigen messages toe aan je testen om nog beter te kunnen kijken wat eventueel misloopt.
Integration testen
Test Doubles
Stel dat we nu een andere klasse hebben Doubler die een methode heeft doubleCalculator. Die methode neemt 3 parameters: operation,x,y en voert dus de gekozen operatie uit met de Calculator klasse en verdubbeld gewoon het resultaat.
Hoe testen we de doubleCalculator() methode, zonder de effectieve implementatie van Calculator te moeten gebruiken? Want testen moeten geïsoleerd zijn.
Door middel van test doubles.
Zoals Arnie in zijn films bij gevaarlijke scenes een stuntman lookalike gebruikt, zo gaan wij in onze code een Calculator lookalike gebruiken, zodat de Doubler-klasse dénkt dat hij Calculator-methoden aanroept, terwijl dit in werkelijkheid niet zo is. Daarvoor gaan we Mocks gebruiken. (Je kan ook een interface CalculatorInterface voorzien zodat je overal waar je Calculator wil gebruiken ook een eigen CalculatorMock-klasse kan gebruiken met dezelfde methodes maar waar je een aantal testscenarios gewoon hardcode, dit gaan we hier niet voordoen.)
Fakes are objects that have working implementations. On the other hand, mocks are objects that have predefined behavior. Lastly, stubs are objects that return predefined values. When choosing a test double, we should use the simplest test double to get the job done.
Mockito is verreweg het meest populaire Unit Test Framework dat bovenop JUnit wordt gebruikt om heel snel Test Doubles en integratietesten op te bouwen.
Het ideale gedrag dat we met zo een mock willen bekomen is dat overal waar we een andere klasse gebruiken met new in onze testen dat we dit kunnen onderscheppen en in de plaats onze “Test Double” kunnen meegeven. Dit kon vroeger gedaan worden met PowerMocks. Dit kan lukt echter niet meer in de nieuwere versies van de Java JVM en met goede reden.
Wanneer je binnen een bepaald klasse een andere klasse wil gebruiken, maak je best gebruik van Dependency Injection, wat wil zeggen dat je niet in een methode een instantie aanmaakt van een andere klasse, maar op voorhand een object aanmaakt van die klasse en dan als parameter aan de methode of datamember van de klasse meegeeft. Op die manier kunnen we ook veel simpeler testen. Als we nu de methode oproepen kunnen we als parameter simpelweg onze Test Double meegeven in plaats van een echt object.
We gaan hiervoor dus Mockito gebruiken om in onze testen. Hiervoor heb je volgende dependency nodig:
packagebe.ses;importorg.junit.Test;import staticorg.assertj.core.api.Assertions.*;import staticorg.mockito.Mockito.*;publicclassDoublerTest{@TestpublicvoidgegevenOperationDivideX2Y1_wanneerDoubleCalculator_danResultIs4(){CalculatorcalculatorMock=mock(Calculator.class);when(calculatorMock.divide(2f,1f)).thenReturn(2.0f);Doublerdoubler=newDoubler();floatresult=doubler.doubleCalculator(calculatorMock,"divide",2,1);assertThat(result).isEqualTo(4.0f).withFailMessage("result was "+result+" but expected 4.0.");verify(calculatorMock).divide(2f,1f);}}
Merk op dat we met de laatste regel nog even dubbel checken dat wel zeker de Test Double (calculatorMock) gebruikt werd met de juiste methode en parameters.
Oefening
Breid de Doubler klasse uit om ook add, subtract en multiply te doen. En schrijf natuurlijk eerst enkele tests en gebruik correct Test Doubles/Mocks.
Als je nu de Gradle task test gebruikt om je testen uit te voeren, wordt er door Gradle automatisch een interactief verslag in de vorm van een webpagina gegenereerd in de build/reports/test/test-directory. Je vind hier een file index.html. Deze file openen in de browser geeft bijvoorbeeld volgend verslag:
Extra oude Opgaven: geen verplichting
Klik hier om de opgaven te bekijken🔽
Opgave 1
De Artisanale Bakkers Associatie vertrouwt op uw technische bekwaamheid om hun probleem op te lossen.
Er wordt veel Hasseltse Speculaas gebakken, maar niemand weet
precies wat de beste Speculaas is. Schrijf een methode die speculaas beoordeelt op basis van de ingrediënten.
De methode, in de klasse Speculaas, zou er zo uit moeten zien:
publicintbeoordeel(){// TODO ...}
De functie geeft een nummer terug - hoe hoger dit nummer, hoe beter de beoordeling en hoe gelukkiger de bakker. Een speculaas kan de volgende ingrediënten bevatten: kruiden, boter, suiker, eieren, melk, honing, bloem, zout. Elke eigenschap is een nummer dat de hoeveelheid in gram uitdrukt.
Het principe is simpel: hoe meer ingrediënten, hoe beter de beoordeling.
Kijk naar een voorbeeld test hoe de methodes te hanteren. Er zijn al enkele testen voorzien. Die kan je uitvoeren met IntelliJ door op het groen pijltje te drukken, of met Gralde: ./gradlew test. Dit genereert een test rapport HTML bestand in de build/test map.
We zijn dus geïnteresseerd in edge cases. Probeer alle mogelijkheden te controleren. Denk bij het testen aan de volgende zaken:
Hoe zit het met een industriële speculaas, zonder kruiden of boter?
Wat doet de functie beoordeel als het argument null is?
Wat als een speculaas wordt meegegeven zonder ingrediënten?
Er is een foutje geslopen in de login module, waardoor Abigail altijd kan inloggen, maar jos soms wel en soms niet. De senior programmeur in ons team heeft de bug geïdentificeerd en beweert dat het in een stukje oude code zit,
maar hij heeft geen tijd om dit op te lossen. Nu is het aan jou. De logins.json file bevat alle geldige login namen die mogen inloggen. Er kan kunnen geen twee gebruikers met dezelfde voornaam zijn.
(Andere namen die moeten kunnen inloggen zijn “James”, “Emma”, “Isabella” …)
(Andere namen die niet mogen kunnen inloggen zijn “Arne”, “Kris”, “Markske” …)
Deze methode geeft true terug als Abigail probeert in te loggen, en false als Jos probeert in te loggen. Hoe komt dit? Schrijf éérst een falende test!
B. URL Verificatie fouten
Een tweede bug wordt gemeld: URL verificatie features werken plots niet meer. Deze methode faalt steeds, ook al zijn er reeds unit testen voorzien. Het probleem is dat HTTPS URLs met een SSL certificaat niet werken. Je onderzocht de URL verificatie code en vond de volgende verdachte regels:
De code blijkt reeds unit testen te hebben, dus schrijf éérst een falende test (in VerifierTests).
Opgave 3
Info
Dit is een vervolgopgave van de code van Opgave 1. Werk verder op dat bestaand project, in diezelfde repository!
Een verkoopster werkt in een (goede) speculaasfabriek. De verkoopster wilt graag 2 EUR aanrekenen per speculaas die de fabriek produceert.
Echter, als de klant meer dan 5 stuks verkoopt, mag er een korting van 10% worden aangerekend. In dit voorbeeld gaan we ervan uit dat een fabriek een willekeurig aantal speculaas per dag maakt en dat de klant steeds alle speculazen koopt. De verkoop gebeurt in de Verkoopsterklasse en het bakken van de speculazen gebeurt in de SpeculaasFabriek. Als we nu willen testen of onze verkoop methode uit de Verkoopster-klasse werkt, dan willen we dit isolated doen. We willen dus de onzekerheid van de Fabriek weghalen door specifieke gevallen aan te halen. Dit kan echter niet via de standaard SpeculaasFabriek. Daarom gaan we een test double gebruiken. Hiervoor gaan we deze keer een mock gebruiken zoals verder duidelijk wordt.
publicdoubleverkoop(){vargebakken=speculaasFabriek.bak();// TODO ...}
Je ziet aan bovenstaande code dat de speculaasfabriek instantie wordt gebruikt. We hebben dus eigenlijk geen controle op de hoeveelheid speculaas die deze fabriek produceert.
Unit testen
Hoe kunnen we dan toch nog testen wat we willen testen? Mogelijke scenario’s:
De fabriek produceert niets. De klant betaalt niets.
De fabriek produceert minder dan 5 speculaasjes. De klant betaalt per stuk, 2 EUR.
De fabriek produceert meer dan 5 stuks. De klant krijgt 10% korting op zijn totaal.
TDD in Python
Unit tests
Je zou simpelweg de ingebouwde assert functie in Python kunnen gebruiken om testen te schrijven zoals hieronder is weergegeven. Je kan de testfile dan runnen met python3 calculator_test.py en wanneer een assert niet klopt zal je een AssertionError krijgen.
Dit geeft echter niet zo een mooie output zoals we in Java met Junit hebben gezien en Python komt standaard met een testmodule genaamd unittest. We kunnen onze testfile dan uitvoeren met python3 -m unittest calculator_test.py. Unittest beschouwd onze functie echter nog niet als een test, hiervoor moeten we een aantal dingen wijzigen aan onze testfile:
Een testklasse aanmaken genaamd die erft van de klasse unittest.TestCase: TestCalculator(unittest.TestCase)
De testen als klasse methodes definiëren
De methoden moeten beginnen met test_
Je moet asserten met self.assert... (self verwijst hierbij naar de klasse zelf)
De main methode van unittest oproepen: unittest.main()
importunittestfromcalculatorimportdivideclassTestCalculator(unittest.TestCase):deftest_gegevenTeller2Noemer1_wanneerDivide_danResult2(self):result=divide(2,1)self.assertEqual(result,1)# Let op: 2/1 = 2, dus deze test zal falen!if__name__=="__main__":unittest.main()
Nu krijgen we bij het runnen van deze testfile een mooiere output:
arne@LT3210121:~/ses/tddpythondemo/src$ python3 -m unittest calculator_test.py
.
----------------------------------------------------------------------
Ran 1test in 0.000s
OK
We kunnen onze testfile nu uitbreiden net zoals we dat in Java gedaan hebben:
Er bestaan ook nog andere modules om testen uit te voeren in Python. Een zeer populaire module is Pytest. Deze module biedt onder andere een iets mooiere output met groene en rode highlights voor respectievelijk geslaagde en gefaalde testen.
Setting up VSCode for Python testing
Je kan de built-in VSCode tool gebruiken voor debugging (gecombineerd met de juiste extensies) om een mooie Gui interface te hebben voor de testen. Hiervoor klik je op onderstaande view panel en configureer je het juiste Testing framework, de plaats waar VSCode moet zoeken naar de test files en hoe de test files noemen.
Test suite for VSCode
Nadat je alles correct geconfigureerd hebt zouden je testen er als volgt moeten uitzien:
Voorbeeld testoutput Python
Integration tests
Net zoals in Java kunnen we Mocks hiervoor gebruiken om Test Doubles aan te maken. Gelukkig is ‘mocken’ een functionaliteit die rechtstreeks in unittest is ingebouwd. De doubler.py-file en doubler_test.py-file analoog aan het voorbeeld in integration testen in Java:
doubler.py: We geven nu een referentie naar de functie mee als parameter om te voldoen aan het principe van Dependency Injection.
importunittestfromunittest.mockimportpatchfromdoublerimportdouble_calculatorclassTestCalculator(unittest.TestCase):@patch("doubler.divide")# Mock de geïmporteerde `divide` functie in `doubler.py`deftest_gegevenOperationDivideX2Y1_wanneerDoubleCalculator_danResultIs4(self,mock_divide):# Arrange# De mock werd meegegeven als parameter en je stelt nu de return value inmock_divide.return_value=2.0# Actresult=double_calculator(mock_divide,2,1)# Assertself.assertEqual(result,4.0)# Verifieer dat `divide` werd aangeroepen met (2, 1)mock_divide.assert_called_once_with(2,1)if__name__=="__main__":unittest.main()
Onderstaande Python file bevat een aantal functies die getest worden met een testfile. Er zijn echter een heel deel testen die nog falen. Verbeter nu de python functies zodat alle testen slagen:
Dit is een stuk complexer en wordt ook net iets minder gebruikt dan in de higher level languages zoals Python en Java, maar nog altijd zeer handig. Je moet echter de juiste instellingen voor je compiler en dergelijke instellen. Om die reden laten we dit als zelfstudie voor de student die hierin geïnteresseerd is en refereren naar een video die gebruik maakt van CMake en Gtests om TDD in C mogelijk te maken.
Warning
Hoewel dat de tutorial gemaakt is rond C++ files, maakt dit niet uit omdat je hier ook gewoon C functies kan gebruiken. Het is echter wel waar dat de testen zelf WEL in een .cpp-file (C++) geschreven moeten zijn! In de oude cursus Besturingssystemen & C kan je ook nog eens de nodige info terugvinden indien gewenst.
Meer leermateriaal
Extra leermateriaal
Lees de volgende artikels om een beter inzicht te krijgen in de capaciteiten van de Test-Driven benadering:
Het softwareontwikkel proces is een continu proces: als een eerste versie van het product klaar is, en wordt overgemaakt aan klanten, volgt het onderhoud en een mogelijke volgende versie. Elke wijziging maakt potentiëel dingen kapot (geminimaliseerd met TDD), of introduceert nieuwe features. Dat betekent dat bij elke wijziging, een computer het hele build proces moet doorlopen om te controleren of er niets stuk is. Dit noemen we het “integreren” van nieuwe code, vandaar de naam.
De CI Server
In de praktijk worden aparte servers verantwoordelijk gesteld om regelmatig de hele codebase te downloaden, alles te compileren, en testen uit te voeren. Als iets niet compileert, of een test faalt, rapporteert dit systeem via diverse kanalen aan de ontwikkelaars. Niemand wilt dit in de achtergrond op zijn eigen machine een tweede build systeem geïnstalleerd hebben, die CPU kracht afsnoept van je eigen PC. De volgende Figuur verduidelijkt de flow (bron):
Dit hele proces verloopt volledig automatisch: niemand drukt op een “build” knop, maar door middel van ingecheckte code wijzigingen (1) start dit systeem. De CI server haalt wijzigingen op (2), build (3) en test (4) om te kunnen rapporteren of dit lukte of niet (5), via verschillende kanalen (6).
De Source Control server, zoals Github.com of een lokale git server, werd reeds besproken in labo ‘versiebeheer’. Er zijn voor Java verschillende populaire CI software systemen:
Travis—de eerste CI die naadloos met GitHub integreerde, maar nu betalend is
CirlceCi—een populair hosted alternatief voor Travis
Jenkins—een ondertussen oudere speler op de CI markt
Drone—een nieuwe speler met focus op Docker module integratie
Dagger—een CI/CD engine die in containers runt en programmeerbaar in plaats van configureerbaar is
Deze systemen zijn configureerbaar en beschikken over een UI, zoals bijvoorbeeld zichtbaar in de TeamCity screencast. Betalende systemen zoals TeamCity en Bamboo zijn erg uitgebreid, en er zijn ook een aantal gratis alternatieven zoals CircleCI. CircleCI is eenvoudig configureerbaar door middel van een .yml bestand en het gratis plan integreert naadloos met GitHub. Wij gaan echter rechtstreeks via Github Actions werken aangezien dit rechtstreeks via Github kan geconfigureerd worden en op zeer gelijkaardige manier werkt zoals bovenstaande services.
De flow van een CI server
Je Github Actions moeten gedefinieerd worden in de folder .github/workflows in een .yml-bestand. Github helpt je echter om snel veelgebruikte actions op te stellen op basis van je code in de repository.
Via het Actions tabblad worden enkele voorstellen gedaan. Je kan gebruik maken van de Gradle test template. Je .yml-bestand wordt automatisch aangemaakt en zie er uit zoals op onderstaande figuur is weergegeven
Let op: de yml-syntax is zeer gevoelig aan het juiste gebruik van tabs en spaces (gelijkaardig aan python)
1. on
Onder de on sectie ga je definiëren bij welke specifieke acties de onderstaande jobs uitgevoerd moeten worden.
2. Setup Environment
In de jobs sectie kunnen we dan verschillende jobs oplijsten. In het begin van elke job moeten we vermelden op welk besturingssysteem we onze job willen uitvoeren. (Bv. Windows, Mac, Linux).
Je kan jobs ook automatisch op meerdere besturingssystemen laten uitvoeren.
We moeten er natuurlijk ook voor zorgen dat alle applicaties aanwezig zijn om onze code te kunnen compileren en runnen. Daarvoor moeten we de nodige tools installeren in deze virtuele omgeving!
3. Build Code
Nu kunnen we simpelweg gebruik maken van de Gradle tasks.
4. Package Code & Upload Artifact
Zodra één stap mis gaat (zoals een falende test), worden volgende stappen niet uitgevoerd. Als alles goed gaat is de output van het builden de binary die we het artifact noemen, die de huidige buildstamp draagt (meestal een datumcode).
Alle gebuilde artifacts kunnen daarna worden geupload naar een repository server, zoals de Central Maven Repository, of onze eigen artifact server, waar een historiek wordt bijgehouden. (Dit gaan we niet doen in deze cursus)
5. Publish Results
In de README.md van je repository kan je een Github Badge plaatsen die de status van je action snel weergeeft.
Bij de opdrachten zal je repository onder de organisatie KULeuven-Diepenbeek staan, en niet onder je eigen username.
2. Continuous Deployment (CD)
Automatisch code compileren, testen, en packagen, is slechts één stap in het continuous development proces. De volgende stap is deze package ook automatisch deployen, of installeren. Op deze manier staat er altijd de laatste versie op een interne development website, kunnen installaties automatisch worden uitgevoerd op bepaalde tijdstippen, …
Modules van moderne CI systemen zoals hier boven vermeld, bijvoorbeeld Dagger/Drone Deployment
Eigen scripts gebaseerd op CI systemen
Cloud deployment systemen (Amazon AWS, Heroku, Google Apps, …)
Automatisch packagen en installeren van programma’s stopt hier niet. Een niveau hoger is het automatisch packagen en installeren van hele omgevingen: het virtualiseren van een server. Infrastructuur automatisatie is een vak apart, en wordt vaak uitgevoerd met behulp van tools als Puppet, Docker, Terraform, Ansible of Chef. Dit valt buiten de scope van deze cursus.
2.1. De flow van deployment en releases beheren
Nieuwe features in productie plaatsen brengt altijd een risico met zich mee. Het zou kunnen dat bepaalde nieuwe features bugs veroorzaken in de gekende oude features, of dat het (slecht getest) systeem helemaal niet werkt zoals men verwachtte. Om dat zo veel mogelijk te vermijden wordt er een release plan opgesteld waarin men aan ‘smart routing’ doet.
Stel, onze bibliotheek-website is toe aan vernieuwing. We wensen een nieuw scherm te ontwerpen om efficiënter te zoeken. We zijn echter niet zeker wat de gebruikers gaan vinden van deze nieuwe manier van werken, en beslissen daarom om slechts een aantal gebruikers bloot te stellen aan deze radicale verandering. Dat kan op twee manieren:
Blue/green releasing: Een ‘harde switch’ in het systeem bepaalt welke personen (bijvoorbeeld op regio of IP) naar versie van het zoekscherm worden begeleid.
Canary releasing: Een load balancer verdeelt het netwerkverkeer over verschillende servers, waarvan op één server de nieuwe versie is geïnstalleerd. In het begin gaat 90% van de bezoekers nog steeds naar de oude versie. Dit kan gradueel worden verhoogd, totdat de oude server wordt uitgeschakeld.
De juiste logging en monitoring tools zorgen ervoor dat we een idee krijgen over het gebruik van het nieuwe scherm (groen, versie B). Gaat alles zoals verwacht, dan wordt de switch weggehaald in geval (1), of wordt de loadbalancer ge-herconfigureerd zodat het hele verkeer naar de nieuwe site gaat in geval (2). Ook deze aanpassingen zijn volledig geautomatiseerd. Na verloop van tijd valt de oude versie (blauw, versie A) volledig weg, en kunnen we ons concentreren op de volgende uitbreidingen.
Het groene vlak, de ‘Ambassador/API gateway’, kan aanzien worden als:
Een fysiek aparte machine, zoals een loadbalancer.
Een publieke API, die de juiste redirects verzorgt.
Een switch in de code, die binnen dezelfde toepassing naar scherm 1 of 2 verwijst.
Versie A en B hoeven dus niet noodzakelijk aparte versies van applicaties te zijn: dit kan binnen dezelfde applicatie, softwarematig dus, worden ingebouwd.
Oefeningen (enkel ter oefening, de verplichte opdracht rond CI vind je bij de andere opdrachten)
Volgende videos kunnen je helpen bij het oplossen van de oefeningen en de uiteindelijke opdracht:
Video over Github actions met Gradle: Hier kan je dan specifiek terugvinden hoe je de concepten van in de vorige video kan toepassen op je Gradle projecten.
Oefening 1
Ontwerp een ‘calculator’ applicatie die 2 getallen kan optellen, aftrekken, het product en het quotient (zonder rest) kan berekenen. (gebruik Gradle)
Schrijf een aantal testen voor deze applicatie.
Maak een github repository aan en gebruik Github Actions om je applicatie automatisch te laten testen bij een push naar de main branch.
Oefening 2
Gebruik een andere workflow.yml om je repo uit opgave 1 te beveiligen tegen pull-requests. Zorg ervoor dat eerst je testen moeten slagen en je applicatie gebuild kan worden voordat je een pull request kan toelaten. (Test dit ook uit met een medestudent).
Kijk in Github bij de settings van je repo -> branches -> require status checks
Let op: je repo moet hiervoor public zijn. Je kan zoeken op status checks via de job names in je .yml action files
Software Management Skills
De chaos van het Werk
De technische kant van het ontwikkelproces is slechts één kant van de medaille. De keerzijde bestaat uit het werk beheren en beheersen, zonder ten prooi te vallen aan de grillen van de klant of de chaos van de organisatie ervan.
Stel, een gemeente vraagt om een nieuwe website te ontwikkelen voor de lokale bibliotheek. Er wordt een vergadering ingepland met stafhouders om samen met jou te beslissen wat de vereisten zijn. Ze dachten aan een mooie visuele website (1) waar men ook on-line kan verlengen (2) en het profiel raadplegen (3). De website moet uiteraard de gebruiker laten weten of een boek is uitgeleend of niet (4), en nieuwe aanwisten verdienen een aparte pagina (5). Men wil kunnen inloggen met de eID (6), én met een speciale bibkaart (7). Een bezoek aan de bib zelf levert je nog meer eisen op: het personeel wilt immers de nadruk leggen op andere zaken dan de stafhouders.
Hoe ga je te werk om dit te beheren en tot een succesvol einde te brengen? Er is een raamwerk nodig om:
Werk overzichtelijk in kaart te brengen
Werk in te schatten en op te delen in beheersbare deeltaken
Werk eenvoudig te kunnen verdelen
Werk zichtbaar te maken: wie met wat bezig is, wanneer het klaar is, …
De klant in elke stap van het proces zo nauw mogelijk te betrekken
A. Werk managen: Scrum
De alsmaar populair wordende term ‘scrum’ komt vanuit de Amerikaanse sport rugby, waar men letterlijk de koppen bij elkaar steekt om nog een laatste peptalk uit te delen voordat de chaos van de wedstrijd zelf losbarst (Bron):
Het principe van deze coördinatie is gemuteerd naar de softwareontwikkeling, waar het Scrum framework een oplossing kan bieden voor ons beheer probleem, waarvan alle 5 bovenstaande punten van het probleem op een structurele manier worden aangepakt.
Een Werk ‘raamwerk’
Een agile software development methodologie is een methodologie om snel te kunnen ingrijpen op veranderingen. Klanten zijn van nature erg wispelturig en weten vaak zelf niet goed wat ze willen. Daarom is een ontwikkelmethode die sneller van richting kan veranderen tegenwoordig veel waardevoller dan een log systeem waarbij veel analysewerk op voorhand wordt verricht, om daarna alles te ontwikkelen. Een voorbeeld van zo’n klassiek log systeem is het waterval model. Een voorbeeld van zo’n modern agile systeem is scrum.
De volgende Figuur representeert het Scrum principe in de technische wereld:
Het Scrum principe verdeelt de ontwikkeltijd in een aantal iteraties, waarbij een iteratie een vooropgestelde tijd is, meestal 14 of 30 dagen, die een vaste kadans bepaalt waarin teams software ontwikkelen. Elke iteratie gebeurt er hetzelfde: bepalen wat te doen binnen die iteratie, de ontwikkeling ervan, en het vrijgeven van een nieuwe versie van het al dan niet volledig afgewerkt product.
De introductie van itererende blokken waar taken van de backlog worden genomen en verwerkt, biedt de mogelijkheid tot veranderen. Tussen elke iteratie in kan er worden bijgestuurd door functionaliteit op de (product) backlog te verwijderen, toe te voegen, of te wijzigen.
1. De Backlog
De backlog is een grote lijst van zaken die moeten worden ontwikkeld voordat de klanten tevreden zijn (een ‘story’’). Men begint met één grote product backlog waar op een hoog niveau alle eisen in zijn beschreven. Voor elke sprint van x dagen (30 in de Figuur), beslist het team welke items van deze backlog naar de sprint backlog worden verplaatst: dit zijn de zaken die het team denkt te kunnen verwerken in één sprint.
Een product backlog bevat stories die nog niet goed geanalyseerd en beschreven zijn, waarvan moeilijk te zeggen valt hoeveel werk dit effectief kost. Wanneer dit in een iteratie terecht komt, wordt dit nauwer bekeken door het team. De betere beschrijving leidt tot een accurate inschatting, en mogelijks zelfs meerdere backlog items.
Het volgende filmpje verduidelijkt de rol van de backlog in het team:
2. Taken
Een sprint backlog item wordt typisch door ontwikkelaars nog verder opgesplitst in kleinere taken om het werk beter te kunnen verdelen. Bijvoorbeeld, de mogelijkheid tot inloggen met eID kan bestaan uit (1) authenticatiestappen en het inlezen van een kaart, en (2) het UI gedeelte waar de gebruiker mee interageert. Misschien beschikt al één teamlid over authenticatie kennis, maar nog niemand weet hoe de UI aan te pakken. In dat geval is taak (1) sneller gedaan dan taak (2).
Als alle taken zijn opgesplitst, worden er inschattingen gemaakt van elke taak, ten opzichte van een referentie taak. Inschattingen zijn relatieve getallen in Fibonacci nummers: 1, 2, 3, 5, 8, … Hoe moeilijker of groter de taak, hoe vaker teams verkeerde inschattingen maken, en hoe meer impact dit heeft op de planning, vandaar de snel groeiende nummers. De referentie taak heeft een vast nummer in de rij, zoals bijvoorbeeld 2. Merk op dat dit nummer dus niets te maken heeft met een concreet aantal (uren, dagen) werk.
Stel dat een simpel loginscherm reeds werd ontwikkeld. Men schat dan de moeilijkhei van het authenticeren van eID in ten opzichte van het werk aan het loginscherm: zou dat veel minder werk kosten, of meer? Minder werk: 1. Meer werk: veel moeilijker of een beetje? Veel: 5 of meer. Een beetje: 3.
3. Een iteratie
Elke iteratie bestaande uit bijvoorbeeld 30 dagen kan een team een bepaald aantal ingeschatte punten verwerken, zoals 30. Men kiest een aantal taken waarvan de som van de inschatting niet dit maximum overschrijdt, en men begint aan de iteratie.
Elke dag is er ’s morgens een scrum stand-up: een korte, rechtstaande vergadering, waarin iedereen vertelt:
Wat hij of zij gisteren gedaan heeft
Wat hij of zij vandaag gaat doen
Met welke problemen hij of zij kampt
Op die manier is onmiddellijk iedereen op de hoogte. Dit noemt men ook wel een scrum meeting, geïnspireerd op de Rugby scrum. Als men merkt dat collega’s problemen hebben kan er dan beslist worden om taken weg te laten, toe te voegen, enzovoort.
Werk visualiseren
Een van de belangrijkste redenen om voor een agile methodologie te kiezen als Scrum, is de mogelijkheid tot het visualiseren van werk. Een team van 6 mensen kan daardoor ten allen tijden in één oogopslag zien aan welke taken wordt gewerkt, welke taken/stories in de iteratie zijn opgenomen, en waar het schoentje knelt (bron):
Elke story wordt een ‘swimlane’ toegewezen: een horizontale band, die wordt opgedeeld in een aantal kolommen. Wanneer alle taken van één story van links naar rechts zijn verhuisd, weet het team dat die bepaalde functionaliteit (de story) klaar is voor de eindgebruikers. De taken kunnen worden gecategoriseerd met behulp van kleurcodes (technische opzet, database werk, UI werk, scherm x, y, …) De kolommen zelf variëren van team tot team. Een voorbeeld:
(Task) Todo
Doing (in progress in bovenstaande Figuur)
To Review/To Test/Done
Een taak die van Doing naar Done wordt verschoven is daarom niet volledig afgewerkt. Het kan zijn dat de ontwikkeling af is, maar dit nog moet worden nagekeken door een technische collega (to review), of worden getest door de customer proxy (to test), of worden uitgerold naar een interne test- of acceptatieomgeving (to deploy).
In het volgende filmpje komen verschillende implementaties van persoonlijke scrumborden aan bod, om je een idee te geven van de aanpasbaarheid van zulke borden:
Hier wordt het woord kanban gebruikt om aan te geven dat werk wordt gevisualiseerd - maar niet noodzakelijk een deel is van agile softwareontwikkeling of van scrum. Zoals je kan zien is het dus ook een effectieve manier om voor jezelf TODO/DOING/DONE items (school taken, hobby’s, …) op te hangen en dus visueel te maken.
B. Teams managen: Rolverdelingen
Werk verdelen en managen is niet het enige sterkte punt van Scrum. Een efficiënt team opbouwen dat in staat is om verschillende rollen op te nemen is nog een van die punten. Om bovenstaande stories en taken als onderdeel daarvan zo goed mogelijk te kunnen afwerken, dient het werk verdeeld te worden. Hieronder volgt een kort overzicht van de vaakst voorkomende rollen.
De Product Owner
Een Scrum team is sterk klant-georiënteerd, waarbij stories hoofdzakelijk het gevolv zijn van de vraag van de klant. Ook welke stories in welke iteratie worden opgenomen is het gevolg van de wil van de klant. In de praktijk heeft de klant, een ander bedrijf dus, nauwelijks tijd om het ontwikkelproces op de voet op te volgen.
Daarom dat de product owner (PO) die belangrijke taak van de klant overneemt. Een Product Owner is een domein expert van het ontwikkelbedrijf die het overzicht bewaart: hij of zij weet welke stories nog moeten volgen, kan wijzigingen laten doorvoeren, en kent het product vanbinnen en vanbuiten. De PO overlegt vaak met de klant zelf en vertegenwoordigt de belangen van de klant bij het ontwikkelbedrijf.
De Customer Proxy
Stories zijn niet meer dan kleine briefjes waar in een paar zinnen staat beschreven welke grote blokken functionaliteit de klant verwacht. Voordat dit kan worden opgesplitst in deeltaken dient er een grondige analyse uitgevoerd te worden. Dit gebeurt door de customer proxy, vaak ook gewoon analist genoemd.
De customer proxy is, zoals het woord zegt, een vervanger voor de klant, die expert is in bepaalde deeldomeinen. De Product Owner bewaart het overzicht op een hoog niveau, van het héle product, en de Customer Proxy is expert in bepaalde deeldomeinen. Ontwikkelaars en proxies discussiëren en beslissen vaak samen de werking van bepaalde delen van het product, waarbij de proxy de belangen van de klant hierin verdedigt. Er zijn typisch meerdere proxies aanwezig in één team, terwijl er maar één product owner is.
De Ontwikkelaar
Stories worden opgesplitst in taken door de proxy en ontwikkelaar, die de functionele en technische gebundelde kennis gebruiken om problemen op te delen in kleinere, en makkelijker verwerkbare, deelproblemen. De ontwikkelaar is verantwoordelijk voor de implementatie van taken, en kan daardoor taken van todo naar doing en done verhuizen op het scrumbord.
De Team leider
Ontwikkelen en analyseren loopt nooit van een leien dakje. Een leider zorgt er voor dat het team zo weinig mogelijk last heeft van eender welke vorm van afleiding. Dat betekent zowel te veel druk van de klant als mogelijke administratieve taken die het bedrijf eist van elke werknemer. Een goede team leider is een onzichtbare die er voor zorgt dat iedereen zijn werk kan doen.
Een tweede taak van de team leider is het opvolgen van vooruitgang van de sprint. Het zou kunnen dat ingeschatte stories meer werk kosten dan initiëel gepland, of dat er een gaatje is om extra werk op te nemen. Dit wordt gevisualiseerd door middel van een ‘burndown chart’, een grafiek die het aantal nog te behandelen taken afbeeldt op de resterende tijd in dagen of uren (bron):
Pieken boven de groene ideaallijn betekent onverwacht extra werk die niet werd ingepland, waarbij de team leider mogelijk moet ingrijpen. Dalingen onder de groene lijn betekent vlot werk met mogelijkheid tot iets extra buiten de sprint. De team leider overlegt vaak met de Product Owner om de verwachtingen zo goed mogelijk bij de ideaallijn gealigneerd te krijgen.
Trends in burndown charts evalueren is een belangrijke vaardigheid voor elk lid van het team. Iedereen kan in één oogopslag onmiddellijk zien of het team meer werk heeft gedaan dan initieel ingepland, nog op schema ligt, of erg achter hinkt. Pieken en dalingen in de grafiek zijn duidelijke tekenen van onder- of overschattingen van de story’s. In bovenstaande grafiek krijgt het team bijvoorbeeld na dag 4 (X-as) een zware dobber te verwerken dat meer tijd kost dan eerst gedacht, die pas na dag 9 wordt opgelost. Tussen dag 11 en 15 verloopt alles op cruise snelheid - het gaat zelfs lichtjes beter dan verwacht. De grafiek ‘remaining effort’ is de belangrijkste (bron).
Het effect is erg uitgesproken op de tweede grafiek. Op dag 3 valt het team volledig stil (de oorzaak is natuurlijk niet af te lezen, dit kan zowel liggen aan technische problemen als aan bijvoorbeeld ziekte van een aantal programmeurs), wat de scherpe piek verklaart. De rest van de iteratie zwoegt het team om het verloren werk in te halen, wat pas na dag 10 uiteindelijk bijna lukt.
Deze informatie is erg belangrijk omdat het kan worden gebruikt om toekomstige sprints in te plannen. Grafieken worden ge-extrapoleerd om een betere inschatting te kunnen maken van het toekomstige werk. Een consistent goed presterend team - de remaining effort lijn ligt onder de remaining time - kan in de toekomst bijvoorbeeld meer werk aan binnen dezelfde iteratie. Het is dan aan de team leider om dit naargelang in te plannen.
Lees meer over de burndown chart via de extra bronnen.
Opdrachten
Dit deel bevat geen opdrachten maar kennis ervan zal afgetoetst worden tijdens het schriftelijk examen. Bijvoorbeeld wat betekent SCRUM en wat is Agile Development.
Hier start het tweede deel van de cursus, waarin we enkele geavanceerdere Java-concepten bekijken.
Het uiteindelijk doel is het behandelen van Java Collections en Streams, die een basis geven om complexere problemen efficiënt op te lossen, alsook het voorbereiden op het werken met recursie en backtracking.
We bekijken hier concepten uit Java, maar deze hebben vaak een equivalent in andere talen.
Aan het begin van elk hoofdstuk lijsten we daarom ook kort op welke concepten uit andere programmeertalen hier het dichtst bij aanleunen.
Als je Java-kennis wat roestig is (of wanneer je meer ervaring hebt in een andere programmeertaal), kan je je Java-kennis even opfrissen aan de hand van deze pagina.
We maken vanaf nu ook niet langer gebruik van VSCode, maar schakelen over naar Jetbrains IntelliJ IDEA, een van de vaakst gebruikte professionele Java IDE’s.
De gratis Community Edition volstaat voor dit vak, maar je kan als student ook een gratis licentie voor de Ultimate Edition aanvragen.
Subsections of 7. Advanced Java
7.1 Records
In andere programmeertalen
De concepten in andere programmeertalen die het dichtst aanleunen bij Java records, pattern matching en sealed interfaces zijn
structs in C en C++ (pattern matching in C++ is nog niet beschikbaar, maar er wordt gewerkt aan dit toe te voegen aan de taal)
@dataclass en structured pattern matching in Python
(sealed) record types en pattern matching in C#
Wat zijn records
Wanneer we object-georiënteerd programmeren, maken we gebruik van encapsulatie: we maken de velden van een klasse gewoonlijk privaat, zodat ze worden afgeschermd van andere klassen. Op die manier kunnen we de interne representatie (de velden en hun types) makkelijk aanpassen: zolang de publieke methodes hetzelfde blijven, heeft zo’n aanpassing geen effect op de rest van het systeem.
Maar soms is encapsulatie niet echt nodig: sommige klassen zijn niet meer dan een bundeling van verschillende waarden.
Welgekende voorbeelden zijn een coordinaat (bestaande uit een x- en y-attribuut), een geldbedrag (een bedrag en een munteenheid), een adres (straat, huisnummer, postcode, gemeente), etc.
Ze bevatten vaak geen complex gedrag, en objecten van deze klassen hoeven ook niet aanpasbaar te zijn; je kan makkelijk een nieuw object maken met andere waarden.
We noemen dit data-oriented programming.
Voor dergelijke klassen heeft doorgedreven encapsulatie weinig zin.
Een record in Java is een eenvoudige klasse die gebruikt kan worden voor data-oriented programming.
Een record-object dient voornamelijk als data-drager, waarbij verschillende objecten met dezelfde attribuut-waarden gewoonlijk volledig inwisselbaar (equivalent) zijn.
De attributen van een record-object mogen daarom niet veranderen doorheen de tijd (het object is dus immutable).
Als voorbeeld definiëren we een coördinaat-klasse als een record, met 2 attributen: een x- en y-coördinaat.
publicrecordCoordinate(doublex,doubley){}
Merk het verschil op met de definitie van een gewone klasse: de attributen van de record staan hier vlak na de klassenaam, en er is geen constructor nodig.
Objecten van een record maak je gewoon aan met new, zoals elk ander object:
wanneer je een type wil definiëren dat overeenkomt met een ander, reeds bestaand datatype, maar met beperkingen.
recordPositiveNumber(intnumber){publicPositiveNumber{if(number<=0)thrownewIllegalArgumentException("Number must be larger than 0");}}
wanneer je een (immutable) datatype wil maken dat zonder probleem door meerdere threads gebruikt kan worden; dit komt later nog aan bod in het onderwerp Multithreading en concurrency.
Merk op dat bij records in de eerste plaats gaat over het creëren van een nieuw datatype, door (primitievere) data of andere records te bundelen, of beperkingen op te leggen aan mogelijke waarden.
Je maakt dus als het ware een nieuw ‘primitief’ datatype, zoals int, double, of String.
Dit in tegenstelling tot gewone klassen, waar encapsulatie en mogelijkheden om de toestand van een object aan te passen (mutatie-methodes) ook essentieel zijn.
Achter de schermen
Een record is eigenlijk een gewone klasse, waarbij de Java-compiler zelf enkele zaken voorziet:
een constructor, die de velden initialiseert;
methodes om de attributen op te vragen;
een toString-methode om een leesbare versie van het record uit te printen; en
een equals- en hashCode-methode, die ervoor zorgen dat objecten met dezelfde parameters als gelijk worden beschouwd.
De klasse is ook final, zodat er geen subklassen van gemaakt kunnen worden.
De coördinaat-record van hierboven is equivalent aan volgende klasse-definitie:
Merk wel op dat, omdat de klasse immutable is, je in een methode geen nieuwe waarde kan toekennen aan de velden. Code als
this.x=5;
in een methode van een record is dus ongeldig, en leidt tot een foutmelding van de compiler.
Constructor van een record
Als je geen enkele constructor definieert, krijgt een record een standaard constructor met de opgegeven attributen als parameters (in dezelfde volgorde).
Maar je kan ook zelf een of meerdere constructoren definiëren voor een record, net zoals bij klassen (je krijgt dan geen default-constructor meer).
Je moet in die constructoren zorgen dat alle attributen van het record geïnitialiseerd worden.
publicrecordCoordinate(doublex,doubley){publicCoordinate(doublex,doubley){this.x=x;this.y=y;}publicCoordinate(doublex){// constructor for points on the x-axisthis(x,0);}}
Er is ook een verkorte notatie, waarbij je de parameters niet meer moet herhalen (die staan immers al achter de naam van het record).
Je hoeft met deze notatie ook de parameters niet toe te kennen aan de velden; dat gebeurt automatisch.
Het belangrijkste nut hiervan is om de geldigheid van de waarden te controleren bij het aanmaken van een object:
publicrecordCoordinate(doublex,doubley){publicCoordinate{if(x<0)thrownewIllegalArgumentException("x must be non-negative");if(y<0)thrownewIllegalArgumentException("y must be non-negative");}}
Records en overerving
Zoals eerder al vermeld werd, komt een record overeen met een final klasse.
Je kan er dus niet van overerven.
Een record zelf kan ook geen subklasse zijn van een andere klasse of record, maar kan wel interfaces implementeren.
Immutable
Een record is immutable (onveranderbaar): de attributen krijgen een waarde wanneer het object geconstrueerd wordt, en kunnen daarna nooit meer wijzigen.
Als je een object wil met andere waarden, moet je dus een nieuw object maken.
Bijvoorbeeld, als we een translate methode willen maken voor Coordinate, dan kunnen we de x- en y-coordinaten niet aanpassen.
We moeten een nieuw Coordinate-object maken, en dat teruggeven:
publicrecordCoordinate(doublex,doubley){publicCoordinatetranslate(doubledeltaX,doubledeltaY){// NIET:// this.x += deltaX; <-- kan niet; de x-waarde mag niet meer gewijzigd worden// WEL: een nieuw object makenreturnnewCoordinate(this.x+deltaX,this.y+deltaY);}}
Let op! Als een van de velden van het record een object is dat zelf wél gewijzigd kan worden (bijvoorbeeld een array of ArrayList), dan kan je de data die geassocieerd wordt met het record dus wel nog wijzigen.
Vermijd deze situatie!
Bijvoorbeeld:
publicrecordSong(Stringtitle,Stringartist){}publicrecordPlaylist(ArrayList<Song>songs){}varsongs=newArrayList<>(List.of(newSong("Hello","Adele")));varplaylist1=newPlaylist(songs);varplaylist2=newPlaylist(newArrayList<>(songs));System.out.println(playlist1.equals(playlist2));// => true: beide playlists bevatten dezelfde liedjessongs.add(newSong("Bye bye bye","NSYNC"));System.out.println(playlist1.equals(playlist2));//=>false
Hier zijn twee record-objecten eerst gelijk, maar later niet meer.
Dat schendt het principe dat, voor data-objecten, de identiteit van het object niet zou mogen uitmaken.
De objecten zijn immers niet meer dan de aggregatie van de data die ze bevatten.
Overal waar playlist1 gebruikt wordt, zou ook playlist2 gebruikt moeten kunnen worden en vice versa.
Twee record-objecten die gelijk zijn, moeten altijd gelijk blijven, onafhankelijk van wat er later nog gebeurt.
Gebruik dus bij voorkeur immutable data types in een record.
Pattern matching
Je kan records ook gebruiken in switch statements.
Dit heet pattern matching, en is vooral nuttig wanneer je meerdere record-types hebt die eenzelfde interface implementeren.
Bijvoorbeeld:
Merk op dat je zowel kan matchen op het object als geheel (Square s in het voorbeeld hierboven), individuele argumenten (Circle(double radius) in het voorbeeld), en zelfs geneste patronen (Rectangle(Coordinate(double topLeftX, double topLeftY), Coordinate bottomRight)).
De switch-expressie hierboven is verschillend van het (oudere) switch-statement in Java:
er wordt -> gebruikt in plaats van :
er is geen break nodig op het einde van elke case
de switch-expressie geeft een waarde terug die kan toegekend worden aan een variabele, of gebruikt kan worden in een return-statement (zoals in het voorbeeld hierboven).
Tenslotte is er in een switch-expressie de mogelijkheid om een conditie toe te voegen door middel van een when-clausule:
publicdoublearea(Shapeshape){returnswitch(shape){caseSquares->s.side()*s.side();caseCircle(doubleradius)->Math.PI*radius*radius;caseRectangle(Coordinate(doubletopLeftX,doubletopLeftY),CoordinatebottomRight)whentopLeftX<=bottomRight.x()&&topLeftY<=bottomRight.y()->// <= when-clausule(bottomRight.x()-topLeftX)*(bottomRight.y()-topLeftY);default->thrownewIllegalArgumentException("Unknown or invalid shape");};}
Op die manier kan je extra voorwaarden toevoegen om een case te laten matchen, bovenop het type van het element.
Sealed interfaces
Wanneer je alle klassen kent die een bepaalde interface zullen implementeren (of van een abstracte klasse zullen overerven), kan je van deze interface (of klasse) een sealed interface (of klasse) maken.
Met een permits clausule kan je aangeven welke klassen de interface mogen implementeren:
Indien je geen permits-clausule opgeeft, zijn enkel de klassen die in hetzelfde bestand staan toegestaan.
Omdat elk Java-bestand slechts 1 publieke top-level klasse (of interface/record) mag hebben, zal je vaak ook zien dat de records in de interface-definitie geplaatst worden:
Omdat de compiler kan nagaan wat alle mogelijkheden zijn, kan je bij pattern matching op een sealed klasse in een switch statement ook de default case weglaten:
Omgekeerd zal de compiler je ook waarschuwen wanneer er een geval ontbreekt.
publicdoublearea(Shapeshape){returnswitch(shape){caseSquares->s.side()*s.side();caseCircle(doubleradius)->Math.PI*radius*radius;// <= compiler error: ontbrekende case voor 'Rectangle'};}
Geef enkele voorbeelden van types die volgens jou best als record gecodeerd worden, en ook enkele types die best als klasse gecodeerd worden.
Kan je, voor een van je voorbeelden, een situatie bedenken waarin je van record naar klasse zou gaan, en omgekeerd?
Antwoord
Records zijn vooral geschikt voor het bijhouden van stateless informatie (objecten zonder gedrag).
Bijvoorbeeld: Money, ISBN, BookInfo, ProductDetails, …
Klassen zijn geschikt als de identiteit van het object van belang is en constant blijft, maar de state (data) doorheen de tijd kan wijzigen.
Bijvoorbeeld: BankAccount, ShoppingCart, GameCharacter, OrderProcessor, …
Overgaan van de ene naar de andere vorm kan wanneer er gedrag toegevoegd of verwijderd wordt.
Bijvoorbeeld, BookInfo zou een klasse kunnen worden indien we er (in de context van een bibliotheek) ook informatie over ontleningen in willen bijhouden. Omgekeerd kan BankAccount van klasse naar object gaan indien het enkel een voorstelling wordt van rekeninginformatie (rekeningnummer en naam van de houder bijvoorbeeld), en de balans en transacties naar een ander object (bv. TransactionHistory) verplaatst worden.
Sealed interface
Kan je een voorbeeld bedenken van een nuttig gebruik van een sealed interface?
Antwoord
Sealed interfaces zijn vooral nuttig om een uitgebreidere vorm van enum’s te maken, waar elke optie ook extra informatie met zich kan meedragen.
Bijvoorbeeld:
sealed interface PaymentMethod om een manier van betalen voor te stellen, met subtypes (records) CreditCard(cardName, cardNumber, expirationDate), PayPal(email), BankTransfer(iban), …
sealed interface Command wat een commando voorstelt dat uitgevoerd kan worden, met subtypes (records) CreateUser(name, email), DeleteUser(uuid), UpdateUser(uuid, newEmail), …
Email
Definieer (volgens de principes van TDD) een Email-record dat een geldig e-mailadres voorstelt.
Het mail-adres wordt voorgesteld door een String.
Controleer de geldigheid van de String bij het aanmaken van een Email-object:
Maak (volgens de principes van TDD) een Money-record dat een geldbedrag (bijvoorbeeld 20) en een munteenheid (bijvoorbeeld “EUR”) bevat.
Voeg ook methodes toe om twee geldbedragen op te tellen. Dit mag enkel wanneer de munteenheid van beiden gelijk is; zoniet moet er een exception gegooid worden.
Interval
Maak (volgens de principes van TDD) een Interval-record dat een periode tussen twee tijdstippen voorstelt, bijvoorbeeld voor een vergadering. Elk tijdstip wordt voorgesteld door een niet-negatieve long-waarde.
Het eind-tijdstip mag niet voor het start-tijdstip liggen.
Voeg een methode toe om na te kijken of een interval overlapt met een ander interval.
Intervallen worden beschouwd als half-open: twee aansluitende intervallen overlappen niet, bijvoorbeeld [15, 16) en [16, 17).
Rechthoek
Schrijf (volgens de principes van TDD) een record die een rechthoek voorstelt.
Een rechthoek wordt gedefinieerd door 2 punten (linksboven en rechtsonder).
Gebruik een Coordinaat-record om deze hoekpunten voor te stellen.
Zorg ervoor dat enkel geldige rechthoeken aangemaakt kunnen worden (dus: het hoekpunt linksboven ligt zowel links als boven het hoekpunt rechtsonder).
Voeg extra methodes toe:
om de twee andere hoekpunten (linksonder en rechtsboven) op te vragen
om na te gaan of een gegeven punt zich binnen de rechthoek bevindt
om na te gaan of een rechthoek overlapt met een andere rechthoek. (Hint: bij twee overlappende rechthoeken ligt minstens één hoekpunt van de ene rechthoek binnen de andere)
Expressie-hierarchie
Maak een set van records om een wiskundige uitdrukking voor te stellen.
Alle records moeten een sealed interface Expression implementeren.
De mogelijke expressies zijn:
een Literal: een constante getal-waarde (een double)
een Variable: een naam (een String), bijvoorbeeld “x”
een Sum: bevat twee expressies, een linker en een rechter
een Product: gelijkaardig aan Som, maar stelt een product voor
een Power: een expressie tot een constante macht
De veelterm \( 3x^2+5 \) kan dus voorgesteld worden als:
Gebruik pattern matching (en TDD) voor elk van volgende opdrachten:
Schrijf de methode prettyPrint die de gegeven expressie omzet in een string, bijvoorbeeld prettyPrint(poly) geeft (3.0) * ((x)^2.0) + 5.0.
zorg ervoor dat er geen onnodige haakjes verschijnen in het resultaat van prettyPrint, door rekening te houden met de volgorde van de bewerkingen. (Hint: geef elke expressie een numerieke prioriteit)
de methode simplify moet de gegeven expressie te vereenvoudigen door enkele vereenvoudigingsregels toe te passen. Bijvoorbeeld, het vervangen van \(3 + 7\) door \(10\), vervangen van \(x+0\), \(x*1\), en \(x^1\) door \(x\); vervangen van \(x * 0\) door \(0\), …
de methode evaluate moet de gegeven expressie evalueren voor de gegeven waarden van de variabelen. Bijvoorbeeld, \( 3x^2+5 \) evalueren met \( x=7 \) geeft \(152\). De parameter variableValues bevat een lijst van toekenningen van een waarde aan een variabele. Je moet de klasse Assignment eerst zelf nog maken (Hint: gebruik hiervoor ook een record).
de methode differentiate moet de afgeleide berekenen van de gegeven expressie in de gegeven variabele (bv. \( \frac{d}{dx} 3x^2+5x = 6x+5 \)).
Denkvraag
Wat is het voor- en nadeel van het gebruik van pattern matching tegenover het gebruik van overerving en dynamische binding?
Met andere woorden, wat is het verschil met bijvoorbeeld de methodes simplify(), evaluate(), … in de interface Expression zelf te definiëren, en ze te implementeren in elke subklasse?
7.2 Generics
In andere programmeertalen
De concepten in andere programmeertalen die het dichtst aanleunen bij Java generics zijn
templates in C++
generic types in Python (as type hints)
generics in C#
In dit hoofdstuk behandelen we generics. Die worden veelvuldig gebruikt in datastructuren, en een goed begrip ervan is dan ook essentieel.
Generics zijn een manier om klassen en methodes te voorzien van type-parameters.
Bijvoorbeeld, neem de volgende klasse ArrayList1:
Stel dat we deze klasse makkelijk willen kunnen herbruiken, telkens met een ander type van elementen in de lijst.
We kunnen nu nog niet zeggen wat het type wordt van die elementen.
Gaan er Student-objecten in de lijst terechtkomen? Of Animal-objecten?
Dat weten we nog niet.
We kiezen daarom voor Object, het meest algemene type in Java.
Maar dat betekent ook dat je nu objecten van verschillende, niet-gerelateerde types kan opnemen in één en dezelfde lijst, hoewel dat niet de bedoeling is!
Stel bijvoorbeeld dat je een lijst van studenten wil bijhouden, dan houdt de compiler je niet tegen om ook andere types van objecten toe te voegen:
ArrayListstudents=newArrayList();Studentstudent=newStudent();students.add(student);Animalanimal=newAnimal();students.add(animal);// <-- compiler vindt dit OK 🙁
Om dat tegen te gaan, zou je afzonderlijke klassen ArrayListOfStudents, ArrayListOfAnimals, … kunnen maken, waar het bedoelde type van elementen wel duidelijk is, en ook wordt afgedwongen door de compiler.
Bijvoorbeeld:
De prijs die we hiervoor betalen is echter dat we nu veel quasi-identieke implementaties moeten maken, die enkel verschillen in het type van hun elementen.
Dat leidt tot veel onnodige en ongewenste code-duplicatie.
Met generics kan je een type gebruiken als parameter voor een klasse (of methode, zie later) om code-duplicatie zoals hierboven te vermijden.
Dat ziet er dan als volgt uit (we gaan zodadelijk verder in op de details):
classArrayList<T>{privateT[]elements;// ...}
Generics geven je dus een combinatie van de beste eigenschappen van de twee opties die we overwogen hebben:
er moet slechts één implementatie gemaakt worden (zoals bij ArrayList hierboven), en
deze implementatie kan gebruikt worden om lijsten te maken waarbij het gegarandeerd is dat alle elementen een specifiek type hebben (zoals bij ArrayListOfStudents).
In de volgende secties vind je meer informatie over het gebruik van generics.
Deze klasse is geïnspireerd op de ArrayList-klasse die standaard in Java zit. ↩︎
Subsections of 7.2 Generics
7.2.1 Definiëren en gebruiken
Om een klasse generisch te maken, moet je een generische parameter (meestal aangegeven met een enkele letter) toevoegen, zoals de generische parameter T bij ArrayList<T>.
In het algemeen zijn er slechts twee plaatsen in je code waar je een nieuwe generische parameter mag introduceren:
Bij de definitie van een klasse (of interface, record, …)
Bij de definitie van een methode (of constructor)
Een generische klasse definiëren
Om een generische klasse te definiëren (de eerste optie), zet je de type-parameter tussen < en > achter de naam van de klasse die je definieert.
Vervolgens kan je die parameter (bijna1) overal in die klasse gebruiken als type:
classMyGenericClass<E>{// je kan hier (bijna) overal E gebruiken als type}
Bijvoorbeeld, volgende klasse is een nieuwe versie van de ArrayList-klasse van eerder, maar nu met type-parameter E (waar E staat voor ‘het type van de elementen’).
Deze E wordt vervolgens gebruikt als type voor de elements-array, de parameter van de add-method, en het resultaat-type van de get-method:
Je zal heel vaak zien dat generische type-parameters slechts bestaan uit 1 letter (populaire letters zijn bijvoorbeeld E, R, T, U, V). Dat is geen vereiste: onderstaande code mag ook, en is volledig equivalent aan die van hierboven.
De reden waarom vaak met individuele letters gewerkt wordt, is om duidelijk te maken dat het over een type-parameter gaat, en niet over een bestaande klasse.
Je kan een generische klasse ook zien als een functie (soms een type constructor genoemd).
Die functie geeft geen object terug op basis van een of meerdere parameters zoals je dat gewoon bent van een functie, bijvoorbeeld getPet : (Person p) → Animal, maar geeft een nieuw type (een nieuwe klasse) terug, gebaseerd op de type-parameters.
Bijvoorbeeld, de generische klasse ArrayList<T> kan je beschouwen als een functie ArrayList : (Type T) → Type, die het type ArrayListOfStudents of ArrayListOfAnimals teruggeeft wanneer je ze oproept met respectievelijk T=Student of T=Animal.
In plaats van ArrayListOfStudents schrijven we dat type als ArrayList<Student>.
Een generische klasse gebruiken
Bij het gebruik van een generische klasse (bijvoorbeeld ArrayList<E> van hierboven) moet je een concreet type opgeven voor de type-parameter (E).
Bijvoorbeeld, op plaatsen waar je een lijst met enkel studenten verwacht, gebruik je ArrayList<Student> als type.
Je kan dan de klasse gebruiken op dezelfde manier als de ArrayListOfStudents klasse van hierboven:
ArrayList<Student>students=newArrayList<Student>();StudentsomeStudent=newStudent();students.add(someStudent);// <-- OK 👍// students.add(animal); // <-- niet toegelaten (compiler error) 👍StudentfirstStudent=students.get(0);//<--OK👍
Merk op hoe de compiler afdwingt en garandeert dat er enkel Student-objecten in deze lijst terecht kunnen komen.
Om wat typwerk te besparen, laat Java in veel gevallen ook toe om het type weg te laten bij het instantiëren, met behulp van <>.
Dat type kan immers automatisch afgeleid worden van het type van de variabele:
Een type-parameter <E> zoals we die tot nu toe gezien hebben kan om het even welk type voorstellen.
Soms willen we dat niet, en willen we beperkingen opleggen.
Stel bijvoorbeeld dat we volgende klasse-hierarchie hebben:
De Food-klasse is enkel bedoeld om met Animal (en de subklassen van Animal) gebruikt te worden, bijvoorbeeld Food<Cat> en Food<Dog>.
Maar niets houdt ons op dit moment tegen om ook een Food<Student of een Food<String> te maken.
Daarenboven zal de compiler (terecht) ook een compilatiefout geven in de methode giveTo van Food: er wordt een Animal-specifieke methode opgeroepen (namelijk showLike) op de parameter animal, maar die heeft type A en dat kan eender wat zijn, bijvoorbeeld ook String.
En String biedt natuurlijk geen methode showLike() aan.
We kunnen daarom aangeven dat type A een subtype moet zijn van Animal door bij de definitie van de generische parameter <A extends Animal> te schrijven.
Je zal dan niet langer Food<String> mogen schrijven, aangezien String geen subklasse is van Animal.
We begrenzen dus de mogelijke types die gebruikt kunnen worden voor de type-parameter A tot alle types die overerven van Animal (inclusief Animal zelf).
classFood<AextendsAnimal>{publicvoidgiveTo(Aanimal){/* ... */animal.showLike();// <= OK! 👍}}Food<Cat>catFood=newFood<>();// nog steeds OKFood<String>stringFood=newFood<>();//<--compilererror👍
Note
Wanneer je deze materie later opnieuw doorneemt, heb je naast extends ook al gehoord van super en wildcards (?) — dit wordt later besproken.
Het is belangrijk om op te merken dat je super en ?nooit kan gebruiken bij de definitie van een nieuwe generische parameter (de Java compiler laat dit niet toe).
Dat kan enkel op de plaatsen waar je een generische klasse of methode gebruikt.
Onthoud dus: op de plaatsen waar je een nieuwe parameter (een nieuwe ’letter’) introduceert, kan je enkel aangeven dat die een subtype van iets moet zijn met behulp van extends.
Een generische methode definiëren en gebruiken
In de voorbeelden hierboven hebben we steeds een hele klasse generisch gemaakt.
Naast een generische klasse was er ook een tweede plaats om een generische parameter te definiëren, namelijk eentje die enkel in één methode gebruikt kan worden.
Dat doe je door de parameter te declareren vóór het terugkeertype van die methode, opnieuw tussen < en >.
Dat kan ook in een klasse die zelf geen type-parameters heeft.
Je kan die parameter dan gebruiken in de methode zelf, en ook in de types van de parameters en het terugkeertype (dus overal na de definitie ervan).
Bijvoorbeeld, onderstaande methodes doSomething en doSomethingElse hebben beiden een generische parameter T.
Die parameter hoort enkel bij elke individuele methode; beide generische types staan dus volledig los van elkaar.
Ook NormalClass is geen generische klasse; enkel de twee methodes zijn generisch.
classNormalClass{public<T>intdoSomething(ArrayList<T>elements){// je kan overal in deze methode type T gebruiken}publicstatic<T>ArrayList<T>doSomethingElse(ArrayList<T>elements,Telement){// deze T is onafhankelijk van die in doSomething}}
Het is trouwens ook mogelijk om generische klassen en generische methodes te combineren:
classFoo<T>{public<U>ArrayList<U>doSomething(ArrayList<T>ts,ArrayList<U>us){// code met T en U}}
Type inference
Bij het gebruik van een generische methode zal de Java-compiler zelf proberen om de juiste types te vinden; dit heet type inference. Je kan de methode meestal gewoon oproepen zoals elke andere methode, en hoeft dus (in tegenstelling tot bij klassen) niet zelf aan te geven hoe de generische parameters geïnstantieerd worden.
In de uitzonderlijke gevallen waar type inference faalt, of wanneer je het type van de generische parameter expliciet wil maken, kan je die zelf opgeven als volgt:
Merk op hoe we, tussen het . en de naam van de methode, de generische parameter <Dog> toevoegen.
Voorbeeld
Als voorbeeld definiëren we (in een niet-generische klasse AnimalHelper) een generische (statische) methode findHappyAnimals.
Deze heeft 1 generische parameter T, en we leggen meteen ook op dat dat een subtype van Animal moet zijn (<T extends Animal>).
Merk op dat we het type T zowel gebruiken bij de animals-parameter als bij het terugkeertype van de methode.
Zo kunnen we garanderen dat de teruggegeven lijst precies hetzelfde type elementen heeft als de lijst animals, zonder dat we al moeten vastleggen welk type dier (bv. Cat of Dog) dat precies is.
Dus: als we een ArrayList<Cat> meegeven aan de methode, krijgen we ook een ArrayList<Cat> terug.
Op dezelfde manier kan je ook het type van meerdere parameters (en eventueel het terugkeertype) aan elkaar vastkoppelen.
In het voorbeeld hieronder zie je een methode die paren kan maken tussen dieren; de methode kan gebruikt worden voor elk type dier, maar kan enkel paren maken van dezelfde diersoort.
Je ziet meteen ook een voorbeeld van een generisch record-type AnimalPair.
classAnimalHelper{// voorbeeld van een generisch recordpublicrecordAnimalPair<TextendsAnimal>(Tmale,Tfemale){}publicstatic<TextendsAnimal>ArrayList<AnimalPair<T>>makePairs(ArrayList<T>males,ArrayList<T>females){/* ... */}}ArrayList<Cat>maleCats=...ArrayList<Cat>femaleCats=...ArrayList<Dog>femaleDogs=...ArrayList<AnimalPair<Cat>>pairedCats=makePairs(maleCats,femaleCats);// OKArrayList<AnimalPair<Animal>>pairedMix=makePairs(maleCats,femaleDogs);//nietOK(compilererror)👍
Merk hierboven op hoe, door de parameter T op verschillende plaatsen te gebruiken in de methode, deze methode enkel gebruikt kan worden om twee lijsten met dezelfde diersoorten te koppelen, en er meteen ook gegarandeerd wordt dat de AnimalPair-objecten die teruggegeven worden ook hetzelfde type dier bevatten.
Als het type T niet van belang is omdat het nergens terugkomt (niet in het terugkeertype van de methode, niet bij een andere parameter, en ook niet in de body van de methode), dan heb je strikt gezien geen generische methode nodig.
Zoals we later bij het gebruik van wildcards zullen zien, kan je dan ook gewoon het wildcard-type <? extends X> gebruiken, of <?> indien het type niet begrensd moet worden.
In plaats van
publicstatic<TextendsAnimal>voidfeedAll(ArrayList<T>animals){// code die T nergens vermeldt}
kan je dus ook de generische parameter T weglaten, en hetvolgende schrijven:
Dit is nu geen generische methode meer (er wordt geen nieuwe generische parameter geïntroduceerd); de parameter animals maakt wel gebruik van een generisch type.
Je leest deze methode-signatuur als ‘de methode feedAll neemt als parameter een lijst met elementen van een willekeurig (niet nader bepaald) subtype van Animal’.
Onthoud
Er zijn slechts 2 plaatsen waar je een nieuwe generische parameter (een ’letter’ zoals T of U) mag introduceren:
vlak na de naam van een klasse (of record, interface, …) die je definieert (class Foo<T> { ... }); of
vlak vóór het terugkeertype van een methode (public <T> void doSomething(...) { }).
Op alle andere plaatsen waar je naar een generische parameter verwijst (door de letter te gebruiken), moet je ervoor zorgen dat deze eerst gedefinieerd werd op één van deze twee plaatsen.
Meerdere type-parameters
De ArrayList<E>-klasse hierboven had één generische parameter (E).
Een generische klasse of methode kan ook meerdere type-parameters hebben, bijvoorbeeld een tuple van 3 elementen van mogelijk verschillend type (we maken hier een record in plaats van een klasse):
Bij het gebruik van deze klasse (bijvoorbeeld bij het aanmaken van een nieuw object) moet je dan voor elke parameter (T1, T2, en T3) een concreet type opgeven:
Ook hier kan je met de verkorte notatie <> werken om jezelf niet te moeten herhalen.
Note
Het lijkt erg handig om zo’n Tuple-type overal in je code te gebruiken waar je drie objecten samen wil bundelen, maar dat wordt afgeraden.
Niet omdat het drie generische parameters heeft (dat is perfect legitiem), maar wel omdat het niets zegt over de betekenis van de velden (wat zit er in ‘first’, ‘second’, ’third’?).
Gebruik in plaats van een algemene Tuple-klasse veel liever een record waar je de individuele componenten een zinvolle naam geeft.
Bijvoorbeeld: record Enrollment(String student, int year, String courseId) {} of record Point3D(double x, double y, double x) {}.
De generische parameter kan niet gebruikt worden in de statische velden, methodes, inner classes, … van de klasse. ↩︎
7.2.2 Generics en subtyping
Stel we hebben klassen Animal, Mammal, Cat, Dog, en Bird met volgende overervingsrelatie:
Een van de basisregels van object-georiënteerd programmeren is dat overal waar een object van type X verwacht wordt, ook een object van een subtype van X toegelaten wordt.
De Java compiler respecteert deze regel uiteraard.
Volgende toekenningen zijn bijvoorbeeld toegelaten:
maar mammal = new Bird(); is bijvoorbeeld niet toegelaten, want Bird is geen subtype van Mammal.
In onderstaande code is de eerste oproep toegelaten (cat heeft type Cat, en dat is een subtype van Mammal), maar de tweede niet (cat is geen Dog) en de derde ook niet (Cat is geen subtype van Bird):
staticvoidpet(Mammalmammal){/* ... */}staticvoidbark(Dogdog){/* ... */}staticvoidlayEgg(Birdbird){/* ... */}Catcat=newCat();pet(cat);// <- toegelaten (voldoet aan principe)bark(cat);// <- niet toegelaten (compiler error) 👍layEgg(cat);//<-niettoegelaten(compilererror)👍
Subtyping en generische lijsten
Een lijst in Java is een geordende groep van elementen van hetzelfde type.
List<E> is de interface1 die aan de basis ligt van alle lijsten.
ArrayList<E> is een klasse die een lijst implementeert met behulp van een array.
ArrayList<E> is een subtype van List<E>; dus overal waar een List-object verwacht wordt, mag ook een ArrayList gebruikt worden.
Later (in het hoofdstuk rond Collections) zullen we ook zien dat er een interface Collection<E> bestaat, wat een willekeurige groep van elementen voorstelt: niet enkel een lijst, maar bijvoorbeeld ook verzamelingen (Set) of wachtrijen (Queue).
List<E> is een subtype van Collection<E>. Bijgevolg (via transitiviteit) is ArrayList<E> dus ook subtype van Collection<E>.
Het lijkt intuïtief misschien logisch dat ArrayList<Cat> ook een subtype moet zijn van ArrayList<Animal>.
Een lijst van katten lijkt tenslotte toch een speciaal geval te zijn van een lijst van dieren?
Maar dat is niet het geval.
Waarom niet?
Stel dat ArrayList<Cat> toch een subtype zou zijn van ArrayList<Animal>. Dan zou volgende code ook geldig zijn:
ArrayList<Cat>cats=newArrayList<Cat>();ArrayList<Animal>animals=cats;// <- dit zou geldig zijn (maar is het niet!)Dogdog=newDog();animals.add(dog);//<-OOPS:erzitnueenhondindelijstvankatten🙁
Je zou dus honden kunnen toevoegen aan je lijst van katten zonder dat de compiler je waarschuwt, en dat is niet gewenst.
Om die reden beschouwt Java ArrayList<Cat> dus niet als subtype van ArrayList<Animal>, ondanks dat Cat wél een subtype van Animal is.
Onthoud
Zelfs als klasse Sub een subtype is van klasse Super, dan is ArrayList<Sub> toch geen subtype van ArrayList<Super>.
Later zullen we zien hoe we hier met wildcards in sommige gevallen wel flexibeler mee kunnen omgaan.
Overerven van een generisch type
Hierboven gebruikten we vooral ArrayList als voorbeeld van een generische klasse.
We hebben echter ook gezien dat je zelf generische klassen kan definiëren, en daarvan kan je uiteraard ook overerven.
Bij de definitie van een subklasse moet je voor de generische parameter van de superklasse een waarde (type) meegeven. Je kan ervoor kiezen om je subklasse zelf generisch te maken (dus een nieuwe generische parameter te introduceren), of om een vooraf bepaald type mee te geven.
Bijvoorbeeld:
De superklasse Super heeft een generische parameter T.
De subklasse SubForAnimal definieert zelf een generische parameter A (hier met begrenzing), en gebruikt parameter A als type voor T uit de superklasse.
De klasse SubForCat tenslotte definieert zelf geen nieuwe generische parameter, maar geeft het type Cat op als type voor parameter A uit diens superklasse.
Een interface kan je zien als een abstracte klasse waarvan alle methodes abstract zijn. Het defineert alle methodes die geïmplementeerd moeten worden, maar bevat zelf geen implementatie. ↩︎
7.2.3 Wildcards
We zagen eerder dat de types List<Dog> en List<Animal> niets met elkaar te maken hebben, ondanks het feit dat Dog een subtype is van Animal.
Dat geldt in het algemeen voor generische types.
Als beide generische parameters hetzelfde type hebben, bestaat er wel een overervingsrelatie. Bijvoorbeeld, in volgende situatie:
is AnimalShelter<Dog> wel degelijk een subtype van Shelter<Dog>, om dezelfde reden dat ArrayList<Dog> een subtype is van List<Dog>.
Volgende toekenning en methode-oproep zijn dus toegelaten:
Dat komt omdat AnimalShelter een subtype is van Shelter, en de generische parameter bij beiden hetzelfde is.
Als de generische parameters verschillend zijn, is er echter geen overervingsrelatie.
Bijvoorveeld, tussen AnimalShelter<Cat> en Shelter<Animal> is er geen overervingsrelatie.
Ook is Shelter<Cat> geen subtype van Shelter<Animal>.
Het volgende is bijgevolg niet toegelaten:
Shelter<Animal>s=newAnimalShelter<Cat>();// NIET toegelatenpublicvoidprotectAnimal(Shelter<Animal>s){...}AnimalShelter<Cat>animalShelter=newAnimalShelter<Cat>();// wel OK!protectAnimal(animalShelter);//NIETtoegelaten
In sommige situaties willen we wel zo’n overervingsrelatie kunnen maken.
We bekijken daarvoor twee soorten relaties, namelijk covariantie en contravariantie.
Note
Opgelet: Zowel covariantie als contravariantie gaan enkel over het gebruik van generische klassen.
Meer bepaald beïnvloeden ze wanneer twee generische klassen door de compiler als subtype van elkaar beschouwd worden.
Dat staat los van de definitie van een generische klasse — die definities (en bijhorende begrenzing) blijven onveranderd!
Covariantie (extends)
Wat als we een methode copyFromTo willen schrijven die de dieren uit een gegeven (bron-)lijst toevoegt aan een andere (doel-)lijst van dieren? Bijvoorbeeld:
publicstaticvoidcopyFromTo(ArrayList<Animal>source,ArrayList<Animal>target){for(Animala:source){target.add(a);}}ArrayList<Animal>animals=newArrayList<>();ArrayList<Cat>cats=/* ... */ArrayList<Dog>dogs=/* ... *//* ... */copyFromTo(dogs,animals);// niet toegelaten 🙁copyFromTo(cats,animals);//niettoegelaten🙁
Volgens de regels die we hierboven gezien hebben, kunnen we deze methode niet gebruiken om de dieren uit een lijst van honden (ArrayList<Dog>) of katten (ArrayList<Cat>) te kopiëren naar een lijst van dieren (ArrayList<Animal>).
Maar dat lijkt wel een zinnige operatie.
Een oplossing kan zijn om verschillende versies van de methode te schrijven:
Merk op dat de oproep target.add(cat), alsook die met dog en bird, toegelaten is, omdat Cat, Dog en Bird subtypes zijn van Animal.
Maar dan lopen we opnieuw tegen het probleem van gedupliceerde code aan.
Een eerste oplossing daarvoor is een generische methode, met een generische parameter die begrensd is (T extends Animal):
Dat werkt, maar de generische parameter T wordt slechts eenmaal gebruikt, namelijk bij de parameter ArrayList<T> source.
In zo’n situatie kunnen we ook gebruik maken van het wildcard-type <? extends X>.
We kunnen bovenstaande methode dus ook zonder generische parameter schrijven als volgt:
Het type ArrayList<? extends Animal> staat dus voor “elke ArrayList waar het element-type een (niet nader bepaald) subtype is van Animal”.
Je kan dit ook bekijken alsof het type ArrayList<? extends Animal> tegelijk staat voor de types ArrayList<Animal>, ArrayList<Mammal>, ArrayList<Cat>, ArrayList<Dog>, alsook een lijst van elk ander type dier.
Dit heet covariantie: omdat Cat een subtype is van Animal, is ArrayList<Cat> een subtype van ArrayList<? extends Animal>.
De ‘co’ in covariantie wijst erop dat de overervingsrelatie tussen Cat en Animal in dezelfde richting loopt als die tussen ArrayList<Cat> en ArrayList<? extends Animal> (in tegenstelling tot contravariantie, wat zodadelijk aan bod komt).
Dat zie je op de afbeelding hieronder:
Tenslotte kan je in Java ook <?> schrijven (bijvoorbeeld ArrayList<?>); dat is een verkorte notatie voor ArrayList<? extends Object>. Je interpreteert ArrayList<?> dus als een lijst van een willekeurig maar niet gekend type. Merk op dat ArrayList<?> dus niet hetzelfde is als ArrayList<Object>. Een ArrayList<Cat> is een subtype van ArrayList<?>, maar niet van ArrayList<Object>.
Hou er ook rekening mee dat elk voorkomen van ? voor een ander type staat (of kan staan). Hetvolgende kan dus niet:
omdat de eerste ArrayList<? extends Mammal> (source) bijvoorbeeld een ArrayList<Cat> kan zijn, en de tweede (target) een ArrayList<Dog>. Als je de types van beide parameters wil linken aan elkaar, moet je een generische methode gebruiken (zoals eerder gezien):
De lijst-variabele is gedeclareerd als een ArrayList met elementen van een ongekend type. Op basis van het type van de variabele kan de compiler niet afleiden dat er Strings toegevoegd mogen worden aan de lijst (het zou evengoed een ArrayList van Animals kunnen zijn).
Het feit dat lijst geinititialiseerd wordt met <String> doet hier niet terzake; enkel het type van de declaratie is van belang.
Onthoud
Het type ArrayList<? extends Mammal> staat tegelijk voor de types ArrayList<Mammal>, ArrayList<Cat>, ArrayList<Dog>, en elk ander type dat overerft van Mammal.
Contravariantie (super)
Wat als we een methode willen die de objecten uit een gegeven bronlijst van katten kopieert naar een doellijst van willekeurige dieren? Bijvoorbeeld:
publicstaticvoidcopyFromCatsTo(ArrayList<Cat>source,ArrayList<Animal>target){for(Catcat:source){target.add(cat);}}ArrayList<Cat>cats=/* ... */ArrayList<Cat>otherCats=newArrayList<>();ArrayList<Mammal>mammals=newArrayList<>();ArrayList<Animal>animals=newArrayList<>();copyFromTo(cats,otherCats);// niet toegelaten 🙁copyFromTo(cats,mammals);// niet toegelaten 🙁copyFromTo(cats,animals);//OK👍
De eerste twee copyFromTo-regels zijn niet toegelaten, maar zouden opnieuw erg nuttig kunnen zijn.
Co-variantie met extends helpt ook niet (target zou dan immers ook een ArrayList<Dog> kunnen zijn):
publicstaticvoidcopyFromCatsTo(ArrayList<Cat>source,ArrayList<?extendsAnimal>target){for(Catcat:source){target.add(cat);}// ook niet toegelaten 🙁}
En aparte methodes schrijven leidt opnieuw tot code-duplicatie:
Zou het nuttig zijn om een methode copyFromCatsToBirds(ArrayList<Cat> source, ArrayList<Bird> target) te voorzien? Waarom (niet)?
De oplossing in dit geval is gebruik maken van het wildcard-type <? super T>.
Het type ArrayList<? super Cat> staat dus voor “elke ArrayList waar het element-type een supertype is van Cat” (inclusief het type Cat zelf).
Of nog: ArrayList<? super Cat> staat tegelijk voor de types ArrayList<Cat>, ArrayList<Mammal>, ArrayList<Animal>, en ArrayList<Object>, alsook elke andere ArrayList met een supertype van Cat als element-type.
copyFromCatsTo_wildcard(cats,otherCats);// OK 👍copyFromCatsTo_wildcard(cats,mammals);// OK 👍copyFromCatsTo_wildcard(cats,animals);//OK👍
Dit heet contravariantie: hoewel Cat een subtype is van Animal, is ArrayList<? super Cat> een supertype vanArrayList<Animal>.
De ‘contra’ in contravariantie wijst erop dat de overervingsrelatie tussen Cat en Animal in de omgekeerde richting loopt als die tussen ArrayList<? super Cat> en ArrayList<Animal>.
Bekijk volgende figuur aandachtig:
Als we ook ArrayList<Mammal>, ArrayList<? super Mammal>, en ArrayList<? super Animal> toevoegen aan het plaatje, ziet dat er als volgt uit:
graph BT
ALCat["ArrayList#lt;Cat>"]
ALsuperCat["ArrayList#lt;? super Cat>"]
ALsuperMammal["ArrayList#lt;? super Mammal>"]
ALsuperAnimal["ArrayList#lt;? super Animal>"]
ALMammal["ArrayList#lt;Mammal>"]
ALAnimal["ArrayList#lt;Animal>"]
ALCat --> ALsuperCat
ALAnimal --> ALsuperAnimal
ALMammal --> ALsuperMammal
ALsuperAnimal --> ALsuperMammal
ALsuperMammal --> ALsuperCat
Cat --> Mammal
Mammal --> Animal
classDef cat fill:#f99,stroke:#333,stroke-width:4px;
classDef mammal fill:#9f9,stroke:#333,stroke-width:4px;
classDef animal fill:#99f,stroke:#333,stroke-width:4px;
class ALCat,ALsuperCat,Cat cat;
class ALMammal,Mammal,ALsuperMammal mammal;
class ALAnimal,Animal,ALsuperAnimal animal;
Aan de hand van de kleuren kan je snel zien dat de overervingsrelatie links en rechts inderdaad omgekeerd verlopen.
Onthoud
Het type ArrayList<? super Mammal> staat tegelijk voor de types ArrayList<Mammal>, ArrayList<Animal>, ArrayList<Object>, en elk ander type dat een superklasse (of interface) is van Mammal.
Covariantie of contravariantie: PECS
Als we covariantie en contravariantie combineren, krijgen we volgend beeld (we focussen op de extends- en super-relatie vanaf Mammal):
Hier zien we dat ArrayList<? extends Mammal> (covariant) als subtypes ArrayList<Mammal> en ArrayList<Cat> heeft.
Het contravariante ArrayList<? super Mammal> heeft óók ArrayList<Mammal> als subtype, maar ook ArrayList<Animal>.
Hoe weet je nu wanneer je wat gebruikt als type voor een parameter? Wanneer kies je <? extends T>, en wanneer <? super T>?
Een goede vuistregel is het acroniem PECS, wat staat voor Producer Extends, Consumer Super.
Dus:
Wanneer het object gebruikt wordt als een producent van T’s (met andere woorden, het object is een levancier van T-objecten voor jouw code, die ze vervolgens gebruikt), gebruik je <? extends T>. Dat is logisch: als jouw code met aangeleverde T’s omkan, dan kan jouw code ook om met de aanlevering van een subklasse van T (basisprincipe objectgeoriënteerd programmeren).
Wanneer het object gebruikt wordt als een consument van T’s (met andere woorden, het neemt T-objecten aan van jouw code), gebruik je <? super T>. Ook dat is logisch: een object dat beweert om te kunnen met elke superklasse van T moet zeker overweg kunnen met een T die jouw code aanlevert.
Wanneer het object zowel als consument als als producent gebruikt wordt, gebruik je gewoon <T> (dus geen co- of contra-variantie). Er is dan weinig tot geen flexibiliteit meer in het type.
Een voorbeeld om PECS toe te passen: we willen een methode copyFromTo die zo flexibel mogelijk is, om elementen uit een lijst van zoogdieren te kopiëren naar een andere lijst.
Met deze methode kunnen we nu alle zinvolle operaties uitvoeren, terwijl de zinloze operaties tegengehouden worden door de compiler:
ArrayList<Cat>cats=/* ... */ArrayList<Dog>dogs=/* ... */ArrayList<Bird>birds=/* ... */ArrayList<Mammal>mammals=/* ... */ArrayList<Animal>animals=/* ... */copyMammalsFromTo(cats,animals);// OK 👍copyMammalsFromTo(cats,mammals);// OK 👍copyMammalsFromTo(cats,cats);// OK 👍copyMammalsFromTo(mammals,animals);// OK 👍copyMammalsFromTo(cats,dogs);// compiler error (Dog is geen supertype van Mammal) 👍copyMammalsFromTo(birds,animals);//compilererror(BirdisgeensubtypevanMammal)👍
Merk op dat het type Mammal in onze laatste versie van copyMammalsFromTo hierboven eigenlijk onnodig is. We kunnen de methode nog verder veralgemenen door er een generische methode van te maken, die werkt voor alle lijsten (niet enkel lijsten van zoogdieren):
Met deze versie kunnen we nu bijvoorbeeld ook Birds kopiëren naar een lijst van dieren:
copyFromTo(birds,animals);//OK👍
Opmerking
Wanneer een parameter zowel een producent (co-variant) als een consument (contra-variant) is, gebruik je geen wildcards.
De generische parameter heet dan invariant.
Bijvoorbeeld:
publicstatic<T>voidreverse(List<T>list){intleft=0;intright=list.size()-1;while(left<right){// Producent (get)Ttemp=list.get(left);// Consumer (set)list.set(left,list.get(right));list.set(right,temp);left++;right--;}}
Arrays en type erasure
In tegenstelling tot ArrayLists (en andere generische types), beschouwt Java arrays wél altijd als covariant.
Dat betekent dat Cat[] een subtype is van Animal[].
Volgende code compileert dus (maar gooit een uitzondering bij het uitvoeren):
De reden hiervoor is, in het kort, dat informatie over generics gewist wordt bij het compileren van de code.
Dit heet type erasure.
In de gecompileerde code is een ArrayList<Animal> en ArrayList<Cat> dus exact hetzelfde.
Er kan dus, tijdens de uitvoering, niet gecontroleerd worden of je steeds het juiste type gebruikt.
Daarom moet de compiler dat doen, en die neemt het zekere voor het onzekere: alles wat mogelijk fout zou kunnen aflopen, wordt geweigerd.
Bij arrays wordt er wel type-informatie bijgehouden na het compileren, en kan dus tijdens de uitvoering nog gecontroleerd worden of je geen elementen met een ongeldig type toevoegt. De compiler hoeft het niet af te dwingen — maar het wordt wel nog steeds gecontroleerd tijdens de uitvoering, en kan leiden tot een exception.
Aandachtspunten
Enkel bij generische types!
Tenslotte nog een opmerking (op basis van vaak gemaakte fouten op examens).
Co- en contra-variantie (extends, super, en wildcards dus) zijn enkel van toepassing op generische types.
Alles wat we hierboven gezien hebben is dus enkel nuttig op plaatsen waar je een generisch type (List<T>, Food<T>, …) gebruikt voor een parameter, terugkeertype, variabele, ….
Dergelijke types kan je met behulp van co-/contra-variantie en wildcards verrijken tot bijvoorbeeld List<? extends T>, Food<? super T>, …
Maar je kan deze constructies niet gebruiken op plaatsen waar een gewoon type verwacht wordt, bijvoorbeeld bij een parameter of terugkeertype.
Onderstaande regels code zijn dus allemaal ongeldig:
Deze methode kan óók al opgeroepen worden met een Cat-object, Dog-object, of elk ander type Mammal als argument.
Je hebt hier geen co- of contra-variantie van generische types nodig; je maakt gewoon gebruik van overerving uit objectgeoriënteerd programmeren.
Onthoud
Wildcards (?), co-variantie (? extends) en contra-variantie (? super) zijn enkel van toepassing bij generische types! Je kan ze dus niet gebruiken als een op zichzelf staand type. Je kan ze ook niet gebruiken bij de definitie van een nieuwe generische parameter (voor een klasse of methode), maar enkel bij het gebruik ervan.
Bounds vs. co-/contravariantie en wildcards
Tot slot is het nuttig om nog eens te benadrukken dat er een verschil is tussen het begrenzen van een generische parameter (met extends) enerzijds, en het gebruik van co-variantie, contra-variantie en wildcards (? extends T, ? super T) anderzijds. Het feit dat extends in beide gevallen gebruikt wordt, kan misschien tot wat verwarring leiden.
Een begrenzing (via <T extends SomeClass>) beperkt welke types geldige waarden zijn voor de type-parameter T. Dus: elke keer wanneer je een concreet type wil meegeven in de plaats van T moet dat type voldoen aan bepaalde eisen.
Je kan zo’n begrenzing enkel aangeven op de plaats waar je een nieuwe generische parameter (T) introduceert (dus bij een nieuwe klasse-definitie of methode-definitie).
Bijvoorbeeld: class Food<T extends Animal> laat later enkel toe om Food<X> te schrijven als type wanneer X ook een subtype is van Animal.
Door co- en contra-variantie (met <? extends X> en <? super X>) te gebruiken verbreed je de toegelaten types.
Een methode-parameter met als type Food<? extends Animal> laat een Food<Animal> toe als argument, maar ook een Food<Cat> of Food<Dog>.
Omgekeerd zal een parameter met als type Food<? super Cat> een Food<Cat> toelaten, maar ook een Food<Animal>.
Er wordt in beide gevallen dus meer toegelaten, wat meer flexibiliteit biedt.
Je kan co- en contravariantie toepassen op elke plaats waar je een generisch type gebruikt (en waar dat gepast is volgens de PECS regels).
Het kan dus perfect zijn dat je de ene keer in je code eens Food<Cat> gebruikt, ergens anders Food<? extends Cat>, en nog ergens anders Food<? super Cat>.
Bij begrenzing is dat niet zo; dat legt de grenzen eenmalig vast, en die moeten overal gerespecteerd worden waar het generisch type gebruikt wordt.
Onthoud
Een begrenzing (T extends X) is een eenmalige beperking op het type dat gebruikt kan worden als waarden voor een nieuw geïntroduceerde generische parameter. Dit kan enkel voorkomen in de definitie van een nieuwe generische parameter (bij een generische klasse of methode).
Co-en contravariantie (? extends X, ? super X) met wildcard ?versoepelen de types die aanvaard worden door de compiler. Ze komen enkel voor op plaatsen waar een generisch type gebruikt wordt.
Arrays met generisch type
Als je een array wil maken van een generisch type, laat de Java-compiler dat niet toe:
classMyClass<T>{privateT[]array;publicMyClass(){array=newT[10];// <-- niet toegelaten ☹️}}
De reden is opnieuw type erasure.
Aangezien arrays covariant zijn, moet tijdens de uitvoering gecontroleerd kunnen worden of objecten die in de array terechtkomen een geschikt type hebben.
Aangezien generische parameters verwijderd worden door de compiler, kan dat niet.
Een oplossing voor bovenstaand probleem is om een cast toe te voegen. Met een @SuppressWarning annotatie kan je de waarschuwing die door de compiler gegeven wordt negeren.
Schrijf een generische klasse (of record) Maybe die een object voorstelt dat nul of één waarde van een bepaald type kan bevatten.
Dat type wordt bepaald door een generische parameter. Je kan Maybe-objecten enkel aanmaken via de statische methodes some en none.
Hieronder vind je twee tests:
Maak de print-methode hieronder ook generisch, zodat deze niet enkel werkt voor een Maybe<String> maar ook voor andere types dan String.
classMaybePrint{publicstaticvoidprint(Maybe<String>maybe){if(maybe.hasValue()){System.out.println("Contains a value: "+maybe.getValue());}else{System.out.println("No value :(");}}publicstaticvoidmain(String[]args){Maybe<String>maybeAString=Maybe.some("yes");Maybe<String>maybeAnotherString=Maybe.none();print(maybeAString);print(maybeAnotherString);}}
Voeg aan Maybe een generische methode map toe die een java.util.function.Function<T, R>-object als parameter heeft, en die een nieuw Maybe-object teruggeeft, met daarin het resultaat van de functie toegepast op het element als er een element is, of een leeg Maybe-object in het andere geval.
Zie de tests hieronder voor een voorbeeld van hoe deze map-functie gebruikt wordt:
(optioneel) Herschrijf Maybe als een sealed interface met twee record-subklassen None en Some.
Geef een voorbeeld van hoe je deze klasse gebruikt met pattern matching.
Kan je ervoor zorgen dat je getValue() nooit kan oproepen als er geen waarde is (compiler error)?
Info
Java bevat een ingebouwd type gelijkaardig aan de Maybe-klasse uit deze oefening, namelijk Optional<T>.
Repository
Schrijf een generische klasse Repository die een repository van objecten voorstelt. De objecten hebben ook een ID. Zowel het type van objecten als het type van de ID moeten generische parameters zijn.
Definieer en implementeer volgende methodes (maak gebruik van een ArrayList):
add(id, obj): toevoegen van een object
findById(id): opvragen van een object aan de hand van de id
findAll(): opvragen van alle objecten in de repository
update(id, obj): vervangen van een object met gegeven id door het meegegeven object
remove(id): verwijderen van een object aan de hand van een id
SuccessOrFail
Schrijf een generische klasse (of record) SuccessOrFail die een object voorstelt dat precies één element bevat.
Dat element heeft 1 van 2 mogelijke types (die types zijn generische parameters).
Het eerste type stelt het type van een succesvol resultaat voor; het tweede type is dat van een fout.
Je kan objecten enkel aanmaken via de statische methodes success en fail.
Een voorbeeld van tests voor die klasse vind je hieronder:
@Testpublicvoidsuccess(){SuccessOrFail<String,Exception>result=SuccessOrFail.success("This is the result");assertThat(result.isSuccess()).isTrue();assertThat(result.successValue()).isEqualTo("This is the result");}@Testpublicvoidfailure(){SuccessOrFail<String,Exception>result=SuccessOrFail.fail(newIllegalStateException());assertThat(result.isSuccess()).isFalse();assertThat(result.failValue()).isInstanceOf(IllegalStateException.class);}
Subtyping: voertuigen
Vetrek van volgende klasse-hiërarchie en zeg van elk van volgende lijnen code of ze toegelaten worden door de Java compiler:
graph BT
Bike --> Vehicle
Motorized --> Vehicle
Car --> Motorized
Plane --> Motorized
ArrayList<Cat> is een subtype van List<Cat> en van ArrayList<? extends Cat>.
List<Cat> is een subtype van List<? extends Cat>
ArrayList<? extends Cat> is een subtype van List<? extends Cat> en van ArrayList<? extends Animal>
ArrayList<Animal> is een subtype van ArrayList<? extends Animal> en List<Animal>
List<? extends Cat>, ArrayList<? extends Animal> en List<Animal> zijn alledrie subtypes van List<? extends Animal>
Shop
Maak een klasse Shop die een winkel voorstelt die items (subklasse van StockItem) aankoopt.
Een Shop-object wordt geparametriseerd met het type items dat aangekocht kan worden. We beschouwen hier Fruit en Electronics; daarmee kunnen we dus een fruitwinkel (Shop<Fruit>) en elektronica-winkel (Shop<Electronics>) maken.
Shop heeft twee methodes:
buy, die een lijst van items toevoegt aan de stock;
addStockToInventory, die de lijst van items in stock toevoegt aan de meegegeven inventaris-lijst.
Voor het fruit maak je een abstracte klasse Fruit, en subklassen Apple en Orange.
Maak daarnaast nog een abstracte klasse Electronics, met als subklasse Smartphone.
Zorg dat onderstaande code (ongewijzigd) compileert en dat de test slaagt:
Java bevat een ingebouwde interface java.util.function.Function<T, R>, wat een functie voorstelt met één parameter van type T, en een resultaat van type R. Deze interface voorziet 1 methode R apply(T value) om de functie uit te voeren.
Schrijf nu een generische methode compose die twee functie-objecten als parameters heeft, en als resultaat een nieuwe functie teruggeeft die de compositie voorstelt: eerst wordt de eerste functie uitgevoerd, en dan wordt de tweede functie uitgevoerd op het resultaat van de eerste.
Dus: voor functies
Function<A,B>f1=...Function<B,C>f2=...
moet compose(f1, f2) een Function<A, C> teruggeven, die als resultaat f2.apply(f1.apply(a)) teruggeeft.
Pas de PECS-regel toe om ook functies te kunnen samenstellen die niet exact overeenkomen qua type.
Bijvoorbeeld, volgende code moet compileren en de test moet slagen:
Vul de types en generische parameters aan op de 7 genummerde plaatsen zodat onderstaande code en main-methode compileert (behalve de laatste regel van de main-methode) en voldaan is aan volgende voorwaarden:
Elk actie-type kan enkel uitgevoerd worden door een bepaald karakter-type. Bijvoorbeeld: een FightAction kan enkel uitgevoerd worden door een karakter dat CanFight implementeert.
doAction mag enkel opgeroepen worden met een actie die uitgevoerd kan worden door alle karakters in de meegegeven lijst.
Als er op een bepaalde plaats geen type of generische parameter nodig is, vul je $\emptyset$ in.
Verklaar je keuze voor de combinatie van (5), (6), en (7).
interfaceCharacter{}interfaceCanFightextendsCharacter{}recordWarrior()implementsCanFight{}recordKnight()implementsCanFight{}recordWizard()implementsCharacter{}interfaceAction<___/* 1 */___>{voidexecute(___/* 2 */____character);}classFightActionimplementsAction<___/* 3 */_____>{@Overridepublicvoidexecute(___/* 4 */______character){System.out.println(character+" fights!");}}classGameEngine{public<___/* 5 */______>voiddoAction(List<___/* 6 */____>characters,Action<___/* 7 */____>action){for(varcharacter:characters){action.execute(character);}}}publicstaticvoidmain(String[]args){varengine=newGameEngine();Action<CanFight>fight=newFightAction();List<Warrior>warriors=List.of(newWarrior(),newWarrior());engine.doAction(warriors,fight);List<Wizard>wizards=List.of(newWizard());engine.doAction(wizards,fight);// deze regel mag NIET compileren}
Antwoord
1: C extends Character : acties kunnen enkel uitgevoerd worden door subtypes van Character
2: C: C is het type van Character dat de actie zal uitvoeren
3: CanFight: FightAction is enkel mogelijk voor characters die CanFight implementeren
4: CanFight: aangezien de generische paremeter C van superinterface Action geinitialiseerd werd met CanFight, moet hier ook CanFight gebruikt worden.
5: T extends Character: we noemen T het type van de objecten in de meegeven lijst; we hebben hier een begrenzing nodig (want we willen enkel subtypes van Character toelaten)
6: T: lijst van T’s, zoals verondersteld in 5
7: ? super T: de meegegeven actie moet een actie zijn die door alle T’s uitgevoerd kan worden (dus door T of een van de supertypes van T).
Redenering met behulp van PECS: de meegegeven actie gebruikt/consumeert het character, dus super.
Alternatieve keuze voor 5/6/7:
5: T extends Character: we noemen T het type dat bij de actie hoort; we hebben hier een begrenzing nodig (want we willen enkel subtypes van Character toelaten)
6: ? extends T: lijst van T’s of subtypes ervan.
Redenering met behulp van PECS: de lijst levert/produceert de characters, dus extends.
7: T: het type van de actie, zoals verondersteld in 5
Animal food
Dit is een uitdagende oefening, voor als je je kennis over generics echt wil testen.
Voeg generics (met grenzen/bounds) toe aan de code hieronder, zodat de code (behalve de laatste regel) compileert,
en de compiler enkel toelaat om kattenvoer te geven aan katten, en hondenvoer aan honden:
publicclassAnimalFood{staticclassAnimal{publicvoideat(Foodfood){System.out.println(this.getClass().getSimpleName()+" says 'Yummie!'");}}staticclassMammalextendsAnimal{publicvoiddrink(Milkmilk){this.eat(milk);}}staticclassCatextendsMammal{}staticclassKittenextendsCat{}staticclassDogextendsMammal{}staticclassFood{}staticclassMilkextendsFood{}staticclassMain{publicstaticvoidmain(String[]args){FoodcatFood=newFood();MilkcatMilk=newMilk();FooddogFood=newFood();MilkdogMilk=newMilk();Catcat=newCat();Dogdog=newDog();Kittenkitten=newKitten();cat.eat(catFood);// OK 👍cat.drink(catMilk);// OK 👍dog.eat(dogFood);// OK 👍dog.drink(dogMilk);// OK 👍kitten.eat(catFood);// OK 👍kitten.drink(catMilk);// OK 👍cat.eat(dogFood);// <- moet een compiler error geven! ❌kitten.eat(dogFood);// <- moet een compiler error geven! ❌kitten.drink(dogMilk);// <- moet een compiler error geven! ❌}}}
(Hint: Begin met het type Food te parametriseren met een generische parameter die het Animal-type voorstelt dat dit voedsel eet.)
Self-type
Dit is een uitdagende oefening, voor als je je kennis over generics echt wil testen.
Heb je je al eens afgevraagd hoe assertThat(obj) uit AssertJ werkt?
Afhankelijk van het type van obj dat je meegeeft, worden er andere assertions beschikbaar die door de compiler aanvaard worden:
// een List<String>List<String>someListOfStrings=List.of("hello","there","how","are","you");assertThat(someListOfStrings).isNotNull().hasSize(5).containsItem("hello");// een StringStringsomeString="hello";assertThat(someString).isNotNull().isEqualToIgnoringCase("hello");// een IntegerintsomeInteger=4;assertThat(someInteger).isNotNull().isGreaterThan(4);assertThat(someInteger).isNotNull().isEqualToIgnoringCase("hello");//<=compileertniet❌
Sommige assertions (zoals isNotNull) zijn echter generiek, en wil je slechts op 1 plaats implementeren.
Probeer zelf een assertThat-methode te schrijven die werkt zoals bovenstaande, maar waar isNotNull slechts op 1 plaats geïmplementeerd is.
De assertThat-methode moet een Assertion-object teruggeven, waarop de verschillende methodes gedefinieerd zijn afhankelijk van het type dat meegegeven wordt aan assertThat.
Hint 1: maak verschillende klassen, bijvoorbeeld ListAssertion, StringAssertion, IntegerAssertion die de type-specifieke methodes bevatten. Begin met isNotNull toe te voegen aan elk van die klassen (dus door de implementatie te kopiëren).
Hint 2: in een zogenaamde ‘fluent interface’ geeft elke operatie zoals isNotNull en hasSize het this-object op het einde terug (return this), zodat je oproepen na elkaar kan doen. Bijvoorbeeld .isNotNull().hasSize(5).
Hint 3: maak nu een abstracte klasse GenericAssertion die isNotNull bevat, en waarvan de andere assertions overerven. Verwijder de andere implementaties van isNotNull.
Hint 4: In isNotNull is geen informatie beschikbaar over het type dat gebruikt moet worden als terugkeertype van isNotNull. assertThat(someString).isNotNull() moet bijvoorbeeld opnieuw een StringAssertion teruggeven. Dat kan je oplossen met generics, en een abstracte methode die het juiste object teruggeeft.
Hint 5: Je zal een zogenaamd ‘self-type’ moeten gebruiken. Dat is een generische parameter die wijst naar de (sub)klasse zelf.
Hint 6: op deze pagina wordt uitgelegd hoe AssertJ dit doet. Probeer eerst zelf, zonder dit te lezen!
7.3 Collections
In andere programmeertalen
De concepten in andere programmeertalen die het dichtst aanleunen bij Java collections zijn
de Standard Template Library (STL) in C++
enkele ingebouwde types, alsook de collections module in Python
de collecties in System.Collections.Generic in C#
Totnogtoe hebben we enkel gewerkt met een Java array (vaste grootte), en met ArrayList (kan groter of kleiner worden).
In dit hoofdstuk kijken we in meer detail naar ArrayList, en behandelen we ook verschillende andere collectie-types in Java.
De meeste van die types vind je ook (soms onder een andere naam) terug in andere programmeertalen.
Je kan je afvragen waarom we andere collectie-types nodig hebben; uiteindelijk kan je (met genoeg werk) alles toch implementeren met een ArrayList? Dat klopt, maar de collectie-types verschillen in welke operaties snel zijn, en welke meer tijd vragen. Om dat wat preciezer te maken, kijken we eerst even naar de notie van tijdscomplexiteit.
Behalve de Java Collections API zijn er ook externe bibliotheken met collectie-implementaties die je (bijvoorbeeld via Gradle) kan gebruiken in je projecten.
De twee meest gekende voorbeelden zijn
Tijdscomplexiteit is een essentieel begrip bij de analyse van algoritmes (en bijhorende gegevensstructuren).
Met tijdscomplexiteit wordt gekwantificeerd hoe snel een algoritme zijn invoer verwerkt.
Het is dus een eigenschap van een (implementatie van een) algoritme, naast bijvoorbeeld de correctheid en leesbaarheid van dat algoritme.
In tegenstelling tot wat je misschien zou denken, wordt er bij tijdscomplexiteit niet gekeken naar de tijd (in seconden).
In plaats daarvan wordt het totale aantal operaties dat uitgevoerd wordt geteld.
Daarenboven worden niet alle operaties geteld; er wordt een keuze gemaakt voor een verzameling basisoperaties.
Het voordeel hiervan is dat, in tegenstelling tot de reële tijd, het aantal operaties niet afhankelijk is van de snelheid van de processor.
Bijvoorbeeld, bij sorteeralgoritmes wordt soms de vergelijking tussen twee elementen als (enige) basisoperatie gebruikt, of soms elke operatie die een element uit de te sorteren rij opvraagt of aanpast.
Het totale aantal uitgevoerde basisoperaties is dan (bij benadering) een maat voor de uitvoeringstijd van het algoritme, aangezien elke basisoperatie een bepaalde reële uitvoeringstijd heeft op een machine.
Voor de meeste algoritmes zal een grotere invoer leiden tot meer uit te voeren operaties (denk aan een lus die vaker herhaald wordt).
Grotere invoer leidt dus tot meer operaties, en daardoor tot een langere uitvoeringstijd.
Zo is het bijvoorbeeld niet meer dan logisch dat een hele lange lijst sorteren langer duurt dan een heel korte lijst sorteren.
De grootte van de invoer wordt met \(n\) aangeduid; de tijdscomplexiteit van het algoritme in functie van de invoergrootte met de functie \(T(n)\).
Best, worst, average case
We maken bij tijdscomplexiteit onderscheid tussen:
Best case: een invoer (van een bepaalde grootte \( n \)) die leidt tot de best mogelijke (kortste) uitvoeringstijd. Bijvoorbeeld, sommige sorteeralgoritmes doen minder werk (en zijn dus snel klaar) als de lijst al gesorteerd blijkt te zijn.
Worst case: een invoer (van een bepaalde grootte \( n \)) die leidt tot de slechtst mogelijke (langste) uitvoeringstijd. Bijvoorbeeld, sommige sorteeralgoritmes hebben erg veel last met een omgekeerd gesorteerde lijst: ze moeten dan heel veel operaties uitvoeren om de lijst gesorteerd te krijgen.
Average case: een invoer (van een bepaalde grootte \( n \)) waarover we niets weten en geen veronderstellingen kunnen of willen maken. Bijvoorbeeld, voor sorteeralgoritmes kan dit een willekeurige volgorde (permutatie) van de elementen zijn, zonder speciale structuur. De uitvoeringstijd in de average case zal ergens tussen die van de worst case en best case liggen.
Worst- en best-case zijn in veel gevallen redelijk eenvoudig te bepalen. De average case wordt vaak complexer.
Belangrijk om te onthouden is dat worst/best/average case dus niet gaan over een kleinere of grotere invoer, maar wel over hoe die invoer eruit ziet of gestructureerd is.
Bijvoorbeeld, de best case voor een sorteeralgoritme is niet een lege lijst. Hoewel er in dat geval amper operaties uitgevoerd moeten worden, zegt dat niet veel: het is immers niet meer dan logisch dat een kleinere invoer ook minder tijd vraagt.
Wat we wel willen weten is bijvoorbeeld wélke lijst (van alle mogelijke lijsten van lengte \(n=1000\), bijvoorbeeld) het langst duurt om te sorteren met dat bepaald algoritme.
Voorbeeld: selection sort
Hieronder vind je een voorbeeld-implementatie van selection sort, een eenvoudig sorteeralgoritme.
In elke iteratie van de lus wordt het reeds gesorteerde deel van de lijst (dat is het deel vóór index startUnsorted) uitgebreid met 1 element, namelijk het kleinste nog niet gesorteerde element. Dat element wordt gezocht met de hulpfunctie indexOfSmallest, dewelke de index van het kleinste element in de lijst teruggeeft vanaf een bepaalde index start.
Dat kleinste element wordt vervolgens omgewisseld met het element op plaats startUnsorted.
Dat zoeken en omwisselen blijven we doen tot de hele lijst gesorteerd is.
Bijvoorbeeld, in de vierde iteratie van de lus ziet de situatie er als volgt uit net voordat de swap-operatie uitgevoerd wordt:
block-beta
columns 1
block:before
columns 8
e0["A"] e1["B"] e2["C"] e3["H"] e4["F"] e5["G"] e6["E"] e7["D"]
idx0["0"] idx1["1"] idx2["2"] idx3["startUnsorted=3"] idx4["4"] idx5["5"] idx6["6"] idx7["indexSmallest=7"]
end
classDef ok fill:#6c6,stroke:#393,color:#fff
classDef min fill:#66c,stroke:#339,color:#fff
classDef curr stroke-width:3
classDef index fill:none,stroke:none,color:black,font-size:0.7rem
class e0,e1,e2 ok
class e7 min
class e3 curr
class idx0,idx1,idx2,idx3,idx4,idx5,idx6,idx7 index
En in de vijfde iteratie als volgt:
block-beta
columns 1
block:before
columns 8
e0["A"] e1["B"] e2["C"] e3["D"] e4["F"] e5["G"] e6["E"] e7["H"]
idx0["0"] idx1["1"] idx2["2"] idx3["3"] idx4["startUnsorted=4"] idx5["5"] idx6["indexSmallest=6"] idx7["7"]
end
classDef ok fill:#6c6,stroke:#393,color:#fff
classDef min fill:#66c,stroke:#339,color:#fff
classDef curr stroke-width:3
classDef index fill:none,stroke:none,color:black,font-size:0.7rem
class e0,e1,e2,e3 ok
class e6 min
class e4 curr
class idx0,idx1,idx2,idx3,idx4,idx5,idx6,idx7 index
Merk op hoe de functie het generische type <E extends Comparable<E>> gebruikt.
Dat legt op dat de elementen (aangeduid met generische type E) bovendien de interface Comparable<E> moeten implementeren, waardoor ze met elkaar vergeleken kunnen worden via de compareTo-methode (gebruikt in indexOfSmallest).
Ingebouwde Java-klassen zoals String, Integer, etc. implementeren Comparable, waardoor deze methode gebruikt kan worden om lijsten van Strings te sorteren.
Als je deze methode wil gebruiken om objecten van je eigen klasse (bijvoorbeeld Person) te sorteren, zal je Person-klasse ook de interface Comparable moeten implementeren om aan te geven hoe personen met elkaar vergeleken moeten worden.
Basisoperaties tellen
Afhankelijk van het type algoritme kunnen we verschillende operaties als basisoperatie kiezen.
Als we voor selection sort als enige basisoperatie de methode compareTo nemen, dan zal voor een lijst van lengte \(n\):
tijdens de eerste iteratie het element op index 0 vergeleken worden met alle volgende \(n-1\) elementen;
tijdens de tweede iteratie het element op index 1 vergeleken worden met alle volgende \(n-2\) elementen;
etc.
tijdens iteratie \(n-1\) het element op index \(n-2\) vergeleken worden met het laatste element;
tijdens iteratie \(n\) het element op index \(n-1\) met geen enkel element vergeleken worden.
Er gebeuren in totaal dus
\( T(n) = (n-1) + (n-2) + \ldots + 1 + 0 = \frac{n(n-1)}{2} = \frac{n^2}{2} - \frac{n}{2} \) vergelijkingen van elementen via compareTo.
Daaruit kunnen we ook afleiden dat, als we de invoer 10 keer groter maken, we
\( \frac{T(10n)}{T(n)} = \frac{(10n)^2/2-10n/2}{n^2/2-n/2} = 100 + 90/(n-1) \)
keer zoveel operaties uitvoeren. Als \( n \) voldoende groot is, zal de lijst 10 keer langer maken dus neerkomen op ongeveer 100 keer zoveel werk, en zal selection sort dus 100 keer trager zijn.
In de rest van dit hoofdstuk over datastructuren gaan we verder voornamelijk werken met één specifieke basisoperatie, namelijk het aantal keer dat een element uit het geheugen (of uit een array) bezocht (uitgelezen) worden.
Grote O-notatie
We kunnen het aantal basisoperaties exact berekenen, zoals hierboven voor selection sort.
Dat is echter veel werk, en vaak hebben we dergelijke precisie niet nodig.
Voor tijdscomplexiteit wordt er daarom veelal gebruik gemaakt van grote O-notatie.
Dat is, informeel gezegd, de snelst stijgende term, zonder constanten.
In het voorbeeld van selection sort hierboven is de snelst stijgende term van \( T(n) \) de kwadratische term. We noteren dat als
\( T(n) \in \mathcal{O}(n^2) \)
of soms gewoon
\( T(n) = \mathcal{O}(n^2) \)
Formeel betekent \( T(n) \in \mathcal{O}(f(n)) \) dat \( \exists c, n_0.\ \forall n > n_0.\ T(n)/f(n) \leq c \). Met andere woorden, zodra \( n \) groot genoeg is (groter dan een bepaalde \(n_0\)), is de verhouding \(T(n)/f(n)\) begrensd door een constante, wat uitdrukt dat \(T(n)\) niet sneller stijgt dan \(f(n)\).
\( \mathcal{O}(n^2) \) is de complexiteitsklasse (de verzameling van alle functies die niet sneller dan kwadratisch stijgen), en we zeggen dat selection sort een kwadratische tijdscomplexiteit heeft.
Note
Er zijn nog andere notaties die vaak gebruikt worden in de context van tijdscomplexiteit, waaronder grote theta (\( \Theta \)), grote omega (\( \Omega \)), en tilde (~). Deze hebben allen een andere formele definitie.
In de dagelijkse (informele) praktijk wordt vaak grote O gebruikt, zelfs als eigenlijk een van deze andere bedoeld wordt.
Vaak voorkomende complexiteitsklassen
Hieronder vind je een tabel met vaak voorkomende complexiteitsklassen.
In de laatste kolom vind je de tijd die zo’n algoritme doet over een invoer van grootte \(n=1000\), in de veronderstelling dat het een probleem van grootte \(n=100\) kan oplossen in 1 milliseconde.
Je ziet dat de constante tot en met linearitmische complexiteitsklassen zeer gunstig zijn.
Kwadratisch en kubisch zijn te vermijden, omdat de tijd veel sneller toeneemt dan de grootte van de invoer.
Exponentiële en factoriële algoritmes tenslotte zijn dramatisch en schalen bijzonder slecht naar grotere problemen.
Backtracking-algoritmes (zie later) vallen vaak in deze laatste klassen, en kunnen dus zeer inefficiënt zijn voor grotere problemen.
We zullen deze tijdscomplexiteitsklassen gebruiken om (operaties op) datastructuren te vergelijken.
Onthoud
Tijdscomplexiteit met grote O geeft bij benadering aan hoe het aantal operaties dat een algoritme moet uitvoeren stijgt wanneer de grootte van de invoer stijgt.
7.3.2 Java Collections API
We kunnen nu de Java Collections-API verkennen.
Deze API bestaat uit
implementaties van die interface (bv. ArrayList, LinkedList, Vector, Stack, ArrayDeque, PriorityQueue, HashSet, LinkedHashSet, TreeSet, en TreeMap)
algoritmes voor veel voorkomende operaties (bv. shuffle, sort, swap, reverse, …)
Je vindt een overzicht van de hele API op deze pagina.
We beginnen bij de basisinterface: Iterable.
Iterable en Iterator
Iterable maakt eigenlijk geen deel uit van de Java Collections API, maar is er wel sterk aan verwant.
Een Iterable is namelijk een object dat meerdere objecten van hetzelfde type één voor één kan teruggeven.
Er moet slechts 1 methode geïmplementeerd worden, namelijk iterator(), die een Iterator-object teruggeeft.
hasNext(), wat aangeeft of er nog objecten zijn om terug te geven, en
next(), wat (als er nog objecten zijn) het volgende object teruggeeft.
Elke keer je next() oproept krijg je dus een ander object, tot hasNext() false teruggeeft. Vanaf dan krijg je een exception (NoSuchElementException).
Een Iterator moet dus een toestand bijhouden, om te bepalen welke objecten al teruggegeven zijn en welke nog niet.
Eens alle elementen teruggegeven zijn, en hasNext dus false teruggeeft, is de iterator ‘opgebruikt’.
Als je daarna nog eens over de elementen wil itereren, moet je een nieuwe iterator aanmaken.
Elke klasse die Iterable implementeert, kan gebruikt worden in een ’enhanced for-statement’:
Iterable<E>iterable=...for(varelement:iterable){...// code die element gebruikt}
Achter de schermen wordt hierbij de iterator gebruikt. Het enhanced for-statement van hierboven is equivalent aan:
Iterable<E>iterable=...Iterator<E>iterator=iterable.iterator();while(iterator.hasNext()){Eelement=iterator.next();...// code die element gebruikt}
Alle collectie-types die een verzameling elementen voorstellen (dus alles behalve Map), implementeren deze interface.
Dat betekent dus dat je elk van die collecties in een enhanced for-lus kan gebruiken.
Je kan daarenboven ook zelf een nieuwe klasse maken die deze interface implementeert, en die vervolgens gebruikt kan worden in een enhanced for-loop.
Dat doen we in deze oefening.
Zoals je hierboven zag, kan een Iterable dus enkel elementen opsommen.
De basisinterface Collection erft hiervan over maar is uitgebreider: het stelt een groep objecten voor.
Er zit nog steeds bitter weinig structuur in een Collection:
de volgorde van de elementen in een Collection ligt niet vast
er kunnen wel of geen dubbels in een Collection zitten
De belangrijkste operaties die je op een Collection-object kan uitvoeren zijn
iterator(), geërfd van Iterable
size(): de grootte opvragen
isEmpty(): nagaan of de collectie leeg is
contains en containsAll: nakijken of een of meerdere elementen in de collectie zitten
add en addAll: een of meerdere elementen toevoegen
remove en removeAll: een of meerdere elementen verwijderen
clear: de collectie volledig leegmaken
toArray: alle elementen uit de collectie in een array plaatsen
Alle operaties die een collectie aanpassen (bv. add, addAll, remove, clear, …) zijn optioneel.
Dat betekent dat sommige implementaties een UnsupportedOperationException kunnen gooien als je die methode oproept.
Niet elke collectie hoeft dus alle operaties te ondersteunen.
7.3.3 Lijsten
classDiagram
Iterable <|-- Collection
Collection <|-- List
class Iterable["Iterable#lt;E>"] { <<interface>> }
class Collection["Collection#lt;E>"] { <<interface>> }
class List["List#lt;E>"] {
<<interface>>
get(int index)
add(int index, E element)
set(int index, E element)
remove(int index)
indexOf(E element)
lastIndexOf(E element)
reversed()
subList(int from, int to)
}
style List fill:#cdf,stroke:#99f
List
Een lijst is een collectie waar alle elementen een vaste plaats hebben.
De elementen in een lijst zijn dus geordend (maar niet noodzakelijk gesorteerd).
Een lijst wordt voorgesteld door de List interface, die Collection uitbreidt met operaties die kunnen werken met de plaats (index) van een object.
Bijvoorbeeld:
get(int index): het element op een specifieke plaats opvragen
add(int index, E element): een element invoegen op een specifieke plaats (en de latere elementen opschuiven)
set(int index, E element): het element op een specifieke plaats wijzigen
remove(int index): het element op de gegeven index verwijderen (en de latere elementen opschuiven)
indexOf(E element) en lastIndexOf(E): de eerste en laatste index zoeken waarop het gegeven element voorkomt
reversed(): geeft een lijst terug in de omgekeerde volgorde
subList(int from, int to): geeft een lijst terug die een deel (slice) van de oorspronkelijke lijst voorstelt
Merk op dat de laatste twee methodes (reversed en subList) een zogenaamde view teruggeven op de oorspronkelijke lijst.
Het is dus geen nieuwe lijst, maar gewoon een andere manier om naar de oorspronkelijke lijst te kijken.
Bijvoorbeeld, in onderstaande code:
de lijst rev de aanpassing (het laatste element veranderen in X) in de oorspronkelijke lijst weerspiegelt
de sublist cde leegmaken deze elementen verwijdert uit de oorspronkelijke lijst, en ook uit de omgekeerde view op de lijst (rev)
De reden is dat zowel rev als cde enkel verwijzen naar de onderliggende lijst alphabet, en zelf geen elementen bevatten:
block-beta
block:brev
space:1
rev
end
space
block:alphabet
columns 6
A B C D E F
end
space
block:bcde
cde
space:1
end
rev --> alphabet
cde --> alphabet
classDef node fill:#faa,stroke:#f00
classDef ptr fill:#ddd,stroke:black
classDef empty fill:none,stroke:none
classDef val fill:#ffc,stroke:#f90
class brev,bcde empty
class alphabet node
class rev,cde ptr
class A,B,C,D,E,F val
Indien je wat Python kent: subList is dus een manier om functionaliteit gelijkaardig aan slices te verkrijgen in Java. Maar, in tegenstelling tot slices in Python, maakt subList geen kopie!
ArrayList
ArrayList is de eerste concrete implementatie van de List-interface die we bekijken.
In een ArrayList wordt een array gebruikt om de elementen bij te houden.
Aangezien arrays in Java een vaste grootte hebben, kan je niet zomaar elementen toevoegen als de lijst vol is.
Daarom wordt er een onderscheid gemaakt tussen de de grootte van de lijst (het aantal elementen dat er effectief inzit), en de capaciteit van de lijst (de lengte van de onderliggende array).
Zolang de grootte kleiner is dan de capaciteit, gebeurt er niets speciaals. Op het moment dat de volledige capaciteit benut is, en er nog een element toegevoegd wordt, wordt een nieuwe (grotere) array gemaakt en worden alle huidige elementen daarin gekopieerd.
Het kopiëren van een lijst is een \( \mathcal{O}(n) \) operatie, met \( n\) het huidige aantal elementen in de lijst.
Stel dat we kiezen om, elke keer wanneer we een element toevoegen, de array één extra plaats te geven.
We moeten dan telkens alle vorige elementen kopiëren, en dat wordt al snel erg inefficiënt.
Bijvoorbeeld, stel dat we met een lege array beginnen:
om het eerste element toe te voegen, moeten we niets kopiëren
om het tweede element toe te voegen, moeten we één element kopiëren (het eerste element uit de vorige array van lengte 1)
om het derde element toe te voegen, moeten we twee elementen kopieëren (het eerste en tweede element uit de vorige array van lengte 2)
om het vierde element toe te voegen 3 kopieën, enzovoort.
Eén voor één \(n\) elementen toevoegen aan een initieel lege lijst zou dus neerkomen op \(0+1+…+(n-1) = n(n-1)/2 = \mathcal{O}(n^2)\) kopieën (operaties).
Dat is erg veel werk als \(n\) groot wordt.
Om die reden wordt de lengte van de array niet telkens met 1 verhoogd, maar meteen vermenigvuldigd met een constante (meestal 2, zodat de lengte van de array verdubbelt).
Bijvoorbeeld, voor een lijst met capaciteit 3 en twee elementen:
block-beta
columns 1
block:before
columns 12
e0["A"] e1["B"] e2[" "] space:9
end
space
block:after1
columns 12
ee0["A"] ee1["B"] ee2["C"] space:9
end
space
block:after2
columns 12
eee0["A"] eee1["B"] eee2["C"] eee3["D"] eee4[" "] eee5[" "] space:6
end
space
block:after3
columns 12
eeee0["A"] eeee1["B"] eeee2["C"] eeee3["D"] eeee4["E"] eeee5["F"] eeee6["G"] eeee7[" "] eeee8[" "] eeee9[" "] eeee10[" "] eeee11[" "]
end
before --"C toevoegen (behoud capaciteit)"--> after1
after1 --"D toevoegen (verdubbel capaciteit)"--> after2
after2 --"E, F, G toevoegen (verdubbel capaciteit)"--> after3
classDef ok fill:#6c6,stroke:#393,color:#fff
class e0,e1 ok
class ee0,ee1,ee2 ok
class eee0,eee1,eee2,eee3 ok
class eeee0,eeee1,eeee2,eeee3,eeee4,eeee5,eeee6 ok
De meeste toevoegingen gebeuren dus in \(\mathcal{O}(1)\), maar af en toe kost een toevoeging \(\mathcal{O}(n)\) omdat alle elementen gekopieerd moeten worden naar de grotere array.
Als de lijst op een gegeven moment \(n\) elementen bevat, en de capaciteit telkens verdubbeld werd wanneer de vorige capaciteit bereikt werd, zijn er dus in totaal \(n/2 + n/4 + … \leq n = \mathcal{O}(n)\) elementen gekopieerd. Gemiddeld werd elk van de \(n\) elementen dus met tijdscomplexiteit \(\mathcal{O}(n)/n = \mathcal{O}(1)\) toegevoegd1.
Operatie
Complexiteit (best case)
Complexiteit (worst case)
Opvragen
\(\mathcal{O}(1)\)
\(\mathcal{O}(1)\)
Invoegen
\(\mathcal{O}(1)\) (einde van lijst), of \(\mathcal{O}(n)\) bij uitbreiden capaciteit
\(\mathcal{O}(n)\) (begin van lijst)
Verwijderen
\(\mathcal{O}(1)\) (laatste element)
\(\mathcal{O}(n)\) (eerste element)
Zoeken
\(\mathcal{O}(1)\) (gezochte element is eerste element)
\(\mathcal{O}(n)\) (gezochte element is laatste)
LinkedList
Een gelinkte lijst (LinkedList) is een andere implementatie van de List interface.
Hier wordt geen array gebruikt, maar wordt de lijst opgebouwd uit knopen (nodes).
Elke knoop bevat
een waarde (value)
een verwijzing (next) naar de volgende knoop
(in een dubbel gelinkte lijst) een verwijzing (prev) naar de vorige knoop.
De LinkedList zelf bevat enkel een verwijzing naar de eerste knoop (first), en voor een dubbel gelinkte lijst ook nog een verwijzing naar de laatste knoop van de lijst (last).
Vaak wordt ook het aantal elementen (size) bijgehouden.
Hieronder zie je een grafische voorstelling van een dubbel gelinkte lijst met 3 knopen:
block-beta
block:bf
columns 1
space
first
space
end
space
block:n0
columns 1
e0["A"]
p0["next"]
pp0["prev"]
end
space
block:n1
columns 1
e1["B"]
p1["next"]
pp1["prev"]
end
space
block:n2
columns 1
e2["C"]
p2["next"]
pp2["prev"]
end
space
block:bl
columns 1
space
last
space
end
first --> n0
last --> n2
p0 --> n1
p1 --> n2
pp1 --> n0
pp2 --> n1
classDef node fill:#faa,stroke:#f00
classDef ptr fill:#ddd,stroke:black
classDef empty fill:none,stroke:none
classDef val fill:#ffc,stroke:#f90
class bf,bl empty
class n0,n1,n2 node
class pp0,p0,pp1,p1,pp2,p2,first,last ptr
class e0,e1,e2 val
De tijdscomplexiteit van een (dubbel) gelinkte lijst is anders dan die van een ArrayList:
Operatie
Complexiteit (best case)
Complexiteit (worst case)
Opvragen
\(\mathcal{O}(1)\) (begin of einde van de lijst)
\(\mathcal{O}(n)\) (midden van de lijst)
Invoegen
\(\mathcal{O}(1)\) (begin of einde van de lijst)
\(\mathcal{O}(n)\) (midden van de lijst)
Verwijderen
\(\mathcal{O}(1)\) (begin of einde van de lijst)
\(\mathcal{O}(n)\) (midden van de lijst)
Zoeken
\(\mathcal{O}(1)\) (begin of einde van de lijst)
\(\mathcal{O}(n)\) (midden van de lijst)
Merk op dat we nooit elementen moeten kopiëren of verplaatsen in een gelinkte lijst, enkel referenties aanpassen.
Het aanpassen van een referentie is steeds \(\mathcal{O}(1)\).
Maar: we moeten eerst op de juiste plaats (knoop) geraken in de lijst, en dat kost mogelijk \(\mathcal{O}(n)\) werk: in een dubbel gelinkte lijst moeten we tot \(n/2\) referenties volgen (beginnend bij first of last).
Vandaar de \(\mathcal{O}(n)\) in de laatste kolom in de tabel hierboven.
Een gelinkte lijst is dus de juiste keuze wanneer je verwacht dat je veel aanpassingen aan je lijst zal doen, en die aanpassingen vooral voor- of achteraan zullen plaatsvinden.
Lijsten aanmaken
Je kan natuurlijk steeds een lijst aanmaken door een nieuwe, lege lijst te maken en daaraan je elementen toe te voegen:
Hierbij moet je wel opletten dat de lijst die je zo maakt immutable is. Je kan aan de lijst die je zo gemaakt hebt dus later geen wijzigingen meer aanbrengen via add, remove, etc.:
De List-interface zelf bevat al enkele nuttige operaties op lijsten.
In de Collections-klasse (niet hetzelfde als de Collection-interface!) vind je nog een heleboel extra operaties die je kan uitvoeren op lijsten (of soms op collecties), bijvoorbeeld:
disjoint om na te gaan of twee collecties geen overlappende elementen hebben
sort om een lijst te sorteren
shuffle om een lijst willekeurig te permuteren
swap om twee elementen van plaats te verwisselen
frequency om te tellen hoe vaak een element voorkomt in een lijst
min en max om het grootste element in een collectie te zoeken
indexOfSubList om te zoeken of en waar een lijst voorkomt in een langere lijst
nCopies om een lijst te maken die bestaat uit een aantal keer hetzelfde element
fill om alle elementen in een lijst te vervangen door eenzelfde element
rotate om de elementen in een lijst cyclisch te roteren
Unmodifiable list
Soms wil je een gewone (wijzigbare) lijst teruggeven maar er zeker van zijn dat de lijst niet aangepast kan worden.
Bijvoorbeeld, als je een lijst teruggeeft als resultaat van een methode:
We willen niet dat een gebruiker van de klasse die lijst zomaar kan aanpassen — dat moet via de borrow-methode gaan.
We kunnen natuurlijk een nieuwe lijst teruggeven met een kopie van de elementen:
Er wordt dan geen nieuwe lijst gemaakt, maar wel een ‘view’ op de originele lijst (net zoals we eerder gezien hebben bij reversed en subList).
Het verschil is dat deze view nu geen wijzigingen toelaat; alle operaties die de lijst wijzigen, gooien een UnsupportedOperationException.
Dit heet geamortiseerde (amortized) tijdscomplexiteit. ↩︎
7.3.4 Wachtrijen
classDiagram
Iterable <|-- Collection
Collection <|-- Queue
Queue <|-- Deque
Deque <|-- LinkedList
Queue <|-- PriorityQueue
class Iterable["Iterable#lt;E>"] { <<interface>> }
class Collection["Collection#lt;E>"] { <<interface>> }
class Queue["Queue#lt;E>"] {
<<interface>>
add(E e)
E element()
offer(E e)
E peek()
E poll()
E remove()
}
class Deque["Deque#lt;E>"] {
<<interface>>
addFirst(E e)
addLast(E e)
offerFirst(E e)
offerLast(E e)
E removeFirst()
E removeLast()
E pollFirst()
E pollLast()
E getFirst()
E getLast()
E peekFirst()
E peekLast()
}
style Queue fill:#cdf,stroke:#99f
style Deque fill:#cdf,stroke:#99f
Queue, Deque
Een Queue is een collectie die lijkt op een lijst.
Het voornaamste verschil met een lijst is dat een queue specifiek dient om elk toegevoegd elementen er later terug uit te halen, en dat een queue niet toelaat om te werken met de positie (index) van een element.
Het volgende element dat teruggegeven wordt uit de queue wordt het hoofd (head) van de queue genoemd, de andere elementen de staart (tail).
Er bestaan operaties om het element aan het hoofd van de queue terug te geven en meteen te verwijderen (poll), alsook een andere operatie om het hoofd op te vragen zonder het meteen te verwijderen (peek). Als er geen elementen in de queue zitten, geven beiden null terug.
Sommige subtypes van Queue kunnen begrensd zijn qua capaciteit. Je kan dan geen elementen toevoegen als de queue vol is.
Elementen toevoegen aan een queue kan met add of offer. De add-methode gooit een exception als de queue vol is; offer geeft gewoon false terug.
Ook voor poll en peek bestaan varianten (namelijk remove en element) die een exception gooien in plaats van null terug te geven als de queue leeg is.
De volgorde waarin elementen teruggegeven worden uit de queue bepaalt het soort queue:
een FIFO (first-in-first-out) queue geeft de elementen terug in de volgorde dat ze toegevoegd zijn: het eerst toegevoegde element wordt eerst teruggegeven
een LIFO (last-in-first-out) queue geeft steeds het laatst toegevoegde element als eerste terug. Dit wordt ook een stack genoemd.
een priority queue laat toe om aan elk toegevoegd element een prioriteit toe te kennen, en geeft de elementen terug volgens die prioriteit
Java voorziet ook een Deque interface.
Dit staat voor double-ended queue en laat toe om elementen vooraan en achteraan toe te voegen aan en te verwijderen uit de deque.
Deze interface erft over van Queue.
Bovenop de methodes uit Queue worden methodes toegevoegd met suffix -First en -Last, bijvoorbeeld pollFirst() en offerLast().
De methodes uit Queue om elementen toe te voegen (offer(), add()) komen overeen met offerLast() en addLast().
De methodes uit Queue om elementen op te vragen (peek(), poll(), etc.) komen overeen met peekFirst(), pollFirst(), etc.
De basisimplementatie in Java, zowel voor de Queue als Deque interface, is de klasse LinkedList (een dubbel gelinkte lijst).
In een (dubbel) gelinkte lijst is het immers heel eenvoudig en efficiënt (\(\mathcal{O}(1)\)) om een element vooraan of achteraan toe te voegen en te verwijderen.
Afhankelijk van welke methodes je gebruikt, gebruik je een LinkedList dus als FIFO of LIFO queue.
FIFO queue
In een FIFO queue worden de elementen teruggegeven in de volgorde dat ze toegevoegd zijn:
block-beta
columns 1
block:before0
AA["A"] BB["B"] space:1
end
space
block:before
A B C
end
space
block:after
BBB["B"] CCC["C"] space:1
end
before0 --> before
before --> after
Note
Welke operaties gebruik je om een Deque (of LinkedList) als FIFO queue te gebruiken?
LIFO queue (stack)
In een LIFO queue, vaak ook stack genoemd, worden het laatst toegevoegde element eerst teruggegeven:
block-beta
columns 1
block:before0
AA["A"] BB["B"] space:1
end
space
block:before
A B C
end
space
block:after
AAA["A"] BBB["B"] space:1
end
before0 --> before
before --> after
Note
Welke operaties gebruik je om een Deque (of LinkedList) als LIFO/stack te gebruiken?
PriorityQueue
De PriorityQueue klasse implementeert een priority queue.
De prioriteit van elementen wordt bepaald door:
ofwel de natuurlijke ordening indien de elementen de Comparable-interface implementeren;
ofwel door een Comparator-object dat meegegeven wordt bij het aanmaken van de priority queue.
Deze implementatie is zeer efficiënt voor de vaak voorkomende operaties. De worst-case tijdscomplexiteit om
het hoofd van de queue op te vragen is \(\mathcal{O}(1)\)
het hoofd van de queue te verwijderen uit de queue is \(\mathcal{O}(\log n)\)
een element aan de queue toe te voegen is \(\mathcal{O}(\log n)\)
Nagaan of de queue een element bevat, alsook een willekeurig element verwijderen, is \(\mathcal{O}(n)\) — maar beiden zijn geen typisch gebruik van een queue.
7.3.5 Verzamelingen
classDiagram
Iterable <|-- Collection
Collection <|-- List
Collection <|-- Queue
Collection <|-- Set
class Iterable["Iterable#lt;E>"] { <<interface>> }
class Collection["Collection#lt;E>"] { <<interface>> }
class List["List#lt;E>"] { <<interface>> }
class Queue["Queue#lt;E>"] { <<interface>> }
class Set["Set#lt;E>"] {
<<interface>>
}
style Set fill:#cdf,stroke:#99f
Set
Alle collecties die we totnogtoe gezien hebben, kunnen dubbels bevatten.
Bij een Set is dat niet het geval. Het is een abstractie voor een wiskundige verzameling: elk element komt hoogstens één keer voor.
In een wiskundige verzameling is ook de volgorde van de elementen niet van belang.
De Set interface legt geen volgorde van elementen vast, maar er bestaan sub-interfaces van Set (bijvoorbeeld SequencedSet en SortedSet) die wél toelaten dat de elementen een bepaalde volgorde hebben.
De Set interface voegt in feite geen nieuwe operaties toe aan de Collection-interface. Je kan elementen toevoegen, verwijderen, en nagaan of een element in de verzameling zit.
Het is leerrijk om even stil te staan bij hoe een set efficiënt geïmplementeerd kan worden.
Immers, verzekeren dat er geen duplicaten inzitten vereist dat we gaan zoeken tussen de huidige elementen, en dat wordt al gauw een \(\mathcal{O}(n)\) operatie.
We bekijken twee implementaties van hoe dat efficiënter kan: HashSet en TreeSet.
HashSet
Een HashSet kan gebruikt worden om willekeurige objecten in een set bij te houden.
De objecten worden bijgehouden in een hashtable (in essentie een gewone array).
Om te voorkomen dat we een reeds bestaand element een tweede keer toevoegen, moeten we echter snel kunnen nagaan of het toe te voegen element al in de set voorkomt.
De hele hashtable overlopen kost teveel tijd (\(\mathcal{O}(n)\) met \(n\) het aantal objecten in de set), dus dat moeten we verbeteren.
Een HashSet kan nagaan of een element bestaat, alsook een element toevoegen en verwijderen, in \(\mathcal{O}(1)\).
De sleutel om dat te doen is de hashCode() methode die ieder object in Java heeft.
Die methode moet, voor elk object, een hashCode (een int) teruggeven, zodanig dat als twee objecten gelijk zijn volgens hun equals-methode, ook hun hashcodes gelijk zijn.
Gewoonlijk zal je, als je equals zelf implementeert, ook hashCode moeten implementeren en omgekeerd.
De hashCode moet niet uniek zijn: meerdere objecten mogen dezelfde hashCode hebben, ook al zijn ze niet gelijk (al kan dat tot een tragere werking van een HashSet leiden; zie verder). Hoe uniformer de hashCode verdeeld is over alle objecten, hoe beter.
Note
Java records voorzien standaard een zinvolle equals- en hashCode-methode die afhangt van de attributen van het record.
Deze hoef je dus normaliter niet zelf te voorzien.
De hashCode wordt gebruikt om een index te bepalen in de onderliggende hashtable (array).
De plaats in die hashtable is een bucket.
Het element wordt opgeslagen in de bucket op die index.
Als we later willen nagaan of een element al voorkomt in de hashtable, berekenen we opnieuw de index aan de hand van de hashCode en kijken we of het element zich effectief in de overeenkomstige bucket bevindt.
Idealiter geeft elk object dus een unieke hashCode, en zorgen die voor perfecte spreiding van alle objecten in de hashtable.
Er zijn echter twee problemen in de praktijk:
twee verschillende objecten kunnen dezelfde hashCode hebben. Dat is een collision. Hiermee moeten we kunnen omgaan.
als er teveel elementen toegevoegd worden, moet de onderliggende hashtable dynamisch kunnen uitbreiden. Dat maakt dat elementen plots op een andere plaats (index) terecht kunnen komen als voorheen. Uitbreiden vraagt vaak rehashing, oftwel het opnieuw berekenen van de index (nu in een grotere hashtable) aan de hand van de hashcodes. De load factor van de hash table geeft aan hoe vol de hashtable mag zijn voor ze uitgebreid wordt. Bijvoorbeeld, een load factor van 0.75 betekent dat het aantal elementen in de hashtable tot 75% van het aantal buckets mag gaan.
Beide problemen zijn al goed onderzocht in de computerwetenschappen.
We overlopen twee technieken voor het eerste probleem (collisions): chaining en probing.
Chaining
Bij chaining houden we in de hashtable niet rechtstreeks de elementen bij, maar wijzen we in elke bucket naar een afzonderlijke gelinkte lijst.
Elke keer wanneer we een element toevoegen, voegen we een knoop toe aan de gelinkte lijst in de bucket.
Wanneer we een element opvragen, doorlopen we de gelinkte lijst om na te gaan of het element daarin voorkomt.
Als er veel collisions zijn, verliezen we zo natuurlijk het performantie-voordeel van de hashtable.
Inderdaad, in extremis hebben alle objecten dezelfde hashcode, en bevat de hashtable slechts één gelinkte lijst met daarin alle elementen.
Een goede hashfunctie, die elementen goed verspreidt over de verschillende buckets, is dus essentieel voor de performantie.
block-beta
columns 5
block:tab
columns 1
i0["0"]
i1["1"]
i2["2"]
end
space
block:lls:3
columns 1
block:ll0
columns 3
block:ll00
columns 2
ll00v["obj1"]
ll00p[" "]
end
space
block:ll01
columns 2
ll01v["obj2"]
ll01p[" "]
end
end
space
block:ll2
columns 3
block:ll20
columns 2
ll20v["obj3"]
ll20p[" "]
end
space:2
end
end
i0 --> ll00
ll00p --> ll01
i2 --> ll20
classDef none fill:none,stroke:none
class lls none
Probing (open addressing)
Een andere techniek om om te gaan met collisions is probing.
Als we een element willen toevoegen op een index, en er al een (ander) element op die index staat, berekenen we (volgens een deterministische formule) een volgende index, en proberen we daar opnieuw.
Dat doen we tot we een lege plaats tegenkomen, waar we het element kunnen bijhouden.
Die volgende index kan bijvoorbeeld (heel eenvoudig) index+1 zijn, maar we kunnen ook complexere formules bedenken waarmee we naar een heel andere plaats in de lijst springen.
Bij het opzoeken volgen we hetzelfde stramien: blijven zoeken tot we het element terugvinden, of een lege plaats tegenkomen.
Een element verwijderen wordt nu wel wat complexer: we moeten ervoor zorgen dat we geen lege plaats veroorzaken in de sequentie van indices.
SortedSet en TreeSet
Naast Set bestaat ook de interface SortedSet.
In tegenstelling tot een Set, kan een SortedSet geen willekeurige objecten bevatten.
De objecten moeten een volgorde hebben (hetzij door Comparable te implementeren, hetzij door een Comparator-object mee te geven).
De elementen worden steeds in gesorteerde volgorde opgeslagen en teruggegeven.
De TreeSet klasse is een implementatie van SortedSet die gebruik maakt van een gebalanceerde boomstructuur (een red-black tree — de werking daarvan is hier niet van belang).
Alle basisoperaties (add, remove, contains) hebben worst-case tijdscomplexiteit \(\mathcal{O}(\log n)\); invoegen en verwijderen zijn best-case \(\mathcal{O}(1)\).
De oorsprong van het logaritme wordt duidelijk aan de hand van deze oefening.
7.3.6 Maps
Map (Dictionary)
De collecties hierboven stellen allemaal een groep elementen voor, en erven over van de Collection-interface.
Een Map is iets anders.
Hier worden sleutels bijgehouden, en bij elke sleutel hoort een waarde (een object).
Denk aan een telefoonboek, waar bij elke naam (de sleutel) een telefoonnummer (de waarde) hoort, of een woordenboek waar bij elk woord (de sleutel) een definitie hoort (de waarde).
Een andere naam voor een map is dan ook een dictionary.
Sleutels mogen slechts één keer voorkomen; eenzelfde waarde mag wel onder meerdere sleutels opgeslagen worden.
De interface Map<K, V> heeft twee generische parameters: een (K) voor het type van de sleutels, een een (V) voor het type van de waarden.
Elementen toevoegen aan een Map<K, V> gaat via de put(K key, V value)-methode.
De waarde opvragen kan via de methode V get(K key).
Verder zijn er methodes om na te gaan of een map een bepaalde sleutel of waarde bevat.
Een Map is vaak geoptimaliseerd voor deze operaties; deze hebben vaak tijdscomplexiteit \(\mathcal{O}(1)\) (hashtable-gebaseerde implementaties) of \(\mathcal{O}(\log n)\) (boom-gebaseerde implementaties).
Er zijn verder ook drie manieren om een Map<K, V> als Collection te beschouwen:
de keySet: de verzameling sleutels (een Set<K>)
de values: de collectie waarden (een Collection<V>, want dubbels zijn mogelijk)
de entrySet: een verzameling (Set<Entry<K, V>>) van sleutel-waarde paren (de entries).
Belangrijk om te onthouden is dat een Map geoptimaliseerd is om waardes op te vragen aan de hand van hun sleutels.
HashMap
Net zoals bij Set kunnen we de Map-interface implementeren met een hashtable.
Dat gebeurt in de HashMap klasse.
Entries in een hashmap worden in een niet-gespecifieerde volgorde bijgehouden.
De werking van een hashmap is zeer gelijkaardig aan wat we besproken hebben bij HashSet hierboven.
Meer zelfs, de implementatie van HashSet in Java maakt gebruik van een HashMap.
Het belangrijkste verschil met de HashSet is dat we in een HashMap, naast de waarde, ook de sleutel moeten bewaren.
SortedMap en TreeMap
Een SortedMap is een map waarbij de sleutels (dus niet de waarden) gesorteerd worden bijgehouden (zoals bij een SortedSet).
De TreeMap klasse implementeert een SortedMap aan de hand van een boomstructuur.
Oefeningen
De Collections API
IntRange
Schrijf eerst een klasse IntRangeIterator die Iterator<Integer> implementeert, en alle getallen teruggeeft tussen twee grensgetallen lowest en highest (beiden inclusief) die je meegeeft aan de constructor. Je houdt hiervoor enkel de onder- en bovengrens bij, alsook het volgende terug te geven getal.
Schrijf nu ook een record IntRange die Iterable<Integer> implementeert, en die een IntRangeIterator-object aanmaakt en teruggeeft.
Je moet deze klasse nu als volgt kunnen gebruiken:
Java laat niet toe om primitieve types als generische parameters te gebruiken.
Voor elk primitief type bestaat er een wrapper-klasse, bijvoorbeeld Integer voor int.
Daarom gebruiken we hierboven bijvoorbeeld Iterator<Integer> in plaats van Iterator<int>.
Achter de schermen worden int-waarden automatisch omgezet in Integer-objecten en omgekeerd.
Dat heet auto-boxing en -unboxing.
Je kan beide types in je code grotendeels door elkaar gebruiken zonder problemen.
Lijsten
MyArrayList
Schrijf zelf een simpele klasse MyArrayList<E> die werkt zoals de ArrayList uit Java.
Voorzie in je lijst een initiële capaciteit van 4, maar zonder elementen.
Implementeer volgende operaties:
int size() die de grootte (het huidig aantal elementen in de lijst) teruggeeft
int capacity() die de huidige capaciteit (het aantal plaatsen in de array) van de lijst teruggeeft
E get(int index) om het element op positie index op te vragen (of een IndexOutOfBoundsException indien de index ongeldig is)
void add(E element) om een element achteraan toe te voegen (en de onderliggende array dubbel zo groot te maken indien nodig)
void remove(int index) om het element op plaats index te verwijderen (of een IndexOutOfBoundsException indien de index ongeldig is). De capaciteit moet niet terug dalen als er veel elementen verwijderd werden (dat gebeurt in Java ook niet).
E last() om het laatste element terug te krijgen (of een NoSuchElementException indien de lijst leeg is)
Hier vind je een test die een deel van dit gedrag controleert:
Testcode
@Testpublicvoidtest_my_arraylist(){MyArrayList<String>lst=newMyArrayList<>();// initial capacity and sizeassertThat(lst.capacity()).isEqualTo(4);assertThat(lst.size()).isEqualTo(0);// adding elementsfor(inti=0;i<4;i++){lst.add("item"+i);}assertThat(lst.size()).isEqualTo(4);assertThat(lst.capacity()).isEqualTo(4);assertThat(lst.last()).isEqualTo("item3");// adding more elementsfor(inti=4;i<10;i++){lst.add("item"+i);}assertThat(lst.size()).isEqualTo(10);assertThat(lst.capacity()).isEqualTo(16);assertThat(lst.last()).isEqualTo("item9");// remove an elementlst.remove(3);assertThat(lst.size()).isEqualTo(9);assertThat(lst.capacity()).isEqualTo(16);assertThat(lst.get(3)).isEqualTo("item4");assertThatThrownBy(()->lst.get(10)).isInstanceOf(IndexOutOfBoundsException.class);}
Linked list
Schrijf zelf een klasse MyLinkedList<E> om een dubbel gelinkte lijst voor te stellen. Voorzie volgende operaties:
int size() om het aantal elementen terug te geven
void add(E element) om het gegeven element achteraan toe te voegen
E get(int index) om het element op positie index op te vragen
void remove(int index) om het element op positie index te verwijderen
Hieronder vind je enkele tests voor je klasse. Je zal misschien merken dat je implementatie helemaal juist krijgen niet zo eenvoudig is als het op het eerste zicht lijkt, zeker bij de remove-methode.
Gebruik de visuele voorstelling van eerder, en ga na wat je moet doen om elk van de getekende knopen te verwijderen.
Implementeer zelf een klasse MyFIFO<E> die een FIFO queue voorstelt met beperkte capaciteit, en die gebruik maakt van een array om de elementen te bewaren.
De capaciteit wordt opgegeven bij het aanmaken.
Invoegen en terug verwijderen van elementen moet zeer efficiënt zijn (\(\mathcal{O}(1)\)).
Implementeer volgende operaties:
size(): het aantal elementen in je queue
E poll(): het hoofd van je FIFO queue opvragen en verwijderen; geeft null terug indien de queue leeg is
boolean add(E): een element (niet-null) toevoegen aan je queue; geeft false terug indien er niet voldoende capaciteit is
Wat zou je moeten doen om je MyFIFO-klasse dynamisch te laten groeien als er meer elementen aan toegevoegd worden?
Priority boarding
Hieronder is een record die een vliegtuigpassagier voorstelt.
recordPassenger(Stringname,intpriority){}
Maak een klasse PriorityBoarding waar je een PriorityQueue gebruikt om passagiers te laten boarden.
Passagiers mogen het vliegtuig betreden volgens oplopende prioriteit (prioriteit 1 mag voor 2), en bij gelijke prioriteit, in alfabetische volgorde (A mag voor B).
Maak daarvoor twee operaties in je klasse:
checkin(Passenger p) om een passagier toe te voegen aan de lijst van passagiers die kunnen instappen
Passenger nextPassenger() die de volgende passagier teruggeeft die mag instappen, of null indien er geen passagiers meer zijn.
Hieronder vind je een test.
Hint: schrijf een Comparator. Je kan daarbij gebruik maken van de statische methodes uit de Comparator-interface.
Test uit hoe belangrijk het is dat de hashcodes van verschillende objecten in een HashSet goed verdeeld zijn aan de hand van de code hieronder.
Deze code meet hoelang het duurt om een HashSet te vullen met 50000 objecten; de eerste keer met goed verspreide hashcodes, en de tweede keer een keer met steeds dezelfde hashcode. Voer uit; merk je een verschil?
importjava.util.HashSet;importjava.util.concurrent.TimeUnit;importjava.util.function.Function;publicclassTiming{recordDefaultHashcode(inti){}recordCustomHashcode(inti){@OverridepublicinthashCode(){return4;}}publicstaticvoidmain(String[]args){System.out.print("With default hashcode: ");test(DefaultHashcode::new);System.gc();System.out.print("With identical hashcode: ");test(CustomHashcode::new);}privatestatic<T>voidtest(Function<Integer,T>ctor){varset=newHashSet<T>();varstart=System.nanoTime();for(inti=0;i<50_000;i++){set.add(ctor.apply(i));}varend=System.nanoTime();System.out.println(set.size()+" elements added in "+TimeUnit.NANOSECONDS.toMillis(end-start)+"ms");}}
Veranderende hashcode
Is het nodig dat de hashCode van een object hetzelfde blijft doorheen de levensduur van het object, of mag deze veranderen?
Verklaar je antwoord.
Antwoord
Nee, deze mag niet veranderen. Mocht die wel veranderen, kan het zijn dat je een object niet meer terugvindt in een set, omdat er (door de veranderde hashcode) in een andere bucket gezocht wordt dan waar het object zich bevindt.
flowchart TB
subgraph s0 ["d=0"]
direction LR
R
end
subgraph s1 ["d=1"]
direction LR
A
X10[" "]
X11[" "]
B
end
subgraph s2 ["d=2"]
direction LR
C
D
X20[" "]
X21[" "]
X22[" "]
end
subgraph s3 ["d=3"]
direction LR
X30[" "]
X31[" "]
E
X32[" "]
X33[" "]
X34[" "]
end
R --> A
R --> B
A --> C
A --> D
D --> E
classDef empty fill:none,stroke:none
classDef depth stroke:none
class X10,X11,X20,X21,X22,X30,X31,X32,X33,X34 empty
class s0,s1,s2,s3 depth
Stel dat je een binaire boom hebt: een boomstructuur hebt waar elke knoop in de boom maximaal 2 kinderen heeft, zoals het voorbeeld hierboven.
De diepte van een knoop is de afstand van die knoop tot de wortelknoop (bv. in de boom hierboven heeft knoop A diepte 1, knoop E diepte 3, en knoop R diepte 0).
De hoogte van de boom is de maximale diepte van alle knopen in de boom (of dus: de diepte van de knoop die het verst van de wortel ligt). In het voorbeeld ligt knoop E het verst (met diepte 3), dus de hoogte van deze boom is 3.
wat is de maximale hoogte van een binaire boom met \(n\) knopen?
wat is het maximaal aantal elementen met diepte gelijk aan \(d\) in een binaire boom?
wanneer heeft een binaire boom met \(n\) knopen een minimale hoogte? Wat is die hoogte?
Antwoord
Om de maximale hoogte te bekomen, geven we elke knoop slechts 1 kind. We krijgen dan gewoon een lineaire ketting van \(n\) knopen, en de hoogte daarvan is \(n-1\).
Op diepte \(d\) kunnen zich in totaal maximaal \(2^d\) knopen bevinden.
De minimale hoogte bekomen we wanneer alle knopen precies 2 kinderen hebben (behalve de knopen op het diepste niveau).
Veronderstellen we dus dat de boom \(n\) elementen bevat en elke laag volledig opgevuld is, dan bevat die
$$n = 2^0 + 2^1 + ... + 2^h = 2^{h+1} - 1 $$ knopen, met \(h\) de hoogte van de boom.
Daaruit leiden we af dat \( h = \log_2(n+1) - 1 \in \mathcal{O}(\log_2 n) \).
Met andere woorden, de hoogte van de boom groeit logaritmisch in functie van het aantal elementen.
Scheduler
Maak een record Job met attributen time (je kan gewoon een double gebruiken) en een description (String).
Maak ook een klasse Scheduler die een TreeSet gebruikt om volgende methodes te implementeren:
schedule(Job job) om de gegeven job toe te voegen aan de scheduler
List<Job> allJobs() om alle jobs (in volgorde van hun tijd) terug te krijgen
Job nextJob(double after) om de eerstvolgende job op of na het gegeven tijdstip terug te vinden. (Hint: bekijk eerst of er bruikbare methodes zijn in de klasse TreeSet.)
Hieronder vind je een test die deze methodes gebruikt.
Leg uit hoe je een HashSet zou kunnen implementeren gebruik makend van een HashMap.
(Dit is ook wat Java (en Python) doen in de praktijk.)
Antwoord
Je gebruikt de elementen die je in de set wil opslaan als sleutel (key), en als waarde (value) neem je een willekeurig object.
Als het element in de HashMap een geassocieerde waarde heeft, zit het in de set; anders niet.
Parking
Maak een klasse Parking die gebruikt wordt voor betalend parkeren.
Kies een of meerdere datastructuren om volgende methodes te implementeren:
enterParking(String licensePlate): een auto rijdt de parking binnen
double amountToPay(String licensePlate): bereken het te betalen bedrag voor de gegeven auto (nummerplaat). De parking kost 2 euro per begonnen uur.
pay(String licensePlate): markeer dat de auto met de gegeven nummerplaat betaald heeft
boolean leaveParking(String licensePlate): geef terug of de gegeven auto de parking mag verlaten (betaald heeft), en verwijder de auto uit het systeem indien betaald werd.
Om te werken met huidige tijd en intervallen tussen twee tijdstippen, kan je gebruik maken van java.time.Instant.
Schrijf een klasse MultiMap die een map voorstelt, maar waar bij elke key een verzameling (Set) van waarden hoort in plaats van slechts één waarde.
Gebruik een Map in je implementatie.
Hieronder vind je enkele tests, die ook aangeven welke methodes je moet voorzien.
De concepten in andere programmeertalen die het dichtst aanleunen bij Java’s multithreading en concurrency primitieven zijn
pthreads in C
std::thread en aanverwanten in C++
de threading en multiprocessing modules in Python
de System.Threading namespace in C#
Wat en waarom?
We maken eerst onderscheid tussen de begrippen parallellisme en concurrency.
Bij parallellisme (soms ook multiprocessing genoemd) worden meerdere instructies gelijktijdig uitgevoerd.
Dit vereist meerdere verwerkingseenheden (machines, CPU’s, CPU cores, …) waarop die instructies uitgevoerd kunnen worden.
Parallellisme wordt interessant wanneer er veel instructies uitgevoerd moeten worden die onafhankelijk van elkaar kunnen gebeuren.
Hoe meer afhankelijkheden er zijn, hoe meer er gecoördineerd moet worden, wat leidt tot meer overhead.
Bij concurrency (soms ook multitasking genoemd) zijn meerdere taken actief op een bepaald moment.
Dat betekent dat een tweede taak al kan starten voordat de eerste afgelopen is.
Het hoeft echter niet zo te zijn dat er ook gelijktijdig instructies voor elk van die taken uitgevoerd worden.
Concurrency impliceert dus geen parallelisme: je kan concurrency hebben met slechts 1 single-core CPU.
Er zijn meerdere mogelijkheden om concurrency te bereiken met slechts één CPU:
pre-emption: een externe scheduler (bijvoorbeeld in het besturingssysteem) beslist wanneer en voor hoelang een bepaalde taak de processor ter beschikking krijgt. Een voorbeeld hiervan is time slicing: het besturingssysteem onderbreekt elke taak na een bepaalde vaste tijd (of aantal instructies), om de controle vervolgens aan een andere taak te geven.
cooperative multitasking: een taak beslist zelf wanneer die de controle teruggeeft, bijvoorbeeld wanneer er gewacht moet worden op een ’trage’ operatie zoals het lezen van een bestand, het ontvangen van een inkomend netwerkpakket, …. Veel programmeertalen (maar niet Java) ondersteunen coöperatieve multitasking via coroutines en async/await keywords.
Het spreekt voor zich dat, op moderne (multi-core) machines, concurrency en parallelisme vaak gecombineerd worden. Er zijn dus meerdere taken actief, waarvan sommige ook tegelijkertijd uitgevoerd worden.
Multithreading tenslotte is een specifiek concurrency-mechanisme: er worden, binnen eenzelfde proces van het besturingssysteem, meerdere ’threads’ gestart om taken uit te voeren.
Deze threads delen hetzelfde geheugen (namelijk dat van het proces), en kunnen daardoor dus efficiënter data uitwisselen dan wanneer elke taak als apart proces gestart zou worden.
Op elk moment kunnen er dus meerdere threads bestaan, die (afhankelijk van of er ook parallellisme is in het systeem) al dan niet gelijktijdig instructies uitvoeren.
Binnen een Java-programma is multithreading de voornaamste manier om zowel parallelisme als concurrency te bereiken.
IO-bound vs CPU-bound tasks
De taken die al dan niet gelijktijdig uitgevoerd moeten worden, kunnen onderverdeeld worden in zogenaamde CPU-bound en IO-bound taken.
Bij een CPU-bound taak wordt de uitvoeringstijd voornamelijk gedomineerd door de uitvoering van instructies (berekeningen), bijvoorbeeld algoritmes, beeldverwerking, simulaties, ….
Hoe sneller de CPU, hoe sneller de taak dus afgewerkt kan worden.
Een IO-bound task daarentegen is vaak aan het wachten op externe gebeurtenissen (interrupts), zoals netwerk- of schijf-toegang, timers, gebruikersinvoer, …
Een snellere CPU zal deze taak niet sneller doen gaan.
In het algemeen is parallellisme vooral nuttig wanneer er veel CPU-bound taken zijn.
De totale tijd die nodig is om alle taken uit te voeren kan op die manier geminimaliseerd worden.
Voor IO-bound tasks is parallelisme niet noodzakelijk een goede oplossing: de verschillende CPU’s zouden gelijktijdig aan het wachten zijn op externe interrupts.
Het gebruik van concurrency kan hier wel soelaas bieden: terwijl één taak wacht op een externe gebeurtenis, kan een andere verder uitgevoerd worden.
Threads in Java
De basisklasse in Java om te werken met multithreading is de Thread-klasse.
Typisch geef je een Runnable mee die uitgevoerd moet worden.
Dat kan een object zijn, een lambda, of een method reference.
Een aangemaakte thread start niet automatisch; om de uitvoering van de thread te starten, moet je de start-methode oproepen.
Als je wil wachten tot de thread afgelopen is, kan dat via de join-methode. Deze blokkeert de uitvoering tot de uitvoering van de thread afloopt.
Je kan ook een timeout meegeven aan join, met de maximale tijdsduur dat je wilt wachten. Als deze tijd voorbij is vooraleer de thread afloopt, wordt een InterruptedException gegooid.
Bijvoorbeeld: onderstaande code start een nieuwe thread, die een regel uitprint.
Er zijn in het programma dus twee threads actief: (1) de thread die de nieuwe thread aanmaakt, start, en er vervolgens (maximaal 2 seconden) op wacht; en (2) de thread die de boodschap uitprint.
Note
Een exception in een thread komt niet terug terecht in de originele thread; deze kan dus ‘verloren’ gaan.
vart1=newThread(()->{System.out.println("Hello from a new thread");});t1.start();...t1.join(Duration.ofSeconds(2));
Een standaard Java thread wordt uitgevoerd met een thread van het besturingssysteem (een platform thread, die typisch overeenkomt met een kernel thread).
Zo krijg je dus, op machines die het ondersteunen, ook automatisch parallellisme in Java: meerdere threads kunnen effectief tegelijkertijd instructies uitvoeren.
Een nadeel hiervan is dat het aantal threads beperkt is: elke platform-thread komt met een zekere overhead.
Sinds Java 21 beschikt Java ook over virtual threads.
Dat zijn threads die beheerd worden door de Java Virtual Machine (JVM) zelf, en dus niet 1-1 overeenkomen met een platform thread.
Je JVM zal een virtuele thread koppelen aan een platform thread, en deze terug ontkoppelen op het moment dat de virtuele thread moet wachten op een trage operatie.
De platform thread is dan terug vrij, en kan gebruikt worden om een andere virtuele thread uit te voeren.
Virtual threads zijn het Java-alternatief voor cooperative multitasking.
Het teruggeven van de controle wordt echter afgehandeld door de onderliggende bibliotheken, dus als programmeur word je niet geconfronteerd met wachten op blokkerende operaties, of async/await keywords. Je schrijft je code dus gewoon zoals sequentiële code zonder rekening te houden met mogelijk blokkerende operaties.
Virtuele threads hebben ook weinig overhead; je kan er zonder probleem duizenden van aanmaken zonder significante impact op de performantie.
We gaan het in de rest van dit hoofdstuk enkel hebben over de gewone (platform) threads, dus niet over virtual threads.
Synchronisatie
Wanneer meerdere threads (of processen) met gedeelde data werken, ontstaat er een nood aan synchronisatie.
Het kan immers zijn dat beide threads dezelfde data lezen en/of aanpassen, waardoor de data inconsistent wordt.
Dat wordt een race-conditie genoemd.
Race-conditie
Neem het voorbeeld hieronder: één thread verhoogt de waarde van een teller 10.000 keer, de andere verlaagt die 10.000 keer.
Welke waarde van de teller verwacht je op het einde van de code?
Op mijn machine gaven drie uitvoeringen van dit programma achtereenvolgens de waarden -803, -5134, en 3041.
Hoe kan dat? Er worden toch evenveel increments uitgevoerd als decrements, waardoor de teller terug op 0 moet uitkomen?
De reden is dat het lezen en terug wegschrijven van de (verhoogde of verlaagde) variabele (i.e., count++ en count--) twee afzonderlijke operaties zijn,
die door het onderlinge verschil in timing tussen de threads op verschillende momenten kunnen plaatsvinden.
Bijvoorbeeld, stel dat count op een bepaald moment gelijk is aan 40; de inc-thread verhoogt de teller 2 keer, en de dec-thread verlaagt de teller 1 keer,
in onderstaande volgorde:
Thread inc
Thread dec
lees count=40
lees count=40
zet count=41
lees count=41
zet count=42
zet count=39
De teller is nu niet 41 (wat de correcte waarde zou zijn na 2 verhogingen en 1 verlaging), maar 39.
De twee verhogingen hebben dus geen enkel effect gehad.
Door andere volgordes (interleavings) van beide threads kan je zo verschillende resultaten krijgen: count kan (in theorie) elke waarde tussen -10.000 en 10.000 hebben op het einde van het programma.
We zeggen dat de Counter-klasse niet thread-safe is: ze kan niet correct gebruikt worden door meerdere threads zonder extra maatregelen.
Volatile variabelen
Bovenstaande code heeft ook nog een ander probleem: wanneer de code uitgevoerd wordt op meerdere CPU’s of cores, kan het kan zijn de count variabele enkel in het lokale geheugen van één CPU aangepast wordt (bv. een register, L1 cache, …), en nog niet meteen naar het (gedeelde) geheugen teruggeschreven wordt.
Zelfs als de tweede thread de waarde van count pas uitleest nadat deze aangepast is door de eerste thread, kan het zijn dat die nog een oude waarde ziet:
Thread inc
Thread dec
lees count=40
zet count=41
lees count=41
zet count=42
lees count=40
zet count=39
schrijf count naar geheugen
schrijf count naar geheugen
Om dit op te lossen, kan je in Java een variabele als volatile markeren.
Dat garandeert dat alle aanpassingen aan die variabele meteen naar het centrale geheugen geschreven worden, en vermijdt bovenstaande situatie.
Een volatile variabele is dus een variabele waarvan alle threads steeds de laatste waarde zien.
Technisch gezien komt volatile eigenlijk met een nog sterkere garantie: als thread B een volatile variabele leest die door thread A geschreven werd, zullen ook alle andere (niet-volatile) variabelen die B daarna leest, de waarde hebben die ze in thread A hadden voordat die thread de volatile variabele aanpaste. Met andere woorden, thread B ziet consistente waarden voor alle variabelen die een invloed gehad zouden kunnen hebben op de huidige waarde van de volatile variabele.
Het schrijven en vervolgens lezen van een volatile variabele is dus een synchronisatie-punt tussen threads.
Bijvoorbeeld, stel dat t, u, v, en w vier variabelen zijn, waarvan enkel v als volatile gemarkeerd is. Alle variabelen hebben oorspronkelijk 0 als waarde.
In de tabel hieronder is met een * aangegeven welke waarden in thread B gegarandeerd de meest recente waarden zijn.
In het bijzonder zal variabele w niet noodzakelijk de laatste waarde hebben, omdat die door thread A na variabele v geschreven werd.
Ook variabele u zal, vooraleer v gelezen wordt, niet noodzakelijk de meest recente waarde bevatten.
Thread A
Thread B
schrijf t=1
schrijf u=1
schrijf v=1 (volatile)
schrijf w=1
lees u=?
lees v=1 (volatile) *
lees t=1 *
lees u=1 *
lees w=?
Merk wel op dat het gebruik van volatile de race-conditie van hierboven nog steeds niet oplost!
Een variabele als volatile markeren heeft immers enkel invloed op de zichtbaarheid van die variabele. Het biedt geen garanties bij gelijktijdige aanpassingen door meerdere threads.
Daarvoor hebben we synchronisatie nodig.
Synchronizatie-primitieven
Om race condities te voorkomen, moet je toegang tot gedeeld geheugen op een of andere manier synchroniseren.
Meer specifiek wil je een sequentie van operaties atomisch kunnen maken: ze moeten worden uitgevoerd alsof ze één primitieve operatie zijn, die niet onderbroken kan worden door een andere thread.
In het voorbeeld van hierboven wil je dus dat het lezen, verhogen, en wegschrijven van de teller steeds als één geheel uitgevoerd wordt, in plaats van als twee aparte operaties.
Java biedt meerdere synchronizatie-primitieven aan. Sommigen zitten ingebouwd in de taal (bv. volatile en synchronized; zie later), andere zitten in de packages java.util.concurrent en java.util.concurrent.locks.
We bespreken er hier enkele.
Semafoor
Een Semaphore deelt een gegeven maximum aantal toestemmingen uit aan threads (via acquire()). Alle volgende threads die toestemming willen, moeten wachten tot een van de vorige toestemmingen terug ingeleverd wordt (via release()). Een binaire semafoor (met maximaal 1 toestemming) kan dienen als mutual exclusion lock (mutex). Een semafoor houdt (conceptueel) enkel een teller bij van het resterend aantal toestemmingen, en niet welke thread al toestemming heeft. Er is dan ook geen enkele garantie of verificatie dat een thread die release() oproept effectief zo’n toestemming verkregen had; het aantal beschikbare toestemmingen wordt gewoon terug verhoogd.
Een voorbeeld van het gebruik van een semafoor voor de implementatie van Counter:
Met het gebruik van een semafoor voor synchronisatie krijg je ook automatisch zichtbaarheids-garanties; je kan het vrijgeven van de semafoor beschouwen als het schrijven naar een volatile variabele, en het verkrijgen van een toestemming als het lezen van een volatile variabele.
Alle wijzigingen die gedaan worden voor het vrijgeven van de semafoor zijn dus zichtbaar voor alle threads die nadien een toestemming verkrijgen.
Lock
De Lock interface, en de implementaties ReentrantLock, stellen een mechanisme voor waarbij een thread de lock kan verkrijgen (lock()) en terug vrijgeven (unlock()). Hierbij wordt wél nagegaan dat enkel de thread die de lock verkregen heeft, de lock terug kan vrijgeven. ‘Re-entrant’ betekent dat een thread die reeds een lock heeft, verder mag gaan wanneer die een tweede keer lock() oproept (op hetzelfde lock-object).
Een voorbeeld van het gebruik van een ReentrantLock voor de implementatie van Counter:
Net zoals bij een semafoor krijg je bij het gebruik van een lock ook automatisch zichtbaarheids-garanties; je kan ‘unlock()’ beschouwen als het schrijven naar een volatile variabele, en ’lock()’ als het lezen van een volatile variabele.
Alle wijzigingen die gedaan worden voor het unlocken zijn dus zichtbaar voor alle threads die nadien lock uitvoeren.
AtomicInteger
Specifiek voor primitieve types biedt Java ook een verzameling atomische objecten aan, bijvoorbeeld AtomicInteger. Daarop zijn operaties gedefinieerd zoals incrementAndGet, updateAndGet, getAndAdd, …. Bovenstaande counter kan dus ook eenvoudig als volgt geïmplementeerd worden:
Er zijn ook andere klassen, bijvoorbeeld AtomicLong, AtomicBoolean, AtomicIntegerArray, AtomicReferenceArray, …
Deze klassen zijn efficiënt geïmplementeerd, en hebben dus de voorkeur wanneer er enkel gesynchroniseerd moet worden om een primitieve variabele of array aan te passen.
Synchronized
Het gebruiken van een lock is zo alomtegenwoordig dat, in Java, elk object als een lock gebruikt kan worden door middel van het synchronized keyword.
Bijvoorbeeld:
Op deze manier beschikt elk Counter-object over een eigen object dat als lock gebruikt wordt. Het synchronized-block geeft aan dat dat lock nodig is om de code in het bijhorende blok uit te voeren, en de lock wordt automatisch terug vrijgegeven op het einde van dat block.
Omdat elk object in Java zo als lock gebruikt kan worden, hoeven we geen apart object aan te maken; we kunnen ook gewoon this gebruiken:
Tenslotte laat Java ook toe om een hele methode als synchronized te definiëren.
Dat heeft hetzelfde effect als de code met synchronized(this) hierboven:
Code die gebruik maakt van synchronized heeft ook zichtbaarheids-garanties; je kan het einde van een synchronized-block beschouwen als het schrijven naar een volatile variabele (die hoort bij het object waarop gesynchroniseerd wordt), en het begin ervan als het lezen van die volatile variabele.
Alle wijzigingen die gedaan worden voor of in een synchronized-blok of methode zijn dus zichtbaar voor alle threads die nadien een synchronized-blok of methode uitvoeren, tenminste als er gesynchroniseerd wordt op hetzelfde object.
In onderstaande code is er dus geen garantie dat thread B de acties van thread A ziet:
Objectlock1=newObject();Objectlock2=newObject();// in thread Asynchronized(lock1){// acties van A}// later...// in thread Bsynchronized(lock2){// acties van B}
Deadlocks
Threads die werken met locks kunnen in een deadlock geraken wanneer ze op elkaar wachten.
Geen enkele thread kan dan vooruitgang maken.
Bijvoorbeeld, in onderstaande code heeft de counter een apart read- en write-lock.
Beide threads proberen om de twee locks te verkrijgen, maar in omgekeerde volgorde.
In sommige gevallen kan dat leiden tot een deadlock, namelijk wanneer een thread onderbroken wordt tussen het verkrijgen van beide locks:
Thread inc
Thread dec
acquire readLock
acquire writeLock
wait (forever) for readLock …
wait (forever) for writeLock…
Er is geen eenvoudige manier om deadlocks te vermijden, behalve de applicatie erg zorgvuldig te ontwerpen.
Enkele technieken die hierbij kunnen helpen zijn:
niet meer locken dan strikt noodzakelijk
locks altijd in een welbepaalde volgorde verkrijgen
timeouts gebruiken
Immutability
Wanneer we praten over concurrency, is het een goed idee om ook immutability te vermelden.
Een immutable object kan nooit van waarde wijzigen: eens aangemaakt blijven alle waardes hetzelfde.
In de praktijk betekent dat dat alle velden van het object als final gedeclareerd worden.
Wanneer meerdere threads eenzelfde immutable object gebruiken, kunnen er per definitie geen race condities optreden; ook beschikt elke thread altijd over de laatste waarde (volatile is dus niet nodig).
Wanneer mogelijk, gebruik je dus best immutable objecten in een applicatie met concurrency.
Records zijn uiterst geschikt om eenvoudig dergelijke immutable objecten te maken, en gaan dus goed samen met concurrency!
Concurrent data structures
Zoals we hierboven gezien hebben, is niet elke klasse automatisch geschikt om (correct) door meerdere threads tegelijk gebruikt te worden.
Ook de ingebouwde collectie-types (bv. ArrayList, LinkedList) zijn niet thread-safe.
Je kan een thread-safe collectie verkrijgen door hulpfuncties in de Collections-klasse, bijvoorbeeld Collections.synchronizedList(unsafeList).
Dat geeft een view op de gegeven collectie terug waarvan alle methodes synchronized zijn.
Dat houdt in de praktijk dus in dat de collectie op elk moment slechts door 1 thread gebruikt kan worden.
Bovendien, wanneer je itereert over de elementen in de collectie, bijvoorbeeld via een iterator of stream (zie later), moet het gebruik van die iterator ook in een sycnhronized blok staan (met de collectie als object), zodat de lijst niet aangepast kan worden tijdens het itereren.
Omdat ook een foreach-lus een iterator gebruikt, moet die lus ook in een synchronized-blok staan:
Bovenop die manier om ‘gewone’ collecties thread-safe te maken, zijn er ook implementaties die specifiek ontworpen zijn voor concurrency.
Bijvoorbeeld, een CopyOnWriteArrayList is een thread-safe lijst, geschikt voor wanneer er veel vaker gelezen wordt uit de lijst dan dat er naar geschreven wordt.
Deze implementatie dan is efficiënter dan een gewone ArrayList die gesynchroniseerd wordt via de synchronizedList-hulpfunctie.
Zoals de naam zegt wordt de onderliggende array nooit aangepast, maar wel volledig gekopieerd elke keer ernaar geschreven wordt.
Het voordeel is dat lezen kan zonder enige synchronizatie, omdat de inhoud van de array zelf nooit meer aangepast wordt.
Er bestaat ook een ConcurrentHashMap, die een thread-safe Map implementeert.
Ook hier is deze efficiënter dan een gesynchroniseerde Map, omdat nooit de hele datastructuur gelockt wordt.
High-level concurrency abstractions
Totnogtoe hebben we steeds zelf threads aangemaakt.
Zoals eerder vermeld komt elke thread echter met een redelijk grote overhead, en wordt het al snel complex om bij te houden welke threads we hebben en hoeveel er zijn.
We willen daarom de gebruikte threads makkelijk kunnen beheren.
Executor
Java biedt enkele hoog-niveau abstracties aan om te werken met threads.
De basisklasse daarvoor is een Executor.
Deze ontkoppelt de taak die uitgevoerd moet worden van de thread waarin die uitgevoerd wordt.
Aan een Executor kan je dus Runnable’s (of lambda’s of method references) geven die uitgevoerd moeten worden, zonder zelf de threads te beheren.
Er is ook een subinterface ExecutorService die enkele methodes toevoegt, bijvoorbeeld het afsluiten van een executor.
Tenslotte is er de ScheduledExecutorService die methodes toevoegt om taken te plannen (bijvoorbeeld uitvoeren na een bepaalde tijd, eenmalig of herhaald).
Vaak willen we het aantal threads dat gebruikt wordt beperken, bijvoorbeeld tot ongeveer het aantal aanwezige processoren of cores.
Dat kan met een ThreadPool: een verzameling van threads die, eenmaal aangemaakt, herbruikt kunnen worden.
Er is een ThreadPoolExecutor die exact dat doet.
Je kan die zelf aanmaken, maar dat kan makkelijker via hulpmethodes in de Executors klasse.
Bijvoorbeeld:
maakt een ExecutorService aan met ten hoogste vijf threads.
Nieuwe taken die aangeboden worden wanneer alle vijf threads bezig zijn met andere taken, belanden in een wachtrij tot ze aan de beurt zijn.
Met de close-methode zorg je ervoor dat er geen nieuwe taken meer kunnen bijkomen, en wacht je tot alle bestaande taken afgelopen zijn.
Er zijn ook andere varianten die je kan aanmaken via de Executors-klasse, bijvoorbeeld
newCachedThreadPool: herbruikt threads indien voorhanden, en maakt anders een nieuwe thread aan. Threads zonder werk worden na een bepaalde tijd beëindigd.
newSingleThreadExecutor: een executor met slechts één thread die al het aangeboden werk (na elkaar) uitvoert.
newSingleThreadScheduledExecutor: zelf de als hierboven, maar dan met een ScheduledExecutor als resultaat. Hiermee kan je dus bijvoorbeeld taken op regelmatige tijdstippen uitvoeren.
Het aanmaken en vervolgens wachten op alle taken kan ook via een zogenaamd try-with-resources statement.
Dat is een eenvoudige manier om te wachten op alle taken en ervoor te zorgen dat de pool altijd afgesloten wordt, ook als er exceptions gegooid worden.
(Een try-with-resource statement kan in Java ook voor andere zaken gebruikt worden, in het bijzonder met resources die na gebruik terug gesloten moeten worden, bijvoorbeeld een bestand.)
Een fork-join pool is een specifiek type Executor voor taken die (recursief) nieuwe subtaken genereren.
Deze subtaken worden dan mee opgenomen in de lijst van uit te voeren taken.
Een fork-join pool is vooral nuttig wanneer de taken onafhankelijk van elkaar zijn, en achteraf gecombineerd worden.
Er wordt gebruik gemaakt van work stealing: threads in de pool die niets meer te doen hebben, kunnen subtaken beginnen uitvoeren die gegenereerd werden door een andere thread.
We gaan hier niet verder in op het gebruik van een fork-join pool.
Testen van concurrent code
Het testen of bepaalde code thread-safe is, is allesbehalve eenvoudig.
Ten eerste moeten we de tests uitvoeren met meerdere threads; concurrency-problemen zijn immers veelal onzichtbaar als er maar één thread in het spel is.
Bovendien is een correct resultaat na één uitvoering van de test geen garantie dat de code ook correct is.
Er kan immers een probleem zijn dat niet tot uiting kwam bij die specifieke uitvoering (met andere woorden, bij die specifieke interleaving van de threads), maar dat zich wel zou manifesteren bij een andere uitvoeringsvolgorde.
De test meerdere keren herhalen kan in zo’n gevallen soelaas bieden.
In Java kunnen we gebruik maken van een library zoals jcstress.
Deze library laat toe om testcode te schrijven die onder verschillende regimes uitgevoerd wordt, om zo de kans te vergroten dat thread safety problemen (bv. race-condities) aan het licht komen.
De jcstress library wordt ook gebruikt om de concurrency-aspecten van de implementatie van de Java Virtual Machine zelf te testen.
jcstress toevoegen aan je project
Voeg volgende regel toe aan de plugins sectie van je build.gradle:
Zorg ervoor dat IntelliJ je nieuwe Gradle-configuratie verwerkt (klik op het olifantje dat verschijnt).
In je project maak je nu een extra folder onder src, namelijk src/jcstress, en daaronder een folder src/jcstress/java.
Voeg deze java folder toe als ‘Sources root’: rechtsklik op de java folder > Mark Directory As > Sources root.
IntelliJ zou de folder nu blauw moeten kleuren.
Anatomie van een jcstress test
Een test in jcstress maakt geen gebruik van JUnit-annotaties (@Test etc), maar van een eigen set van annotaties.
Hier vind je een voorbeeld en wat meer uitleg over de betekenis annotaties.
We bespreken de belangrijkste annotaties ook hieronder, met als voorbeeld het testen van onze originele Counter-klasse.
packagedemo;importorg.openjdk.jcstress.annotations.*;importorg.openjdk.jcstress.infra.results.I_Result;@JCStressTest@Outcome(id="0",expect=Expect.ACCEPTABLE,desc="Incremented and decremented atomically")@Outcome(id="",expect=Expect.FORBIDDEN,desc="Unexpected counter value")@StatepublicclassCounterTest{privatefinalCountercounter=newCounter();privatefinalintN=5;@Actorpublicvoidincrementer(){for(inti=0;i<N;i++){counter.increment();}}@Actorpublicvoiddecrementer(){for(inti=0;i<N;i++){counter.decrement();}}@Arbiterpublicvoidarbiter(I_Resultresult){result.r1=counter.getCount();}}
We zien in de test hetvolgende:
De annotatie @JCStressTest duidt aan dat het om een jcstress-test gaat
De annotatie @State geeft aan in welke klasse we de te testen state-variabelen vinden; hier is dat de variabele counter, in de CounterTest klasse zelf. Het object met @State zal erg vaak aangemaakt worden, en moet voldoen aan bepaalde voorwaarden (bv. publieke constructor zonder argumenten).
We negeren @Outcome nog even, en kijken naar de actoren en arbiter:
Elke methode met @Actor staat voor de code die door één thread uitgevoerd wordt. Hier hebben we 2 verschillende actoren: eentje die de teller N keer verhoogt, en een andere die de teller N keer verlaagt. Het jcstress framework zal de actoren uitvoeren met zoveel mogelijk verschillende interleavings, in de hoop zo eventuele problemen bloot te leggen.
De methode met @Arbiter (gewoonlijk 1 per test) wordt uitgevoerd nadat alle actoren helemaal klaar zijn. De arbiter bezorgt de data waaraan we kunnen zien of er zich een probleem voorgedaan heeft. In dit geval kijken we naar de waarde van de counter na afloop van de verhogingen en verlagingen. We kennen die counter-waarde toe aan attribuut r1 van het resultaat-object. Dat heeft type I_Result, wat staat voor een resultaat met 1 integer (I). Er zijn ook andere types beschikbaar in de library, bv. II_Result (2 integers), DBI_Result (een double, een byte, en een integer), etc.
Letters in Result-type
Volgende letters worden gebruikt in de types van de Result-objecten:
I: int
Z: boolean
F: float
J: long
S: short
B: byte
C: char
D: double
L: object
Opmerking
Het is niet strikt noodzakelijk om een @Arbiter-methode te hebben.
Je kan ook één van de @Actor-methodes een result-parameter geven en zonder arbiter werken.
Tenslotte bespreken we de @Outcome-annotaties. Die geven aan welke resultaat-objecten verwacht/acceptabel zijn en welke niet.
In het voorbeeld hierboven:
Een resultaat met r1=0 is acceptabel; dat is wat we verwachten van een thread-safe counter. Dat geven we aan met @Outcome(id="0", expect = Expect.ACCEPTABLE, ...). Merk op dat de id parameter het verwachte resultaat (uit de I_Result parameter) voorstelt.
Elk ander resultaat is niet toegelaten: @Outcome(id="", expect = Expect.FORBIDDEN, ...).
Als we een resultaat met meerdere waarden zouden hebben (bv. een II_Result) dan specifiëren we de outcome bijvoorbeeld als @Outcome(id="0, 1", ...), wat overeenkomt met r1=0 en r1=1.
Tests uitvoeren
Je kan de tests uitvoeren via de jcstress taak in Gradle: ./gradlew jcstress.
Dit genereert uitvoer op de console, en ook een html-bestand in build/reports/jcstress.
Daarin vinden we een tabel zoals onderstaande, die aangeeft hoe vaak elk resultaat bekomen werd:
We zien hieruit duidelijk dat onze counter niet thread-safe is: alle waarden van -5 tot 5 werden bekomen in sommige tests.
Waarom zou je niet elke variabele als volatile markeren?
Antwoord
Dat zou als voordeel hebben dat je meer zichtbaarheisgaranties hebt, maar dit komt wel met een belangrijke kost: veel performantie-optimalisaties die gebruik maken van een lokale cache worden zo teniet gedaan, en je programma wordt dus trager.
Bovendien heb je met volatile variabelen nog steeds geen thread safety; je moet nog steeds synchronisatie toevoegen.
Volatile (2)
We zagen in de uitleg bij het synchroniseren met semaforen dat deze ook zichbaarheidsgaranties bieden, net zoals volatile.
Is in onderstaand voorbeeld het volatile keyword dan nog nuttig? Waarom (niet)?
Ja, volatile is nog steeds nuttig. Niet voor de increment- en decrement-methodes en alle andere code die daarvan gebruik maakt (de semafoor geeft dezelfde garanties als volatile), maar wel voor het gebruik van count in de getCount-methode.
Zonder volatile heb je geen garantie dat verschillende threads dezelfde/de meest recente waarde van de counter zouden uitlezen.
Volatile (3)
Welke zichtbaarheidsgaranties krijg je bij een volatile variabele met een lijst als type, bijvoorbeeld private volatile ArrayList<Element> myList?
Antwoord
De zichtbaarheidsgaranties gaan enkel over de referentie (pointer) naar de lijst, die in variabele myList zit.
Met andere woorden: variabele myList wijzigen zodat die naar een andere lijst verwijst (myList = new ArrayList<>()) zal steeds meteen zichtbaar zijn voor andere threads.
Maar: wijzigingen in de lijst zelf (bv. elementen toevoegen of van plaats veranderen) zijn niet automatisch zichtbaar voor andere threads.
Synchronized
Waarom zou je niet gewoon elke methode van een klasse synchronized maken?
Antwoord
Als je dat doet, verlies je mogelijk veel van de voordelen van multithreading.
Er kan immers op elk moment slechts één thread met je object werken; al de rest moet wachten, zelfs wanneer al die threads enkel lees-operaties zouden uitvoeren en er dus geen reden is om synchronisatie te gebruiken.
Bovendien volstaat dit niet om thread-safe te zijn (zie het voorbeeld met het gebruik van de iterators).
Counter + jcstress
Pas de jcstress-test uit het voorbeeld aan om de thread-safe Counter-implementatie uit oefening 2 te testen.
Ticketverkoop
Hieronder vind je een klasse voor een ticket-verkoop met bijhorende zitplaatsen (bijvoorbeeld van een bioscoopzaal, vliegtuig, …).
Deze bevat ook een main-methode die deze klasse gebruikt.
De main-methode simuleert hoe het BookingSystem door meerdere klanten tegelijk gebruikt wordt.
We maken hierbij gebruik van een CyclicBarrier: een ander synchronisatie-primitief, waarmee je kan wachten tot een aantal threads hetzelfde punt bereikt hebben voor ze verder mogen gaan.
We gebruiken dit om de grote toestroom te simuleren bij het openen van de ticketverkoop, door elke thread (klant) te laten wachten tot ook alle andere threads (klanten) klaar staan.
Bekijk ook de andere concurrency-aspecten van deze code, zoals het gebruik van een thread pool en synchronized list.
Opmerking: voor de eenvoud maakt het niet uit welke zitjes een klant krijgt.
Voer de huidige versie uit. Wat zie je?
Maak deze klasse thread-safe, zodat meerdere klanten tegelijk tickets kunnen boeken zonder dezelfde zitplaatsen toegewezen te krijgen.
Doe dit op de simpelste manier die je kan bedenken (hint: synchronized). Wat is het effect op de uitvoeringstijd?
(De main-methode moet je niet aanpassen)
Probeer daarna om de oplossing efficiënter (maar nog steeds thread-safe) te maken. (De main-methode moet je niet aanpassen)
importjava.util.*;importjava.util.concurrent.BrokenBarrierException;importjava.util.concurrent.CyclicBarrier;importjava.util.concurrent.Executors;publicclassBookingSystem{privatefinalString[]seats={"A","B","C","D","E","F"};privateintavailableTickets=seats.length;privatefinalMap<String,String>seatAssignments=newHashMap<>();publicbooleanisAssigned(Stringseat){returnseatAssignments.containsKey(seat);}privatevoidgenerateTicket(Stringseat,Stringcustomer){// simulate that creating a ticket takes some timetry{Thread.sleep(100);}catch(InterruptedExceptionignored){}}publicList<String>bookSeats(Stringcustomer,intrequestedSeats){if(requestedSeats>availableTickets)thrownewIllegalStateException("Not enough seats available");// simulate that preprocessing takes a lot of timetry{Thread.sleep(2000);}catch(InterruptedExceptionignored){}List<String>seatsForCustomer=newArrayList<>();intseatIndex=0;while(seatsForCustomer.size()<requestedSeats){Stringseat=seats[seatIndex];if(!isAssigned(seat)){generateTicket(seat,customer);seatAssignments.put(seat,customer);seatsForCustomer.add(seat);}seatIndex++;}availableTickets-=requestedSeats;returnseatsForCustomer;}publicstaticvoidmain(String[]args){varnbCustomers=20;varbookingSystem=newBookingSystem();varassignedSeats=Collections.synchronizedList(newArrayList<String>());try(varexecutor=Executors.newFixedThreadPool(nbCustomers)){// wait until all customers are ready: simulates opening ticket salesvarticketSalesOpening=newCyclicBarrier(nbCustomers);for(inti=0;i<nbCustomers;i++){finalvarcustomer="Customer %02d".formatted(i);executor.execute(()->{try{ticketSalesOpening.await();varseats=bookingSystem.bookSeats(customer,1);System.out.println(customer+": "+seats);assignedSeats.addAll(seats);}catch(IllegalStateExceptione){// no tickets left for customer}catch(BrokenBarrierException|InterruptedExceptione){e.printStackTrace();}});}}varuniqueSeats=newHashSet<>(assignedSeats);System.out.println((assignedSeats.size()-uniqueSeats.size())+" overbookings.");}}
Thread-safe cache
Hieronder vind je een Downloader-klasse die een (in-memory) cache gebruikt.
Als de te downloaden URL nog niet in de cache zit, wordt die gedownload.
Anders wordt de inhoud uit de cache teruggegeven.
In plaats van een echte URL te downloaden, wordt een download hier gesimuleerd door 1 seconde te wachten
importjava.util.HashMap;importjava.util.Map;publicclassDownloader{privatefinalMap<String,String>cache=newHashMap<>();publicStringget(Stringurl){if(!cache.containsKey(url)){varcontents=download(url);cache.put(url,contents);}returncache.get(url);}privatestaticStringdownload(Stringurl){try{System.out.println("Downloading "+url+" ("+Thread.currentThread()+")");Thread.sleep(1000);// 1 secondereturn"Contents of "+url;}catch(InterruptedExceptione){thrownewRuntimeException(e);}}}
Wat kan er gebeuren er als twee threads dezelfde URL op hetzelfde moment willen opvragen uit eenzelfde Downloader-object?
Zou een ConcurrentHashMap gebruiken in plaats van een gewone HashMap dit probleem oplossen?
Maak de klasse thread-safe, maar nog steeds zo efficiënt mogelijk (zodat threads nooit onnodig moeten wachten).
Maak ook een Client-klasse met een main-methode, waarin 4 threads elk 100 keer een random URL opvragen (gebruik een thread pool).
In plaats van echte URL’s kan je gewoon strings gebruiken.
Hieronder vind je een methode die een willekeurige String uit de lijst van url’s teruggeeft:
Lijst met veelgebruikte termen en afkortingen in deze online cursus
Versiebeheer = Version control = source control
VCS = Version Control Systems = versiebeheer systemen
OS = Operating system = Besturingssysteem (Bv. Windows, Mac OS, Linux)
File Explorer = Windows verkenner
Directory ≈ folder ≈ map
Bestand = file
GUI (Graphical User Interface)
CLI (Command Line Interface)
IDE = Integrated Development Environment (bv. IntelliJ, PyCharm, Netbeans …)
Bijlage B - Java in een notendop
Dit document biedt een overzicht van enkele Java-specifieke concepten en syntax voor studenten die wel vertrouwd zijn met een object-georiënteerde programmeertaal, maar niet met Java.
Algemeen
Java is een sterk getypeerde taal: alle variabelen hebben types, en de compiler dwingt af dat de types overeenkomen.
Het type wordt steeds vóór de variabele geschreven: int x.
Java is ook object-georiënteerd: elke variabele is ofwel een referentie naar een object, ofwel een primitieve waarde (int, boolean, …). De speciale waarde null wordt gebruikt voor referentie-variabelen die naar geen enkel object verwijzen.
Een methode oproepen op een object gebeurt via een punt: ontvanger.methode(parameter1, parameter2)
Primitieve datatypes
De primitieve datatypes in Java zijn
int (32-bit integer)
long (64-bit integer)
short (16-bit integer)
boolean
float (single-precision)
double (double-precision)
char (16-bit unicode character)
byte (signed! -128..127)
Daarnaast is ook String een ingebouwd datatype (maar strikt genomen geen primitief type).
Strings zijn immutable: eens aangemaakt, kan geen karakters wijzigen/toevoegen/….
Tenslotte bestaan er ook zogenaamde ‘boxed’ types: Integer, Long, Short, Boolean, Float, Double, Char, Byte. Dit zijn objecten die exact één primitieve waarde van het overeenkomstige type bevatten.
De Java compiler zorgt zelf voor boxing en unboxing:
inta=2;Integerx=a;Doubley=3.2;doublez=x*y;
Je kan ze dus bijna overal door elkaar gebruiken, maar het is aangeraden om, waar mogelijk de primitieve types te gebruiken.
Het voornaamste gebruik van de boxed types is als type voor ArrayLists (zie hieronder), bijvoorbeeld ArrayList<Integer> om een lijst van gehele getallen voor te stellen.
Main method
De uitvoering van een Java-programma start steeds in een methode public static void main(String[] args), die in een klasse moet staan.
Java heeft arrays; deze hebben een vaste lengte die bepaald wordt bij het aanmaken en daarna niet meer gewijzigd kan worden.
Het type van een array van type T is T[]; bij het aanmaken geef je de lengte op: new T[lengte].
De lengte kan je opvragen via het .length attribuut.
Individuele elementen vraag je op en verander je via [index]; het eerste element heeft index 0:
Een ArrayList is een collectie zoals een array, maar die groter en kleiner kan worden.
Je voegt elementen toe met add(element), en vraagt ze op via get(index).
Je moet ArrayList importeren uit het java.util package om het te kunnen gebruiken.
Een if-statement in java bevat steeds een conditie (haakjes verplicht!).
Er kunnen ook meerdere else if blokken aan toegevoegd worden,
en/of één else-blok.
behalve dat je, bij de for-loop, de variabelen die in de initializer gedeclareerd worden (number in het voorbeeld hierboven) niet meer kan gebruiken na de lus (die zijn out of scope).
Enhanced for-loop (foreach)
Een enhanced for-loop (‘foreach’) kan je gebruiken om over de elementen van een array of collectie (bijvoorbeeld een ArrayList) te itereren (meer in detail: alle klassen die de Iterable interface implementeren).
een of meerdere constructoren, die opgeroepen worden wanneer een object aangemaakt wordt. Indien je geen constructor schrijft, wordt een default constructor voorzien (zonder parameters).
velden/attributen: de eigenschappen van een object, telkens met een type
methodes: de acties die uitgevoerd kunnen worden door een object. Elke methode heeft
precies 1 terugkeertype, of void indien de methode geen resultaat teruggeeft. Je geeft een waarde terug via een return-statement.
0 of meer parameters
Een voorbeeld:
packageexample.person;publicclassPerson{// veldenprivateStringname;privatePersonmarriedTo=null;// default null// constructorpublicPerson(Stringname){this.name=name;}// getter en setter voor naampublicStringgetName(){returnname;// zelfde als 'return this.name;'}publicvoidsetName(StringnewName){this.name=newName;}publicbooleanisMarried(){returnmarriedTo!=null;}publicvoidmarryTo(Personother){if(isMarried()){thrownewIllegalStateException("Already married");}if(other.isMarried()){thrownewIllegalArgumentException("Other person is already married");}this.marriedTo=other;other.marriedTo=this;// OK ondanks `private` modifier (want zelfde klasse)}}
Elke publieke klasse moet in een bestand staan met dezelfde naam als de klasse, bijvoorbeeld Person.java.
Als de klasse in een package zit, moet het pad overeenkomen met dat package, bijvoorbeeld example/person/Person.java.
Objecten aanmaken
Een object van een klasse wordt aangemaakt via new. Daarbij wordt de constructor opgeroepen.
Java heeft automatische garbage collection: objecten die niet meer gebruikt worden, worden automatisch uit het geheugen verwijderd.
Modifiers
Java heeft 4 visibility modifiers voor klassen, velden, en methodes:
public: het element is overal toegankelijk
private: het element is enkel toegankelijk binnen het bestand zelf (de compilation unit)
protected: het element is toegankelijk voor 1) klassen binnen hetzelfde package, en 2) subklassen
(package): het element is toegankelijk voor klassen binnen hetzelfde package. Er is geen specifieke modifier hiervoor (dit is de standaard als je niets schrijft).
Daarnaast zijn er nog volgende modifiers:
static: het element is statisch, dit wil zeggen dat het geen deel uitmaakt van individuele objecten, maar van de klasse als geheel (het bestaat dus slechts 1 keer, en wordt gedeeld)
final:
voor een klasse: er kan niet van overgeërfd worden
voor een methode: de methode kan niet overridden worden in een subklasse
voor een veld/attribuut en variabele: de referentie (of waarde voor een primitief type) van het veld kan niet meer gewijzigd worden (opgelet: het object waarnaar verwezen wordt, kan zelf wel nog gewijzigd worden! Enkel de referentie is onwijzigbaar).
abstract: de klasse of de methode is abstract; zie verder.
Java heeft enkelvoudige overerving: elke klasse erft over van exact 1 superklasse (indien er geen opgegeven wordt, is dat Object). Overerving van een klasse wordt aangegeven door middel van extends na de klasse-naam:
Een klasse die zelf niet rechtstreeks geïnstantieerd kan worden, is abstract.
Een abstracte klasse kan abstracte methodes hebben, die door (niet-abstracte) subklassen geïmplementeerd moeten worden.
Enkel niet-abstracte subklassen kunnen aangemaakt worden via new.
Over het algemeen is het gebruik van instanceof een ‘code smell’, en kijk je beter of je gebruik kan maken van polymorfisme.
Polymorfisme en dynamic dispatch
In Java kan je methodes overloaden (zelfde naam maar verschillende parameter-types binnen eenzelfde klasse) en overriden (zelfde naam en parameters in super- en subklasse). Bij het overriden van een methode kan voor een object dezelfde methode dus bestaan in de klasse en in een of meerdere van de superklassen.
Daarom is er een mechanisme nodig om te bepalen welke methode uiteindelijk uitgevoerd zal worden. Dat wordt in Java bepaald door
het gedeclareerde (statisch) type van de argumenten (at compile time)
het effectieve (dynamische) type van het object (at runtime)
Het statisch type van food (at compile time) is Food.
Het dynamisch type van person (at runtime) is Student.
Dus wordt de methode eat(Food food) uit de klasse Student uitgevoerd.
Exceptions
Wanneer zich een onvoorziene situatie voordoet, kan je dat in Java aangeven door een exception-object te gooien via throw.
Bijvoorbeeld:
classBreuk{privatefinalintteller,noemer;publicBreuk(intteller,intnoemer){if(noemer==0)thrownewIllegalArgumentException("Noemer mag niet 0 zijn");this.teller=teller;this.noemer=noemer;}}
De uitvoering breekt dan onmiddellijk af, en er wordt gezocht naar code die de uitzondering kan opvangen.
Exceptions opvangen doe je via een try .. catch block:
try{// probeer code uit te voerenBreukbreuk=newBreuk(3,0);// gelukt, ga verder met breuk}catch(IllegalArgumentExceptione){// wordt (enkel) uitgevoerd wanneer de code in het try-block een IllegalArgumentException gooideSystem.out.println("Oeps");}
Er wordt eerst gekeken naar een try-catch blok in de methode die de methode opgeroepen heeft waarin een uitzondering gegooid werd. Is daar geen try-catch block aanwezig, wordt gekeken in de methode die die methode opgeroepen heeft, etc, tot in de main-methode.
Als er geen enkele try-catch block gevonden wordt die de uitzondering kan afhandelen, stopt het programma.
Hierboven gebruikten we unchecked exceptions; alle unchecked exceptions erven over van de klasse RuntimeException. Java heeft ook checked exceptions; deze erven over van de klasse Exception. Checked exceptions moeten vermeld worden in de methode-hoofding, en code die de methode oproept moet ze opvangen of ze opnieuw vermelden in de methode-hoofding.
classNoemerIsNulextendsException{}classBreuk{privatefinalintteller,noemer;publicBreuk(intteller,intnoemer)throwsNoemerIsNul{// <- exception moet vermeld worden in hoofdingif(noemer==0)thrownewNoemerIsNul();this.teller=teller;this.noemer=noemer;}}try{Breukbreuk=newBreuk(3,0);// compiler geeft een fout als NoemerIsNul niet opgevangen wordt}catch(NoemerIsNule){System.out.println("Oeps");}
Checked exceptions maken het vaak lastig om iets te programmeren, en de voorkeur wordt tegenwoordig vaak gegeven aan unchecked exceptions.
Opdrachten
Algemene informatie
Hier vind je de opdrachten voor de permanente evaluatie van SES+EES.
De opdrachten voor deel 1 worden gemaakt door alle studenten (SES+EES).
De opdrachten voor deel 2 worden enkel gemaakt door de studenten SES.
GitHub repository linken
Om de permanente evaluatie opdrachten in te dienen voor het vak Software Engineering Skills maken we gebruik van GitHub. In de lessen zal je onder begeleiding een gratis account aanmaken op dit platform.
Je maakt de opdrachten in een repository die je aanmaakt via GitHub Classroom. Daarvoor moet je je GitHub gebruikersnaam linken aan je naam, zodat wij weten bij welke student de repository van ‘FluffyBunny’ hoort :). Zie de instructies bij de eerste opdracht voor meer details.
Extra start files van andere start repositories downloaden in je eigen repo
Om jullie het leven makkelijk te maken, hebben we voor veel van de opdrachten al startcode geschreven in de vorm van een github repository. Die nieuwe repositories simpelweg clonen in een subdirectory van je eigen repository zorgt echter voor een aantal problemen. Die nieuwe directories met startcode worden dan namelijk als een submodule toegevoegd aan je main repo. Die repo wordt dus als een soort link beschouwd en aangezien die nieuwe repo niet van jullie zelf is ga je dan ook geen veranderingen aan die code kunnen pushen. In de plaats moeten we die start-code-repositories als een subtree toevoegen. Dat heeft als voordeel dat je zelf gewoon wijzigingen kan aanbrengen, committen en pushen naar je eigen hoofd repository. Bovendien blijft er ook een zachte link met de originele start-code-repository zodat als hier iets aan wijzigt je die online wijzigingen nog wel kan fetchen. (Bijvoorbeeld als we merken dat er nog een foutje in de startcode stond!).
Zo een subtree aanmaken doe je dan op de volgende manier:
Voeg de remote-start-code-repository toe aan je eigen repo met volgende commando:
met --prefix= geef je aan in welke subdirectory de inhoud van de externe repo moet komen.
je kan een specifieke branch meegeven (meestal main)
met --squash condenseer je de git history van de externe repo tot één enkele commit. (Je kan deze optie weglaten als je toch de hele git history wil bekijken)
Zodra de files in jouw repo staan, kan je ze bewerken zoals normaal. De changes worden dan onderdeel van de history van JOUW repository, onafhankelijk van de oorspronkelijke externe repository.
Moesten er later toch wijzigingen aangebracht worden in de remote-start-code-repository die je wil fetchen dan kan je volgend commando gebruiken:
Heb je toch al zo een repo gecloned en is dit nu een een submodule waarvan je de changes niet kan committen dan kan je best je wijzigingen even kopieren naar een andere locatie buiten je repo. De subdirectory verwijderen en nu als subtree toevoegen en dan je oplossingen terug erin zetten.
Naamgeving bij indienen (tags)
Om de verschillende (sub)opdrachten te identificeren wordt er gebruik gemaakt van “tags” bij het committen van je finale resultaat. (In de eerste lessen worden deze onderwerpen klassikaal uitgelegd zodat alle studenten de juiste werkwijze gebruiken).
De naamgeving: voor het committen van je resultaat van “Opdracht 1” gebruik je de tag v1, voor “Opdracht 2” gebruik je de tag v2, enzoverder.
Tags pushen naar github: git push --tagsTags op github verwijderen: git push --delete origin <tagname>. Dit moet je doen als je bijvoorbeeld lokaal een tag verplaatst hebt.
Lokaal een tag deleten: git tag --delete <tagname>
Deadlines
Er is 1 deadline voor alle opdrachten van het eerste deel (SES+EES): vrijdag 21/03/2025 23u59.
De deadline voor alle opdrachten van het tweede deel (enkel SES) is vrijdag 23/05/2025 23u59.
Opdrachten deel 1
Deze opdrachten worden door alle studenten (SES+EES) gemaakt en ingediend via Github Classroom. Klik hier voor de invite van de assignment “Opdrachten deel 1”.
Versiebeheer
Opdracht 1:
Maak een nieuwe directory aan op je computer voor deze opdracht.
Commit nu de veranderingen in je directory en tag als v1.
Opdracht 2:
Los alle TODO’s in de file Candy_Crush_spelregels.txt op en commit de veranderingen in je directory en tag als v2.
Opdracht 3:
Maak een branch aan met als naam “opdracht_branch”.
Switch naar de nieuwe branch.
In de Candy_Crush_spelregels.txt vul de gegevens voor auteur aan met je eigen voornaam en achternaam. Commit nu je aanpassingen in deze branch.
Switch terug naar de main branch en vul daar andere waarden in voor de auteur gegevens (Dit is bijvoorbeeld een bug in je software). Commit nu je aanpassingen in deze branch.
Merge de “opdracht_brach” met je main branch. Hiervoor zal je dus ook het mergeconflict moeten oplossen.
Commit nu de veranderingen in je directory en tag als v3.
Build systems
Opdracht 4:
Maak een folder in de root directory van deze git repository aan genaamd ‘build-systems’. Maak hierin 3 subdirectories: ‘c’, ‘java’ en ‘python’.
Extract alle files in dit zip bestand naar de ./build-systems/c directory en los alle TODO’s in de documenten op. (Je kan met de TODO tree extensie in VSCode gemakkelijk zien of je alle TODO’s gedaan hebt) OF clone de repository van het startproject into ./build-systems/c.
Je moet de makefile aanpassen zodat het C-programma kan gecompiled+gelinked worden met make compile, dat de binaries en object files gedeletet worden met make clean en dat je project gerund kan worden met make run (je moet hier ook flags kunnen meegeven). De uiteindelijk binary moet in de root van je c-project directory staan met als naam friendshiptester.bin
In de ./build-systems/java-directory:
Extract alle files in dit zip bestand OF clone de repository. Schrijf een simpele makefile dat de volgende dingen kan doen en plaats het in de root van je directory:
compile: Compileert de bronbestanden naar de /build-directory
jar : packaged alle klassen naar een jar genaamd ‘app.jar’ in de ‘build’-directory met entrypoint de ‘App’-klasse.
run : Voert de jar file uit
clean: Verwijdert de ‘.class’-bestanden en het ‘.jar’-bestand uit de ‘build’-directory
in de ./build-systems/python-directory:
Extract alle files in dit zip bestand OF clone de repository. Schrijf een simpele makefile dat de volgende dingen kan doen en plaats het in de root van je directory:
compile: Compileert de bronbestanden naar de single ‘friendshiptester.bin’ file in de “/dist”-directory
clean: Verwijdert het ‘.bin’-bestand
run : Voert de ‘friendshiptester.bin’ uit
test : Voert de ‘app.py’ uit
Commit nu de veranderingen en tag als v4.
Dependency management
Opdracht 5:
Maak een folder in de root directory van je git repository aan genaamd ‘dependency-management’. Maak een javaproject met gradle aan in een subdirectory genaamd “checkneighbours” met package “be.ses”.
Je kan de package name meegeven met het ‘gradle init’ commando op de volgende manier: gradle init --package be.ses
Voeg het volgende toe aan de .gitignore-file in de root van je repository.
In je checkneigbours gradle project, moet je nu een klasse ‘CheckNeighboursInGrid’ maken en onderstaande static method in implementeren, los ook de TODO op:
CheckNeighboursInGrid.java (klik om te verbergen/tonen)
/**
* This method takes a 1D Iterable and an element in the array and gives back an iterable containing the indexes of all neighbours with the same value as the specified element
*@return - Returns a 1D Iterable of ints, the Integers represent the indexes of all neighbours with the same value as the specified element on index 'indexToCheck'.
*@param grid - This is a 1D Iterable containing all elements of the grid. The elements are integers.
*@param width - Specifies the width of the grid.
*@param height - Specifies the height of the grid (extra for checking if 1D grid is complete given the specified width)
*@param indexToCheck - Specifies the index of the element which neighbours that need to be checked
*/publicstaticIterable<Integer>getSameNeighboursIds(Iterable<Integer>grid,intwidth,intheight,intindexToCheck){// TODO write your code below so you return the correct resultIterable<Integer>result=null;returnresult;}
Genereer een checkneigbours.jar file van deze Javaklasse in de build/libs directory van je gradle project. Kopiëer de jar-file naar de folder “./dependency-management/” (dus ./dependency-management/checkneigbours.jar).
Commit nu de veranderingen en tag als v5.
Opdracht 6:
Maak een folder in de “./dependency-management/” directory van deze git repository genaamd ‘checkneighbours_example’.
Extract het volgende gradle project in dit zip bestand naar die ./dependency-management/checkneighbours_example directory en los alle TODO’s in de documenten op. (Je kan met de TODO tree extensie in VSCode gemakkelijk zien of je alle TODO’s gedaan hebt) OF clone via subtree de repository van het startproject into ./dependency-management/checkneighbours_example.
kopieer je checkneigbours.jar naar de app/lib directory van het gradle project
Pas de build.gradle aan zodat de main-method in de App.java correct gebuild en gerund kan worden.
Voeg ook het volgende toe aan de .gitignore-file in de root van je repository.
Krijg je een type error in de main method van App.java in het ‘checkneighbours_example’ project. Verwissel de code ``
Test Driven Development
Opdracht 7:
Voeg minstens 6 unittesten toe aan je gradle project checkneighbours waarbij de unittesten je oplossing voor de getSameNeighboursIds-methode testen van de CheckNeighboursInGrid-klasse. De testen moeten aan volgende vereisten voldoen:
Gebruik de correcte naamgeving voor de testen
Gebruik het correcte Arrange, Act, Assert principe
Maak minstens 1 test die een Exception test
Probeer de randgevallen te testen en elke test moet een ander scenario testen. (bv. 1 test waarbij je een element aan de linker rand test mag maar 1 keer voorkomen. Eentje in een hoek testen kan dan wel al een ander scenario zijn.)
Commit nu de veranderingen en tag als v7 en push.
Opdracht 8: getSameNeighboursIds functie in python testen
Maak een folder in de root van je repository aan genaamd “./tdd/”.
Extract het volgende python project in dit zip bestand naar die ./tdd directory OF clone via subtree de repository het startproject into ./tdd.
Het project bevat een checkneighbours.py-bestand met een implementatie van de functie get_same_neighbours_ids()
Het project bevat ook een file checkneighbours_test.py met het geraamte van het unittest framework al ingevuld.
Commit nu de veranderingen en tag als v8 en push.
Continuous Integration and Continuous Deployment
Opdracht 9:
Gebruik Github Actions om een CI-pipeline aan te maken die steeds je testen van je ‘checkneighbours’ uit opdracht 7 uitvoert wanneer je naar de main branch pusht. Voorzie ook een badge in een README.md in de root folder van je git directory zodat je in één opslag kan zien wanneer de tests falen.
Let op! Je wil nu dus de testen runnen van een specifiek gradle project dat in een subdirectory staat. Meer info hier
Commit nu de veranderingen en tag als v9 en push.
Opdrachten deel 2
Deze opdrachten worden enkel door de studenten van SES gemaakt en ingediend via Github Classroom.
Gebruik van GenAI en samenwerking
GenAI (ChatGPT, Copilot, Claude, …) maakt een razendsnelle opgang in het leven van iedereen, en dus ook van informatici. Deze tools zijn dan ook zeer onderlegd in het schrijven van (beperkte hoeveelheden) code.
Omdat dit deel van het vak gericht is op het zelf leren programmeren, en de opdrachten gequoteerd worden, is het niet toegestaan om dergelijke tools in te schakelen om de opdrachten op te lossen. Met andere woorden, we verwachten dus dat je alle ingediende code zelf geschreven hebt. Ook code delen met mede-studenten, of buitenstaanders om hulp vragen, is om dezelfde reden niet toegestaan.
Op het examen zal een examenvraag toegevoegd worden die nagaat in welke mate je deze opdracht effectief onder de knie hebt. De score op die vraag zal ook invloed hebben op de punten van je werk doorheen het semester.
Tenslotte is het absoluut geen probleem om GenAI tools te gebruiken bij het studeren en verwerken van de leerstof. Begrijp je een concept niet goed, en wil je meer uitleg of voorbeelden? Wil je hulp bij het oplossen van een oefening? Krijg je een compiler error niet opgelost? Gebruik deze tools dan gerust (hou er wel rekening mee dat er ook fouten in hun antwoord kunnen zitten)! Maar gebruik ze dus niet voor de opdracht.
Setup
Volg onderstaande stappen nauwgezet om je project juist te configureren.
Maak hier (via GitHub Classroom) je repository aan voor de assignment “Opdrachten deel 2”. Deze repository is nog leeg.
Clone jouw (lege) repository naar een folder op je eigen machine.
Voeg vervolgens een tweede remote aan die repository op je machine toe, namelijk git@github.com:KULeuven-Diepenbeek/ses-startcode-deel2-2425.git onder de naam startcode. Dat kan je met volgend commando:
Je lokale repository heeft nu dus twee remote repositories (kijk dit na met git remote -v):
origin: je eigen GitHub repository
startcode: de GitHub repository met de startcode die door ons aangeleverd wordt
Haal de laatste versie van de startcode op en merge die in je repository via git pull startcode main. Doe dit minstens voor elke nieuwe opdracht, en eventueel ook tussendoor (als er wijzigingen/bugfixes aan onze startcode gebeurd zijn).
Open de folder CandyCrush uit de repository als project in IntelliJ. Opgelet! zorg dat je de CandyCrush folder als project opent in IntelliJ, en niet de bovenliggende folder van je repository (ses-opdrachten-deel2-...)!
Als alles goed gegaan is, wordt je project herkend en geïmporteerd als een Gradle-Java-project. Het is normaal dat de testen nog fouten geven bij het compileren, aangezien die code verwachten die je nog moet schrijven als deel van de opdracht.
Je kan normaalgezien wel al de main-methode in CandyCrushMain uitvoeren.
Startcode
De startcode bevat een JavaFX-applicatie voor het spel CandyCrush.
Het is een Gradle-project voor IntelliJ, en maakt gebruik van Java 21.
Je kan de folder openen als project in IntelliJ.
De applicatie is gestructureerd volgens het Model-View-Controller (MVC) patroon.
Er zijn ook reeds enkele testen voorgedefinieerd met AssertJ, maar de set van testen is niet volledig. De voorgedefinieerde testen dienen voornamelijk om na te gaan of je oplossing automatisch getest kan worden.
Belangrijk!
Omdat je inzendingen (deels) automatisch verbeterd zullen worden, is het noodzakelijk dat alle gegeven testen compileren zonder enige aanpassingen.
Je mag uiteraard wel extra testen toevoegen.
Ook is het geen groot probleem indien een bepaalde test niet slaagt — zolang hij maar uitgevoerd kan worden.
Opdracht 1: Records
Startcode
Merge eerst de laatste versie van de startcode in je repository door git pull startcode main uit te voeren in jouw lokale repository.
Maak, in package ses.candycrush.board een record genaamd BoardSize dat de grootte van een candycrush speelveld voorstelt als een aantal rijen (rows) en aantal kolommen (columns).
Het aantal rijen en kolommen moeten beiden groter zijn dan 0, zoniet gooi je een IllegalArgumentException.
Maak, in hetzelfde package, ook een tweede record genaamd Position dat een geldige positie van een cel op een candycrush-speelveld voorstelt (row en column).
Rijen en kolommen worden genummerd vanaf 0.
Aan de constructor van een Position-object moeten een rij- en kolomnummer alsook een BoardSize meegegeven worden.
Indien de positie ongeldig is voor de grootte van het speelveld, moet je een IllegalArgumentException gooien.
Voeg in Position volgende methodes toe, samen met zinvolle tests voor elke methode:
een methode int toIndex() die de positie omzet in een index. Voor veld met 2 rijen en 4 kolommen lopen de indices als volgt:
0 1 2 3
4 5 6 7
een statische methode Position fromIndex(int index, BoardSize size) die de positie teruggeeft die overeenkomt met de gegeven index.
Deze methode moet een IllegalArgumentException gooien indien de index ongeldig is.
methodes boolean isFirstRow(), boolean isFirstColumm(), boolean isLastRow(), en boolean isLastColumn() die aangeven of de positie zich in de eerste/laatste rij/kolom van het bord bevindt.
een methode Collection<Position> neighbors() die alle posities van (geldige) directe buren (horizontaal en vertikaal) in het speelveld teruggeeft.
een methode boolean isNeighborOf(Position other) die nagaat of de gegeven positie een directe buur is van de huidige positie. Gooit een IllegalArgumentException als de gegeven positie bij een andere bordgrootte hoort.
Voeg in BoardSize de volgende methodes toe, samen met zinvolle tests:
een methode Collection<Position> positions() die een collectie (bv. een ArrayList) met daarin alle posities op het bord teruggeeft.
Voeg, in package ses.candycrush.model, een sealed interfaceCandy toe, met subklassen (telkens een record, die je in de Candy-interface plaatst) voor
NoCandy, wat staat voor het ontbreken van een snoepje.
NormalCandy, met een attribuut color (een int met mogelijke waarden 0, 1, 2, of 3); je gooit een IllegalArgumentException indien een ongeldige kleur opgegeven wordt.
Elk van de volgende speciale soorten snoepjes:
een RowSnapper
een MultiCandy
een RareCandy
een TurnMaster
Voeg, in het package ses.candycrush.model, een record Switch toe. Een Switch-object stelt een mogelijke wissel voor tussen twee posities first en second.
Beide posities moeten buren zijn van elkaar; je constructor moet een IllegalArgumentException gooien indien dat niet het geval is.
Zorg ervoor dat het niet uitmaakt in welke volgorde de twee posities meegegeven worden aan de constructor; maar het veld first moet uiteindelijk de positie bevatten met de kleinste index (zoals gedefinieerd bij toIndex()).
Voeg aan dat Switch-record een operatie other(Position pos) toe die de andere positie teruggeeft dan de gegeven positie (dus als je first meegeeft, krijg je second terug, en omgekeerd).
Indien de gegeven positie geen deel uitmaakt van het Switch-object, gooi je een IllegalArgumentException.
Pas nu je code (CandyCrushGame, CandyCrushBoardUI, en Controller) aan zodat die op zoveel mogelijk plaatsen gebruik maakt van bovenstaande records in plaats van int’s (Switch moet je nog niet gebruiken). Dus:
op elke plaats waar voorheen een int voor width en/of height gebruikt of teruggegeven werd, moet nu BoardSize gebruikt worden
op elke plaats waar voorheen een rij- en/of kolomnummer gebruikt of teruggegeven werd, moet nu een Position object gebruikt worden
op elke plaats waar voorheen een int gebruikt of teruggegeven werd om een snoepje aan te duiden, moet nu een Candy object gebruikt worden.
In de klasse CandyCrushBoardUI moet je pattern matching gebruiken om een JavaFX Node aan te maken voor de gegeven candy op de gegeven positie.
Laat de compiler je helpen met het vinden van de nog aan te passen code, door bv. eerst de type van een veld te veranderen.
Maak in de model-klasse CandyCrushGame een publieke methode Collection<Switch> getPotentialSwitchesOf(Position pos) die alle mogelijke wissels teruggeeft (bv. in een ArrayList) van positie pos. Positie pos kan wisselen met een andere positie indien (1) ze buren zijn; (2) geen van beiden een NoCandy zijn; én (3) de snoepjes op beide posities verschillend zijn (qua soort of kleur). (Deze methode zullen we in een latere opdracht verfijnen, maar voorlopig volstaat dit).
In de update()-methode van CandyCrushBoardUI kan je nu code uit commentaar halen, gerelateerd aan het tonen van hints (zie de TODO daarin).
Als je dit alles correct gedaan hebt, zou alle code moeten compileren, zou de applicatie moeten uitvoeren (./gradlew run), en zouden de testen moeten slagen.
Tag het resultaat als v1 en push dit naar jouw remote repository (origin) op GitHub: git push origin.
Opdracht 2: Generics
Startcode
Merge eerst de laatste versie van de startcode in je repository door git pull startcode 02-generics uit te voeren in de main-branch van jouw lokale repository.
Een rechthoekig spelbord met cellen (vakjes) kan ook voor andere spellen dan Candycrush gebruikt worden; denk bijvoorbeeld aan schaken, dammen, zeeslag, go, … . In deze opdracht ga je daarom een algemene klasse ontwikkelen voor een rechthoekig spelbord.
Maak, in package ses.candycrush.board, een generische Board-klasse, met een generische parameter die het type van elke cel weergeeft.
Deze klasse moet maximaal gebruik maken van de BoardSize en Position records uit de vorige opdracht. De inhoud van de cellen kan je bijhouden als een array of ArrayList.
De constructor van Board vereist enkel een BoardSize. Alle cellen zijn initieel null.
Voeg volgende publieke methodes toe en implementeer ze:
BoardSize getSize() die de grootte van het bord teruggeeft (als BoardSize-object)
boolean isValidPosition(position) die nagaat of de gegeven positie geldig is voor dit bord (dus of ze in het bord ligt).
getCellAt(position) om de cel op een gegeven positie van het bord op te vragen. Als de positie ongeldig is, gooi je een IllegalArgumentException.
void replaceCellAt(position, newCell) om de cel op een gegeven positie te vervangen door een meegegeven object. Als de positie ongeldig is, gooi je een IllegalArgumentException.
void fill(cellCreatorFunction) om het hele bord te vullen met objecten die teruggegeven worden door de cellCreatorFunction. De cellCreatorFunction is een java.util.function.Function-object dat, gegeven een Position-object als argument, het cel-object teruggeeft wat op die positie geplaatst moet worden.
een methode copyTo(otherBoard) die alle cellen van het huidige bord kopieert naar het meegegeven bord. Als het meegegeven bord niet dezelfde afmetingen heeft, gooi je een IllegalArgumentException.
Zorg dat deze laatste twee methodes zo algemeen mogelijk zijn qua type, en schrijf telkens een test waarin je hier gebruik van maakt.
Gebruik de Board-klasse nu zoveel mogelijk in CandyCrushGame, waarbij de cellen Candy-objecten zijn.
Tag het resultaat als v2 en push dit naar je remote repository op GitHub.
Opdracht 3: Collections
Startcode
Merge eerst de laatste versie van de startcode in je repository door git pull startcode 03-collections uit te voeren in de main-branch van jouw lokale repository.
Hou in je klasse Board de elementen (cellen) bij via een Map, met Position als de sleutel.
Hou ook een omgekeerde Map bij, dus van een element (cel) naar alle posities (in een Set) waarop dat element voorkomt.
Dus: (in de context van CandyCrush) kan je uit deze Map een Set halen met alle posities waarop een bepaalde Candy voorkomt.
Deze structuur moet altijd overeenkomen met de andere map (van positie naar cel).
Zorg er dus voor dat andere klassen die de klasse Board gebruiken beide maps enkel via gepaste methodes kunnen aanpassen.
Voorzie een methode getPositionsOfElement die alle posities teruggeeft waarop het gegeven element (cel) voorkomt, gebruik makend van de Map van hierboven.
De teruggegeven collectie mag niet aanpasbaar zijn (dus: de ontvanger mag ze niet kunnen aanpassen).
Als je klasse Board goed geëncapsuleerd was, hoef je geen andere code (buiten de klasse Board) aan te passen na bovenstaande wijzigingen.
Tag het resultaat als v3 en push dit naar je remote repository op Github.
Opdracht 4: Concurrency
Startcode
Merge eerst de laatste versie van de startcode in je repository door git pull startcode 04-concurrency uit te voeren in de main-branch van jouw lokale repository.
In deze opdracht gaan we uit van een situatie dat er meerdere spelers (threads) tegelijk met één Board-object werken.
Maak de fill-methode in de Board-klasse thread-safe. Wanneer meerdere threads fill oproepen, moet het resultaat overeenkomen met dan van één van de threads (met andere woorden, het uiteindelijke bord mag niet deels opgevuld zijn door één thread, en deels door een andere).
Schrijf een test (met jcstress) die het correcte gedrag van fill nagaat.
In de startcode is jcstress al toegevoegd als dependency, en staat er een klasse ses.candycrush.board.Assignment04_Concurrency_Tests klaar in de map src/jcstress/java die je kan aanpassen. Vergeet niet eerst je gradle configuratie te herladen in IntelliJ.
Schrijf in package ses.candycrush.experiment een klasse MultithreadedBoardClient met een main-methode.
In die methode maak je een Board<Integer> aan (met grootte 10x10 en 0 als initiële waarde voor elke cel).
Gebruik vervolgens een Executor om 10 threads te maken die elk de waarde van elke cel verhogen met 1.
Als dat correct gebeurt, zodat alle cellen van het bord uiteindelijk waarde 10 moeten hebben (ga dat na).
(Dit client-programma moet geen GUI starten en/of JavaFX gebruiken.)
Tag het resultaat als v4 en push dit naar je remote repository op Github.