CodeGym /Kurse /Kotlin SELF /Methoden und Zustand: Verhalten nahe bei Daten, this und ...

Methoden und Zustand: Verhalten nahe bei Daten, this und grundlegende Pruefungen

Kotlin SELF
Level 30, Lektion 2
Verfügbar

1. Warum Verhalten nahe bei den Daten halten

Wenn man anfängt, ein Programm zu schreiben, moechte man die gesamte Logik sehr gern „draussen“ halten: hier sind die Daten, und hier getrennt davon die Funktionen, die irgendetwas damit machen. Und das ist normal – so ist der Einstieg einfacher. Aber mit wachsendem Code tritt ein unangenehmer Effekt auf: Regeln fuer die Arbeit mit den Daten beginnen sich an verschiedenen Stellen zu wiederholen, und Aenderungen werden aehnlich wie ein Spiel „finde alle Stellen, an denen ich die Pruefung vergessen habe“.

Stellen Sie sich vor, wir haben eine Ausgabe: Bezeichnung und Betrag. Solange Sie sie nur einmal ausgeben – alles gut. Aber sobald Sie anfangen, die Bezeichnung zu aendern, den Betrag zu aendern, zu pruefen, ob es eine „grosse Ausgabe“ ist, die Ausgabe zu formatieren – verteilt sich die Logik ueber das ganze Projekt.

Am Anfang sieht das ungefaehr so aus:

fun isLargeExpense(amount: Int): Boolean {
    return amount >= 1000
}

fun main() {
    val title = "Taxi"
    val amount = 1200

    println("$title: $amount")                 // Taxi: 1200
    println(isLargeExpense(amount))            // true
}

Funktioniert es? Ja. Skaliert es? Wie ein Abschlussprojekt, schnell auf dem Knie zusammengebastelt – auch ja, aber nicht lange.

Und genau hier entsteht die Idee: Wenn die „Grosse-Pruefung“ ein inhaltlicher Teil einer Ausgabe ist, warum sollte das nicht eine Methode der Ausgabe sein?

2. Methoden: Deklaration, Aufruf und Zugriff auf Eigenschaften

Klassenmethode: wie deklarieren und wie aufrufen

Eine Methode ist eine ganz normale Funktion, aber sie lebt innerhalb einer Klasse und wird am Objekt per Punkt aufgerufen. In Kotlin liest sich das sehr natuerlich: expense.isLarge() klingt wie ein Satz und nicht wie ein Zauberspruch aus einem uralten Framework.

Fangen wir mit dem Einfachsten an: Wir fuegen eine Methode hinzu, die eine Ausgabe schoen in einer Zeile ausgibt.

class Expense(val title: String, val amount: Int) {
    fun asLine(): String {
        return "$title: $amount"
    }
}

fun main() {
    val e = Expense("Coffee", 300)
    println(e.asLine()) // Coffee: 300
}

Achten Sie auf zwei Punkte. Erstens: Eine Methode wird als fun innerhalb der geschweiften Klammern der Klasse deklariert. Zweitens: Sie wird als e.asLine() aufgerufen: Objekt links, Methode rechts. Das ist eine wichtige Gewohnheit, denn spaeter wird fast alles in OOP in Kotlin „per Punkt“ passieren.

Warum Methoden die Eigenschaften des Objekts „gratis“ sehen

Der angenehmste Teil an Methoden fuer Einsteiger ist, dass die Methode das Objekt bereits „in der Hand“ hat. Das heisst, man muss nicht separat title und amount uebergeben, weil sie bereits im Objekt enthalten sind. Das wirkt wie Magie, ist aber eigentlich nur Logik: Eine Methode wird auf einem konkreten Objekt aufgerufen, also arbeitet sie mit dessen Zustand.

Vergleichen wir zwei Ansaetze – eine Funktion ausserhalb und eine Methode innerhalb.

Funktion ausserhalb:

class Expense(val title: String, val amount: Int)

fun asLine(e: Expense): String {
    return "${e.title}: ${e.amount}"
}

fun main() {
    val e = Expense("Lunch", 900)
    println(asLine(e)) // Lunch: 900
}

Dasselbe, aber „nahe bei den Daten“:

