⚔ MonsterBattles Docs
Phuc PhamGitHub ↗
WEEK 02 · KOTLIN

Object-Oriented Programming

Van losse variabelen naar een betrouwbaar domeinmodel. Leer waarom classes, constructors, encapsulation, interfaces en Kotlin-typeconstructies belangrijk zijn, en wanneer je ze juist niet nodig hebt.

26 onderwerpenZelfstandige Kotlin-voorbeeldenVast MonsterBattles-domeinmodel

Wat bouwen we deze week?

In Week 01 werkten we met losse waarden en een consolebattle. Nu modelleren we de vaste gegevens van monsters met drie types: Element, Stats en Monster. De battle-engine volgt in Week 03. Zo houden we het domein klein en vermijden we dat dezelfde HP op verschillende plaatsen wordt opgeslagen.

Ontwerpcontract v5.0: Monster(id, name, element, stats); Stats(hp, attack, defense, speed); Element.FIRE/WATER/WIND. De constructor valideert de basiswaarden. Het domein heeft géén permanente currentHp en geen verplichte Attackable-interface. Die concepten behandelen we waar nuttig als zelfstandige OO-demonstratie.

Alle 26 voorbeelden zijn afzonderlijke mini-programma’s; herhaalde classnamen in verschillende voorbeelden zijn geen extra domeinmodellen. Alleen onderwerp 26 legt de definitieve modelstructuur vast.

De 26 OO-onderwerpen

Elk onderwerp bevat een uitleg, de werking, een compleet Kotlin-voorbeeld, de verwachte uitvoer, de reden voor de aanpak, een afweging en de toepassing in MonsterBattles. De kleine codeblokken zijn afzonderlijk uitvoerbaar; ze zijn niet bedoeld om samen als één bestand te worden geplakt.

01Object-Oriented Programming

Wat is het? Objectgeoriënteerd programmeren bundelt gegevens en gedrag in objecten. Een object heeft een eigen toestand en functies die op die toestand werken.

Hoe werkt het? Een class bevat properties en memberfuncties. Met een constructor maak je een object; daarna roep je gedrag via de puntnotatie aan.

OopIntro.ktKOTLIN
class Monster(val name: String, val hp: Int) {
    fun describe(): String = "$name has $hp HP"
}

fun main() {
    val pyron = Monster("Pyron", 100)
    println(pyron.describe())
}
▸ Terminal / outputVerwachte uitvoer
Pyron has 100 HP

Waarom deze aanpak? Een monster bewaart niet alleen zijn naam, maar kan ook zijn eigen toestand beschrijven.

Wanneer kies je iets anders? Losse top-level functies zijn prima voor kleine berekeningen; zet gedrag in classes als gegevens en gedrag werkelijk bij elkaar horen.

02Classes

Wat is het? Een class is een blauwdruk voor objecten. In de class definieer je welke eigenschappen en functies elk object heeft.

Hoe werkt het? Een class is een typebeschrijving. Constructorparameters met val worden direct publieke, alleen-leesbare properties; een memberfunctie kan ze gebruiken.

Classes.ktKOTLIN
class Monster(val name: String, val attack: Int) {
    fun showAttack() = println("$name: $attack ATK")
}

fun main() {
    Monster("Pyron", 30).showAttack()
}
▸ Terminal / outputVerwachte uitvoer
Pyron: 30 ATK

Waarom deze aanpak? We definiëren een herbruikbaar monstertype in plaats van losse variabelen per monster.

Wanneer kies je iets anders? Een data class past beter bij louter waardedata. Maak geen aparte class als één eenvoudige top-level functie al volstaat.

03Objects: instanties aanmaken

Wat is het? Met een constructoraanroep maak je een instantie van een class. Elke instantie heeft zijn eigen propertywaarden.

Hoe werkt het? Twee aanroepen van dezelfde constructor maken afzonderlijke instanties. Een wijziging aan de ene instantie verandert niet automatisch de andere.

Objects.ktKOTLIN
class Monster(val name: String, var hp: Int)

fun main() {
    val pyron = Monster("Pyron", 100)
    val aquara = Monster("Aquara", 110)
    pyron.hp -= 10
    println("${pyron.name}: ${pyron.hp}")
    println("${aquara.name}: ${aquara.hp}")
}
▸ Terminal / outputVerwachte uitvoer
Pyron: 90
Aquara: 110

