Die Webseiten der Fachschaft Informatik am ERG Saalfeld


OOP am Beispiel von "Türme von Hanoi"


Es soll hier das Spiel "Türme von Hanoi" als Animation im Textmodus programmiert werden. Als Vorlage dient die Umsetzung in Pascal, mit der auch die Screenshot zur Seite zu den "Türmen von Hanoi" erstellt wurden. Dabei soll an einem bekannten Beispiel wesentliche Begriffe wie Klasse, Attribute, Methoden, Objekt/Instanz ebenso wie die Vererbung verdeutlicht werden.


Es wird hier davon ausgegangen, dass das Spiel einschließlich des Algorithmus (Randoffsche Algorithmus) bekannt ist. Es soll jetzt Folgendes erreicht werden:
es werden auf der Eingabeaufforderung Scheiben dargestellt, die entsprechend des Algorithmus von einem Stab auf den anderen wandern. Das sieht dann z.B. so aus:


Vorbereitung

Unter Pascal gibt es die goto-Anweisung, um einen String an eine vorgegebene Stelle zu plazieren. Das kenne ich von Python nicht bzw. habe ich so nicht gefunden. Um diese Anweisung zu simulieren, habe ich unter https://rosettacode.org/wiki/Terminal_control/Cursor_positioning#Python gefunden, wie eine "print_at"-Anweisung bereitgestellt werden kann. Diese leistet das von mir Gewünschte. Genaueres hier.

Um einen definierten Bildschirm zu haben, muss dieser durch das Programm gelöscht werden. Dazu nutze ich den Befehl 'cls' über das Modul 'os'. Genaueres hier.

Bei so einer Animation stört der Cursor. Um den abzuschalten, findet sich unter https://rosettacode.org/wiki/Terminal_control/Hiding_the_cursor#Python eine einfache Lösung. Genaueres hier.

Um das Tempo zu steuern, verwende ich die Anweisung 'sleep' aus dem Modul 'time'. Genaueres hier.


Die Darstellung der Scheiben

Um die Scheiben darzustellen, habe ich eine Liste von Scheiben ("my_disc") definiert. Die kleinste Scheibe habe ich 3 Zeichen breit gewählt, um in die Mitte noch den Stab (Pin) drüber zeichen zu lassen. Es sind 11 Scheiben definiert: my_disc[1] bis my_disc[11]. my_disc[0] dient zum Löschen von Scheiben.

my_disc = ["                       ",
           "          ###          ",
           "         XXXXX         ",
           "        &&&&&&&        ",
           "       %%%%%%%%%       ",
           "      $$$$$$$$$$$      ",
           "     #############     ",
           "    XXXXXXXXXXXXXXX    ",
           "   &&&&&&&&&&&&&&&&&   ",
           "  %%%%%%%%%%%%%%%%%%%  ",
           " $$$$$$$$$$$$$$$$$$$$$ ",
           "#######################"]


Klassendefinitionen

Scheibe

Die Scheiben haben eine Form. Die ist in "my_disc" definiert. Damit ergibt sich erstmal für die Klasse Scheibe:

class Scheibe():
    def __init__(self, nummer_scheibe):
        Scheibe.form = my_disc[nummer_scheibe]

Das bedeutet, die Scheibe hat das Attribut "form". In diesem Attribut ist der zugehöringe String aus "my_disc" abgelegt.


Jede Scheibe soll sich selbst darstellen können, also zeichnen und auch wieder löschen. Um die Scheibe zu zeichnen muss einfach der zugehöringe String aus my_disc an der richtigen Position mit der print_at-Anweisung geschrieben werden. Die beiden Gleichungen zur Berechnung der beiden Koordinaten werden auf einer extra Seite hergeleitet. Diese sind:

pos_oben = scheiben_nr_auf_stab + 17
pos_rechts = 37 * stab - 34


Die Methode "zeichnen" für die Scheibe sieht damit so aus:

    def zeichnen(self, stab, scheiben_nr_auf_stab):
        pos_rechts =  37 * stab - 34           # stab_a = 1, stab_b = 2, stab_c = 3
        pos_oben   =  scheiben_nr_auf_stab + 17
        print_at(pos_oben, pos_rechts, self.form)