class Expense(val title: String, val amount: Int) {
    fun asLine(): String = "$title: $amount"
}

fun main() {
    val e = Expense("Lunch", 900)
    println(e.asLine()) // Lunch: 900
}

Die zweite Variante liest sich meist leichter: weniger Rauschen im Aufruf und weniger Risiko, „das falsche Objekt“ zu uebergeben. Und noch wichtiger: Die Formatierungsregeln leben an einer Stelle, statt sich ueber das Projekt zu verteilen.

3. Objektzustand: var und das „Objekt in der Zeit“

Zustand sind die aktuellen Werte der var-Eigenschaften eines Objekts. Wenn ein Objekt veraenderliche Felder hat, kann sich das Objekt ueber die Zeit aendern: erst so, dann anders. Das ist nuetzlich fuer Zaehler, Geldboersen, aktuelle Einstellungen und generell alles, was „lebt“ und sich aendert.

Machen wir ein Mini-Beispiel: ein Zaehler, den man erhoehen kann.

class Counter(var value: Int = 0) {
    fun inc() {
        value += 1
    }
}

fun main() {
    val c = Counter()
    c.inc()
    c.inc()
    println(c.value) // 2
}

Und ein sehr wichtiges Detail, das am Anfang oft den Kopf verdreht: val c = Counter() verbietet, die Referenz neu zuzuweisen (d. h. man kann nicht c = Counter() machen), verbietet aber nicht, den Zustand des Objekts zu aendern, wenn innen var vorhanden ist.

Das heisst: val macht ein Objekt nicht „eingefroren“, sondern macht die Variable „nicht neu zuweisbar“.

4. this: wann es nicht noetig ist und wann es rettet

Das Wort this bedeutet in Kotlin „das aktuelle Objekt“. In den meisten Faellen erlaubt Kotlin, es nicht zu schreiben: Wenn Sie auf title zugreifen, versteht der Compiler, dass das this.title ist.

Aber manchmal braucht man this, um Mehrdeutigkeit zu beseitigen – besonders dann, wenn ein Methodenparameter denselben Namen hat wie eine Eigenschaft. Das ist haeufig bei „Umbenennen“, „Betrag aktualisieren“ und aehnlichen Operationen.

Hier ein Beispiel, in dem this die Absicht eindeutig macht:

class Person(var name: String) {
    fun rename(name: String) {
        this.name = name.trim()
    }
}

fun main() {
    val p = Person("Ann")
    p.rename("  Anna  ")
    println(p.name) // Anna
}

Die praktische Regel ist einfach: Solange es keine Namenskonflikte gibt, kann man this weglassen. Sobald das Gefuehl auftaucht „irgendwas ist hier verdaechtig“, hilft this dabei, klar zu zeigen, dass Sie den Zustand genau dieses Objekts aendern.

5. Invarianten und Pruefungen: das Objekt in einem korrekten Zustand halten

Eine Invariante ist eine einfache Regel fuer einen korrekten Zustand. Alltagsbedeutung: „Was muss immer wahr sein, damit das Objekt Sinn ergibt“. Zum Beispiel darf bei einer Ausgabe der Betrag nicht negativ sein, und der Name darf nicht leer sein.

Invarianten prueft man am besten dort, wo die Aenderung passiert, also in Methoden. Dann hat man „einen Kontrollpunkt“, und man muss sich nicht in jedem Programmbereich an die Pruefungen erinnern.

Machen wir Expense veraenderlich und geben ihm Update-Methoden mit minimalen Pruefungen:

class Expense(var title: String, var amount: Int) {

    fun rename(newTitle: String): Boolean {
        val t = newTitle.trim()
        if (t.isBlank()) return false
        title = t
        return true
    }

    fun changeAmount(newAmount: Int): Boolean {
        if (newAmount < 0) return false
        amount = newAmount
        return true
    }
}

Hier gibt die Methode Boolean zurueck, und das ist ein sehr „bodenstaendiger“ Vertrag: true – hat geklappt, false – hat nicht geklappt, und das Objekt ist nicht kaputt. Dieser Ansatz ist gut, wenn ein Fehler ein erwarteter Teil des Lebens ist (der Benutzer hat Unsinn eingegeben – kommt vor).

