34. Vererbung

Bisher haben wir nur Großbuchstaben als Nichtterminale und andere Zeichen als Terminale. Wir wollen aber richtige Sätze haben und NP und VP als Nichtterminale.

Abhilfe: Wir definieren Klassen, die als Symbole fungieren.

Beachtet, dass das auch gleich bedeutet, dass wir unsere Wörter nicht mehr als Strings modellieren können – Wörter sind Sequenzen von Symbolen, und wenn unsere Symbole keine einfachen Zeichen mehr sind, können die Wörter keine Strings (also Sequenzen einfacher Zeichen) mehr sein. Diese Änderung wird uns also noch viel Arbeit machen.

Terminale und Nichtterminale haben gemeinsame Eigenschaften, unterscheiden sich aber in Details. Dafür gibt es Vererbung: Wir definieren eine Basisklasse Symbol mit den gemeinsamen Eigenschaften und leiten daraus zwei Unterklassen ab.

class Symbol:
  def __init__(self, rawStr):
    self.content = rawStr

  def getContent(self):
    return self.content

  def __str__(self):
    return self.content

  def __cmp__(self, other):
    try:
      return cmp(self.content, other.content)
    except AttributeError:
      return -1

class Terminal(Symbol):
  def __repr__(self):
    return "Terminal('%s')"%self.content

  def __hash__(self):
    raise TypeError("NonTerminal not hashable")


class NonTerminal(Symbol):
  def __repr__(self):
    return "NonTerminal('%s')"%self.content

  def __hash__(self):
    return hash(self.content)

In der Klassendefinition einer Subklasse steht also hinter ihrem Namen der Name der Basisklasse in Klammern. Wenn eine Methode aufgerufen wird, die in der Unterklasse nicht vorhanden ist, wird sie in der Basisklasse gesucht – das geht sogar für __init__ und die anderen magischen Namen. Ähnliches gilt für andere Attribute.

Wir haben hier zwei weitere magische Namen: __cmp__, das zwei Argumente nimmt und bestimmt, wie der Vergleich zwischen zwei Objekten ausgeht, und __hash__, das eine Art Kennung des Objekts zurückgibt, die zum Beispiel verwendet wird, wenn ein Objekt als Schlüssel in einem Dictionary verwendet wird.

Wir müssen das hier definieren, weil wir wollen, dass Symbole, die beispielsweise das Wort “Himmel” enthalten, gleich sind, selbst wenn sie nicht der gleiche Wert sind (also das gleiche Objekt darstellen). Ohne weiteres würden die Objekte nach den Speicheradressen verglichen, an denen sie gespeichert sind, was hier natürlich sinnlos ist. Ein kleines Problem ist bei dem hier gewählten Ansatz, dass Terminal("Himmel")==NonTerminal("Himmel") – aber das stört uns nicht.

Die eingebaute Funktion cmp, die in unserer __cmp__-Methode verwendet wird, vergleicht übrigens ganz schlicht ihre beiden Argumente und gibt -1 (arg1 < arg2), 0 (arg1 == arg2) oder 1 zurück.

Terminal löst eine Exception aus, wenn es nach einem Hash gefragt wird. Wir wollen Terminals vorläufig nicht als Schlüssel in einem Dictionary haben und gehen auf Nummer sicher.

Übungen zu diesem Abschnitt

Ihr solltet euch wenigstens an den rötlich unterlegten Aufgaben versuchen

(1)

Um der Vererbung etwas näher zu kommen, kann man sich Spielzeug vorstellen. Alle Spielzeuge haben natürlich einen Namen. Definiert also zunächst eine Klasse Toy, die mit einem Namen konstruiert wird und eine Methode getName hat.

Jetzt soll es Spielzeuge geben, die zusätzlich piepen können. Definiert eine Klasse BeepingToy, die von Toy erbt, aber zusätzlich eine Methode beep hat, die analog der von Tiny funktioniert.

Andere Spielzeuge können grummeln – definiert entsprechend eine Klasse GrowlingToy, die von Toy erbt und eine Methode growl hat, bei deren Aufruf eine Nachricht ausgegeben wird, das Spielzeug mit entsprechenden Namen würde growlen.

Wir entdecken erst jetzt, dass Kinder ihren Spielzeugen manchmal neue Namen geben. Wie können wir diese Entdeckung verarbeiten? Was müssten wir tun, wenn Spielzeuge auch kaputt gehen können sollen?

(2)

Klassen können Methoden ihrer Oberklassen überschreiben. In der Tat machen das unsere Symbole schon, denn natürlich hat schon Symbol ein __repr__, wenn auch nur implizit.

Im Spielzeugbeispiel kann man das etwas expliziter machen: Natürlich mit allen Spielzeugen spielen, aber die konkrete Natur des Spielen hängt vom Spielzeug ab. Definiert also eine Methode Toy.play, die vielleicht nur sagt, es sei langweilig (ein generisches Spielzeug ist nicht so spannend). Definiert dann noch play-Methoden der Unterklassen, die etwa einfach beep bzw. growl aufrufen.

So etwas ist eine einfache Form des so genannten Polymorphismus – das Programm entscheidet sich bei Anwendung ein und derselben Operation (hier ein Methodenaufruf) in Abhängigkeit von den Operatoren (hier die Instanz, für die die Methode aufgerufen wird) für die Ausführung verschiedenen Codes (hier eben die verschiedenen Methoden). Das ist für uns nichts Neues, denn viele Operatoren und Funktionen von Python sind sozusagen “von Natur aus” polymorph. In Sprachen mit statischer Typprüfung (Java, C++ und Freunde) ist Polymorphismus hingegen viel aufregender.

Dateien zu diesem Abschnitt


Markus Demleitner

Copyright Notice