Die Methode "loeschen" funktioniert genauso wie das zeichen, nur das eben mit einer "leeren Scheibe", also my_disc[0], gezeichnet wird. die Methode "loeschen" sieht damit so aus:

    def loeschen(self, stab, scheiben_nr_auf_stab):
        pos_rechts =  37 * stab - 34           # stab_a = 1, stab_b = 2, stab_c = 3
        pos_oben   =  scheiben_nr_auf_stab + 17
        print_at(pos_oben, pos_rechts, my_disc[0])


zusammengefasst: die Klassendefinition für Scheibe sieht so aus:

class Scheibe():
    def __init__(self, nummer_scheibe):
        Scheibe.form = my_disc[nummer_scheibe]

    def zeichnen(self, stab, scheiben_nr_auf_stab):
        pos_rechts =  37 * stab - 34           # stab_a = 1, stab_b = 2, stab_c = 3
        pos_oben   =  scheiben_nr_auf_stab + 17
        print_at(pos_oben, pos_rechts, self.form)

    def loeschen(self, stab, scheiben_nr_auf_stab):
        pos_rechts =  37 * stab - 34           # stab_a = 1, stab_b = 2, stab_c = 3
        pos_oben   =  scheiben_nr_auf_stab + 17
        print_at(pos_oben, pos_rechts, my_disc[0])


Stab

Es gibt bei dem Spiel "Türme von Hanoi" 3 Stäbe. diese sollen sich selbst "verwalten". Diese Stäbe erhalten Nummern von 1 bis 3. Damit bekommt ein Stab das Attribut "nummer". Der Stab soll natürlich wissen, wie viele Scheiben (also die Anzahl der Scheiben) auf dem Stab sind. Deshalb erhält der Stab das Attribut "anzahl". Außerdem muss der Stab wissen, welche Scheiben und auch in welcher Reihenfolge auf dem Stab sind. Dazu verpasse ich dem Stab das Attribut "scheiben", welches eine Liste darstellt. In diese Liste kommen die Nummern der Scheiben, wobei die Nummern der Scheibe, den Nummern der Scheibe auf "my_disc" entspricht, denn dadurch ist die Form der Scheibe ja hinreichend angegeben.

Damit ergibt sich erstmal für die klasse Stab:

class Stab():
    def __init__(self,nr):
        self.anzahl = 0
        self.nummer = nr      # z.B.: stab_a = 1, stab_b = 2, stab_c = 3
        self.scheiben = []      # Liste von Scheiben, die auf diesem Stab sind


Der Stab braucht 2 Methoden: "scheibe_aufnehmen" und "scheibe_abgeben".

Für "scheibe_aufnehmen" wird die Scheibe in die Liste "scheiben" gesteckt, die Anzahl wird um 1 erhöht und dann muss diese Scheibe sich zeichnen.

    scheibe_aufnehmen(self, nummer_scheibe):
        self.scheiben.append(nummer_scheibe)
        self.anzahl += 1                      # Anzahl um 1 erhöhen
        hilf = Scheibe(nummer_scheibe)
        stab_nr = self.nummer
        scheiben_nr_auf_stab = self.anzahl
        hilf.zeichnen(stab_nr, scheiben_nr_auf_stab)

scheibe_abgeben:

    scheibe_abgeben(self, nummer_scheibe):
        aktuelle_scheibe = self.scheiben.pop() # Rückgabe ist Nummer der Scheibe (int)
        stab_nr = self.nummer
        scheiben_nr_auf_stab = self.anzahl
        hilf = Scheibe(scheiben_nr_auf_stab)
        hilf.loeschen(stab_nr, scheiben_nr_auf_stab)
        self.anzahl -= 1                       # Anzahl um 1 verkleinert
        return aktuelle_scheibe            # das ist die Nummer der Scheibe / my_disc


zusammengefasst: die Klassendefinition für Stab sieht so aus:

class Stab():
    def __init__(self,nr):
        self.anzahl = 0
        self.nummer = nr      # z.B.: stab_a = 1, stab_b = 2, stab_c = 3
        self.scheiben = []      # Liste von Scheiben, die auf diesem Stab sind

    def scheibe_aufnehmen(self, nummer_scheibe):
        self.scheiben.append(nummer_scheibe)
        self.anzahl += 1                      # Anzahl um 1 erhöhen
        hilf = Scheibe(nummer_scheibe)
        stab_nr = self.nummer
        scheiben_nr_auf_stab = self.anzahl
        hilf.zeichnen(stab_nr, scheiben_nr_auf_stab)

    def scheibe_abgeben(self):
        aktuelle_scheibe = self.scheiben.pop() # Rückgabe ist Nummer der Scheibe (int)
        stab_nr = self.nummer
        scheiben_nr_auf_stab = self.anzahl
        hilf = Scheibe(scheiben_nr_auf_stab)
        hilf.loeschen(stab_nr, scheiben_nr_auf_stab)
        self.anzahl -= 1                       # Anzahl um 1 verkleinert
        return aktuelle_scheibe            # das ist die Nummer der Scheibe / my_disc