Frueher return und require/check: unterschiedliche Szenarien

Manchmal ist ein Fehler eine normale Situation, manchmal ist es eine logische Katastrophe (so nach dem Motto „in der Geldboerse ist ein negativer Kontostand, obwohl wir versprochen haben, dass so etwas nicht vorkommt“). Kotlin gibt Ihnen zwei gaengige Werkzeuge: den weichen Stil ueber return false und den harten Stil ueber require/check.

  • require(...) prueft normalerweise die Korrektheit der Eingabeargumente und wirft bei Verletzung eine IllegalArgumentException.
  • check(...) prueft normalerweise den Zustand und wirft bei Verletzung eine IllegalStateException.

Vergleichen wir die Ansaetze am Beispiel einer Geldboerse.

Weiche Variante (wir erwarten, dass der Benutzer sich irren kann):

class Wallet(var balance: Int) {

    fun withdraw(amount: Int): Boolean {
        if (amount <= 0) return false
        if (amount > balance) return false
        balance -= amount
        return true
    }
}

fun main() {
    val w = Wallet(100)
    println(w.withdraw(30))  // true
    println(w.balance)       // 70
    println(w.withdraw(200)) // false
}

Harte Variante (wir gehen davon aus: „hier darf man nicht mit falschen Daten auftauchen“):

class Wallet(var balance: Int) {

    fun withdraw(amount: Int) {
        require(amount > 0) { "Amount must be positive: $amount" }
        check(balance >= 0) { "Balance is broken: $balance" }

        require(amount <= balance) {
            "Not enough money. Balance=$balance, amount=$amount"
        }
        balance -= amount
    }
}

Der Unterschied liegt nicht in der Syntax, sondern in der Philosophie.

  • Die Rueckgabe von false erlaubt dem Programm weiterzulaufen und den Benutzer ruhig zu bitten: „Bitte noch einmal eingeben“.
  • require/check ist ein Signal: „In so einer Situation darf das Programm nicht weitermachen; das ist eine Vertragsverletzung“.

6. In der Praxis: Expense ueber Methoden aktualisieren

Wir koennen die richtige Idee schon an einem kleinen Ausschnitt zeigen: Externer Code darf nicht alle Regeln zum Aendern einer Ausgabe kennen. Externer Code soll das Objekt bitten, eine Aktion auszufuehren, und das Objekt entscheidet selbst, was zulaessig ist.

So kann ein Update der Bezeichnung aussehen: Wir machen nicht expense.title = ... direkt, sondern rufen rename(...) auf.

class Expense(var title: String, var amount: Int) {
    fun rename(newTitle: String): Boolean {
        val t = newTitle.trim()
        if (t.isBlank()) return false
        title = t
        return true
    }
}

fun main() {
    val e = Expense("Coffee", 300)

    val ok = e.rename("   ")
    println(ok)       // false
    println(e.title)  // Coffee
}

Beachten Sie: Im Fehlerfall bleibt das Objekt im bisherigen korrekten Zustand. Das ist der kleine „Sieg“ der Invariante: Wir haben nicht erlaubt, ein komisches Objekt zu erzeugen, mit dem man spaeter nicht weiss, was man tun soll.

Wenn Sie auch den Betrag aktualisieren moechten, „kennt“ der externe Code die Regeln wieder nicht – er ruft einfach die Methode auf:

class Expense(var title: String, var amount: Int) {
    fun changeAmount(newAmount: Int): Boolean {
        if (newAmount < 0) return false
        amount = newAmount
        return true
    }
}

fun main() {
    val e = Expense("Taxi", 1200)

    e.changeAmount(1500)
    println(e.amount) // 1500
}

Das wirkt wie eine Kleinigkeit, spart aber in echten Projekten viele Nerven: Sie muessen nicht im ganzen Code suchen „wo koennten wir einen negativen Betrag zugewiesen haben“, weil Sie Zuweisungen „von aussen“ fast nicht machen.

7. Typische Fehler