Waarom deze aanpak? Deze mutatie demonstreert een los onderwijsvoorbeeld; in het definitieve domein staat alleen de vaste Stats.hp, niet de actuele battle-HP.

Wanneer kies je iets anders? Gebruik kopieën van onveranderlijke data wanneer mutatie onnodig is; dat voorkomt onbedoelde bijwerkingen.

04Primary constructors

Wat is het? De primary constructor staat in de class-header. Parameters met val of var worden meteen properties; zonder val of var zijn het alleen parameters.

Hoe werkt het? De primary constructor staat direct achter de classnaam. Parameters met val of var worden properties, de overige parameters zijn alleen tijdens initialisatie beschikbaar.

PrimaryConstructor.ktKOTLIN
class Monster(val name: String, val element: String, val maxHp: Int)

fun main() {
    val monster = Monster("Pyron", "FIRE", 100)
    println("${monster.name} (${monster.element}): ${monster.maxHp} HP")
}
▸ Terminal / outputVerwachte uitvoer
Pyron (FIRE): 100 HP

Waarom deze aanpak? Naam, element en begin-HP zijn verplicht bij het aanmaken van een monster.

Wanneer kies je iets anders? Gebruik een factoryfunctie als objectconstructie veel controles of alternatieve creatiepaden vereist.

05Secondary constructors

Wat is het? Een secondary constructor biedt een extra manier om een object aan te maken. Als een primary constructor bestaat, moet de secondary constructor daarnaar delegeren.

Hoe werkt het? Een secondary constructor staat in de classbody en gebruikt bij aanwezigheid van een primary constructor verplicht this(...) om daarnaar te delegeren.

SecondaryConstructor.ktKOTLIN
class Monster(val name: String, val hp: Int) {
    constructor(name: String) : this(name, 100)
}

fun main() {
    println(Monster("Pyron").hp)
    println(Monster("Aquara", 110).hp)
}
▸ Terminal / outputVerwachte uitvoer
100
110

Waarom deze aanpak? Een testmonster kan een standaardhoeveelheid HP krijgen zonder die bij iedere aanroep op te geven.

Wanneer kies je iets anders? Default arguments zijn meestal eenvoudiger dan extra constructors. Gebruik secondary constructors alleen bij een echt andere aanmaakroute.

06Constructor chaining

Wat is het? Constructor chaining betekent dat een constructor een andere constructor aanroept met this(...). Zo loopt alle initialisatie via een gedeeld pad.

Hoe werkt het? Met this(...) roept een constructor een andere constructor van dezelfde class aan. Daardoor kan gemeenschappelijke initialisatie op één plaats staan.

ConstructorChaining.ktKOTLIN
class Monster(val name: String, val hp: Int) {
    constructor(name: String) : this(name, 100)
    constructor() : this("Unknown")
}

fun main() {
    println("${Monster().name}: ${Monster().hp}")
    println("${Monster("Pyron").name}: ${Monster("Pyron").hp}")
}
▸ Terminal / outputVerwachte uitvoer
Unknown: 100
Pyron: 100

Waarom deze aanpak? Verschillende manieren om monsters te maken gebruiken dezelfde basisinitialisatie.

Wanneer kies je iets anders? Default parameters vermijden vaak constructor chaining. Kies chaining wanneer je meerdere expliciete constructorroutes nodig hebt.

07Initialization blocks: init

Wat is het? Een init-block draait bij het maken van een object. Het is geschikt om constructorargumenten te controleren en afgeleide eigenschappen te initialiseren.

Hoe werkt het? Het init-blok draait tijdens het construeren van een object, nadat de voorafgaande property-initializers zijn uitgevoerd. Gebruik het voor invarianten zoals hp > 0.

InitBlock.ktKOTLIN
class Monster(val name: String, val maxHp: Int) {
    init {
        require(name.isNotBlank()) { "Name is required" }
        require(maxHp > 0) { "HP must be positive" }
    }
}

fun main() {
    val monster = Monster("Pyron", 100)
    println("${monster.name}: ${monster.maxHp} HP")
}
▸ Terminal / outputVerwachte uitvoer
Pyron: 100 HP

Waarom deze aanpak? Ongeldige monsterstats worden al bij de constructie geweigerd.

Wanneer kies je iets anders? In services gebruik je require voor invoer en check voor ongeldige interne toestand; init is geschikt voor objectinvarianten.

08Properties, getters en setters

Wat is het? Properties gebruiken val of var. Een custom getter berekent een waarde wanneer je die opvraagt; een custom setter kan wijzigingen controleren.

Hoe werkt het? Properties hebben desgewenst een eigen getter of setter. Een getter berekent een waarde bij het lezen; een setter controleert of verwerkt nieuwe waarden.

Properties.ktKOTLIN
class Monster(val name: String, initialHp: Int) {
    var hp: Int = initialHp
        set(value) {
            field = value.coerceAtLeast(0)
        }

    val isAlive: Boolean
        get() = hp > 0
}

fun main() {
    val monster = Monster("Pyron", 100)
    monster.hp = -8
    println("HP: ${monster.hp}, alive: ${monster.isAlive}")
}
▸ Terminal / outputVerwachte uitvoer
HP: 0, alive: false

Waarom deze aanpak? We berekenen of een monster nog leeft op basis van zijn actuele HP.

Wanneer kies je iets anders? Een gewone val zonder custom getter is duidelijker als de opgeslagen waarde niet berekend of gevalideerd hoeft te worden.

09Visibility modifiers

Wat is het? public is overal toegankelijk, private alleen binnen de declaratie of class, protected in de class en subclasses, en internal binnen dezelfde module.

Hoe werkt het? public is overal toegankelijk, private alleen binnen de declaratie of bestandscontext, protected binnen de class en subclasses, internal binnen dezelfde module.

Visibility.ktKOTLIN
class Monster(val name: String) {
    private var hp = 100

    fun takeDamage(amount: Int) {
        require(amount >= 0)
        hp = (hp - amount).coerceAtLeast(0)
    }

    fun status(): String = "$name: $hp HP"
}

fun main() {
    val pyron = Monster("Pyron")
    pyron.takeDamage(18)
    println(pyron.status())
}
▸ Terminal / outputVerwachte uitvoer
Pyron: 82 HP

Waarom deze aanpak? Niet iedere helper of muterende property hoeft buiten het monstermodel zichtbaar te zijn.

Wanneer kies je iets anders? Houd zichtbaarheid zo beperkt mogelijk; internal is modulebreed en geen beveiligingsmechanisme.

10Encapsulation

Wat is het? Encapsulation beschermt de interne toestand van een object. Externe code gebruikt gecontroleerde functies in plaats van willekeurig properties te wijzigen.

Hoe werkt het? Maak een waarde van buitenaf leesbaar, maar verbied directe wijziging via private set. Laat alleen gecontroleerde methoden de toestand veranderen.

Encapsulation.ktKOTLIN
class Monster(val name: String, maxHp: Int) {
    var hp: Int = maxHp
        private set

    fun takeDamage(damage: Int) {
        require(damage >= 0)
        hp = (hp - damage).coerceAtLeast(0)
    }
}

fun main() {
    val pyron = Monster("Pyron", 100)
    pyron.takeDamage(18)
    println("${pyron.name}: ${pyron.hp} HP")
}
▸ Terminal / outputVerwachte uitvoer
Pyron: 82 HP

Waarom deze aanpak? Damage kan niet negatief zijn en HP kan niet onder nul zakken.

Wanneer kies je iets anders? Voor onveranderlijke waardedata is een val vaak beter dan een var met private set. Voor de basisstats kiezen we daarom immutable Stats.

11Backing properties

Wat is het? Een private backing property bewaart wijzigbare data; een publieke read-only property maakt die data alleen leesbaar. Dit is een alternatief voor private set.

Hoe werkt het? Een private mutable property met een publieke read-only getter geeft een bewust afgeschermde blik op de interne toestand.

BackingProperty.ktKOTLIN
class Monster(private val maxHp: Int) {
    private var _currentHp = maxHp
    val currentHp: Int get() = _currentHp

    fun takeDamage(amount: Int) {
        require(amount >= 0)
        _currentHp = (_currentHp - amount).coerceAtLeast(0)
    }
}

fun main() {
    val monster = Monster(100)
    monster.takeDamage(18)
    println(monster.currentHp)
}
▸ Terminal / outputVerwachte uitvoer
82

Waarom deze aanpak? De actuele HP is zichtbaar, maar mag alleen via takeDamage() veranderen.

Wanneer kies je iets anders? private set is meestal korter dan een afzonderlijke _property plus getter. Een backing property is nuttig bij verschillende publieke en interne types.

12Abstraction

Wat is het? Abstraction laat alleen relevante details zien. De gebruiker van een class hoeft niet te weten hoe de berekening of interne opslag precies werkt.

Hoe werkt het? Abstraction laat alleen de noodzakelijke interface van een begrip zien: wat het object kan, zonder de interne berekening steeds bloot te leggen.

Abstraction.ktKOTLIN
class Monster(val attack: Int) {
    fun damageAgainst(defense: Int): Int = maxOf(1, attack - defense)
}

fun main() {
    val pyron = Monster(30)
    println(pyron.damageAgainst(12))
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? Een battle-service kan attack() aanroepen zonder de implementatie van de damageberekening te kennen.

Wanneer kies je iets anders? Een pure top-level functie is geschikter voor een berekening die geen objecttoestand nodig heeft, zoals de latere DamageCalculator.

13Interfaces

Wat is het? Een interface beschrijft een contract: welke functies of properties een type moet aanbieden. Verschillende classes kunnen hetzelfde contract implementeren.

Hoe werkt het? Een interface definieert een contract. Een class implementeert de functies met override. Via de interface kan de aanroeper verschillende implementaties gebruiken.

Interfaces.ktKOTLIN
interface Attackable {
    fun calculateDamage(defense: Int): Int
}

class Monster(private val attack: Int) : Attackable {
    override fun calculateDamage(defense: Int): Int =
        maxOf(1, attack - defense)
}

fun main() {
    val attacker: Attackable = Monster(30)
    println(attacker.calculateDamage(12))
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? Andere onderdelen van het spel kunnen met Attackable werken zonder een concrete monsterclass te kennen.

Wanneer kies je iets anders? Een interface voegt waarde toe bij uitwisselbare implementaties. Voeg hem niet toe als er nooit meerdere gedragingen nodig zijn.

14Interface versus abstract class

Wat is het? Een interface legt vooral gedrag vast; een abstract class kan ook gedeelde constructorwaarden en toestand beheren. Beide kunnen abstracte en concrete members hebben.

Hoe werkt het? Een interface legt vooral een contract vast; een abstract class kan gedeelde toestand en implementatie bevatten. Een concrete subclass moet abstracte leden implementeren.

InterfaceVsAbstract.ktKOTLIN
interface Attackable {
    fun attack(): Int
}

abstract class Monster(val name: String) : Attackable {
    fun introduce() = println("I am $name")
}

class FireMonster(name: String) : Monster(name) {
    override fun attack(): Int = 30
}

fun main() {
    val pyron = FireMonster("Pyron")
    pyron.introduce()
    println(pyron.attack())
}
▸ Terminal / outputVerwachte uitvoer
I am Pyron
30

Waarom deze aanpak? Een Monster kan een gedeelde basisclass krijgen en daarnaast Attackable implementeren.

Wanneer kies je iets anders? Composition is vaak eenvoudiger dan inheritance. De echte basisgame heeft daarom geen FireMonster-subclass nodig.

15Enum classes

Wat is het? Een enum class vertegenwoordigt een vaste verzameling benoemde waarden. De compiler voorkomt dat willekeurige strings als element worden gebruikt.

Hoe werkt het? Een enum class beperkt waarden tot vooraf gedefinieerde entries. Een enum is geschikt als een element alleen FIRE, WATER of WIND mag zijn.

Element.ktKOTLIN
enum class Element { FIRE, WATER, WIND }

fun main() {
    val element = Element.FIRE
    println(element)
    println(Element.entries.size)
}
▸ Terminal / outputVerwachte uitvoer
FIRE
3

Waarom deze aanpak? De toegestane monsterelementen zijn FIRE, WATER en WIND.

Wanneer kies je iets anders? Een sealed hiërarchie past beter als ieder type wezenlijk verschillend gedrag of data heeft; een enum is eenvoudiger voor drie vaste labels.

16Enum properties

Wat is het? Een enum kan constructorparameters, properties en functies hebben. Elke enumwaarde draagt dan eigen vaste metadata.

Hoe werkt het? Elke enum-entry kan constructorargumenten krijgen. Daarmee heeft bijvoorbeeld FIRE een presentatienaam zonder losse when-vertakkingen.

ElementProperties.ktKOTLIN
enum class Element(val displayName: String) {
    FIRE("Fire"), WATER("Water"), WIND("Wind")
}

fun main() {
    println(Element.FIRE.displayName)
    println(Element.WATER.displayName)
}
▸ Terminal / outputVerwachte uitvoer
Fire
Water

Waarom deze aanpak? We geven elk element een leesbare naam zonder losse if- of when-constructies.

Wanneer kies je iets anders? Zet alleen stabiele, elementgebonden data in de enum; dynamische spelregels horen in afzonderlijke code.

17Data classes

Wat is het? Een data class is bedoeld voor waarden en gegevens. Kotlin genereert onder andere equals(), hashCode(), toString(), componentN() en copy() op basis van de primary-constructorproperties.

Hoe werkt het? Een data class genereert value-based equals, hashCode, toString, componentN en copy op basis van de primary-constructorproperties.

Stats.ktKOTLIN
data class Stats(val hp: Int, val attack: Int, val defense: Int, val speed: Int)

fun main() {
    val stats = Stats(100, 30, 10, 110)
    println(stats)
}
▸ Terminal / outputVerwachte uitvoer
Stats(hp=100, attack=30, defense=10, speed=110)

Waarom deze aanpak? Stats is een goede data class; een Monster met veranderende HP en identiteit modelleren we als gewone class.

Wanneer kies je iets anders? Een gewone class is passend wanneer identiteit of levenscyclus belangrijker is dan value equality, zoals bij Monster.

18Data class-functionaliteit

Wat is het? Bij een data class werken equals(), hashCode(), toString(), copy() en componentN() op de properties in de primary constructor. copy() maakt een shallow copy.

Hoe werkt het? copy maakt een nieuw object met optioneel aangepaste velden. equals en hashCode vergelijken datawaarden; component1 levert het eerste constructorveld.

DataClassFeatures.ktKOTLIN
data class Stats(val hp: Int, val attack: Int)

fun main() {
    val original = Stats(100, 30)
    val boosted = original.copy(attack = 35)
    println(original == Stats(100, 30))
    println(original.hashCode() == Stats(100, 30).hashCode())
    println(boosted)
    println(original.component1())
}
▸ Terminal / outputVerwachte uitvoer
true
true
Stats(hp=100, attack=35)
100

Waarom deze aanpak? We kunnen een gewijzigde statset maken zonder het origineel aan te passen.

Wanneer kies je iets anders? copy is shallow: geneste mutable objecten worden niet automatisch diep gekopieerd. Vermijd dat misverstand.

19Destructuring declarations

Wat is het? Met destructuring haal je meerdere componenten uit een object. Bij data classes gebruikt Kotlin hiervoor automatisch gegenereerde componentN()-functies.

Hoe werkt het? Destructuring gebruikt automatisch gegenereerde componentN-functies om onderdelen van een object in afzonderlijke lokale variabelen te plaatsen.

Destructuring.ktKOTLIN
data class Stats(val hp: Int, val attack: Int)

fun main() {
    val stats = Stats(100, 30)
    val (hp, attack) = stats
    println("HP: $hp, Attack: $attack")
}
▸ Terminal / outputVerwachte uitvoer
HP: 100, Attack: 30

Waarom deze aanpak? We kunnen HP en Attack overzichtelijk uit een Stats-object halen.

Wanneer kies je iets anders? Gebruik named property access (stats.hp) als dit duidelijker leest dan een lange destructuringlijst.

20Equality: == versus ===

Wat is het? == vergelijkt waarden via equals(); === vergelijkt of twee referenties exact naar hetzelfde object wijzen. Bij data classes is value equality standaard gegenereerd.

Hoe werkt het? == roept gelijkheidsvergelijking via equals aan; === test of twee referenties naar exact hetzelfde object wijzen.

Equality.ktKOTLIN
data class Stats(val hp: Int)

fun main() {
    val first = Stats(100)
    val second = Stats(100)
    val alias = first
    println(first == second)
    println(first === second)
    println(first === alias)
}
▸ Terminal / outputVerwachte uitvoer
true
false
true

Waarom deze aanpak? Twee Stats-objecten kunnen inhoudelijk gelijk zijn, zonder dezelfde instantie te zijn.

Wanneer kies je iets anders? Gebruik == voor gewone waardevergelijkingen; === alleen wanneer objectidentiteit echt relevant is.

21Het keyword this

Wat is het? this verwijst naar de huidige instantie. Het is nuttig om een property te onderscheiden van een parameter met dezelfde naam.

Hoe werkt het? Met this verwijs je expliciet naar de huidige instantie. Dat is vooral nuttig bij naamconflicten tussen parameter en property.

ThisKeyword.ktKOTLIN
class Monster(name: String) {
    var name: String = name
        private set

    fun rename(name: String) {
        this.name = name
    }
}

fun main() {
    val monster = Monster("Pyron")
    monster.rename("Pyron Prime")
    println(monster.name)
}
▸ Terminal / outputVerwachte uitvoer
Pyron Prime

Waarom deze aanpak? Een monster kan een nieuwe naam krijgen via een gecontroleerde methode.

Wanneer kies je iets anders? Als veldnamen verschillend zijn is this vaak niet nodig. Het expliciet gebruiken is geen verplichting.

22Sealed classes

Wat is het? Een sealed class begrenst welke directe subtypes bestaan. In een when-expressie kan Kotlin controleren of alle mogelijke gevallen zijn behandeld.

Hoe werkt het? Een sealed class beperkt de toegestane subtypen. Een when over alle bekende varianten kan daardoor exhaustief zijn, zonder else.

SealedClasses.ktKOTLIN
sealed class BattleAction {
    data class Attack(val damage: Int) : BattleAction()
    data object Surrender : BattleAction()
}

fun describe(action: BattleAction): String = when (action) {
    is BattleAction.Attack -> "Attack: ${action.damage}"
    BattleAction.Surrender -> "Surrender"
}

fun main() {
    println(describe(BattleAction.Attack(18)))
    println(describe(BattleAction.Surrender))
}
▸ Terminal / outputVerwachte uitvoer
Attack: 18
Surrender

Waarom deze aanpak? Een battleactie heeft een beperkt aantal vormen: aanval of overgave.

Wanneer kies je iets anders? Een enum is compacter voor louter vaste waarden; een sealed class is interessanter als varianten verschillende gegevens bevatten.

23Sealed interfaces

Wat is het? Een sealed interface beperkt de directe implementaties. Anders dan een class heeft een interface geen constructor en geen eigen backing state.

Hoe werkt het? Een sealed interface heeft een gesloten groep implementaties, maar erft zelf niet van één vaste superclass. when kan ook hier alle varianten afhandelen.

SealedInterfaces.ktKOTLIN
sealed interface BattleOutcome {
    data class Winner(val name: String) : BattleOutcome
    data object Draw : BattleOutcome
}

fun message(outcome: BattleOutcome): String = when (outcome) {
    is BattleOutcome.Winner -> "Winner: ${outcome.name}"
    BattleOutcome.Draw -> "Draw"
}

fun main() {
    println(message(BattleOutcome.Winner("Pyron")))
}
▸ Terminal / outputVerwachte uitvoer
Winner: Pyron

Waarom deze aanpak? We modelleren een vast aantal mogelijke uitkomsten van een battle.

Wanneer kies je iets anders? Een gewone interface is beter wanneer externe modules nieuwe implementaties moeten kunnen aanbieden.

24Functional interfaces en SAM

Wat is het? Een fun interface heeft precies één abstracte functie. Daardoor kan Kotlin een lambda omzetten naar een implementatie van die interface: SAM-conversie.

Hoe werkt het? Een fun interface heeft precies één abstracte functie en kan met een lambda worden ingevuld via SAM-conversie.

FunctionalInterface.ktKOTLIN
fun interface DamageRule {
    fun calculate(attack: Int, defense: Int): Int
}

fun main() {
    val normalAttack = DamageRule { attack, defense ->
        maxOf(1, attack - defense)
    }
    println(normalAttack.calculate(30, 12))
}
▸ Terminal / outputVerwachte uitvoer
18

Waarom deze aanpak? We geven een damageberekening door zonder een aparte implementatieclass te schrijven.

Wanneer kies je iets anders? Een lambda-parameter van een higher-order function is eenvoudiger wanneer een apart genoemd contract geen waarde toevoegt.

25Kotlin type hierarchy

Wat is het? Any is het basistype van alle niet-nullbare Kotlin-types. Subtyping laat je een specifiek object behandelen als algemener type. Nullable types staan hier los van: Any? kan ook null bevatten.

Hoe werkt het? Any is de bovenliggende niet-nullbare supertypefamilie; Any? laat ook null toe. Een subtype kan als algemener type worden behandeld.

TypeHierarchy.ktKOTLIN
open class Monster(val name: String)
class FireMonster(name: String) : Monster(name)

fun main() {
    val monster: Monster = FireMonster("Pyron")
    val general: Any = monster
    val nullable: Any? = null
    println((general as Monster).name)
    println(nullable == null)
}
▸ Terminal / outputVerwachte uitvoer
Pyron
true

Waarom deze aanpak? Een generieke functie kan verschillende monsters en statwaarden ontvangen, maar moet bij Any? rekening houden met null.

Wanneer kies je iets anders? Gebruik concrete typen aan de rand van je domein, en interfaces/basistypen waar abstractie flexibiliteit geeft. Vermijd onveilige casts.

26MonsterBattles-domeinmodel

Wat is het? We verbinden de lesstof met het definitieve, compacte MonsterBattles-domein: Element, Stats en Monster. Dit voorbeeld toont objectcreatie en validatie, geen volledige battle-engine.

Hoe werkt het? De definitieve game gebruikt Element als enum, Stats als immutable data class en Monster als gewone class met identiteit. De constructor valideert waarden; de battle houdt actuele HP later lokaal bij.

MonsterDomainDemo.ktKOTLIN
enum class Element { FIRE, WATER, WIND }

data class Stats(
    val hp: Int,
    val attack: Int,
    val defense: Int,
    val speed: Int
) {
    init {
        require(hp > 0) { "HP must be positive" }
        require(attack >= 0) { "Attack cannot be negative" }
        require(defense >= 0) { "Defense cannot be negative" }
        require(speed >= 0) { "Speed cannot be negative" }
    }
}

class Monster(
    val id: Int,
    val name: String,
    val element: Element,
    val stats: Stats
) {
    init {
        require(id > 0) { "ID must be positive" }
        require(name.isNotBlank()) { "Name is required" }
    }
}

fun main() {
    val pyron = Monster(1, "Pyron", Element.FIRE, Stats(100, 30, 10, 110))
    val aquara = Monster(2, "Aquara", Element.WATER, Stats(110, 25, 12, 95))
    println("${pyron.name} (${pyron.element}): ${pyron.stats.hp} HP")
    println("${aquara.name} (${aquara.element}): ${aquara.stats.hp} HP")
    println("First attacker: ${if (pyron.stats.speed >= aquara.stats.speed) pyron.name else aquara.name}")
}
▸ Terminal / outputVerwachte uitvoer
Pyron (FIRE): 100 HP
Aquara (WATER): 110 HP
First attacker: Pyron

Waarom deze aanpak? Deze drie types worden in Week 03, 04 en 07 opnieuw gebruikt. Actuele battle-HP hoort bewust niet thuis in het opgeslagen Monster-object.

Wanneer kies je iets anders? Voeg Attackable of elementsubclasses alleen toe als de opdracht of echt verschillend gedrag dat vraagt. In de basisgame blijft damage een pure functie in Week 03.

UML-classdiagram van Monster, Stats en Element: Monster bevat Stats en gebruikt Element
Classdiagram v5.0 — Monster heeft één Stats-object en verwijst naar één Element. Klik om de bestaande PNG volledig te bekijken. Plaats het bestand op assets/images/diagrams/02-class-diagram.png.

Cheat Sheet

KotlinBetekenisGebruik
class Monster(...)Gewone class met identiteitDomeinmodel
data class Stats(...)Waardedata, value equality en copy()Immutable basisstats
enum class ElementBeperkte set vaste labelsFIRE, WATER, WIND
init { require(...) }Constructorinvarianten bewakenPositieve ID en HP
val / varNiet opnieuw toewijzen / wel opnieuw toewijzenBeheer mutability bewust
private setPubliek leesbaar, setter afgeschermdOnderwijsvoorbeeld encapsulation
private / internal / protected / publicToegankelijkheid regelenKleine publieke API
constructor(...) : this(...)Secondary constructor en chainingAlternatieve constructieroute
interface / overrideContract implementerenUitwisselbaar gedrag
abstract classOnvolledige gedeelde basisclassBij gedeelde toestand of gedrag
copy(), componentN()Gegenereerde functies van data classesStats vergelijken / uitpakken
== / ===Gelijkheid van inhoud / objectidentiteitWaardedata versus referentie
sealed class / sealed interfaceGesloten verzameling variantenExhaustive when
fun interfaceEén abstracte functie (SAM)Lambda als implementatie
thisVerwijzing naar huidige instantieNaamconflict oplossen
Any / Any?Algemeen type / inclusief nullTypehiërarchie
MonsterBattles Docs · Code, comments & output