die rekursive Prozedur "bewege"

Die Prozedur "bewege" ist auf unserer Homepage so angegeben und stellt den eigentlichen Algorithmus für "Hanoi" dar.

def bewege (anzahl,stab_a,stab_b,stab_c):
  if anzahl > 0:
      bewege(anzahl-1,stab_a,stab_c,stab_b)
      print('verschiebe oberste Scheibe von ',stab_a,' nach ',stab_c)
      bewege(anzahl-1,stab_b,stab_a,stab_c)


Dabei ist nur die Zeile "print('verschiebe oberste Scheibe von ',stab_a,' nach ',stab_c)" zu ersetzen, denn es soll ja jetzt kein Text mehr geschrieben werden, sondern statt dessen die Scheiben bewegt werden. Ein Stab gibt eine Scheibe ab und ein anderer Stab nimmt diese Scheibe auf. Diese Prozedur sieht für uns dann so aus:

def bewege (anzahl,stab_a,stab_b,stab_c):
    if anzahl > 0:
        bewege(anzahl-1,stab_a,stab_c,stab_b)
        aktuelle_disc = stab_a.scheibe_abgeben()
        stab_c.scheibe_aufnehmen(aktuelle_disc)
        time.sleep(5/10)                 # sonst ist das Programm viel zu schnell
        bewege(anzahl-1,stab_b,stab_a,stab_c)



Jetzt zum Hauptprogramm.

Wir löschen den Bildschirm, verstecken den Cursor, lesen die Anzahl der Scheiben ein und zeichnen den Boden des Spiels.
Dann erstellen wir die 3 Stäbe, also die 3 Objekte (= Instanzen der Klasse Stab). Das sieht so aus:

stab_a = Stab(1)
stab_b = Stab(2)
stab_c = Stab(3)


Bevor wir beginnen können, legen wir so viele Scheiben auf den 1. Stab, wie als Parameter angegeben wurden. Dabei die größte Scheibe zuerst, dann die nächstkleinere usw. bis zur kleinsten Scheibe. D.h. wir zählen rückwärts. Das sieht so aus:

for i in range(anzahl_scheiben, 0, -1):
    stab_a.scheibe_aufnehmen(i)

Jetzt können wir das Spiel ablaufen lassen, also die Prozedur "bewege()" aufrufen.

bewege(anzahl_scheiben,stab_a,stab_b,stab_c)


Zum Schluss schreiben wir noch hin, dass wir fertig sind - sorry, der Sinn ist, dass am Ende ja der Pfad angezeigt wird und der soll nicht in den Scheiben stehen und wir positionieren diese Bemerkung einfach unter unserem Spiel. Und wir machen den Cursor wieder sichtbar. - Das war's.

Hier das vollständige Programm. Und hier als PDF-Datei.


Verbesserungen

Wie man leicht sieht, entspricht das Programm nicht dem, was am Anfang als Screenshot dargestellt ist. Das soll jetzt ergänzt werden.


Zähler

Um die Anzahl der Bewegungen zu zählen, folgen die üblichen 3 Schritte. Wir initialisieren den Zähler (über den Klassendefintionen) mit:

anzahl_bewegungen = 0                                       # Initialisierung


In der Prozedur "bewege" erhöhen wir diesen Zähler nach dem Abgeben bzw. Aufnehmen der Scheibe um 1. Die Prozedzr sieht dann so aus:

def bewege (anzahl,stab_a,stab_b,stab_c):
    if anzahl > 0:
        bewege(anzahl-1,stab_a,stab_c,stab_b)
        aktuelle_disc = stab_a.scheibe_abgeben()
        stab_c.scheibe_aufnehmen(aktuelle_disc)
        global anzahl_bewegungen
        anzahl_bewegungen += 1                 # Anzahl um 1 erhöhen
        time.sleep(5/10)                              # sonst ist das Programm viel zu schnell
        bewege(anzahl-1,stab_b,stab_a,stab_c)