Fehler Nr. 1: Die Klasse existiert, aber die gesamte Logik lebt trotzdem draussen.
Ein Einsteiger legt class Expense(...) an, schreibt dann aber weiterhin zehn Funktionen vom Typ formatExpense(e), isLargeExpense(e), renameExpense(e, ...), validateExpense(e) usw. Am Ende wird die Klasse zu einem „Feldersack“, und die Regeln bleiben verstreut. Wenn eine Aktion natuerlich zu einem konkreten Objekt gehoert, ist es besser, sie als Methode zu implementieren: So ist die Chance geringer, die Regel an einer Stelle zu vergessen.

Fehler Nr. 2: Veraenderlichkeit (var) wird „zur Sicherheit“ hinzugefuegt, nicht aus Bedeutung heraus.
Wenn Sie alle Eigenschaften als var machen, kann jeder Code-Teil das Objekt beliebig aendern – wie auch immer und wann auch immer. Dann sehen Sie ploetzlich amount = -100 und beginnen zu vermuten, dass dunkle Magie ins Projekt eingedrungen ist. Besser ist es, mit val zu starten und var nur dann hinzuzufuegen, wenn eine Aenderung im Szenario wirklich sinnvoll und notwendig ist.

Fehler Nr. 3: Zustandsaenderungen werden direkt gemacht und umgehen die Methoden.
Sie haben rename() mit Pruefungen geschrieben, aber irgendwo im Code steht trotzdem noch expense.title = readln(). In diesem Moment verlieren die Pruefungen ihren Sinn: Ein Teil der Aenderungen geht durch den „richtigen Eingang“, ein Teil durch den „Hintereingang“. Wenn Sie sich entschieden haben, Invarianten ueber Methoden zu schuetzen, versuchen Sie, Methoden aufzurufen statt Felder direkt zu aendern.

Fehler Nr. 4: Eine Methode aendert den Zustand, meldet aber nicht, ob es geklappt hat.
Wenn eine Methode „scheitern“ kann (z. B. ist der neue Name leer), aber dabei Unit zurueckgibt und stillschweigend nichts tut, versteht der aufrufende Code nicht, was passiert ist. Das erzeugt seltsame Szenarien: Der Benutzer gibt eine leere Zeile ein, das Programm laeuft weiter, als waere alles gut, aber die Aenderung wurde nicht angewendet. Fuer erwartete Fehler ist ein Boolean-Vertrag praktisch: Der aufrufende Code kann eine Meldung anzeigen und die Eingabe wiederholen.

Fehler Nr. 5: Verwirrung durch gleiche Namen und fehlendes this.
Die Methode rename(name: String) begegnet Ihnen mit hoher Wahrscheinlichkeit, und mit hoher Wahrscheinlichkeit schreiben Sie einmal name = name.trim() und starren dann eine Minute auf den Monitor, waehrend Sie auf Erleuchtung warten. In solchen Stellen sind this keine „ueberfluessigen Buchstaben“, sondern ein Weg, die Absicht offensichtlich zu machen: this.name = name.trim().

Fehler Nr. 6: require/check werden dort verwendet, wo das Szenario weiterlaufen sollte.
Wenn Sie Benutzereingaben mit require(...) pruefen, dann „stuerzt“ Ihr Programm bei einem Fehler mit einer Exception ab, weil require genau so funktioniert. Das ist gut, um einen Vertrag zu schuetzen, aber schlecht fuer eine freundliche CLI, in der der Benutzer sich einfach vertippt haben kann. Fuer erwartete Fehler ist es besser, false/null zurueckzugeben und um erneute Eingabe zu bitten, und require/check fuer Situationen aufzuheben, in denen „das nicht passieren darf“.

1
Aufgabe
Kotlin SELF, Level 30, Lektion 2
Gesperrt
Profil-Visitenkarte
Profil-Visitenkarte
1
Aufgabe
Kotlin SELF, Level 30, Lektion 2
Gesperrt
Schritte des Helden
Schritte des Helden
1
Aufgabe
Kotlin SELF, Level 30, Lektion 2
Gesperrt
Nickname ohne Ballast
Nickname ohne Ballast
1
Aufgabe
Kotlin SELF, Level 30, Lektion 2
Gesperrt
Regel-Wallet
Regel-Wallet
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION