Collections & Kotlin Advanced
Leer hoe Kotlin verzamelingen verwerkt, waarom read-only niet hetzelfde is als immutable en hoe we de bestaande MonsterBattles API uitbreiden met zoeken en sorteren. De Week 04-domeinclasses en CRUD-routes blijven intact.
Wat bouwen we deze week?
We voegen twee optionele queryparameters toe aan GET /monsters: element (FIRE, WATER, WIND) en sort (name, speed). Zonder sortering blijft ID oplopend. Sorteren op name gebeurt op naam oplopend en daarna ID; sorteren op speed gebeurt op snelheid aflopend en daarna ID.
De repository-interface, het domeinmodel en de bestaande vijf CRUD-endpoints veranderen niet. Alleen MonsterService.list(...) en de bestaande GET-route worden uitgebreid. De meer geavanceerde collectieoperaties blijven leerzame zelfstandige voorbeelden.
main(), soms een contextfragment. De integratie hieronder toont de exacte bestandslocaties en sluit aan op de Week 04-code. Output is verwachte uitvoer, behalve waar een test daadwerkelijk wordt uitgevoerd.De 41 onderwerpen
Bij ieder onderwerp bespreken we definitie, werking, code, verwachte output, ontwerpkeuze en toepassing. Concepten die geen echte functie van MonsterBattles zijn, blijven bewust zelfstandige oefeningen.
01Collection hierarchy
Wat is het? Kotlin gebruikt Iterable en Collection als basis voor meerdere collectiontypes. List bewaart volgorde en kan dubbele waarden bevatten; Set bevat unieke elementen; Map koppelt keys aan waarden. Map valt niet onder Collection, maar wordt wel als collectieachtige structuur behandeld.
Hoe werkt het? Map is geen subtype van Collection; Iterable en Collection vormen de basis van onder andere List en Set.
val names: List<String> = listOf("Pyron", "Aquara")
val elements: Set<String> = setOf("FIRE", "WATER", "FIRE")
val byId: Map<Int, String> = mapOf(1 to "Pyron")
println("${names.size} monsters, ${elements.size} elements, ${byId[1]}")2 monsters, 2 elements, PyronWaarom en wanneer anders? Kies List voor responses, Set voor uniekheid en Map voor ID-opslag.
MonsterBattles: Gebruik List voor een monsterlijst, Set voor unieke elementen en Map om monsters snel op ID op te zoeken.
02Read-only versus mutable
Wat is het? List, Set en Map bieden een read-only interface: je kunt via die referentie geen wijzigingen uitvoeren. MutableList, MutableSet en MutableMap bieden muterende operaties. Read-only garandeert niet dat de onderliggende data onveranderlijk zijn.
Hoe werkt het? Een read-only referentie voorkomt mutaties via die referentie, maar de eigenaar kan de onderliggende collectie nog veranderen.
val roster: List<String> = listOf("Pyron", "Aquara")
val editable: MutableList<String> = mutableListOf("Pyron")
editable.add("Aquara")
println("${roster.size} / ${editable.size}")2 / 2Waarom en wanneer anders? Geef repository-inhoud niet als rechtstreeks aanpasbare MutableList terug.
MonsterBattles: Laat de service bij voorkeur een read-only List retourneren, ook wanneer de repository intern mutabele opslag gebruikt.
03val versus mutable inhoud
Wat is het? val voorkomt het opnieuw toewijzen van een variabele. Het bevriest niet het object waarnaar de variabele verwijst. Een MutableList achter een val blijft wijzigbaar.
Hoe werkt het? val blokkeert her-toewijzing van de referentie, niet mutaties via bijvoorbeeld add of remove.
val monsters = mutableListOf("Pyron")
monsters.add("Aquara")
println(monsters)
// monsters = mutableListOf("Zephyra") // Compile error.[Pyron, Aquara]Waarom en wanneer anders? Gebruik private val voor de in-memory opslag en scherm mutaties af met repositorymethoden.
MonsterBattles: Een private val met een MutableMap kan prima repository-opslag zijn, zolang buitenstaanders die map niet rechtstreeks ontvangen.
04Iterable
Wat is het? Een Iterable levert via iterator() een manier om elementen één voor één te doorlopen. for werkt op Iterable zonder dat je de index hoeft te beheren.
Hoe werkt het? for gebruikt de iterator van een Iterable; indexing is niet nodig om elk element te bezoeken.
val names: Iterable<String> = listOf("Pyron", "Aquara")
for (name in names) println(name)Pyron
AquaraWaarom en wanneer anders? Lees monsters achtereenvolgens uit een snapshotlijst.
MonsterBattles: Laat een verzameling monsters doorlopen om bijvoorbeeld alle namen weer te geven.
05Iterator
Wat is het? Met iterator() krijg je een Iterator. hasNext() controleert of er een volgend element is; next() leest dit. next() op een lege iterator veroorzaakt een exception.
Hoe werkt het? hasNext checkt of next veilig kan worden aangeroepen; next zonder volgend item gooit een exception.
val it = listOf("Pyron", "Aquara").iterator()
while (it.hasNext()) println(it.next())Pyron
AquaraWaarom en wanneer anders? Een for-lus is meestal leesbaarder dan een handmatige iterator.
MonsterBattles: Een Iterator legt uit wat achter een for-loop gebeurt.
06MutableIterator
Wat is het? Een MutableIterator kan elementen tijdens iteratie verwijderen via remove() na next(). Wijzig niet willekeurig de lijst rechtstreeks tijdens die iteratie.
Hoe werkt het? remove na next verwijdert het zojuist gelezen item uit een MutableIterator.
val monsters = mutableListOf("Pyron", "Aquara")
val it = monsters.iterator()
while (it.hasNext()) if (it.next() == "Aquara") it.remove()
println(monsters)[Pyron]Waarom en wanneer anders? Gebruik voor eenvoudige filtering filter; kies een muterende iterator wanneer in-place verwijderen echt nodig is.
MonsterBattles: In-memory opslag kan in bepaalde gevallen tijdens een doorloop gefilterd worden.
07List
Wat is het? Een List is geordend, indexgebaseerd en staat duplicaten toe. Met getOrNull(index) lees je veilig een positie die mogelijk ontbreekt.
Hoe werkt het? List bewaart volgorde en duplicaten; veilige indexopvragen kunnen met getOrNull.
val names = listOf("Pyron", "Aquara", "Pyron")
println(names[0])
println(names.getOrNull(9))Pyron
nullWaarom en wanneer anders? De publieke GET /monsters-response is een geordende List.
MonsterBattles: Een /monsters-response is doorgaans een geordende lijst van monsterobjecten.
08MutableList
Wat is het? Een MutableList biedt add, remove, removeAt en set. Het type is geschikt voor een kleine, tijdelijke verzameling; bij veel opzoeken op ID kan een Map handiger zijn.
Hoe werkt het? MutableList biedt add, remove en set; het type maakt mutaties in de huidige context mogelijk.
val monsters = mutableListOf("Pyron")
monsters.add("Aquara")
monsters[0] = "Pyron Prime"
println(monsters)[Pyron Prime, Aquara]Waarom en wanneer anders? Een interne collectie kan mutabel zijn, terwijl de API alleen snapshots teruggeeft.
MonsterBattles: In een eerste prototype worden monsters toegevoegd en verwijderd in geheugen.
09Set
Wat is het? Een Set bevat geen dubbele elementen volgens equals/hashCode. De Set-interface belooft geen specifieke iteratievolgorde; sommige implementaties behouden die wel.
Hoe werkt het? Een Set houdt unieke elementen bij op basis van equals/hashCode; een HashSet garandeert geen voorspelbare iteratievolgorde.
val elements = setOf("FIRE", "WATER", "FIRE")
println(elements.size)
println("FIRE" in elements)2
trueWaarom en wanneer anders? Unieke elementwaarden kunnen in een Set worden verzameld.
MonsterBattles: Gebruik een Set als alleen unieke elementnamen of skill-ID’s nodig zijn.
10MutableSet
Wat is het? Met MutableSet kun je waarden toevoegen of verwijderen. add retourneert false als de waarde al aanwezig is.
Hoe werkt het? MutableSet biedt add en remove, maar niet automatisch indexering.
val elements = mutableSetOf("FIRE")
println(elements.add("WATER"))
println(elements.add("FIRE"))
println(elements.size)true
false
2Waarom en wanneer anders? Gebruik dit wanneer dubbele waarden niet zijn toegestaan.
MonsterBattles: Een verzameling unieke unlocked skill-ID’s kan hiermee worden gemodelleerd zonder duplicaten.
11Map
Wat is het? Een Map koppelt unieke keys aan waarden. De indexoperator geeft een nullable waarde terug als een key ontbreekt. In Kotlin zijn een key en de bijbehorende value generieke types.
Hoe werkt het? Map biedt lookup per key en valt niet onder de interface Collection.
val monsters = mapOf(1 to "Pyron", 2 to "Aquara")
println(monsters[2])
println(monsters[99])Aquara
nullWaarom en wanneer anders? Monster-ID kan de key van een interne map zijn.
MonsterBattles: Een repository kan op numerieke ID direct het bijbehorende monster vinden.
12MutableMap
Wat is het? Een MutableMap ondersteunt put, remove en vervanging via de indexoperator. Let op het verschil tussen een ontbrekende key en een nullable value.
Hoe werkt het? MutableMap laat entries toevoegen, vervangen en verwijderen; get() kan null retourneren.
val monsters = mutableMapOf(1 to "Pyron")
monsters[2] = "Aquara"
monsters.remove(1)
println(monsters[2])AquaraWaarom en wanneer anders? Een linkedMapOf behoudt invoegvolgorde, maar de API sorteert alsnog expliciet op ID.
MonsterBattles: Bij POST, PUT en DELETE kan een in-memory repository een MutableMap<Integer, Monster> aanpassen.
13Collection constructors
Wat is het? listOf, setOf en mapOf maken read-only views; mutableListOf, mutableSetOf en mutableMapOf maken mutabele verzamelingen. buildList kan stapsgewijs een read-only List opleveren.
Hoe werkt het? listOf, setOf en mapOf maken read-only collectiereferenties; buildList bouwt een resultaat via een scope.
val roster = buildList {
add("Pyron")
add("Aquara")
}
println(roster)[Pyron, Aquara]Waarom en wanneer anders? Kies de constructor die het gewenste contract het duidelijkst maakt.
MonsterBattles: Kies constructors op basis van de bewerkingen die de repository nodig heeft.
14Collection properties
Wat is het? size vertelt hoeveel items aanwezig zijn; isEmpty en isNotEmpty maken condities leesbaar. Deze operations zijn direct beschikbaar op List en Set en vergelijkbare functies op Map.
Hoe werkt het? size, isEmpty en isNotEmpty geven vaak leesbaardere controle dan handmatig tellen.
val monsters = listOf("Pyron", "Aquara")
println(monsters.size)
println(monsters.isNotEmpty())2
trueWaarom en wanneer anders? Controleer of de zoekresultaten leeg zijn zonder zelf over de lijst te itereren.
MonsterBattles: Controleer of er monsters beschikbaar zijn voordat je bijvoorbeeld een battle start.
15List indexing
Wat is het? indices geeft de geldige indexrange, lastIndex is de laatste index en subList(fromIndex, toIndex) geeft een view voor het halfopen interval. Controleer lege lijsten voordat je lastIndex gebruikt.
Hoe werkt het? indices en lastIndex volgen de geldige indexen; subList is een view voor veel List-implementaties.
val names = listOf("Pyron", "Aquara", "Zephyra")
println(names.indices.toList())
println(names.subList(0, 2))[0, 1, 2]
[Pyron, Aquara]Waarom en wanneer anders? Gebruik indexing waar positie betekenis heeft; voor zoeken is find beter.
MonsterBattles: Handig bij paginering of bij het tonen van een beperkt aantal monsters.
16equals en hashCode
Wat is het? Set en Map gebruiken equals en hashCode om objecten als dezelfde sleutel/waarde te herkennen. Data classes genereren die methoden op basis van constructorproperties. Voor mutabele keys is extra voorzichtigheid nodig.
Hoe werkt het? Bij een Set en Map bepalen equals en hashCode de waarde-identiteit; gewone classes en data classes verschillen hierin.
data class MonsterKey(val id: Int, val element: String)
val keys = setOf(MonsterKey(1, "FIRE"), MonsterKey(1, "FIRE"))
println(keys.size)1Waarom en wanneer anders? Monster is bewust een gewone class; vergelijk voor uniekheid expliciet op monster.id.
MonsterBattles: Gebruik bij voorkeur stabiele monster-ID’s als mapkeys in plaats van objecten met veranderlijke HP.
17map()
Wat is het? map() verandert elk element in een nieuwe waarde en behoudt de oorspronkelijke collectie. Het resultaat van map op een List is een nieuwe List.
Hoe werkt het? map produceert per inputelement één resultaat zonder het origineel te veranderen.
val names = listOf("Pyron", "Aquara")
println(names.map { it.uppercase() })[PYRON, AQUARA]Waarom en wanneer anders? Map Monster naar MonsterResponse in de bestaande service.
MonsterBattles: Zet interne monsterobjecten om naar MonsterResponse DTO’s.
18filter()
Wat is het? filter() behoudt elementen waarvoor een Boolean-lambda true teruggeeft. De bronlijst verandert niet.
Hoe werkt het? filter levert alleen elementen die aan de predicate voldoen; het oorspronkelijke overzicht blijft intact.
data class Monster(val name: String, val element: String)
val monsters = listOf(Monster("Pyron", "FIRE"), Monster("Aquara", "WATER"))
println(monsters.filter { it.element == "FIRE" }.map { it.name })[Pyron]Waarom en wanneer anders? Het optionele Element-filter verwerkt de lijst van repository.findAll().
MonsterBattles: Gebruik filter voor GET /monsters?element=FIRE.
19find()
Wat is het? find() retourneert de eerste match of null als niets voldoet. Gebruik singleOrNull als precies één resultaat vereist is en mogelijke dubbele matches relevant zijn.
Hoe werkt het? find levert het eerste passende element of null; first is niet veilig bij ontbrekende matches.
val names = listOf("Pyron", "Aquara")
println(names.find { it.startsWith("A") })
println(names.find { it.startsWith("Z") })Aquara
nullWaarom en wanneer anders? Voor een ID-lookup is repository.findById gerichter dan telkens findAll doorlopen.
MonsterBattles: Een monster kan op naam gezocht worden; voor exacte ID-lookup is een repository-map vaak beter.
20any() en all()
Wat is het? any() controleert of minstens één element voldoet; all() controleert of alle elementen voldoen. Voor een lege collectie is all() true (vacuous truth).
Hoe werkt het? any stopt zodra één match bestaat; all stopt zodra een element faalt. Voor een lege collectie is all waar.
val hp = listOf(100, 110, 0)
println(hp.any { it == 0 })
println(hp.all { it >= 0 })true
trueWaarom en wanneer anders? Gebruik predicates om regels te controleren zonder extra tellers.
MonsterBattles: Gebruik all om te controleren of alle aangeleverde monsterstats niet-negatief zijn.
21Sorting
Wat is het? sortedBy en sortedByDescending leveren een nieuwe gesorteerde lijst. De oorspronkelijke lijst wordt niet aangepast. sortBy mutereert juist een MutableList.
Hoe werkt het? sortedBy en sortedWith maken een nieuwe geordende List; thenBy maakt een stabiele tie-break-regel expliciet.
data class Monster(val name: String, val speed: Int)
val monsters = listOf(Monster("Pyron", 110), Monster("Aquara", 95))
println(monsters.sortedByDescending { it.speed }.map { it.name })[Pyron, Aquara]Waarom en wanneer anders? Sort=name betekent naam oplopend met ID tie-break; speed betekent snelheid aflopend met ID tie-break.
MonsterBattles: Sorteer monsters op Speed voor GET /monsters?sort=speed.
22Comparable
Wat is het? Comparable<T> definieert de natuurlijke volgorde van een type met compareTo. Gebruik dit alleen wanneer je een logische standaardvolgorde hebt.
Hoe werkt het? Comparable definieert natuurlijke ordening in de class; compareTo geeft negatief, nul of positief terug.
data class Ranking(val points: Int) : Comparable<Ranking> {
override fun compareTo(other: Ranking): Int = points.compareTo(other.points)
}
println(listOf(Ranking(5), Ranking(2)).sorted())[Ranking(points=2), Ranking(points=5)]Waarom en wanneer anders? Monster heeft geen vanzelfsprekende natuurlijke sortering: laat het model daarom niet Comparable implementeren.
MonsterBattles: Monsters hebben geen vanzelfsprekende standaardvolgorde; daarom kan een specifieke Comparator leesbaarder zijn.
23Comparator
Wat is het? Een Comparator<T> beschrijft een sorteerregel zonder het type zelf te veranderen. compareBy en thenBy maken samengestelde sorteringen.
Hoe werkt het? Comparator beschrijft een externe volgorde; combineer sorteercriteria met thenBy.
data class Monster(val name: String, val speed: Int)
val monsters = listOf(Monster("Pyron", 110), Monster("Zephyra", 110))
val order = compareByDescending<Monster> { it.speed }.thenBy { it.name }
println(monsters.sortedWith(order).map { it.name })[Pyron, Zephyra]Waarom en wanneer anders? MonsterService kiest een Comparator zonder het model te vervuilen.
MonsterBattles: Bepaal eerst Speed aflopend en gebruik naam als voorspelbare tie-breaker in een overzicht (niet in de battle-turn-regel).
24associateBy()
Wat is het? associateBy() bouwt een Map waarbij elke waarde onder een berekende key terechtkomt. Bij dubbele keys overschrijft de laatste match de vorige.
Hoe werkt het? associateBy maakt een Map met een sleutel per item; gelijke sleutels overschrijven eerder opgenomen waarden.
data class Monster(val id: Int, val name: String)
val monsters = listOf(Monster(1, "Pyron"), Monster(2, "Aquara"))
println(monsters.associateBy { it.id }[2]?.name)AquaraWaarom en wanneer anders? Indexeer een lijst op monster.id wanneer ID-uniekheid gegarandeerd is.
MonsterBattles: Zet een monsterlijst om in een map met monster-ID als key voor snelle lookup.
25associate()
Wat is het? associate() bouwt key-value pairs uit ieder element. Gebruik to om een Pair te maken; ook hier moeten keys uniek zijn als je niets wilt overschrijven.
Hoe werkt het? associate maakt uit elk item een Pair van key en value; het laatste item wint bij dubbele keys.
data class Monster(val id: Int, val name: String)
val monsters = listOf(Monster(1, "Pyron"), Monster(2, "Aquara"))
println(monsters.associate { it.id to it.name }){1=Pyron, 2=Aquara}Waarom en wanneer anders? Een aparte metadataindex kan worden opgebouwd zonder het hoofdmodel te wijzigen.
MonsterBattles: Bouw bijvoorbeeld een compacte index ID → naam voor een API-overzicht.
26groupBy()
Wat is het? groupBy() verzamelt alle elementen met dezelfde key in een List. Dat verschilt van associateBy, dat maximaal één value per key bewaart.
Hoe werkt het? groupBy levert voor iedere key een lijst met matches; dit is anders dan associateBy dat één waarde per key bewaart.
data class Monster(val name: String, val element: String)
val monsters = listOf(Monster("Pyron", "FIRE"), Monster("Ember", "FIRE"))
println(monsters.groupBy { it.element }.mapValues { it.value.size }){FIRE=2}Waarom en wanneer anders? Groepeer monsters naar Element voor een rapportagevoorbeeld, niet als verplichte API-route.
MonsterBattles: Groepeer monsters op FIRE, WATER en WIND voor een statistiekoverzicht.
27partition()
Wat is het? partition() splitst een collectie in twee lijsten op basis van een predicaat: matches en niet-matches. Het retourneert een Pair.
Hoe werkt het? partition verdeelt de volledige lijst in twee groepen op basis van één predicate.
val hp = listOf(100, 0, 35)
val (alive, defeated) = hp.partition { it > 0 }
println("$alive | $defeated")[100, 35] | [0]Waarom en wanneer anders? Dit is een los analysevoorbeeld en geen nieuwe basisgamefeature.
MonsterBattles: Verdeel monsters in actief (HP > 0) en verslagen (HP == 0).
28chunked()
Wat is het? chunked(size) splitst elementen in niet-overlappende blokken. De laatste chunk mag kleiner zijn dan size.
Hoe werkt het? chunked splitst een lijst in blokken; het laatste blok kan korter zijn.
val names = listOf("Pyron", "Aquara", "Zephyra", "Ember", "Ripple")
println(names.chunked(2))[[Pyron, Aquara], [Zephyra, Ember], [Ripple]]Waarom en wanneer anders? Een eenvoudig verwerkingsvoorbeeld; echte paginering heeft een expliciet API-contract nodig.
MonsterBattles: Maak voor een beperkte collectie groepjes monsters voor presentatierijen; echte API-paginering vraagt eigen afspraken.
29windowed()
Wat is het? windowed(size, step) maakt vensters; standaard overlappen opeenvolgende vensters. partialWindows bepaalt of onvolledige vensters worden toegevoegd.
Hoe werkt het? windowed levert vensters van opeenvolgende items; standaard kunnen deze overlappen.
val damage = listOf(18, 20, 15, 25)
println(damage.windowed(size = 2))[[18, 20], [20, 15], [15, 25]]Waarom en wanneer anders? Gebruik dit alleen als naburige items geanalyseerd moeten worden, niet als gewone paginering.
MonsterBattles: Gebruik windowed bijvoorbeeld om opeenvolgende damagewaarden in een battlelog te analyseren.
30Sequence
Wat is het? Een Sequence kan bewerkingen lazy uitvoeren: transformaties worden pas uitgevoerd wanneer een terminal operation de resultaten opvraagt. Een Sequence is niet automatisch sneller.
Hoe werkt het? Een Sequence verwerkt tussenstappen lazy tijdens een terminal operation zoals toList; niet iedere pipeline wordt daardoor sneller.
val result = sequenceOf(1, 2, 3, 4)
.filter { it % 2 == 0 }
.map { it * 10 }
.toList()
println(result)[20, 40]Waarom en wanneer anders? Voor een kleine monsterlijst is een gewone List doorgaans eenvoudiger.
MonsterBattles: Bij grote monstercollecties met meerdere filters kan een Sequence tussentijdse lijsten vermijden.
31List versus Sequence
Wat is het? Collection-operations zoals map/filter op List verwerken de elementen direct (eager). Bij Sequence blijven tussenstappen lazy. Kies op basis van leesbaarheid, hoeveelheid data en aantal operators; benchmark bij prestatievragen.
Hoe werkt het? List map/filter werken eager; Sequence stelt tussenresultaten uit en kan bij grote pipelines nuttig zijn.
val values = listOf(1, 2, 3)
val eager = values.map { it * 2 }
val lazy = values.asSequence().map { it * 2 }
println(eager)
println(lazy.toList())[2, 4, 6]
[2, 4, 6]Waarom en wanneer anders? Kies pas sequences wanneer metingen of datasetgrootte dat rechtvaardigen.
MonsterBattles: Een kleine monsterlijst heeft doorgaans genoeg aan List, terwijl grote pipelines soms baat hebben bij asSequence().
32Extension functions
Wat is het? Een extension function voegt een aanroepvorm aan een bestaand type toe zonder de class te wijzigen. Het is syntactische uitbreiding, geen echte wijziging van de class.
Hoe werkt het? Een extension function lijkt een methode maar wordt statisch opgelost en wijzigt de oorspronkelijke class niet.
data class Monster(val name: String, val hp: Int)
fun Monster.isAlive(): Boolean = hp > 0
println(Monster("Pyron", 100).isAlive())trueWaarom en wanneer anders? Een helper kan MonsterResponse-formattering uitbreiden zonder een nieuwe modelmethode.
MonsterBattles: Definieer Monster.isAlive() als leesbare helper wanneer de domeinarchitectuur dit toestaat.
33Static dispatch
Wat is het? Extensions worden statisch gekozen op basis van het gedeclareerde type van de ontvanger, niet het runtime-type. Member functions winnen bij gelijknamige passende member-aanroepen.
Hoe werkt het? Overload-resolutie van extensions kijkt naar het gedeclareerde ontvangertype, niet naar het runtime subtype.
open class Monster
class FireMonster : Monster()
fun Monster.describe() = "Monster"
fun FireMonster.describe() = "FireMonster"
val monster: Monster = FireMonster()
println(monster.describe())MonsterWaarom en wanneer anders? Vervang polymorfisme nooit door extensions als runtimegedrag noodzakelijk is.
MonsterBattles: Gebruik extensions niet als polymorfisme nodig is voor verschillende monstertypen.
34Scope functions
Wat is het? let, run, with, apply en also helpen bij objectcontext, null-verwerking of extra acties. let/run/with geven het lambdaresultaat terug; apply/also geven het object terug. this en it verschillen per functie.
Hoe werkt het? let/run/with retourneren het lambdaresultaat; apply/also retourneren de receiver; kies op basis van doel en leesbaarheid.
data class Monster(var name: String, var hp: Int)
val monster = Monster("Pyron", 100).apply { hp = 95 }
monster.also { println(it.name) }
println(monster.run { hp })
println(with(monster) { name.length })
println(monster.let { it.hp > 0 })Pyron
95
5
trueWaarom en wanneer anders? Gebruik scope functions alleen wanneer ze mapping of configuratie duidelijker maken.
MonsterBattles: Een nullable monster met let verwerken of een nieuw DTO in apply configureren kan compact zijn; houd het leesbaar.
35Generics
Wat is het? Generics gebruiken typeparameters zoals <T> om classes en functies herbruikbaar en typeveilig te maken. Kotlin ondersteunt ook variantie (in/out), maar een eenvoudige generieke functie is een goed startpunt.
Hoe werkt het? Met <T> definieer je herbruikbare code met typebehoud, zonder Any-casts.
fun <T> firstOrNull(items: List<T>): T? = items.firstOrNull()
println(firstOrNull(listOf("Pyron", "Aquara")))
println(firstOrNull(emptyList<Int>()))Pyron
nullWaarom en wanneer anders? Repository-interfaces kennen concrete Monster-typen; generics hoeven daar niet extra omheen.
MonsterBattles: Een Repository<T> kan later dezelfde abstractie bieden voor meerdere domeintypen; een specifieke repository kan duidelijker zijn.
36Companion objects
Wat is het? Een companion object bevat leden die bij de class horen in plaats van bij een specifieke instantie. Het is onder meer handig voor factory methods en constanten.
Hoe werkt het? Een companion object levert class-gebonden members zonder losse singletoninstantie per object.
data class Monster(val name: String, val hp: Int) {
companion object {
fun starter(name: String) = Monster(name, 100)
}
}
println(Monster.starter("Pyron"))Monster(name=Pyron, hp=100)Waarom en wanneer anders? Constanten of eenvoudige factory helpers kunnen daarin staan, maar zijn niet verplicht.
MonsterBattles: Bied een factory aan voor een standaardmonster met vooraf ingevulde stats.
37Singleton objects
Wat is het? object declareert één instantie (singleton). Dat is handig voor stateless helpers; pas op met globale veranderlijke toestand en gedeeld gebruik door meerdere requests.
Hoe werkt het? object maakt één instantie; gedeelde mutabele toestand kan daarmee tot testvervuiling leiden.
object DamageRules {
fun calculate(attack: Int, defense: Int): Int = maxOf(1, attack - defense)
}
println(DamageRules.calculate(30, 12))18Waarom en wanneer anders? Gebruik geen globale singleton voor de actieve repository in tests.
MonsterBattles: Gebruik een stateless ElementCatalog-object, maar bewaar niet onbedoeld alle battle-state in een globale singleton.
38Top-level vs member functions
Wat is het? Een top-level function staat direct in een Kotlin-bestand. Een member function hoort bij een object en kan diens toestand gebruiken. Kies op basis van verantwoordelijkheid, niet om willekeurig classes te vermijden.
Hoe werkt het? Een top-level functie heeft geen objecttoestand; een member function heeft toegang tot de toestand van een instantie.
fun calculateDamage(attack: Int, defense: Int): Int = maxOf(1, attack - defense)
class Monster(var hp: Int) {
fun takeDamage(damage: Int) { hp = maxOf(0, hp - damage) }
}
val monster = Monster(100)
monster.takeDamage(calculateDamage(30, 12))
println(monster.hp)82Waarom en wanneer anders? DamageCalculator kan puur zijn; zoekgedrag hoort bij MonsterService vanwege de repositoryafhankelijkheid.
MonsterBattles: Een pure damagefunctie kan top-level zijn; takeDamage() hoort bij het Monster dat zijn HP beheert.
39Delegated properties
Wat is het? Met by lazy wordt een property bij eerste toegang berekend en vervolgens bewaard. De standaard lazy-implementatie is gesynchroniseerd. Gebruik dit voor dure, eenmalige initialisatie en niet als vervanging voor bewuste lifecycle-afspraken.
Hoe werkt het? by lazy berekent een waarde bij eerste gebruik en onthoudt het resultaat; thread-safety-modus is contextafhankelijk.
val elementLabel: String by lazy { "FIRE / WATER / WIND" }
println(elementLabel)FIRE / WATER / WINDWaarom en wanneer anders? Dit is een leervoorbeeld, geen noodzakelijke repository-initialisatiestrategie.
MonsterBattles: Een afgeleide, kostbare read-only index kun je lazy opbouwen; let op veroudering als de onderliggende monsters later veranderen.
40Queryparameters
Wat is het? Queryparameters staan achter ? in de URL; meerdere parameters worden met & gescheiden. Lees ze in Ktor via call.request.queryParameters en valideer onbekende waarden.
Hoe werkt het? Queryparameters zijn optionele URL-waarden; parse ze vóór de serviceaanroep, met expliciete foutregels.
// Fragment in MonsterRoutes.kt, inside GET /monsters.
val rawElement = call.request.queryParameters["element"]
val rawSort = call.request.queryParameters["sort"]
val element = rawElement?.let { raw ->
enumValues<Element>().firstOrNull { it.name == raw }
?: throw BadRequestException("Invalid element")
}
if (rawSort != null && rawSort !in setOf("name", "speed")) {
throw BadRequestException("Invalid sort")
}
call.respond(service.list(element, rawSort))GET /monsters?element=FIRE&sort=speed -> 200 OK (Pyron)
GET /monsters?element=UNKNOWN -> 400 Bad RequestWaarom en wanneer anders? element accepteert exact FIRE/WATER/WIND; sort exact name/speed; ontbrekende waarden betekenen geen filter/default ID-volgorde.
MonsterBattles: GET /monsters?element=FIRE&sort=speed combineert filteren en sorteren; een onbekend element geeft 400 in plaats van stilletjes een leeg resultaat.
41Search tests
Wat is het? Tests voor filtering en sorting vergelijken de werkelijke output met een expliciet verwachte lijst. Test lege resultaten, combinaties van filters, tie-breakers en ongeldige querywaarden afzonderlijk.
Hoe werkt het? Test filtering, standaardvolgorde, beide sorteringen, gelijke sorteerwaarden en foutieve queries onafhankelijk van elkaar.
// Unit test (fragment): repository contains Pyron and Aquara.
@Test
fun fireFilterReturnsOnlyFireMonsters() {
val service = MonsterService(InMemoryMonsterRepository())
val names = service.list(Element.FIRE).map { it.name }
assertEquals(listOf("Pyron"), names)
}Verwacht: test slaagt; lijst bevat alleen Pyron.Waarom en wanneer anders? Unit-test de service met een fake repository; test HTTP 400 en JSON met Ktor testApplication.
MonsterBattles: Een test bevestigt dat FIRE-only geen WATER-monster teruggeeft en dat sort=speed de afgesproken volgorde oplevert.
Geïntegreerde uitbreiding op Week 04
De volgende fragmenten vormen samen de wijziging in de bestaande Amper/Ktor-app. We definiëren Monster, Stats, Element of MonsterRepository niet opnieuw. Het contract blijft uit Week 02 en 04 komen; ook MonsterResponse blijft hetzelfde met alle zeven velden.
Stap 1 — Filteren en sorteren in MonsterService
Dit is de volledige gewijzigde MonsterService.kt, inclusief de bestaande CRUD-methoden. De methode list heeft optionele parameters en valideert de sorteerwaarde. De repository levert alleen data; de service bepaalt de volgorde.
package nl.monsterbattles.api.features.monsters
import nl.monsterbattles.api.features.monsters.model.*
import nl.monsterbattles.api.features.monsters.dto.*
import nl.monsterbattles.api.features.monsters.repository.MonsterRepository
class MonsterNotFoundException(id: Int) : RuntimeException("Monster $id does not exist")
class MonsterService(private val repository: MonsterRepository) {
fun list(element: Element? = null, sort: String? = null): List<MonsterResponse> {
require(sort == null || sort in setOf("name", "speed")) { "Invalid sort" }
val filtered = repository.findAll().filter { monster ->
element == null || monster.element == element
}
val sorted = when (sort) {
"name" -> filtered.sortedWith(compareBy<Monster> { it.name }.thenBy { it.id })
"speed" -> filtered.sortedWith(
compareByDescending<Monster> { it.stats.speed }.thenBy { it.id }
)
else -> filtered.sortedBy { it.id }
}
return sorted.map(Monster::toResponse)
}
fun get(id: Int): MonsterResponse =
(repository.findById(id) ?: throw MonsterNotFoundException(id)).toResponse()
fun create(request: CreateMonsterRequest): MonsterResponse {
val name = request.name.trim()
require(name.isNotBlank()) { "Name is required" }
return repository.create(name, request.element, request.toStats()).toResponse()
}
fun update(id: Int, request: UpdateMonsterRequest): MonsterResponse {
val name = request.name.trim()
require(name.isNotBlank()) { "Name is required" }
val updated = repository.update(id, name, request.element, request.toStats())
?: throw MonsterNotFoundException(id)
return updated.toResponse()
}
fun delete(id: Int) {
if (!repository.delete(id)) throw MonsterNotFoundException(id)
}
}Voorbeeld met de vaste seeddata: alleen Pyron en Aquara bestaan na het opstarten van de Week 04 in-memory repository. De lijst met FIRE-monsters bevat daarom standaard alleen Pyron. Ember is geen seedmonster in de blauwdruk.
Stap 2 — Bestaande GET-route aanpassen
Vervang in de bestaande fun Route.monsterRoutes(service: MonsterService) alleen de handler van GET /monsters door dit fragment. De handlers voor GET-ID, POST, PUT en DELETE blijven precies zoals in Week 04. BadRequestException wordt reeds door StatusPages afgehandeld.
// In route("/monsters") from Week 04:
get {
val rawElement = call.request.queryParameters["element"]
val rawSort = call.request.queryParameters["sort"]
val element = rawElement?.let { raw ->
enumValues<Element>().firstOrNull { it.name == raw }
?: throw BadRequestException("Invalid element: $raw")
}
if (rawSort != null && rawSort !in setOf("name", "speed")) {
throw BadRequestException("Invalid sort: $rawSort")
}
call.respond(service.list(element, rawSort))
}GET /monsters -> 200 OK (Pyron, Aquara)
GET /monsters?element=FIRE -> 200 OK (Pyron)
GET /monsters?sort=speed -> 200 OK (Pyron, Aquara)
GET /monsters?sort=name -> 200 OK (Aquara, Pyron)
GET /monsters?element=UNKNOWN -> 400 Bad Request + ApiError
GET /monsters?sort=unknown -> 400 Bad Request + ApiErrorBelangrijke imports: dit fragment gebruikt nl.monsterbattles.api.features.monsters.model.Element, io.ktor.server.plugins.BadRequestException en de bestaande imports voor call.respond. Voor lege waarden, andere hoofdlettervarianten en onbekende parameters gelden dezelfde expliciete regels: de toegestane waarden zijn exact zoals in de blauwdruk geschreven.
Stap 3 — Reproduceerbare service-demo
Deze zelfstandige Kotlin-console-demo gebruikt dezelfde domeinvelden en zoekregels, zodat je de filtering ook zonder draaiende HTTP-server kunt begrijpen. Het is een uitlegprogramma, geen tweede productie-implementatie.
enum class Element { FIRE, WATER, WIND }
data class Stats(val hp: Int, val attack: Int, val defense: Int, val speed: Int)
class Monster(val id: Int, val name: String, val element: Element, val stats: Stats)
fun search(monsters: List<Monster>, element: Element?, sort: String?): List<Monster> {
require(sort == null || sort in setOf("name", "speed"))
val filtered = monsters.filter { element == null || it.element == element }
return when (sort) {
"name" -> filtered.sortedWith(compareBy<Monster> { it.name }.thenBy { it.id })
"speed" -> filtered.sortedWith(compareByDescending<Monster> { it.stats.speed }.thenBy { it.id })
else -> filtered.sortedBy { it.id }
}
}
fun main() {
val monsters = listOf(
Monster(1, "Pyron", Element.FIRE, Stats(100, 30, 10, 110)),
Monster(2, "Aquara", Element.WATER, Stats(110, 25, 12, 95))
)
println(search(monsters, Element.FIRE, "speed").map { it.name })
println(search(monsters, null, "name").map { it.name })
println(search(monsters, null, null).map { it.id })
}[Pyron]
[Aquara, Pyron]
[1, 2]Stap 4 — Teststrategie voor zoeken en sorteren
Een unit test gebruikt de bestaande InMemoryMonsterRepository of een fake via MonsterRepository. Voor gelijke sorteerwaarden kun je met create() een extra monster aanmaken en bevestigen dat het laagste ID voorop staat. Een HTTP-integratietest met testApplication controleert dat een ongeldig element of sorteerwaarde een 400 met het uniforme ApiError-contract oplevert.
@Test
fun filtersFireAndSortsSpeed() {
val repository = InMemoryMonsterRepository()
repository.create("Ember", Element.FIRE, Stats(95, 26, 8, 80))
val results = MonsterService(repository).list(Element.FIRE, "speed")
assertEquals(listOf("Pyron", "Ember"), results.map { it.name })
}
@Test
fun defaultsToIdAscending() {
val results = MonsterService(InMemoryMonsterRepository()).list()
assertEquals(listOf(1, 2), results.map { it.id })
}
@Test
fun rejectsUnknownSort() {
val service = MonsterService(InMemoryMonsterRepository())
assertFailsWith<IllegalArgumentException> { service.list(sort = "unknown") }
}Verwacht: alle drie tests slagen als onderdeel van de geconfigureerde testmodule.Stap 5 — HTTP-requests en API-contract
De response behoudt alle velden van Week 04, dus id, name, element, hp, attack, defense en speed. Een filter- of sorteerparameter verandert uitsluitend de volgorde/selectie, niet de JSON-vorm.
@base = http://localhost:8080
### All monsters, default ID order
GET {{base}}/monsters
### FIRE monsters, fastest first
GET {{base}}/monsters?element=FIRE&sort=speed
### Sort by name
GET {{base}}/monsters?sort=name
### Invalid filter -> 400
GET {{base}}/monsters?element=UNKNOWN
### Invalid sort -> 400
GET {{base}}/monsters?sort=unknownGET /monsters?element=FIRE&sort=speed
200 OK
[{"id":1,"name":"Pyron","element":"FIRE","hp":100,"attack":30,"defense":10,"speed":110}]
GET /monsters?element=UNKNOWN
400 Bad Request
{"code":"INVALID_REQUEST","message":"Invalid element: UNKNOWN"}Architectuur: route leest queryparameters, service voert filteren en sorteren uit, repository levert de monsters. Geen nieuwe MonsterSearchService, geen nieuw domeinmodel, geen extra API-endpoints en geen databasecode in de route. De afspraken blijven compatible met Week 06 en de Exposed-repository in Week 07.
Cheat Sheet
| Begrip | Gebruik / betekenis |
|---|---|
Iterable / Iterator | Stap voor stap elementen doorlopen |
List / MutableList | Geordende collectie; read-only interface versus mutabele API |
Set / MutableSet | Unieke waarden volgens equals/hashCode |
Map / MutableMap | Key-valueopslag; Map is geen Collection-subtype |
val | Referentie niet opnieuw toewijsbaar; inhoud kan nog muteren |
map / filter / find | Transformeren, selecteren, eerste match of null |
any / all | Minstens één / alle elementen voldoen |
sortedWith | Comparator met expliciete tie-break |
associateBy / associate | Map opbouwen met sleutel per item |
groupBy / partition | Per key groeperen / twee groepen maken |
chunked / windowed | Blokken en (mogelijk overlappende) vensters |
Sequence | Lazy tussenbewerkingen; terminal operation voert uit |
Extensions | Statisch opgeloste helperfuncties |
let / run / with / apply / also | Contextfuncties met verschillend receiver- en returngedrag |
Generics <T> | Herbruikbare code met typebehoud |
object / companion object / by lazy | Singleton, classgebonden leden en lazy initialisatie |
element=FIRE | Optioneel filter; exact FIRE/WATER/WIND |
sort=name | Naam oplopend, daarna ID |
sort=speed | Speed aflopend, daarna ID |
| Geen sort | ID oplopend |
| Ongeldige query | 400 + uniforme ApiError |