Am Ende des Programms geben wir diese Anzahl noch aus (vorletzte Zeile).

print("   Anzahl der Bewegungen:", anzahl_bewegungen)


Stab zeichnen (abgeleitete Klassendefinition)

Auf den 3 Bildern am Anfang der Seite sind bei den Türmen ein "echter" Stab zusehen. Ich nenne die jetzt "Pins". Die Koordinaten ergeben sich wie bei den Scheiben so: print_at(4+i, 37 * stab - 23, "|"), wobei i die nummer der Scheibe auf dem Stab entspricht und "stab" die Nummer des Stabes (also 1, 2 und 3) ist.

Die __init__-Methode dieser abgeleiteten Klasse StabX muss sein. Aber da passiert erstmal nichts anderes wie bei der __init__-Methode von Stab(). Wir rufen deshalb einfach die __init__-Methode der Elternklasse auf mit super().__init__(nr).

Für die abgeleitete Klasse geht es ja darum, dass der Pin gezeichnet wird. Deshalb kommt neu eine Prozedur zeichne_stab.

Für die Methode "scheibe_aufnehmen" rufen wird diese Prozedur der Elternklasse Stab auf (mit super()...) und ergänzen diese Methode um "zeichne_stab". Genauso gehen wir bei der Methode "scheibe_abgeben" vor.

Im Hauptprogramm werden die 3 Instanzen für die Stäbe dann von StabX gebildet.

class StabX(Stab):
    def __init__(self,nr):
        super().__init__(nr)

    def zeichne_stab(self):
        for i in range(1,13,1):
            print_at(4+i, self.nummer * 37 - 23, "|")

    def scheibe_aufnehmen(self, nummer_scheibe):
        super().scheibe_aufnehmen(nummer_scheibe)
        self.zeichne_stab()

    def scheibe_abgeben(self):
        aktuelle_scheibe = super().scheibe_abgeben()
        self.zeichne_stab()
        return aktuelle_scheibe
stab_a = StabX(1)
stab_b = StabX(2)
stab_c = StabX(3)


verzögerter Start und Pins zeichnen

Unschön ist, dass der Turm A die Scheiben aufnimmt und dann geht es sofort los. Es wäre schon schöner, wenn nach dem Aufnehmen der Scheiben ein kurzer Moment gewartet wird und dann erst das Verschieben der Scheiben beginnt.

Wenn man nach dem Aufnehmen eine Pause (time.sleep(2)) rein nimmt, fällt erst recht auf, dass die Pins für den 2. und 3. Stab erst nach dem Bewegen der ersten Scheibe gezeichnet werden. Darum werden nach dem Aufnehmen der Scheiben auch die beiden anderen Stäbe gezeichnet und dann kommt die kleine Pause.

stab_b.zeichne_stab()
stab_c.zeichne_stab()
time.sleep(2)


Länge des Stabes

Die Länge des Stabes wurde jetzt so gewählt, dass alles Scheiben (also max. 11 Scheiben) auf den Stab (genauer die Pins) passen. Wenn man aber nur wenige Scheiben hat, z.B. 5 Scheiben, dann sind die Pins einfach viel zu lang. Es wäre also viel ansprechender, wenn die Pins so lang wären, dass die Scheiben "gerade so" darauf passen. Es soll hier so sein, dass die Länge um 1 größer ist aks die Anzahl der Scheiben.
Dazu verwenden wir bei der Klasse StabX eine Klassenvariable "laenge". Da es eine Klassenvariable ist, steht diese sofort allen Instanzen mit dem gleichen Wert zur Verfügung.

Wir ändern die Methode "zeichne_stab" so ab:

    def zeichne_stab(self):
        for i in range(StabX.laenge, 0, -1):
            print_at(17-i, self.nummer * 37 - 23, "|")  # siehe bei Scheibe.zeichnen

Im Hauptprogramm setzen wir diese Klassenvariable "laenge" bevor die Scheiben aufgenommen werden bzw. die Stäbe gezeichnet werden mit:

StabX.laenge = anzahl_scheiben + 1

 

Hier das vollständige Programm. Und hier als PDF-Datei. Hier das gezippte Python-Programm "hanoi.py" zum Download.

 

zurück


© ERG Saalfeld   -   HD. Kirmse   30.04.2023