Generizität: Difference between revisions

From Alda
Jump to navigationJump to search
No edit summary
 
(115 intermediate revisions by 12 users not shown)
Line 1: Line 1:
                 
==Generische Programmierung - Definition und Motivation ==


== Definition in der Informatik :==
Ziel von [http://de.wikipedia.org/wiki/Generische_Programmierung generischer Programmierung] ist es, Algorithmen und Datenstrukturen so zu entwerfen und zu implementieren, dass sie möglichst '''vielfältig verwendbar''' sind.


Vielfältig verwendbar meint hierbei, dass generische Algorithmen und Datenstrukturen '''ohne Neumiplementation''' in
* den verschiedensten Anwendungen eingesetzt,
* vielseitig miteinander kombiniert und
* als wiederverwendbare Bibliotheken zur Verfügung gestellt werden können.
Dadurch gelingt es, auch sehr komplizierte Algorithmen und Datenstrukturen praktisch nutzbar zu machen. Ohne Generizität müssten diese jedesmal neu implementiert und getestet werden, wofür der Aufwand oft zu hoch wäre. Außerdem muss derselbe Algorithmus dann oft in vielen Varianten implementiert werden, ohne dass sich die Varianten auf interessante Weise unterscheiden.


Algortihmen und Datenstrukturen so zu entwerfen und implementieren, dass sie möglichst<br/> vielvältig verwendbar sind.
===Beispiel eines nicht-generischen Algorithmus===


=== Gemeint sind : ===
Wir demonstrieren am folgenden Beispiel, wie es ohne Generizität nötig wird, denselben Algorithmus 'kopiere Container' für jede Kombination von Quell- und Zielcontainer neu zu implementieren. Dies ist besonders störend, weil die Unterschiede der Varianten trivial sind. Wir zeigen weiter unten, dass mit Generizität eine einzige Implementation ausreichend ist.


*verschiedene Anwendungen                       
====Kopie Array &rarr; Array ====
*mit vielen Kombinationsmöglichkeiten
*als wiederverwendbare Bibliothek


--> ''' ohne Neuimplemenation '''
Die folgende einfache Funktion kopiert die Daten aus einem gegebenen Array a in ein (dynamisches) Array r:
 
*Code austauschen in Bibliotheken
 
 
'''Beispiel : ''' Kopieren eines Containers
 
 
==== 1.Variante: ====


  def copyArray(a):
  def copyArray(a):
   r =[]
   r =[]
   for k in a
   for k in a:
  r.append(k)
      r.append(k)
return k
  return r


====Kopie Array &rarr; verkettete Liste====


==== 2.Variante: ====
Um in eine verkettete Liste kopieren zu können, definieren wir zunächst den Knotentyp der Liste:


  class Node :
  class Node :
   def__init__(self,data,next)
   def __init__(self, data, next)
     self.data = data
     self.data = data
     self.next = next
     self.next = next
  return k


==== 3.Variante: ====
Die Kopie aus einem Array in eine Liste kann dann wie folgt implementiert werden:


  def copyArrayToList(a) :
  def copyArrayToList(a) :
     if len(a) == 0 : return None
     if len(a) == 0 :  
      first = last = Node (a[0], None)
        return None
    first = last = Node (a[0], None)
     for k in a[1:]  :
     for k in a[1:]  :
       last.next = Node(k, None)
       last.next = Node(k, None)
Line 47: Line 43:
     return first
     return first


==== 4.Variante: ====
====Kopie verkettete Liste &rarr; Array====
 
Eine wieder andere Implementation benötigt man, wenn man umgekehrt von einer Liste in ein Array kopieren möchte:
 
  def copyListToArray(l):
  def copyListToArray(l):
     r = []
     r = []
     while l is not in None :
     while l is not None :
         r.append(l.data)
         r.append(l.data)
         l = l.next
         l = l.next
     return r
     return r


==== Entscheidende Beobachtung  ====


Für '''N Datenstrukuren''' ist der Implementationsaufwand <math>O(N^2)</math>, wenn man alle Paare von Datenstrukturen ineinander umwandeln können will.
Alle Funktionen machen das Gleiche, mit nur uninteressanten Unterschieden. Wir wollen daher im Folgenden eine Möglichkeit angeben, das Kopieren der Daten zu vereinheitlichen, so dass nur noch eine generische Kopierfunktion benötigt wird. Das Kopieren ist natürlich dabei nur ein Beispiel -- die verwendeten Programiertechniken sind sehr allgemein und können bei eine Vielzahl von Algorithmen zu generischen Lösungen führen.


==== '''Beobachtung''' : ====
==Transformation in eine generische Form ==


Für '''N Datenstrukuren''' ist der Implementaionsaufwand <math>O({N^2})</math>.
Die Kopierfunktion enthält zwei wesentliche Funktionalitäten:
Alle Funktionen machen das gleiche mit uninteressanten Unterschied
* das Navigieren durch die Quelldaten und
* das Aufbauen der Zieldatenstruktur
Wenn wir die Implementation generisch gestalten wollen, müssen wir diese beiden Aspekte des Kopierens '''verallgemeinern'''.


===Vereinheitlichung der Zieldatenstruktur durch standardisierte Einfügefunktion===


'''Verbesserung durch Verallgemeinerung zweier Aspekte''' :
Wir beginnen mit dem Aufbauen der Zieldatenstruktur. Hier brauchen wir eine verallgemeinerte Methode, neue Elemente an eine bereits existierende Datenstruktur anzuhängen. Dazu bietet es sich an, eine
*standardisierte Funktion <tt>append</tt> zu verwenden.
Das dynamische Array hat diese Funktion bereits. Für eine einfach verkettete Liste kann sie nicht ohne weiteres effizient implementiert werden (einfach verkettete Listen unterstützen in erster Linie das Einfügen eines neuen Elements am ''Anfang'' der Liste, also <tt>prepend</tt>). Wir implementieren deshalb eine neue Datenstruktur <tt>DoublyLinkedList</tt>, die eine in beiden Richtungen verkettete Liste realisiert.


*Navigation durch die Quelldaten
Um Anfang und Ende der Liste zu markieren, verwenden wir eine spezielle Sentinel-Datenstruktur. Da sie nur als Markierung dient, benötigt sie keine eigenen Daten.
*Aufbauen der Zieldatenstruktur


class SentinelTag : pass    # keine Daten


Wie kann man so zwischen einem Array und einer Liste navigieren, so dass man den Unterschied nicht mehr merkt?
Der Knotentyp einer <tt>DoublyLinkedList</tt> enthält zwei Verkettungen: <tt>prev</tt> und <tt>next</tt>. Falls im Konstruktor kein Nachfolger <tt>next</tt> angegeben wird, werden die Verkettungen auf <tt>self</tt> initialisiert (dies bedeutet, dass der Knoten eine leere Liste repräsentiert), andernfalls werden alle Verkettungen so aktualisiert, dass der neue Knoten Vorgänger von <tt>next</tt> wird. Ein Sentinel-Knoten wird dadurch gekennzeichnet, dass sein Datenfeld den Typ <tt>SentinelTag</tt> hat. Dies ist auch die Initialisierung, falls im Konstruktor keine Daten übergeben werden:


-->''' Vereinheitlichung der Zieldatenstruktur :'''
class DoublyLinkedNode:
    def __init__(self, data = SentinelTag(), next = None)
        self.data = data
        if next is None :
            self.prev = self.next = self
        else:
            self.next = next
            self.prev = next.prev
            next.prev.next = self
            next.prev = self
    def isSentinel(self ) :
        return isinstance( self.data, SentinelTag) # Python-Standardfunktion zum Prüfen des Typs einer Variable


*standardisierte Funktion "append"
Die Klasse <tt>DoublyLinkedList</tt> kapselt nun die Verwaltung der Knoten. Die Liste selbst wird dabei als doppelt verbundene, kreisförmige Kette mit dem Sentinel als "Anker" realisiert. Dies hat den Vorteil, dass man das erste bzw. letzte Element der Liste einfach als Nachfolger bzw. Vorgänger des Ankerelements abfragen kann. Ein weiterer Vorteil besteht darin, dass auch die leere Liste noch ein Element enthält (nämlich den Sentinel), so dass beim Einfügen und Löschen von Elementen keine Spezialbehandlung für die leere Liste notwendig ist.
*Array hat sie schon
*Liste : definiere Klasse DoublyLinkedList


 
class DoublyLinkedList :
  class SentinelTag : pass    # keine Daten
    def __init__(self):        # Konstruktor initialisert leere Liste, die nur aus dem Sentinel besteht
        self.sentinel = DoublyLinkedNode()
        self.size = 0
   
    def __len__(self):        # wird ausgeführt, wenn man 'len(list)' aufruft
        return self.size
    def append(self, value):  # value am Ende anhängen, d.h. Sentinel soll ''Nachfolger'' des neuen Elements sein
        DoublyLinkedNode(value, self.sentinel)  # die Verkettungen werden im Konstruktor automatisch aktualisiert
        self.size += 1
    def prepend(self, value):  # value am Anfang (= ''nach'' dem Sentinel, d.h. vor dessen Nachfolger) einfügen
        DoublyLinkedNode(value, self.sentinel.next) 
        self.size += 1
    def __iter__(self):       # Iterator für die Liste erzeugen (siehe unten)
        return ListIterator(self.sentinel.next)
   
   
class DoublyLinkedNode:
    def reverseIterator(self): # Rückwärts-Iterator für die Liste erzeugen (siehe unten)
  def__init__(self,data = sentinelTag(), next = None)
        return ReverseListIterator(self.sentinel.prev)
    if next is None:
    self.prev = self.next = self
    else :
    self.next = next
    self.prev = next.prev
    next.prev.next = self
    next.prev = self


def isSentinel(self) : return isinstance(self.data, sentinelTag)
Damit können wir die Zieldatenstruktur einheitlich durch <tt>ziel.append(value)</tt> aufbauen, unabhängig davon, ob <tt>ziel</tt> nun ein Array oder eine doppelt verkettete Liste ist.


===Vereinheitlichung der Quelldatenstruktur durch Iteratoren===


class DoublyLinkedList :
Die Vereinheitlichung der Quelldatenstruktur ist komplizierter, weil die Navigationmethoden sich von Datenstruktur zu Datenstruktur sehr stark unterscheiden. Ein einheitlicher, standardisierter Funktionenname tut es hier nicht. Der Schlüssel für die Lösung besteht in der Beobachtung, dass wir, unabhängig von der Art der Datenstruktur, immer sequentiell auf die Datenelemente zugreifen. Man definiert deshalb eine Hilfsklasse '''Iterator''', die die Elemente in der richtigen Reihenfolge heraussucht, die aber verbirgt (kapselt), wie jedes Datenelement konkret gefunden wird.  
  def__init__(self):
    self.sentinel = DoublyLinkedNode()
    self.size = 0


def__len__(self): return self.size  #len(l)
;Definition Iterator:  
def append(self, value):
Ein Iterator ist ein Objekt,
    DoublyLinkedNode(value, self.sentinel)
* das auf ein Element des Containers zeigt und die Daten aus diesem Element an den aufrufenden Algorithmus übergeben kann
  def__iter__(self):
* das zum nächsten Element weiter rücken kann
      return ListIterator(self.sentinel.next)
* das anzeigt, wenn das Ende der Sequenz erreicht ist
(siehe dazu auch die [http://de.wikipedia.org/wiki/Iterator Wikipedia]).


Die Syntax dieser Funktionalitäten muss natürlich standardisiert sein, damit man das Ziel der Generizität erreicht. Wie genau die Standardisierung erfolgt, ist von Programmiersprache zu Programmiersprache unterschiedlich (falls die Sprache überhaupt Iteratoren unterstützt -- das gilt z.B. für C++ und Java). In Python gelten folgende Konventionen:
* Das Weiterrücken zum nächsten Element erfolgt durch die Funktion <tt>next()</tt> (in Python 2) bzw. <tt>__next__()</tt> (in Python 3). Diese Funktion gibt gleichzeitig die Daten desjenigen Elements zurück, das man gerade ''verlassen'' hat.
* Alle Datenstrukturen, die Iteratoren anbieten, sowie die Iteratoren selbst, müssen eine Funktion <tt>__iter__()</tt> haben, die einen neuen Iterator (bzw. eine Kopie des schon vorhandenen Iterators) zurückgibt. Diese Funktion wird von Python intern benutzt (siehe unten).
* Das Ende der Sequenz ist erreicht, wenn die Funktion <tt>next</tt> bzw. <tt>__next__</tt> mit einer <tt>StopIteration</tt>-Exception verlassen wird.


=== 2. Navigation in der Quelldatenstruktur(<u>Iteration</u>) soll<br/> für alle Datenstrukturen funktionieren ===
Die Array-Datenstruktur in Python bietet einen solchen Iterator bereits an.


*Objekt, dass auf ein Element des Containers zeigt
====Iterator für die doppelt verkettete Liste====
*zum nächsten Element weiter rücken kann
*anzeigt, wenn das Ende der Sequenz erreicht ist


Aufgrund dieser Konventionen können wir den Iterator für die Klasse <tt>DoublyLinkedList</tt> wie folgt implementieren:


  def genericCopy(quelle, ziele) :
  class ListIterator:
    for k in quelle :
      def __init__(self, node):         # Iterator zeigt anfangs auf den angegebenen Knoten
     ziel.append(k)
          self.node = node
  return ziel
      def next(self):  # in Python 3:  def __next__(self):
          if self.node.isSentinel():
              raise StopIteration()     # Pythonkonvention: Ende der Sequenz erreicht
          v = self.node.data            # speichere die Daten des aktuellen Knotens ...
          self.node = self.node.next    # ... und rücke zum nächsten Knoten weiter ...
          return v                      # ... und gebe vorigen Wert zurück (Pythonkonvention)
      def __iter__(self):
          return ListIterator(self.node) # Pythonkonvention, Kopie des Iterators zurückgeben


liste = genericCopy(array, DoublyLinkedList())
Die Funktion <tt>DoublyLiinkedList.__iter__()</tt> erzeugt einen solchen Iterator und setzt ihn auf das erste Element der Liste.
<u> Statt copyArrayToList </u>


array2 = genericCopy(array,[])
===Generische Kopierfunktion===
<u> Statt copyArray </u>


array3 = genericCopy(liste,[])
Mit den so definierten Schnittstellen genügt nun eine einzige Methode zum Kopieren eines Containers:
<u> Statt copyListToArray </u>


def genericCopy (quelle, ziel) :
    for k in quelle :
        ziel.append(k)
    return ziel
Diese Funktion kann an Stelle der spezialisierten Kopierfunktionen verwendet werden. Das zweite Argument von <tt>genericCopy</tt> zeigt an, in welche Zieldatenstruktur kopiert werden soll:
   
   
  '''was tun''' " for k in quelle":
  liste = genericCopy(array, DoublyLinkedList())     # statt copyArrayToList
  iter = quelle__iter__()
array2 = genericCopy(array, [])                     # statt copyArray
  try :
array3 = genericCopy(liste, [])                      # statt copyListToArray
     while True :
    k = iter.next()
    ...# Schleifeninhalt
    except StopIteration: pass


====Was tut Python bei "<tt>for k in quelle:</tt>" ?====


class ListIterator:
Will man in Python alle Elemente eines Containers durchlaufen, so tut man dies leicht mit einem Statement der Form <code>for k in quelle:</code>. Was passiert dabei eigentlich? (Die folgende Darstellung ist etwas vereinfacht und zeigt die Python-Funktionalität nur für den Fall, dass die Quelldatenstruktur einen Iterator anbietet. In Wirklichkeit unterstützt <tt>for k in quelle:</tt> noch weitere Arten von Datenstrukturen.)
    def__init__(self, node):
        self.node = node
    def next(self):
        if self.node.isSentinel():
          raise StopIteraion()       #Python Konvention
        v = self.node.data
        self.node = self.node.next    # zeigt Ende der Sequenz
        return v                      # Pythonkonvention, gebe vorigen Wert zurück


    def__iter__(self):
Das Schlüsselwort <code>for</code> ruft die Methode <code>__iter__()</code> des Objekts <code>quelle</code> auf, die eine Referenz auf ein Iterator-Objekt zurückgibt. Dieses Objekt definiert eine Methode <code>next()</code>. Der von <code>next()</code> zurückgegebene Wert wird an die Laufvariable <tt>k</tt> zugewiesen, mit der dann der eigentliche Schleifeninhalt ausgefürt wird. Wenn keine weiteren Elemente mehr vorhanden sind, wird die Schleife durch die Exception <code>StopIteration</code> beendet.
      return ListIterator(self.node) # Pythonkonvention, Kopie des Iterators erzeugen
iter = quelle.__iter__()
try :
        while True :
              k = iter.next() # löst am Ende die Exception 'StopIteration' aus
              ...            # Schleifeninhalt
except StopIteration: pass  # alle anderen Expcetions werden normal weitergereicht


'''besser stattdessen''' :
====Kopieren einer Sequenz in umgekehrter Reihenfolge====


      self__class__(self.node)
Die Funktion <tt>genericCopy</tt> ist jetzt mächtig genug, um auch Kopiervorgänge zu unterstützen, an die wir anfangs gar nicht gedacht haben. Ein Beispiel dafür ist das Kopieren der Sequenz in umgekehrter Reihenfolge. Man könnte dies erreichen, indem man die Aufrufe von <tt>append</tt> durch Aufrufe von <tt>prepend</tt> unterstützt, aber dann hätte man eine neue Funktion <tt>reverseCopy</tt>. Viel eleganter ist es, das Auslesen der Quellsequenz zu manipulieren, indem man einen Iterator programmiert, der die Sequenz rückwärts durchläuft.  


Für die doppelt verkettete Liste unterscheidet sich ein solcher Rückwärtsiterator fast gar nicht vom normalen Vorwärtsiterator:
class ReverseListIterator:
      def __init__(self, node):       
          self.node = node
      def next(self):    # in Python 3:  def __next__(self):
          if self.node.isSentinel():
              raise StopIteration()     
          v = self.node.data           
          self.node = self.node.prev    # gehe zum ''Vorgängerknoten'' - einziger wesentlicher Unterschied zum ListIterator
          return v                     
      def __iter__(self):
          return ReverseListIterator(self.node) # die Kopie des Iterators muss natürlich auch vom richtigen Typ sein


class ReverseListIterator(ListIterator)
Der Rückwärtsiterator wird genau wie der Vorwärtsiterator verwendet. Man beachte aber, dass <tt>list.reverseIterator()</tt> den Iterator auf das letzte Element der Liste initialisieren muss (siehe oben in der Implementation von <tt>DoublyLinkedList</tt>):
  def next(self):
      if self.node isSentinel(): raise StopIteration()
      r = self.node.data
      self.node = self.node.prev
      return r


reverseArray = genericCopy(list.reverseIterator(), []),          # Liste rückwärts in ein Array kopieren


revarray = genericCopy(list, reverseIter()[]),
Die Syntax <code>for k in quelle:</code> funktioniert weiterhin, weil auch der Iterator die dazu notwendige Funktion <tt>__iter__</tt> anbietet.
revlist = genericCopy(reversed(array), DoublyLinkedList())


Soll ein Array rückwärts kopiert werden, verwendet man die Python-Standardfunktion <tt>reversed</tt>, um den passenden Iterator zu erzeugen:


'''Verallgemeinerung auf Funktionen die " etwas tun":'''
reverseList  = genericCopy(reversed(array), DoublyLinkedList())  # Array rückwärts in eine Liste kopieren


<code>def sumArray(a):
==Funktoren==
        s = 0
        for k in a :
            s+=a      # s = add(s,k)
      return s </code>


Nachdem wir die Kopierfunktion erfolgreich verallgemeinert haben, liegt die Frage nahe, ob dies auch für andere Funktionen funktioniert, die in der inneren Schleife kompliziertere Dinge tun als einfach die Daten zu kopieren. Bei näherem Hinschauen bemerkt man auch bei solchen Funktionen viele Gemeinsamkeiten.


def maxList(l):
Betrachten wir z.B. eine Funktion, die die Summe der Elemente eines Arrays berechnet:
    m = -1111111
    while l is not None [not l is Sentinel(l)]
        m = max(m, l.data)                    # max ist eingebaute Funkion in Python
        l =l.next


===3.Funktoren===  
def sumArray(a):
    k, sum = 0, 0
    while k < len(a):
        sum = sum + a[k]  # wird später in der generischen Version durch 'sum = add(sum, a[k])' ersetzt
        k += 1
    return sum


'''Zur Verallgemeinerung werden Funktoren eingerichtet:'''
Das Maximum der Werte in einer Liste kann man wie folgt berechnen:


*Funktor muss "callable" sein :
def maxList(node):
falls f Funktor: v = f(a1, a2)
    m = -999999999999999      # eine Zahl, die hoffentlich kleiner ist als das gesuchte Maximum
'''callable''' ist z.B. eine Funktion
    while not node.isSentinel():
        m = max(m, node.data)  # max() ist eingebaute Funktion in Python
        node = node.next
    return m


*Objekt wo __call___ definiert
Offensichtlich sind sich diese Funktionen strukturell sehr ähnlich. Außerdem wissen wir bereits, wie wir die Navigation durch die Datenstruktur mit Hilfe von Iteratoren verallgemeinern können. Es bleibt noch, die Funktionalität in der Schleife generisch zu machen. Dies geschieht mit Hilfe des Konzepts der '''Funktoren''' (auch Funktionsobjekte genannt, siehe auch in der [http://en.wikipedia.org/wiki/Function_object Wikipedia]).


;Definition Funktor:
Ein Objekt wird ''Funktor'' genannt, wenn es sich syntaktisch wie eine Funktion verwenden, das heißt mit Argumentklammer aufrufen, läßt. Wenn <tt>f</tt> ein Funktor ist, unterstützt er also Ausdrücke der Form
    f(a)      # unärer Funktor
    f(a, b)  # binärer Funktor
Das betreffende Objekt <tt>f</tt> wird dann auch als ''callable'' bezeichnet. Je nach Anzahl der erlaubten Funktionsargumente unterscheidet man ''unäre'' Funktoren (ein Argument), ''binäre'' Funktoren (zwei Argumente) usw.


  def doSomethingGeneric(functor, iterator, for k in iterator: intial):
In Python ist jede Funktion trivialerweise ein Funktor, denn man kann sie "wie eine Funktion" aufrufen. Anstelle des in <tt>sumArray</tt> verwendeten Additionsoperator kann man z.B. folgenden Funktor verwenden:
    initial = functor(initial, k)
  def add(a, b):
     return initial
    return a + b
Es ist aber auch möglich, Objekte als Funktoren zu verwenden. Dazu muss die entsprechende Klasse eine Funktion <tt>__call__</tt> bereitstellen. Die Summation kann so auch als Klassenfunktor implementiert werden:
class AddFunctor:  
    def __call__(self, a, b):
        return a + b
addf = AddFunctor()  # Funktorobjekt erzeugen
print addf(1, 2)    # AddFunctor.__call__ wird aufgerufen und gibt 3 aus


  m = doSomethingGeneric(max,list -1111111)
Die obigen Funktionen <tt>sumArray</tt> und <tt>maxList</tt> werden nun generisch verallgemeinert, indem man die Funktionalität in der Schleife als Funktor bereitstellt. Die Datenstruktur wird durch einen Iterator repräsentiert. Die Variable, die die Berechnungen in der Schleife akkumuliert, muss jetzt von außen an die Funktion übergeben und passend initialisiert werden:
statt maxList
  def doSomethingGeneric(functor, iterator, accumulator):
    for k in iterator:
        accumulator = functor(accumulator, k)
    return accumulator


def add(x,y) return x+y
Damit erhalten wir folgende generische Aufrufe:
  s = doSomethingGeneric(add, array,o)
statt sumArray


  def append(x,y):
sum = doSomethingGeneric(add,  array, 0)            # statt sumArray()
sum = doSomethingGeneric(addf, array, 0)            # dito, aber mit Funktorobjekt
m = doSomethingGeneric(max, list, -999999999999999)  # statt maxList()
  def append(x, y):   # Kapselt das Anfügen an eine Datenstruktur in einem Funktor
       x.append(y)
       x.append(y)
    return x
      return x
array4 = doSomethingGeneric(append, array, [])      # statt genericCopy()
Aufgrund ihrer großen Allgemeinheit und Nützlichkeit gibt es eine zu '''doSomethingGeneric''' äquivalente Funktion in vielen Programmiersprachen. Nur die Namen der Funktion und andere Details unterscheiden sich, z.B.:
*in Python und Lisp: <tt>reduce</tt> (in Python sind <tt>doSomethingGeneric</tt> und <tt>reduce</tt> vollkommen äquivalent, außer in der Geschwindigkeit, weil <tt>reduce</tt> in C implementiert ist.)
*in C++ : <tt>std::accumulate</tt>
*in Haskell: <tt>foldl</tt>


array4 = doSomethingGeneric(append, array[])
====Verwandte generische Funktionen====
<u>Statt genericCopy</u>


*'''doSomethingGeneric'''() gibt es in vielen Programmiersprachen
Einige mit <tt>reduce</tt> eng verwandte Funktionen werden ebenfalls in vielen Programmiersprachen häufig angeboten. In Python gibt es z.B. die folgenden Funktionen


**in Python : reduce     in C++ : accumulate
;map:  
Gegeben eine Sequenz, erzeugt <tt>map</tt> eine neue Sequenz, wo ein Funktor auf jedes Element der ursprünglichen Sequenz angewendet wurde
  res = map(f, [x1, x2,...])    # gibt [f(x1), f(x2),...] zurück
Der Funktor f muss hier ein unärer Funktor sein. Angewendet auf ein Array, ist die Funktion <tt>map</tt> damit äquivalent zu
  def apply_f(x, y):
      x.append(f(y))
      return x
  res = reduce(apply_f, array, [])


'''verwandte generische Funkionen'''
;filter:
Gegeben eine Sequenz, erzeugt <tt>filter</tt> eine neue Sequenz, die nur diejenigen Elemente enthält, bei denen der Funktor den Wert 'True' zurückgegeben hat (f wird dann auch als ''unäres Prädikat'' bezeichnet):
  res = filter(f, [x1, x2, x3, x4,...])  # gibt [x2, x3,...] zurück, wenn f(x1) und f(x4) 'False' war
Angewendet auf ein Array, ist die Funktion <tt>filter</tt> damit äquivalent zu
  def copy_if(x, y):
      if f(y):
          x.append(y)
      return x
  res = reduce(copy_if, array, [])


map: [x1, x2,...] --> [f(x1),f(x2),...]    alle funktionalen Sprachen ( Haskell,Lisp,...)
==Definition von Generischen Schittstellen zwischen Algorithmen und Datenstrukturen==


map  ist in C++ "transform"


Eine Schnittstelle (Interface) standardisiert die Kommunikation zwischen einem Algorithmus und einer Datenstruktur. Die Schnittstelle ist generisch, wenn sie nur generische Funktionalität wie z.B. standardisierte Einfügeoperationen (<tt>append</tt>), Iteratoren und Funktoren verwendet. Generischer Scnittstelle ist eng mit der Definition von ''Abstrakten Datentypen'' verwandt.


===Offered Interface versus Required Interface===
===Abstrakte Datentypen===


Abstrakte Datentypen sind, wie im Kapitel [[Einführung#Definition von Datenstrukturen|Einführung]] bereits erwähnt, durch abstrake Operationen und deren Semantik (Bedeutung ) charakterisiert. Genauer muss ein abstrakter Datentyp folgendes definieren:
* Ein ''Universum'' legaler Werte für die Objekte des Datentyps. Beim Datentyp <tt>int</tt> sind dies z.B. gewöhnlich die ganzen Zahlen zwischen -2147483648 und 2147483647, in Python jedoch alle ganzen Zahlen, soweit sie mit dem verfügbaren Hauptspeicher darstellbar sind (ein solcher Datentyp wird auch als ''big int'' bezeichnet).
* Ein (oder mehrere) Konstruktoren, die ein Objekt des Typs erzeugen, das einen legalen Initialwert repräsentiert (bei <tt>int</tt> gewöhnlich die 0).
* Eventuell Destruktoren, die die Resourcen von nicht mehr benötigten Objekten (insbesondere ihren Speicher) wieder freigeben.
* Eine Menge von Operatoren, die Objekte des Datentyps in einen anderen legalen Zustand transformieren (bei <tt>int</tt> z.B. <tt>k += 1</tt>) bzw. neue Objekte des Typs erzeugen (z.B. <tt>k + l</tt>). Die Operatoren müssen zwei Eigenschaften haben:
:* Sie müssen vollständig sein, d.h. jeder legale Zustand muss durch eine geeignete Sequenz von Operatoraufrufen erreichbar sein.
:* Sie müssen abgeschlossen sein, d.h. kein Zustand außerhalb des legalen Universums darf jemals entstehen.
:Beispielsweise erfüllen die gewöhnlichen arithmetischen Operationen diese Anforderungen beim Datentyp <tt>int</tt>.


'''Interface:'''
Für mathematische Interessierte: Der Datentyp <tt>int</tt> implementiert das mathematische Konzept eines [http://en.wikipedia.org/wiki/Ring_(mathematics) kommutativen Rings]. Darüber hinaus bietet er eine Divisionsoperation, die aber nicht die Anforderungen an einen algebraischen Zahlenkörper (engl. [http://en.wikipedia.org/wiki/Field_%28mathematics%29 field]) erfüllt.


Ein anderer typischer abstrakter Datentyp ist der ''Stack'': Das Universum ist hier die Menge aller geordneten Folgen von Elementen (soweit sie in den Hauptspeicher passen), und die Operationen sind der Konstruktor (erzeugt einen leeren Stack), <tt>push</tt> (fügt ein Element ein) und <tt>pop</tt> (entfernt das zuletzt eingefügte Element). Die Abgeschlossenheit wird sichergestellt, indem der Aufruf von <tt>pop</tt> bei einem leeren Stack eine Exception auslöst.


*standardisierte Schnittstelle zwischen Algorithmen und Datenstruktur
===Offered Interface versus Required Interface===
 
 
====Offered Interface:====
 
*Funktionalität, die eine Datenstruktur anbietet.
*Die Datensruktur sollte möglichst vielseitig sein.
 
'''z.B. PythonList:'''
 
* dynamisches Array
* stack
* deque
* linkedList
 
*standardisiert durch abstrakte Datentypen
 
 
 
====Required Interface:====
 
 
*Funktionalität, die Algorihmus benutzt
*das '''required Interface''' ist meist weniger als '''das offered Interface'''
 
z.B.:
 
'''RI''' lesender Zugriff
'''OI''' lesender Zugriff  schreibender Zugriff  Konstruktor remove...
 
 
* standardisiert über Konzepte
* ADT sind Sammlungen zusammengehörender Konzepte
* RI sollten <u>minimal</u> sein
 
 
 
====Konzepte ( + Hierarchie)====
 
*copy Constructible ( P: copy.deepcopy)
*Default Constructible
 
v1 = v.__class__()  # DoublylinkedNode
 
*EqualityComparable('=='), LessThanComparable('<')
 
ThreeWayComparable(__cmp__)


Wenn man Schnittstellen vom Standpunkt der generischen Programmierung betrachtet, fällt auf, das man eine grundlegende Unterscheidung zwischen ''Offered Interfaces'' (d.h. was eine Datenstruktur anbietet) und ''Required Interfaces'' (d.h. was ein Algorithmus tatsächlich verwendet) treffen muss.


* Indexable("a[k]")
;Definition Offered Interface:
* Mapping("a[key]")
Das ''Offered Interface'' umfaßt die gesamte standardisierte Funktionalität, die eine Datenstruktur ''anbietet''.


Damit eine Datenstruktur möglichst vielseitig verwendbar ist, kann ihr Offered Interface sehr breit angelegt sein. Es ist z.B. typisch, dass eine gegeben Datenstruktur '''mehrere abstrakte Datentypen gleichzeitig''' implementiert. Das Python-Array (Datentyp <tt>list</tt>) unterstützt beispielsweise die Funktionalität:
* dynamisches Array (<tt>a[i]</tt>, <tt>a.append(x)</tt> etc.)
* Stack (<tt>a.pop()</tt> und <tt>a.append(x)</tt> statt <tt>push</tt>)
* Queue und Deque (via <tt>a.insert(0, x)</tt>)
* verkettete Liste (Einfügen und Löschen an beliebiger Stelle via <tt>a.insert(index, x)</tt> und <tt>del a[index]</tt>)
Die beiden letzten Punkte sind allerdings (gegenüber spezialisierten Datenstrukturen) mit Abstrichen in der Performanz verbunden -- diese Operationen erfordern beim Python-Array lineare Zeit, anstatt konstanter wie bei einer echten Queue oder verketten Liste.


* Iteratoren : Forward(next), BidirektionalIterator(next, prev), RandomAccessIterator(nex(k))
Oft bietet ein Offered Interface auch die gleiche Funktionalität unter mehreren Namen, damit die Datenstruktur in verschiedenen Umgebungen verwendbar ist (beispielsweise kann eine Funktion zum Zwecke der Rückwärtskompatibiltät beibehalten werden, obwohl sie inzwischen als ''deprecated'' gekennzeichnet wurde). Häufig findet man auch sogenannte ''convenience functions'', die im strengen Sinne nicht notwendig sind (weil sie aus bereits vorhandenen Funktionen zusammengebaut werden können), aber trotzdem das Leben des Programmierers erleichtern. Beispielsweise würde es beim Datentyp <tt>int</tt> vollkommen ausreichen, wenn nur der Vergleichsoperator "<" angeboten würde, denn alle anderen Vergleiche kann man leicht damit implementieren:
:::a = b &hArr; &not;(a < b) &and; &not;(b < a)
:::a &ne; b &hArr; (a < b) &or; (b < a)
:::a &le; b &hArr; &not;(b < a)
:::a > b &hArr; b < a
:::a &ge; b &hArr; &not;(a < b)
Trotzdem bietet <tt>int</tt> alle Vergleichsoperatoren, weil obige Ersetzungen unbequem und schwerer lesbar wären.


* Container : Sequence
;Definition Required Interface:
Das ''Required Interface'' umfasst die Funktionalität einer Datenstruktur, die ein Algorithmus tatsächlich ''benutzt''.


Das Required Interface umfasst gewöhnlich nur einen (evtl. kleinen) Teil des Offered Interface. Ein typischer Fall ist es beispielsweise, dass ein Algorithmus nur lesend auf eine Datenstruktur zugreift, obwohl die Datenstruktur auch schreibenden Zugriff anbietet. Bei Zahlen kommt es häufig vor, dass nur die Addition benutzt wird, obwohl <tt>int</tt> auch die Subtraktion und Multiplikation etc. unterstützt.


Der entscheidende Punkt besteht nun darin, dass Required Interfaces '''minimal''' sein sollten und '''getrennt''' von den Offered Interfaces (welche ja gerade nicht minimal sind) standardisiert werden müssen, damit Algorithmen ihre volle Generizität erreichen. Beispielsweise unterstützen alle Python-Containerdatentypen, so verschieden sie sonst auch sind, die Funktion <tt>len(container)</tt>. Die Standardisierung des Required Interface fordert nun, dass diese Funktion auch tatsächlich bei allen Containern <tt>len</tt> heißt, und nicht einmal <tt>size</tt>, und dann wieder <tt>numberOfElements</tt>. Man beachte, dass diese Standardisierung in der Tat unabhängig von den Offered Interfaces ist, weil dieselbe Datenstruktur durchaus <tt>size</tt>, <tt>length</tt> und <tt>numberOfElements</tt> gleichzeitig anbieten kann, wenn sie in mehreren Algorithmenumgebungen lauffähig sein soll, deren Required Interfaces verschiedenen Standards unterliegen.


====Mathematische Konzepte :====
Im Sinne der Minimalität kann sich ein Algorithmus z.B. darauf beschränken, nur den Vergleichsoperator "<" zu verwenden, und die anderen Vergleichsoperatoren wie oben beschrieben zu ersetzen, auch wenn dies weniger bequem ist. Dann wäre der Algorithmus immer noch lauffähig, wenn eine Datenstruktur nur "<" implementiert, auch wenn dies ein eher hypothetischer Fall ist.


Addable(__add__)
Required Interfaces werden häufig in Form von '''Konzepten''' standardisiert. Ein Konzept umfaßt eine Menge von Operationen, die ein Algorithmus typischerweise zusammen benötigt. Konzepte sind damit das Gegenstück zu Abstrakten Datentypen, da letztere Operationen enthalten, die Datenstrukturen typischerweise zusammen ''anbieten''. Das Konzept für Container, die die Funktion <tt>len(container)</tt> unterstützen, könnte z.B. <tt>Sized</tt> genannt werden (falls es Algorithmen gibt, die sonst weiter nichts von einer Datenstruktur fordern. Andernfalls wäre die Einführung eines separaten Konzepts hierfür überflüssig.) Die Entwicklung einer Menge von sorgfältig aufeinander abgestimmten Konzepten, und ihre Organisation in Konzepthierarchien, ist zur Zeit ein aktives Forschungsgebiet. Die [http://www.sgi.com/tech/stl/ Standard Template Library] der Progammiersprache C++ muss in dieser Hinsicht als vorbildlich bezeichnet werden. Hier sind die Konzepte extrem sorgfältig definiert, und Offered Interfaces sind konsequent als Sammlung aller Konzepte definiert, die eine gegebene Datenstruktur effizient unterstützen kann.
Subtractable(__sub__)
Multiplyable(__mul__)
Dividable(__div__)


====Wichtige Konzepte====


Einige wichtige Konzepte sollen hier erwähnt werden:
* '''Copy Constructible:''' Objekte, die man kopieren kann (in Python durch <tt>copy.deepcopy</tt>).
* '''Singleton:''' Objekte, die man ''nicht'' kopieren kann (z.B. weil sie einen Drucker repräsentieren, der physisch nur einmal vorhanden ist).
* '''Default Constructible:''' Klassen, die einen Konstruktor ohne Argumente unterstützen, welcher ein Objekt in einem Standardzustand initialisiert (in Python z.B. <tt>list()</tt> für ein leeres Array, <tt>int()</tt> für die Zahl 0, und allgemein <tt>v1 = v.__class__()</tt> für beliebige Klassen).
* Vergleiche:
:*'''Equality Comparable:''' unterstützt <tt>a == b</tt>
:*'''LessThan Comparable:''' unterstützt <tt>a < b</tt>
:*'''ThreeWay Comparable:''' unterstützt <tt>cmp(a, b)</tt> mit Ergebnis -1, falls a &lt; b, 0 falls a = b und +1 falls a &gt; b.
* '''Indexable:''' unterstützt <tt>x = a[k]</tt> und <tt>a[k] = x</tt>, wobei <tt>k</tt> ein Integer im Bereich 0, ..., len(a)-1 ist.
* '''Mapping:''' unterstützt <tt>x = a[key]</tt> und <tt>a[key] = x</tt>, wobei der Typ von <tt>key</tt> beliebig sein kann, solange er ''Hashable'' ist.
* '''Hashable:''' <tt>key</tt> unterstützt <tt>hash(key)</tt>, so dass <tt>key</tt> als Schlüssel in einer Hashtabelle verwendet werden kann.
* Iteratoren:
:*'''ForwardIterator''' unterstützt <tt>iter.next()</tt> um zum nächsten Element zu gelangen
:*'''BidirectionalIterator''' unterstützt zusätzlich <tt>iter.prev()</tt> um zum vorhergehenden Element zu gelangen
:*'''RandomAccessIterator''' unterstützt zusätzlich <tt>iter.next(k)</tt> und <tt>iter.prev(k)</tt> um vorwärts oder rückwärts <tt>k</tt> Elemente zu überspringen
:Die beiden letzeren Konzepte sind zur Zeit in Python nicht standardisiert, jedoch z.B. in [http://www.sgi.com/tech/stl/Iterators.html C++]. Forward Iteratoren werden normalerweise von allen Containern unterstützt (manche bieten sogar mehrere, je nach gewünschter Reihenfolge, z.B. Preorder-, Inorder-, und Postordertraversal bei einem Binärbaum). Bidirektionale Iteratoren werden typischerweise von doppelt verketteten Listen unterstützt, random access Iteratoren typischerweise von statischen und dynamischen Arrays. Wenn man für eine Datenstruktur mindestens einen bidirektionalen Iterator hat, kann man stets einen Rückwärtsiterator implementieren (indem man <tt>next</tt> und <tt>prev</tt> vertauscht).
* Funktoren:
:*'''Unary Functor:''' unterstützt <tt>y = f(x)</tt>
:*'''Binary Functor:''' unterstützt <tt>y = f(x1, x2)</tt>
:*'''Unary Predicate:''' unterstützt <tt>f(x) &rarr; {True, False}</tt>
:etc.
* Mathematische Konzepte: 
:*'''Addable:''' unterstützt <tt>a + b</tt>
:*'''Subtractable:''' unterstützt <tt>a - b</tt>
:*'''Multiplicable:''' unterstützt <tt>a * b</tt>
:*'''Dividable:''' unterstützt <tt>a / b</tt>
:etc.


Wenn man in Python eine ''Klasse'' implementieren will, die eins der genannten Konzepte unterstützt, gibt es standardisierte Funktionennamen, die mit zwei Unterstrichen beginnen und enden, wie z.B. <tt>__init__</tt> (Konstruktor), <tt>__cmp__</tt> (3-Wege-Vergleich), <tt>__hash__</tt> (Hashwert), <tt>__getitem__</tt> und <tt>__setitem__</tt> (lesender und schreibender Arrayzugriff) , <tt>__iter__</tt> (Iterator), <tt>__add__</tt> (Addition),  <tt>__sub__</tt> (Subtraktion), <tt>__mul__</tt> (Multiplikation), <tt>__div__</tt> (Division) etc (eine vollständige Liste findet man im [http://docs.python.org/lib/genindex.html Python Index]). Der Python-Interpreter transformiert die normale Pythonsyntax automatisch in Aufrufe dieser Funktionen, falls ein Ausdruck Objekte enthält, die die entsprechenden Konzepte unterstützen.


'''Ein offered Interface is mehr als ein required Interface.'''
[[Graphen und Graphenalgorithmen|Nächstes Thema]]

Latest revision as of 16:21, 26 June 2014

Generische Programmierung - Definition und Motivation

Ziel von generischer Programmierung ist es, Algorithmen und Datenstrukturen so zu entwerfen und zu implementieren, dass sie möglichst vielfältig verwendbar sind.

Vielfältig verwendbar meint hierbei, dass generische Algorithmen und Datenstrukturen ohne Neumiplementation in

  • den verschiedensten Anwendungen eingesetzt,
  • vielseitig miteinander kombiniert und
  • als wiederverwendbare Bibliotheken zur Verfügung gestellt werden können.

Dadurch gelingt es, auch sehr komplizierte Algorithmen und Datenstrukturen praktisch nutzbar zu machen. Ohne Generizität müssten diese jedesmal neu implementiert und getestet werden, wofür der Aufwand oft zu hoch wäre. Außerdem muss derselbe Algorithmus dann oft in vielen Varianten implementiert werden, ohne dass sich die Varianten auf interessante Weise unterscheiden.

Beispiel eines nicht-generischen Algorithmus

Wir demonstrieren am folgenden Beispiel, wie es ohne Generizität nötig wird, denselben Algorithmus 'kopiere Container' für jede Kombination von Quell- und Zielcontainer neu zu implementieren. Dies ist besonders störend, weil die Unterschiede der Varianten trivial sind. Wir zeigen weiter unten, dass mit Generizität eine einzige Implementation ausreichend ist.

Kopie Array → Array

Die folgende einfache Funktion kopiert die Daten aus einem gegebenen Array a in ein (dynamisches) Array r:

def copyArray(a):
  r =[]
  for k in a:
     r.append(k)
  return r

Kopie Array → verkettete Liste

Um in eine verkettete Liste kopieren zu können, definieren wir zunächst den Knotentyp der Liste:

class Node :
 def __init__(self, data, next)
    self.data = data
    self.next = next

Die Kopie aus einem Array in eine Liste kann dann wie folgt implementiert werden:

def copyArrayToList(a) :
   if len(a) == 0 : 
        return None
   first = last = Node (a[0], None)
   for k in a[1:]  :
      last.next = Node(k, None)
      last = last.next
   return first

Kopie verkettete Liste → Array

Eine wieder andere Implementation benötigt man, wenn man umgekehrt von einer Liste in ein Array kopieren möchte:

def copyListToArray(l):
    r = []
    while l is not None :
        r.append(l.data)
        l = l.next
    return r

Entscheidende Beobachtung

Für N Datenstrukuren ist der Implementationsaufwand <math>O(N^2)</math>, wenn man alle Paare von Datenstrukturen ineinander umwandeln können will. Alle Funktionen machen das Gleiche, mit nur uninteressanten Unterschieden. Wir wollen daher im Folgenden eine Möglichkeit angeben, das Kopieren der Daten zu vereinheitlichen, so dass nur noch eine generische Kopierfunktion benötigt wird. Das Kopieren ist natürlich dabei nur ein Beispiel -- die verwendeten Programiertechniken sind sehr allgemein und können bei eine Vielzahl von Algorithmen zu generischen Lösungen führen.

Transformation in eine generische Form

Die Kopierfunktion enthält zwei wesentliche Funktionalitäten:

  • das Navigieren durch die Quelldaten und
  • das Aufbauen der Zieldatenstruktur

Wenn wir die Implementation generisch gestalten wollen, müssen wir diese beiden Aspekte des Kopierens verallgemeinern.

Vereinheitlichung der Zieldatenstruktur durch standardisierte Einfügefunktion

Wir beginnen mit dem Aufbauen der Zieldatenstruktur. Hier brauchen wir eine verallgemeinerte Methode, neue Elemente an eine bereits existierende Datenstruktur anzuhängen. Dazu bietet es sich an, eine

  • standardisierte Funktion append zu verwenden.

Das dynamische Array hat diese Funktion bereits. Für eine einfach verkettete Liste kann sie nicht ohne weiteres effizient implementiert werden (einfach verkettete Listen unterstützen in erster Linie das Einfügen eines neuen Elements am Anfang der Liste, also prepend). Wir implementieren deshalb eine neue Datenstruktur DoublyLinkedList, die eine in beiden Richtungen verkettete Liste realisiert.

Um Anfang und Ende der Liste zu markieren, verwenden wir eine spezielle Sentinel-Datenstruktur. Da sie nur als Markierung dient, benötigt sie keine eigenen Daten.

class SentinelTag : pass     # keine Daten

Der Knotentyp einer DoublyLinkedList enthält zwei Verkettungen: prev und next. Falls im Konstruktor kein Nachfolger next angegeben wird, werden die Verkettungen auf self initialisiert (dies bedeutet, dass der Knoten eine leere Liste repräsentiert), andernfalls werden alle Verkettungen so aktualisiert, dass der neue Knoten Vorgänger von next wird. Ein Sentinel-Knoten wird dadurch gekennzeichnet, dass sein Datenfeld den Typ SentinelTag hat. Dies ist auch die Initialisierung, falls im Konstruktor keine Daten übergeben werden:

class DoublyLinkedNode:

    def __init__(self, data = SentinelTag(), next = None)
        self.data = data
        if next is None :
            self.prev = self.next = self
        else:
            self.next = next
            self.prev = next.prev
            next.prev.next = self
            next.prev = self

   def isSentinel(self ) : 
        return isinstance( self.data, SentinelTag) # Python-Standardfunktion zum Prüfen des Typs einer Variable

Die Klasse DoublyLinkedList kapselt nun die Verwaltung der Knoten. Die Liste selbst wird dabei als doppelt verbundene, kreisförmige Kette mit dem Sentinel als "Anker" realisiert. Dies hat den Vorteil, dass man das erste bzw. letzte Element der Liste einfach als Nachfolger bzw. Vorgänger des Ankerelements abfragen kann. Ein weiterer Vorteil besteht darin, dass auch die leere Liste noch ein Element enthält (nämlich den Sentinel), so dass beim Einfügen und Löschen von Elementen keine Spezialbehandlung für die leere Liste notwendig ist.

class DoublyLinkedList : 

    def __init__(self):        # Konstruktor initialisert leere Liste, die nur aus dem Sentinel besteht
        self.sentinel = DoublyLinkedNode()
        self.size = 0

    def __len__(self):         # wird ausgeführt, wenn man 'len(list)' aufruft
        return self.size 

    def append(self, value):   # value am Ende anhängen, d.h. Sentinel soll Nachfolger des neuen Elements sein
        DoublyLinkedNode(value, self.sentinel)  # die Verkettungen werden im Konstruktor automatisch aktualisiert
        self.size += 1

    def prepend(self, value):  # value am Anfang (= nach dem Sentinel, d.h. vor dessen Nachfolger) einfügen
        DoublyLinkedNode(value, self.sentinel.next)  
        self.size += 1

    def __iter__(self):        # Iterator für die Liste erzeugen (siehe unten)
        return ListIterator(self.sentinel.next)

    def reverseIterator(self): # Rückwärts-Iterator für die Liste erzeugen (siehe unten)
        return ReverseListIterator(self.sentinel.prev)

Damit können wir die Zieldatenstruktur einheitlich durch ziel.append(value) aufbauen, unabhängig davon, ob ziel nun ein Array oder eine doppelt verkettete Liste ist.

Vereinheitlichung der Quelldatenstruktur durch Iteratoren

Die Vereinheitlichung der Quelldatenstruktur ist komplizierter, weil die Navigationmethoden sich von Datenstruktur zu Datenstruktur sehr stark unterscheiden. Ein einheitlicher, standardisierter Funktionenname tut es hier nicht. Der Schlüssel für die Lösung besteht in der Beobachtung, dass wir, unabhängig von der Art der Datenstruktur, immer sequentiell auf die Datenelemente zugreifen. Man definiert deshalb eine Hilfsklasse Iterator, die die Elemente in der richtigen Reihenfolge heraussucht, die aber verbirgt (kapselt), wie jedes Datenelement konkret gefunden wird.

Definition Iterator

Ein Iterator ist ein Objekt,

  • das auf ein Element des Containers zeigt und die Daten aus diesem Element an den aufrufenden Algorithmus übergeben kann
  • das zum nächsten Element weiter rücken kann
  • das anzeigt, wenn das Ende der Sequenz erreicht ist

(siehe dazu auch die Wikipedia).

Die Syntax dieser Funktionalitäten muss natürlich standardisiert sein, damit man das Ziel der Generizität erreicht. Wie genau die Standardisierung erfolgt, ist von Programmiersprache zu Programmiersprache unterschiedlich (falls die Sprache überhaupt Iteratoren unterstützt -- das gilt z.B. für C++ und Java). In Python gelten folgende Konventionen:

  • Das Weiterrücken zum nächsten Element erfolgt durch die Funktion next() (in Python 2) bzw. __next__() (in Python 3). Diese Funktion gibt gleichzeitig die Daten desjenigen Elements zurück, das man gerade verlassen hat.
  • Alle Datenstrukturen, die Iteratoren anbieten, sowie die Iteratoren selbst, müssen eine Funktion __iter__() haben, die einen neuen Iterator (bzw. eine Kopie des schon vorhandenen Iterators) zurückgibt. Diese Funktion wird von Python intern benutzt (siehe unten).
  • Das Ende der Sequenz ist erreicht, wenn die Funktion next bzw. __next__ mit einer StopIteration-Exception verlassen wird.

Die Array-Datenstruktur in Python bietet einen solchen Iterator bereits an.

Iterator für die doppelt verkettete Liste

Aufgrund dieser Konventionen können wir den Iterator für die Klasse DoublyLinkedList wie folgt implementieren:

class ListIterator:
      def __init__(self, node):          # Iterator zeigt anfangs auf den angegebenen Knoten
          self.node = node

      def next(self):  # in Python 3:  def __next__(self):
          if self.node.isSentinel():
              raise StopIteration()      # Pythonkonvention: Ende der Sequenz erreicht
          v = self.node.data             # speichere die Daten des aktuellen Knotens ...
          self.node = self.node.next     # ... und rücke zum nächsten Knoten weiter ...
          return v                       # ... und gebe vorigen Wert zurück (Pythonkonvention)

      def __iter__(self):
          return ListIterator(self.node) # Pythonkonvention, Kopie des Iterators zurückgeben

Die Funktion DoublyLiinkedList.__iter__() erzeugt einen solchen Iterator und setzt ihn auf das erste Element der Liste.

Generische Kopierfunktion

Mit den so definierten Schnittstellen genügt nun eine einzige Methode zum Kopieren eines Containers:

def genericCopy (quelle, ziel) :
    for k in quelle :
        ziel.append(k)
    return ziel

Diese Funktion kann an Stelle der spezialisierten Kopierfunktionen verwendet werden. Das zweite Argument von genericCopy zeigt an, in welche Zieldatenstruktur kopiert werden soll:

liste  = genericCopy(array, DoublyLinkedList())      # statt copyArrayToList
array2 = genericCopy(array, [])                      # statt copyArray 
array3 = genericCopy(liste, [])                      # statt copyListToArray

Was tut Python bei "for k in quelle:" ?

Will man in Python alle Elemente eines Containers durchlaufen, so tut man dies leicht mit einem Statement der Form for k in quelle:. Was passiert dabei eigentlich? (Die folgende Darstellung ist etwas vereinfacht und zeigt die Python-Funktionalität nur für den Fall, dass die Quelldatenstruktur einen Iterator anbietet. In Wirklichkeit unterstützt for k in quelle: noch weitere Arten von Datenstrukturen.)

Das Schlüsselwort for ruft die Methode __iter__() des Objekts quelle auf, die eine Referenz auf ein Iterator-Objekt zurückgibt. Dieses Objekt definiert eine Methode next(). Der von next() zurückgegebene Wert wird an die Laufvariable k zugewiesen, mit der dann der eigentliche Schleifeninhalt ausgefürt wird. Wenn keine weiteren Elemente mehr vorhanden sind, wird die Schleife durch die Exception StopIteration beendet.

iter = quelle.__iter__()
try :
       while True :
             k = iter.next() # löst am Ende die Exception 'StopIteration' aus
             ...             # Schleifeninhalt
except StopIteration: pass   # alle anderen Expcetions werden normal weitergereicht

Kopieren einer Sequenz in umgekehrter Reihenfolge

Die Funktion genericCopy ist jetzt mächtig genug, um auch Kopiervorgänge zu unterstützen, an die wir anfangs gar nicht gedacht haben. Ein Beispiel dafür ist das Kopieren der Sequenz in umgekehrter Reihenfolge. Man könnte dies erreichen, indem man die Aufrufe von append durch Aufrufe von prepend unterstützt, aber dann hätte man eine neue Funktion reverseCopy. Viel eleganter ist es, das Auslesen der Quellsequenz zu manipulieren, indem man einen Iterator programmiert, der die Sequenz rückwärts durchläuft.

Für die doppelt verkettete Liste unterscheidet sich ein solcher Rückwärtsiterator fast gar nicht vom normalen Vorwärtsiterator:

class ReverseListIterator:
      def __init__(self, node):         
          self.node = node

      def next(self):    # in Python 3:   def __next__(self):
          if self.node.isSentinel():
              raise StopIteration()      
          v = self.node.data             
          self.node = self.node.prev     # gehe zum Vorgängerknoten - einziger wesentlicher Unterschied zum ListIterator
          return v                       

      def __iter__(self):
          return ReverseListIterator(self.node) # die Kopie des Iterators muss natürlich auch vom richtigen Typ sein

Der Rückwärtsiterator wird genau wie der Vorwärtsiterator verwendet. Man beachte aber, dass list.reverseIterator() den Iterator auf das letzte Element der Liste initialisieren muss (siehe oben in der Implementation von DoublyLinkedList):

reverseArray = genericCopy(list.reverseIterator(), []),          # Liste rückwärts in ein Array kopieren

Die Syntax for k in quelle: funktioniert weiterhin, weil auch der Iterator die dazu notwendige Funktion __iter__ anbietet.

Soll ein Array rückwärts kopiert werden, verwendet man die Python-Standardfunktion reversed, um den passenden Iterator zu erzeugen:

reverseList  = genericCopy(reversed(array), DoublyLinkedList())  # Array rückwärts in eine Liste kopieren

Funktoren

Nachdem wir die Kopierfunktion erfolgreich verallgemeinert haben, liegt die Frage nahe, ob dies auch für andere Funktionen funktioniert, die in der inneren Schleife kompliziertere Dinge tun als einfach die Daten zu kopieren. Bei näherem Hinschauen bemerkt man auch bei solchen Funktionen viele Gemeinsamkeiten.

Betrachten wir z.B. eine Funktion, die die Summe der Elemente eines Arrays berechnet:

def sumArray(a):
    k, sum = 0, 0
    while k < len(a):
        sum = sum + a[k]  # wird später in der generischen Version durch 'sum = add(sum, a[k])' ersetzt
        k += 1
    return sum

Das Maximum der Werte in einer Liste kann man wie folgt berechnen:

def maxList(node):
    m = -999999999999999       # eine Zahl, die hoffentlich kleiner ist als das gesuchte Maximum
    while not node.isSentinel():
        m = max(m, node.data)  # max() ist eingebaute Funktion in Python
        node = node.next
    return m

Offensichtlich sind sich diese Funktionen strukturell sehr ähnlich. Außerdem wissen wir bereits, wie wir die Navigation durch die Datenstruktur mit Hilfe von Iteratoren verallgemeinern können. Es bleibt noch, die Funktionalität in der Schleife generisch zu machen. Dies geschieht mit Hilfe des Konzepts der Funktoren (auch Funktionsobjekte genannt, siehe auch in der Wikipedia).

Definition Funktor

Ein Objekt wird Funktor genannt, wenn es sich syntaktisch wie eine Funktion verwenden, das heißt mit Argumentklammer aufrufen, läßt. Wenn f ein Funktor ist, unterstützt er also Ausdrücke der Form

    f(a)      # unärer Funktor
    f(a, b)   # binärer Funktor

Das betreffende Objekt f wird dann auch als callable bezeichnet. Je nach Anzahl der erlaubten Funktionsargumente unterscheidet man unäre Funktoren (ein Argument), binäre Funktoren (zwei Argumente) usw.

In Python ist jede Funktion trivialerweise ein Funktor, denn man kann sie "wie eine Funktion" aufrufen. Anstelle des in sumArray verwendeten Additionsoperator kann man z.B. folgenden Funktor verwenden:

def add(a, b):
    return a + b

Es ist aber auch möglich, Objekte als Funktoren zu verwenden. Dazu muss die entsprechende Klasse eine Funktion __call__ bereitstellen. Die Summation kann so auch als Klassenfunktor implementiert werden:

class AddFunctor: 
    def __call__(self, a, b):
        return a + b

addf = AddFunctor()  # Funktorobjekt erzeugen
print addf(1, 2)     # AddFunctor.__call__ wird aufgerufen und gibt 3 aus

Die obigen Funktionen sumArray und maxList werden nun generisch verallgemeinert, indem man die Funktionalität in der Schleife als Funktor bereitstellt. Die Datenstruktur wird durch einen Iterator repräsentiert. Die Variable, die die Berechnungen in der Schleife akkumuliert, muss jetzt von außen an die Funktion übergeben und passend initialisiert werden:

def doSomethingGeneric(functor, iterator, accumulator):
    for k in iterator:
        accumulator = functor(accumulator, k)
    return accumulator

Damit erhalten wir folgende generische Aufrufe:

sum = doSomethingGeneric(add,  array, 0)             # statt sumArray()
sum = doSomethingGeneric(addf, array, 0)             # dito, aber mit Funktorobjekt


m = doSomethingGeneric(max, list, -999999999999999)  # statt maxList()


def append(x, y):   # Kapselt das Anfügen an eine Datenstruktur in einem Funktor
     x.append(y)
     return x

array4 = doSomethingGeneric(append, array, [])       # statt genericCopy()

Aufgrund ihrer großen Allgemeinheit und Nützlichkeit gibt es eine zu doSomethingGeneric äquivalente Funktion in vielen Programmiersprachen. Nur die Namen der Funktion und andere Details unterscheiden sich, z.B.:

  • in Python und Lisp: reduce (in Python sind doSomethingGeneric und reduce vollkommen äquivalent, außer in der Geschwindigkeit, weil reduce in C implementiert ist.)
  • in C++ : std::accumulate
  • in Haskell: foldl

Verwandte generische Funktionen

Einige mit reduce eng verwandte Funktionen werden ebenfalls in vielen Programmiersprachen häufig angeboten. In Python gibt es z.B. die folgenden Funktionen

map

Gegeben eine Sequenz, erzeugt map eine neue Sequenz, wo ein Funktor auf jedes Element der ursprünglichen Sequenz angewendet wurde

  res = map(f, [x1, x2,...])    # gibt [f(x1), f(x2),...] zurück

Der Funktor f muss hier ein unärer Funktor sein. Angewendet auf ein Array, ist die Funktion map damit äquivalent zu

  def apply_f(x, y):
      x.append(f(y))
      return x

  res = reduce(apply_f, array, [])
filter

Gegeben eine Sequenz, erzeugt filter eine neue Sequenz, die nur diejenigen Elemente enthält, bei denen der Funktor den Wert 'True' zurückgegeben hat (f wird dann auch als unäres Prädikat bezeichnet):

  res = filter(f, [x1, x2, x3, x4,...])   # gibt [x2, x3,...] zurück, wenn f(x1) und f(x4) 'False' war

Angewendet auf ein Array, ist die Funktion filter damit äquivalent zu

  def copy_if(x, y):
      if f(y):
          x.append(y)
      return x

  res = reduce(copy_if, array, [])

Definition von Generischen Schittstellen zwischen Algorithmen und Datenstrukturen

Eine Schnittstelle (Interface) standardisiert die Kommunikation zwischen einem Algorithmus und einer Datenstruktur. Die Schnittstelle ist generisch, wenn sie nur generische Funktionalität wie z.B. standardisierte Einfügeoperationen (append), Iteratoren und Funktoren verwendet. Generischer Scnittstelle ist eng mit der Definition von Abstrakten Datentypen verwandt.

Abstrakte Datentypen

Abstrakte Datentypen sind, wie im Kapitel Einführung bereits erwähnt, durch abstrake Operationen und deren Semantik (Bedeutung ) charakterisiert. Genauer muss ein abstrakter Datentyp folgendes definieren:

  • Ein Universum legaler Werte für die Objekte des Datentyps. Beim Datentyp int sind dies z.B. gewöhnlich die ganzen Zahlen zwischen -2147483648 und 2147483647, in Python jedoch alle ganzen Zahlen, soweit sie mit dem verfügbaren Hauptspeicher darstellbar sind (ein solcher Datentyp wird auch als big int bezeichnet).
  • Ein (oder mehrere) Konstruktoren, die ein Objekt des Typs erzeugen, das einen legalen Initialwert repräsentiert (bei int gewöhnlich die 0).
  • Eventuell Destruktoren, die die Resourcen von nicht mehr benötigten Objekten (insbesondere ihren Speicher) wieder freigeben.
  • Eine Menge von Operatoren, die Objekte des Datentyps in einen anderen legalen Zustand transformieren (bei int z.B. k += 1) bzw. neue Objekte des Typs erzeugen (z.B. k + l). Die Operatoren müssen zwei Eigenschaften haben:
  • Sie müssen vollständig sein, d.h. jeder legale Zustand muss durch eine geeignete Sequenz von Operatoraufrufen erreichbar sein.
  • Sie müssen abgeschlossen sein, d.h. kein Zustand außerhalb des legalen Universums darf jemals entstehen.
Beispielsweise erfüllen die gewöhnlichen arithmetischen Operationen diese Anforderungen beim Datentyp int.

Für mathematische Interessierte: Der Datentyp int implementiert das mathematische Konzept eines kommutativen Rings. Darüber hinaus bietet er eine Divisionsoperation, die aber nicht die Anforderungen an einen algebraischen Zahlenkörper (engl. field) erfüllt.

Ein anderer typischer abstrakter Datentyp ist der Stack: Das Universum ist hier die Menge aller geordneten Folgen von Elementen (soweit sie in den Hauptspeicher passen), und die Operationen sind der Konstruktor (erzeugt einen leeren Stack), push (fügt ein Element ein) und pop (entfernt das zuletzt eingefügte Element). Die Abgeschlossenheit wird sichergestellt, indem der Aufruf von pop bei einem leeren Stack eine Exception auslöst.

Offered Interface versus Required Interface

Wenn man Schnittstellen vom Standpunkt der generischen Programmierung betrachtet, fällt auf, das man eine grundlegende Unterscheidung zwischen Offered Interfaces (d.h. was eine Datenstruktur anbietet) und Required Interfaces (d.h. was ein Algorithmus tatsächlich verwendet) treffen muss.

Definition Offered Interface

Das Offered Interface umfaßt die gesamte standardisierte Funktionalität, die eine Datenstruktur anbietet.

Damit eine Datenstruktur möglichst vielseitig verwendbar ist, kann ihr Offered Interface sehr breit angelegt sein. Es ist z.B. typisch, dass eine gegeben Datenstruktur mehrere abstrakte Datentypen gleichzeitig implementiert. Das Python-Array (Datentyp list) unterstützt beispielsweise die Funktionalität:

  • dynamisches Array (a[i], a.append(x) etc.)
  • Stack (a.pop() und a.append(x) statt push)
  • Queue und Deque (via a.insert(0, x))
  • verkettete Liste (Einfügen und Löschen an beliebiger Stelle via a.insert(index, x) und del a[index])

Die beiden letzten Punkte sind allerdings (gegenüber spezialisierten Datenstrukturen) mit Abstrichen in der Performanz verbunden -- diese Operationen erfordern beim Python-Array lineare Zeit, anstatt konstanter wie bei einer echten Queue oder verketten Liste.

Oft bietet ein Offered Interface auch die gleiche Funktionalität unter mehreren Namen, damit die Datenstruktur in verschiedenen Umgebungen verwendbar ist (beispielsweise kann eine Funktion zum Zwecke der Rückwärtskompatibiltät beibehalten werden, obwohl sie inzwischen als deprecated gekennzeichnet wurde). Häufig findet man auch sogenannte convenience functions, die im strengen Sinne nicht notwendig sind (weil sie aus bereits vorhandenen Funktionen zusammengebaut werden können), aber trotzdem das Leben des Programmierers erleichtern. Beispielsweise würde es beim Datentyp int vollkommen ausreichen, wenn nur der Vergleichsoperator "<" angeboten würde, denn alle anderen Vergleiche kann man leicht damit implementieren:

a = b ⇔ ¬(a < b) ∧ ¬(b < a)
a ≠ b ⇔ (a < b) ∨ (b < a)
a ≤ b ⇔ ¬(b < a)
a > b ⇔ b < a
a ≥ b ⇔ ¬(a < b)

Trotzdem bietet int alle Vergleichsoperatoren, weil obige Ersetzungen unbequem und schwerer lesbar wären.

Definition Required Interface

Das Required Interface umfasst die Funktionalität einer Datenstruktur, die ein Algorithmus tatsächlich benutzt.

Das Required Interface umfasst gewöhnlich nur einen (evtl. kleinen) Teil des Offered Interface. Ein typischer Fall ist es beispielsweise, dass ein Algorithmus nur lesend auf eine Datenstruktur zugreift, obwohl die Datenstruktur auch schreibenden Zugriff anbietet. Bei Zahlen kommt es häufig vor, dass nur die Addition benutzt wird, obwohl int auch die Subtraktion und Multiplikation etc. unterstützt.

Der entscheidende Punkt besteht nun darin, dass Required Interfaces minimal sein sollten und getrennt von den Offered Interfaces (welche ja gerade nicht minimal sind) standardisiert werden müssen, damit Algorithmen ihre volle Generizität erreichen. Beispielsweise unterstützen alle Python-Containerdatentypen, so verschieden sie sonst auch sind, die Funktion len(container). Die Standardisierung des Required Interface fordert nun, dass diese Funktion auch tatsächlich bei allen Containern len heißt, und nicht einmal size, und dann wieder numberOfElements. Man beachte, dass diese Standardisierung in der Tat unabhängig von den Offered Interfaces ist, weil dieselbe Datenstruktur durchaus size, length und numberOfElements gleichzeitig anbieten kann, wenn sie in mehreren Algorithmenumgebungen lauffähig sein soll, deren Required Interfaces verschiedenen Standards unterliegen.

Im Sinne der Minimalität kann sich ein Algorithmus z.B. darauf beschränken, nur den Vergleichsoperator "<" zu verwenden, und die anderen Vergleichsoperatoren wie oben beschrieben zu ersetzen, auch wenn dies weniger bequem ist. Dann wäre der Algorithmus immer noch lauffähig, wenn eine Datenstruktur nur "<" implementiert, auch wenn dies ein eher hypothetischer Fall ist.

Required Interfaces werden häufig in Form von Konzepten standardisiert. Ein Konzept umfaßt eine Menge von Operationen, die ein Algorithmus typischerweise zusammen benötigt. Konzepte sind damit das Gegenstück zu Abstrakten Datentypen, da letztere Operationen enthalten, die Datenstrukturen typischerweise zusammen anbieten. Das Konzept für Container, die die Funktion len(container) unterstützen, könnte z.B. Sized genannt werden (falls es Algorithmen gibt, die sonst weiter nichts von einer Datenstruktur fordern. Andernfalls wäre die Einführung eines separaten Konzepts hierfür überflüssig.) Die Entwicklung einer Menge von sorgfältig aufeinander abgestimmten Konzepten, und ihre Organisation in Konzepthierarchien, ist zur Zeit ein aktives Forschungsgebiet. Die Standard Template Library der Progammiersprache C++ muss in dieser Hinsicht als vorbildlich bezeichnet werden. Hier sind die Konzepte extrem sorgfältig definiert, und Offered Interfaces sind konsequent als Sammlung aller Konzepte definiert, die eine gegebene Datenstruktur effizient unterstützen kann.

Wichtige Konzepte

Einige wichtige Konzepte sollen hier erwähnt werden:

  • Copy Constructible: Objekte, die man kopieren kann (in Python durch copy.deepcopy).
  • Singleton: Objekte, die man nicht kopieren kann (z.B. weil sie einen Drucker repräsentieren, der physisch nur einmal vorhanden ist).
  • Default Constructible: Klassen, die einen Konstruktor ohne Argumente unterstützen, welcher ein Objekt in einem Standardzustand initialisiert (in Python z.B. list() für ein leeres Array, int() für die Zahl 0, und allgemein v1 = v.__class__() für beliebige Klassen).
  • Vergleiche:
  • Equality Comparable: unterstützt a == b
  • LessThan Comparable: unterstützt a < b
  • ThreeWay Comparable: unterstützt cmp(a, b) mit Ergebnis -1, falls a < b, 0 falls a = b und +1 falls a > b.
  • Indexable: unterstützt x = a[k] und a[k] = x, wobei k ein Integer im Bereich 0, ..., len(a)-1 ist.
  • Mapping: unterstützt x = a[key] und a[key] = x, wobei der Typ von key beliebig sein kann, solange er Hashable ist.
  • Hashable: key unterstützt hash(key), so dass key als Schlüssel in einer Hashtabelle verwendet werden kann.
  • Iteratoren:
  • ForwardIterator unterstützt iter.next() um zum nächsten Element zu gelangen
  • BidirectionalIterator unterstützt zusätzlich iter.prev() um zum vorhergehenden Element zu gelangen
  • RandomAccessIterator unterstützt zusätzlich iter.next(k) und iter.prev(k) um vorwärts oder rückwärts k Elemente zu überspringen
Die beiden letzeren Konzepte sind zur Zeit in Python nicht standardisiert, jedoch z.B. in C++. Forward Iteratoren werden normalerweise von allen Containern unterstützt (manche bieten sogar mehrere, je nach gewünschter Reihenfolge, z.B. Preorder-, Inorder-, und Postordertraversal bei einem Binärbaum). Bidirektionale Iteratoren werden typischerweise von doppelt verketteten Listen unterstützt, random access Iteratoren typischerweise von statischen und dynamischen Arrays. Wenn man für eine Datenstruktur mindestens einen bidirektionalen Iterator hat, kann man stets einen Rückwärtsiterator implementieren (indem man next und prev vertauscht).
  • Funktoren:
  • Unary Functor: unterstützt y = f(x)
  • Binary Functor: unterstützt y = f(x1, x2)
  • Unary Predicate: unterstützt f(x) → {True, False}
etc.
  • Mathematische Konzepte:
  • Addable: unterstützt a + b
  • Subtractable: unterstützt a - b
  • Multiplicable: unterstützt a * b
  • Dividable: unterstützt a / b
etc.

Wenn man in Python eine Klasse implementieren will, die eins der genannten Konzepte unterstützt, gibt es standardisierte Funktionennamen, die mit zwei Unterstrichen beginnen und enden, wie z.B. __init__ (Konstruktor), __cmp__ (3-Wege-Vergleich), __hash__ (Hashwert), __getitem__ und __setitem__ (lesender und schreibender Arrayzugriff) , __iter__ (Iterator), __add__ (Addition), __sub__ (Subtraktion), __mul__ (Multiplikation), __div__ (Division) etc (eine vollständige Liste findet man im Python Index). Der Python-Interpreter transformiert die normale Pythonsyntax automatisch in Aufrufe dieser Funktionen, falls ein Ausdruck Objekte enthält, die die entsprechenden Konzepte unterstützen.

Nächstes Thema