Kapitel 3 Algorithmische Abstraktion mit Python 3.1 Allgemeines zu Python Python ist eine imperative und objekt-orientierte Programmiersprache mit dynamischer Typisierung. Sie ist gut geeignet zum schnellen Ausprobieren von Algorithmen (rapid prototyping) und auch als Script-Sprache. Sie hat als Basiswerte ganze Zahlen, Gleitkommazahlen (Double), und Strings. Sie hat eine umfangreiche Fehlerbehandlung zur Laufzeit (Exception-Handling). Es gibt bereits vordefinierte Datentypen wie Tupel und Listen und auch vordefinierte Listenfunktionen. Auf weitere Möglichkeiten von Python wie eigene Datentypen, Klassen, Methoden, Aus- und Eingabe werden wir hier nicht explizit eingehen. 3.2 Programmierung in Python Wir verwenden den Interpreter in Python zur Auswertung von Definitionen und Ausdrücken. Ziel ist nicht die volle Programmiersprache Python zu lernen, sondern die typischen Programmiermethoden und das Speicher- und Auswertungsmodell einer imperativen Programmiersprache zu verstehen. Zunächst führen wir nur die Programmierung ohne Listen und Listenfunktionen ein. 3.2.1 Python: Struktur eines imperativen Programms Ein Python-Programm-File besteht, ähnlich wie ein Haskell-Programm, aus Definitionen von Funktionen. Es können Deklarationsteile wie ImportDeklarationen enthalten sein. 1 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 2 Die wesentlichen Bausteine einer Funktionsdefinition sind Befehle (Anweisungen, Kommandos), wie Zuweisungen Sprungbefehle, Ein/Ausgabebefehle, usw.. Die meisten Befehle haben das Ziel, irgendeinen Seiteneffekt zu bewirken wie Änderung des Speichers, Ausgabe auf einen Drucker, u.ä. oder den aktuellen Zustand zu ermitteln. Innerhalb der Anweisungen gibt es auch die Möglichkeit Ausdrücke zu schreiben, deren Auswertung ähnlich zu Haskell erfolgt, allerdings ist es immer die strikte Auswertungsreihenfolge. In diesen Ausdrücken ist die Verwendung selbstdefinierter Funktionen möglich, wobei auch die Ergebnisse der Funktionsaufrufe (Returnwert) bei der Berechnung verwendet wird. 3.2.2 Zuweisung und Kontrollstrukturen in Python Python erlaubt die Eingabe von arithmetischen Ausdrücken. Man kann den Interpreter wie einen Taschenrechner, aber mit änderbarem Speicher, verwenden: >>> >>> >>> >>> 25 >>> 5.0 >>> a = 3 b = 4 c = a**2 + b**2 c print math.sqrt(c) Wir werden die Syntax von wesentlichen Konstrukten in Python nun kurz erklären, für weitere Konstrukte sowie Details zur Syntax von Python sei auf Tutorials und Handbücher verwiesen. Die Syntax der Zuweisung (Assignment) ist: variable = ausdruck Zunächst wird das Ergebnis des Ausdruckes berechnet, wobei die Werte von Variablen im Speicher nachgeschaut werden. Das Resultat ist ein im Speicher liegendes “Objekt”. Objekte können hierbei einfache Zahlen, Strings aber auch komplexe Datenstrukturen sein. Anschließend weist die Zuweisung der Variablen als Wert das Objekt zu. Alternative Sichtweise ist, dass das Objekt als Name den Namen der Variablen erhält. Ein Objekt kann durchaus mehrere Namen besitzen. Die Deklaration der Variablen(namen) geschieht in Python mit der ersten Zuweisung, Variablen müssen somit nicht explizit vorher deklariert werden. Man kann auch arithmetische Operatoren mit der Zuweisung verknüpfen: x op = Ausdruck ist möglich: Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 >>> >>> >>> 20 >>> >>> 100 3 a= 100 a /= 5 a a *= 5 a Es ist auch eine (scheinbar parallele) Mehrfach-Zuweisung möglich: x1 , . . . xn = e1 , . . . , en wobei ei Ausdrücke sind. Die Auswertung des Ausdrucks x1 , . . . xn = s1 , . . . , sn ist folgendermaßen: Der Wert aller Ausdrücke si wird zuerst ermittelt, und danach werden den Variablennamen xi die Werte (d.h. Objekte ) si zugewiesen. Das if-then-else-Konstrukt hat in Python die Syntax: if Bedingung: Anweisungen wenn Bedingung wahr else: Anweisungen, wenn Bedingung falsch Eine Besonderheit von Python ist, dass es das Schlüsselwort then nicht gibt, dafür Doppelpunkte zur Trennung einzelner Teilkonstrukte verwendet werden. Zusätzlich bestimmt die Einrückung die Blockstruktur, und sorgt dafür, dass man Klammern weglassen kann. Es gibt noch weitere Varianten von if-then-else in Python, ohne else Zweig oder mit zusätzlichen Abfragen (elif-Zweigen). Die while-Schleife dient zur Iteration, die Syntax ist: while Bedingung: Schleifenkörper Der Anweisungsblock des Schleifenkörpers wird solange ausgeführt bis die Bedingung nicht (mehr) erfüllt ist. Im Schleifenrumpf können die Statements break und continue verwendet werden. Beispiel 3.2.1 >>> a=10 >>> while a > 0: ... print a ... a = a-1 ... Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 4 10 9 8 7 6 5 4 3 2 1 Funktions- bzw Prozedurdefinitionen, werden mit dem Schlüsselwort def eingeleitet: def Funktioname(parameter1, parameter2, . . .) : Anweisungen return Wert Der Rückgabewert der Funktion ist der mit dem Schlüsselwort return zurück gereichte Wert. Die return-Anweisung darf auch weggelassen werden, dann liefert die Funktion nichts zurück (bzw. None). Prozeduraufrufe finden immer mit voller Argumentanzahl und mit eingeklammerten Argumenten statt: f (a, b, c). Komplexe Zahlen sind verfügbar. Sie werden als Paar von zwei DoubleZahlen repräsentiert, der Real- und der Imaginär-Teil. Will man diese aus der komplexen Zahl z extrahieren, dann benutzt man z.real und z.imag. >>> a=1.5+0.5j >>> a.real 1.5 >>> a.imag 0.5 >>> abs(a) 1.5811388300841898 >>> a*a (2+1.5j) >>> Neben einfachen Strings kann man Unicode Strings in Python auf folgende Art erzeugen: >>> u’Hello World !’ u’Hello World !’ Es gibt eine Menge von Modulen, die zur Verfügung stehen. Man kann sie im Interpreter verwenden durch entsprechende import-Anweisungen und qualifizierte Aufrufe: 5 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 >>> import cmath >>> a=1.5+0.5j >>> cmath.sin(a) (1.1248012470579227+0.036860823712804462j) >>> Eine genauere Beschreibung folgt noch. 3.3 3.3.1 Beispiele und Besonderheiten in Python Einige einfache Funktionen in Python In Python kann man wie in Haskell Funktionen definieren. Die Syntax ist etwas anders: Z.B. die Wurzelberechnung mittels Iteration kann man so programmieren – wenn man sie selbst programmieren will, und nicht math.sqrt verwenden will. def wurzel(x): return wurzeliter(1.0,x) def wurzeliter(schaetzwert,x): if gutgenug(schaetzwert,x): return schaetzwert else: return wurzeliter(verbessern(schaetzwert, x), x) def quadrat(x): return x*x def gutgenug(schaetzwert,x): return (abs ((quadrat(schaetzwert) - x) def verbessern(schaetzwert,x): return mittelwert(schaetzwert, def mittelwert(x,y): / x) < 0.00001) (x / schaetzwert)) return (x + y) / 2.0 >>> wurzel(2.0) 1.4142156862745097 >>> 3.3.2 Verletzung der referentiellen Transparenz Die referentielle Transparenz, die in Haskell galt, wird in Python, wie in allen imperativen Programmiersprachen, verletzt: count = 0 def f(x): Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 6 global count count = count + x return count Der Test f (1) == f (1) ergibt 0, d.h. falsch “ bei diesem Beispiel, da die ” globale Variable count durch Seiteneffekte verändert wird. 3.3.3 Flussdiagramme Der größte gemeinsame Teiler kann durch die Prozedur ggtpy berechnet werden: def ggtpy(x,y): if x <= 0 or y <= 0: print ’Eingabe in GGT muss positiv sein’ else: while x != y: if x > y: x = x - y else: y = y - x return x Die schnellere Version von ggt, hier rekursiv: def ggt(x,y): if y == 0: return x else: return ggt(y, (x % y)) Einige Beispielauswertungen: >>> ggtpy(4,6) 2 >>> ggtpy(100,85) 5 >>> ggtpy(3,3) 3 >>> ggtpy(0,0) Eingabe in GGT muss positiv sein 0 >>> ggtpy(1234,4321) 1 >>> ggtpy(1243,3421) 11 Man kann ein sogenanntes Flussdiagramm verwenden, um die Wirkungsweise dieses Programms zu veranschaulichen (siehe Figur 3.1). Allerdings sind Flussdiagramme als Entwurfsmethode oft von begrenztem Nutzen, da zu große Diagramme entstehen. 7 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 x := a; y := b drucke x Ja nein x=y Stop Ja x>y x := x-y nein y := y-x Abbildung 3.1: Flußdiagramm für ggt 3.3.4 Vertauschung ohne Hilfsvariable Mit der Mehrfach-Zuweisung kann man die Vertauschung von Variablenwerten ohne Hilfsvariable durchführen: >>> >>> >>> >>> 2 >>> 1 >>> a = 1 b = 2 a,b = b,a a b In anderen imperativen Programmiersprachen braucht man i.a. die Sequenz: Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 c = a a = b b = c ####### >>> a = >>> b = >>> c = >>> a = >>> b = >>> a 2 >>> b 1 >>> 8 Im Python Interpreter: 1 2 a b c Die Implementierung der Mehrfach-Zuweisung ist doch nicht ganz so parallel wie vermutet >>> a,b,a = 1,2,3 >>> a 3 >>> 3.3.5 Berechnung der Fibonacci-Zahlen Die Berechnung der Fibonacci-Zahlen kleiner als n kann man in Python (optimiert) so hinschreiben: def fib(n): a,b = 0,1 while b < n: print b, a,b = b,a+b Die formale Definition ist f ibn = f ibn−1 + f ibn−2 für n > 1 und f ib0 = 0, f ib1 = 1. >>> fib(1000) 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 3.3.6 Implementierung der 3 ∗ n + 1-Funktion Der Fairness halber die Python-Implementierung von drein in Python: def drein(x): while x != 1: print(x) if x % 2 == 0: x = x/2 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 else: x = print(1) 3.3.7 9 3*x+1 Boolesche Funktionen Beispiel 3.3.1 Zur Demonstration erläutern wir kurz die Wirkungsweise der Booleschen Funktionen: >>> a = 1 >>> not a False >>> a = 0 >>> b = 1 >>> a or b 1 >>> a and b 0 >>> del b >>> b Traceback (most recent call last): File "<input>", line 1, in ? NameError: name ’b’ is not defined >>> a 1 >>> a or b 1 D.h. not, or, and wirken (fast) wie die Booleschen Operatoren. Es gibt eine kleine Besonderheit: or und and werten von links nach rechts aus und geben ihr Ergebnis sofort zurück, wenn der Wert aufgrund des ersten Arguments schon bekannt ist, auch wenn das zweite undefiniert ist. Sie wirken wie folgende Funktionen: def or(x,y): if x: return x else: return y def and(x,y): if x: return y else: return x Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 3.3.8 10 Iterative Prozesse Funktionen, die iterative Prozesse erzeugen, lassen sich leicht mittels einer while-Schleife (oder einer for-Schleife) programmieren: Hier die den HaskellFunktionen entsprechenden Funktionen zur Fakultät in Python. Beachte, dass man die Anweisung zur strikten Auswertung in Python nicht braucht. def fakultaetlin(n): a = faktiter(1,1,n) return a def faktiter(produkt,zaehler,max): if zaehler > max: return produkt else: return faktiter(zaehler * produkt,zaehler + 1,max) def faktwhile(n): produkt = 1 zaehler = 1 max = n while zaehler <= max: produkt = (zaehler * produkt) zaehler = zaehler + 1 return produkt 3.3.9 Module in Python Module dienen zur Strukturierung / Hierarchisierung: Einzelne Programmteile können innerhalb verschiedener Module definiert werden; eine (z. B. inhaltliche) Unterteilung des gesamten Programms ist somit möglich. Hierarchisierung ist möglich, indem kleinere Programmteile mittels Modulimport zu größeren Programmen zusammen gesetzt werden. Kapselung: Nur über Schnittstellen kann auf bestimmte Funktionalitäten zugegriffen werden, die Implementierung bleibt verdeckt. Sie kann somit unabhängig von anderen Programmteilen geändert werden, solange die Funktionalität (bzgl. einer vorher festgelegten Spezifikation) erhalten bleibt. Wiederverwendbarkeit: Ein Modul kann für verschiedene Programme benutzt (d.h. importiert) werden. In Python gibt es ebenfalls Module, hierbei gibt es jedoch keinen Modulkopf wie in Haskell, der eine Datei als Modul auszeichnet. Vielmehr ist jede Datei, die Python-Code enthält, ein Modul und kann von anderen Modulen aus importiert werden. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 11 Modulimport mittels import In einfachster Weise kann ein Modul, durch die Anweisung import Modulname importiert werden, wobei der Modulname dem Dateinamen ohne Dateiendung entspricht. Bei erstmaliger Ausführung der import-Anweisung werden die auf oberster Ebene des Moduls definierten Befehle (d.h. solche die nicht innerhalb einer Funktionsdefinition stehen) ausgeführt. Bei nochmaligem Ausführen der import-Anweisung werden diese Anweisungen nicht erneut ausgeführt. Zur Reinitialisierung eines geladenen Moduls steht jedoch die Funktion reload zur Verfügung, die als Argument das Modulobjekt erwartet, d.h. reload(Modulname) kompiliert den Quellcode erneut (in interpretierbaren Byte-Code) und führt anschließend die Anweisungen auf oberster Ebene des importierten Moduls erneut aus. Beispiel 3.3.2 Wir betrachten die Datei Fib.py, d.h. das Modul Fib: def fib(n): a,b = 0,1 while b < n: print b, a,b = b,a+b X=10 fib(X) # Diese Zeilen werden beim Import # ausgefuehrt Beim Import des Moduls werden die beiden letzten Befehle (im Namensraum des Modules Fib) ausgeführt: >>> import Fib 1 1 2 3 5 8 Auf die Funktionsdefinitionen und globalen Variablen des mittels import eingebundenen Moduls kann nur qualifiziert, d.h. in der Form Modulname.Funktionsname bzw. Modulname.Variablenname, zugegriffen werden. Beispiel 3.3.3 Nach Laden des Moduls Fib aus Beispiel 3.3.2 ist die (globale) Variable X unbekannt, sie existiert jedoch qualifiziert, gleiches gilt für die Funktion fib: >>> X Traceback (most recent call last): File "<stdin>", line 1, in ? NameError: name ’X’ is not defined >>> Fib.X 10 >>> Fib.fib(5) 1 1 2 3 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 12 Lokale Aliase für Modulnamen Importierte Module können analog wie in Haskell unter einem Alias-Namen importiert werden, hierfür gibt es in Python das Schlüsselwort as, z.B. kann man das Modul Fib aus Beispiel 3.3.2 unter dem Namen Fibonacci importieren: >>> 1 1 >>> 1 1 import Fib as Fibonacci 2 3 5 8 Fibonacci.fib(5) 2 3 Einbinden mittels from Mit der from-Anweisung können zum einen die zu importierenden Definitionen selektiert werden, zum anderen sind sie direkt im Namensraum des importierenden Moduls, d.h. unqualifiziert, verfügbar, die Syntax hierbei ist from Modulname import Definitionsliste Z.B. kann man ausschließlich die Funktion fib importieren: >>> 1 1 >>> 1 1 from Fib import fib 2 3 5 8 fib(5) 2 3 Es gibt noch die Variante from Modulname import * die sämtliche Namen eines Moduls in den Namensraum des importierenden Moduls kopiert. Hiervon sei aber abgeraten, da unerwartete Seiteneffekte auftreten können: Beispiel 3.3.4 Wir betrachten das Modul Printer: X = 10; def printer(): global X: print X Wird dieses Modul mit der from *-Anweisung importiert und die importierte globale Variable X verändert, so gilt diese Änderung nur im Namensraum des importierenden Moduls, nicht jedoch im Namensraum der Funktion printer: >>> from Printer import * >>> X 10 >>> printer() 10 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 13 >>> X = 20 >>> printer() 10 >>> X 20 Keine Datenkapselung in Python In Python gibt es keine (echte) Möglichkeit bestimmte Funktionsdefinitionen, o.ä. vom Export auszuschließen, d.h. das importierende Modul kann auf sämtliche definierten Namen des importierten Moduls zugreifen. Eine Datenkapselung ist somit in Python nicht möglich. Es gibt lediglich die Konvention, dass man Definitionen deren Namen mit einem Unterstrich _ beginnen, als versteckt ansehen sollte, und im importierenden Modul nicht benutzen soll. Der Interpreter verbietet den Zugriff jedoch nicht. 3.4 Auswertung von Python-Programmen Der von-Neumann-Rechner ist ein von John von Neumann (1903-1957) vorgeschlagenes Modell eines universellen Rechners, der zum Konzept der imperativen Programmiersprachen passt, genauer: zu einem primitiven AssemblerProgramm. Das Konzept des von-Neumann Rechners ist hilfreich beim Verständnis der Auswertung von imperativen Programmen. Um die volle Auswirkung der Zuweisung zu verstehen, insbesondere die Gültigkeitsbereiche von Variablen, ist es hilfreich, ein solches Modell des Rechners und Speichers zu betrachten. 14 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 Haupt-Speicher Daten Programm Bus Ein/ Ausgabe Steuerwerk Register Programmzähler Datenzeiger Rechenwerk Akkumulator Der Speicher ist in gleichlange Einheiten, die Speicherzellen zu je einem Byte = 8 Bits, eingeteilt, die fortlaufend nummeriert sind und unter dieser Nummer adressierbar sind, und direkt, unter Angabe der Adresse, gelesen und geschrieben werden können. Als weitere größere Einheit hat man manchmal auch (Speicher-)Worte (normalerweise 1 Wort = 4 Byte = 32 Bit). Eine zentrale Idee ist die Speicherung von Daten und Programmen im gleichen Speicher in Binärcode, wobei die Programme in kodierter Form vorliegen. Selbst die von-Neumann Maschine in der einfachsten Form benötigt zumindest: ein Adressregister für das Programm (den Programmzähler), ein Register für Datenadressierung, und ein Register zum Rechnen (Akkumulator). Heutzutage sind die Register 32 oder 64 Bit lang. Mit 32 Bit ist der adressierbare Speicher auf 232 Bytes beschränkt (ca. 4 Gigabyte). Im Steuerwerk werden die Programmbefehle interpretiert, und im Rechenwerk die arithmetischen Operationen ausgeführt. Ein Programm für eine von-Neumann-Maschine besteht aus Befehlen, die man sich durchnummeriert vorstellt: 1.,2.,3. . . . , und die hintereinander im Speicher abgelegt sind. Als Befehle sind möglich: • arithmetische: Addiere Konstante zum Akkumulator. Auch indirekt adressierte: mulitpliziere Zahl an Adresse xxx mit dem Akkumulator und speichere das Ergebnis im Akkumulator. Auch arithmetische Vergleiche sind möglich. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 15 • bedingte und unbedingte Sprungbefehle. Z.B. springe zu Programmbefehl 108. Oder: Wenn Akkumulator 6= 0, dann springe zu Befehl 101, sonst zu 201. • Ein-Ausgabe-Befehle: Drucke den Inhalt des Akkumulators. Oder: Speichere den Inhalt des Akkumulators an der Adresse, die im Datenregister steht. Die zweite zentrale Idee des von-Neumann Rechner-Modells ist die Methode der Abarbeitung des Programms: Es gibt einen Befehlszyklus, der in etwa so abläuft. Start des Programms ist mit dem Programmbefehl 1. Der Befehlszyklus: 1. Lade den Programmbefehl mit der Nummer, die im Programmzähler angegeben ist, in das Steuerwerk. Das Steuerwerk steuert dann, abhängig vom Inhalt des Programmbefehle, die weitere Abarbeitung. 2. • Wenn es ein Sprungbefehl ist, setze den Programmzähler neu. Ende des Zyklus. • Wenn es ein arithmetischer Befehl ist, dann entsprechend abarbeiten. Diese Abarbeitung wird vom Rechenwerk erledigt. Der Programmzähler wird danach um 1 erhöht. Ende des Zyklus. • Wenn es ein Ein-Ausgabebefehle ist, dann entsprechend abarbeiten. Der Programmzähler wird danach um 1 erhöht. Ende des Zyklus. 3. Bei dem speziellen Befehl Stop wird das Programm beendet. Natürlich ist ein richtiger Computer (bzw. Prozessor) komplizierter. Zum Beispiel hat die Recheneinheit noch weitere universelle Register, die meist so groß sind, dass eine Adresse gespeichert werden kann. 3.4.1 Auswertung von Pythonprogrammen In diesem Abschnitt werden wir einen Ausschnitt aus der operationalen Semantik von Python angeben. Das soll das Verständnis der Vorgänge vertiefen. Auch wenn es zunächst wie eine umständliche Erklärung von Zusammenhängen wirkt, die man nach dem Erlernen der Programmiersprache einfach weiß, braucht man eine solche formale Begriffsbildung und Regeln, um die Auswertung von Programmen und Ausdrücken rechnerunabhängig mitteilen und beschreiben zu können. Dieser Formalismus ist es auch notwendig, um zu spezifizieren bzw. zu verstehen, wie man geschachtelte Ausdrücke mit Seiteneffekten und Funktionsaufrufen auswertet. Zudem versteht man danach auch, welche Implementierungen der Modellvorstellung nicht entsprechen. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 9. Dezember 2004 16 Wir nehmen an, dass nur sehr einfach strukturierte Programme vorliegen. D.h. als Python-Programm betrachten wir eine Menge von Funktionsdefinitionen und Variablendefinitionen, wobei nur Zuweisung, if-then-else, while, arithmetische und logische Operationen benutzt werden. Funktionsaufrufe werden erst mal ausgeschlossen, da sie Seiteneffekte auf globalen Variablen bewirken können und auch etwas komplizierter sind. Diese werden später behandelt. An Basiswerten betrachten wir im Moment nur Zahlen, wobei auch Boolesche Werte als Zahlen gespeichert werden 1 bedeutet ’wahr’ und 0 bedeutet ’falsch’. 3.4.2 Die formale Modellierung Wir betrachten auch die globale Umgebung, damit wir Seiteneffekte auf globale Variablen angeben und modellieren können, da dies in den meisten imperativen Programmiersprachen vorkommt. Insbesondere muss die operationale Semantik dann die Reihenfolge der Operationen beschreiben, denn die Seiteneffekte ändern sich, wenn die Reihenfolge der Auswertungen verändert wird. Unsere Modellierung in diesem Abschnitt ist vereinfacht und ist nicht einfach erweiterbar auf geschachtelte Funktionsdefinitionen, da man dazu weitere Konzepte benötigt. Definition 3.4.1 Eine Bindung ist ein Paar (x, v), wobei x ein Variablenname und v ein Basiswert ist oder ⊥. Das schreiben wir etwas deutlicher als x 7→ v. Ein Umgebungsrahmen (Frame) ist eine endliche Menge von Bindungen. Pro Variablennamen x kann nur eine Bindung x 7→ v in einem Umgebungsrahmen enthalten sein. Eine Umgebung (Variablenumgebung) ist ein Paar (R1 , R2 ) von zwei Umgebungsrahmen, des lokalen Umgebungsrahmens R1 und des globalen Umgebungsrahmens R2 . Um das Modell zu vereinfachen, nehmen wir an, dass in unserer Modellierung lokale Variablennamen niemals gleich globalen Variablennamen sind. Das kann man erreichen, indem lokale Variablen in Funktionsrümpfen so umbenannt werden, dass diese disjunkt zu allen anderen Variablen sind. Der Wert wert(x, Z) einer Variablen x in einer Umgebung Z = (R1 , R2 ) ist definiert als der Wert v der Bindung x 7→ v in der Umgebung R1 ∪ R2 . Kommt kein solches Paar in R1 ∪ R2 vor, oder ergibt sich ⊥, so ist der Wert undefiniert. Normalerweise wird bei einer solchen Anfrage das Programm abgebrochen. Die Funktion update(x, v, Z) hat als Ergebnis die Umgebung Zneu , die aus Z = (R1 , R2 ) folgendermaßen entsteht: • Wenn x lokale Variable ist, und es eine Bindung x 7→ v 0 in R1 gibt, wird diese durch x 7→ v ersetzt. Das ergibt einen veränderten Umgebungsrahmen R10 , so dass Zneu := (R10 , R2 ). • Wenn x lokale Variable ist und wenn es keine Bindung x 7→ v 0 in R1 gibt, dann ergibt sich ein Fehler: Wert wird referenziert ohne dass er angelegt wurde. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 15.12.2004 17 • Wenn x globale Variable ist, und es eine Bindung x 7→ v 0 in R2 gibt, wird diese durch x 7→ v ersetzt. Das ergibt einen veränderten Umgebungsrahmen R20 , so dass Zneu := (R1 , R20 ). • Wenn x globale Variable ist und wenn es keine Bindung x 7→ v 0 in R2 gibt, dann ergibt sich ein Fehler: Wert wird referenziert ohne dass er angelegt wurde. Beispiel 3.4.2 Sei die Umgebung definiert als Z = ({x 7→ 1, y 7→ 2, z 7→ 3}, {gx 7→ 2, gu 7→ 100}) Dann ist wert(x, Z) = 1, wert(gu, Z) = 100, und update(x, 30, Z) = ({x 7→ 30, y 7→ 2, z 7→ 3}, {gx 7→ 2, gu 7→ 100}). Definition 3.4.3 Der Wert eines Ausdrucks s in der Umgebung Z ist der Wert, der bei Berechnung des Ausdrucks entsteht, wobei der Wert der Variablen in der Umgebung Z ermittelt wurde. Ist eine (für die Berechnung benötigte) Variable x in s undefiniert, dann ist der Wert von s ebenfalls undefiniert. D.h. wir nehmen bei der Auswertung von Ausdrücken in einfachem Python im Moment an, dass keine Seiteneffekte (d.h. keine Zustandsänderungen) durch Auswertungen von Ausdrücken entstehen. Hier einige formale Definitionen für die Auswertung von arithmetischen und Booleschen Ausdrücken, die seiteneffektfrei sind, d.h. ohne weitere Funktionsaufrufe, wobei der Operator rechts in der Tabelle jeweils der arithmetische Operator ist. Funktionsanwendungen und Kombinationen mit arithmetischen Ausdrücken werden wir weiter unten behandeln. wert(s1 + s2 , Z) wert(s1 ∗ s2 , Z) := := wert(s1 , Z) + wert(s2 , Z) wert(s1 , Z) ∗ wert(s2 , Z) 1 wenn wert(s1 , Z) ≤ wert(s2 , Z) wert(s1 ≤ s2 , Z) := 0 sonst 1 wenn wert(s, Z) = 0 wert(not s, Z) := 0 wenn wert(s, Z) 6= 0 0 wenn wert(s1 , Z) = 0 wert(s1 and s2 , Z) := wert(s2 , Z) wenn wert(s1 , Z) 6= 0 ... ... ... ... ... ... Beispiel 3.4.4 Wert eines seiteneffektfreien Ausdrucks in einer Umgebung. Sei Z = ({x 7→ 1, y 7→ 2, z 7→ 3}, {u 7→ 100}). Dann ergibt sich mit obiger Definition als Wert von x+(y*z): 18 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 15.12.2004 wert(x + (y ∗ z), Z) = wert(x, Z) + wert(y ∗ z, Z) = 1 + (wert(y, Z) ∗ wert(z, Z)) = 1 + (2 ∗ 3) = 7 Als Wert von x+u ergibt sich, wobei u global definiert ist: wert(x + u, Z) = wert(x, Z) + wert(u, Z) = 1 + 100 = 101 Auswertung von Zuweisungen Definition 3.4.5 Wir werden im folgenden die Schreibweise der logischen Regeln verwenden: V oraussetzungen Konsequenz Oberhalb des Striches werden evtl. mehrere Voraussetzungen notiert und unterhalb die Konsequenz. Wenn es keine Voraussetzung gibt, wird nur die Konsequenz notiert (ohne Bruchstrich) Effekt der Ausführung eines Python-Befehls Wir schreiben den Effekt einer Befehlsausführung folgendermaßen hin: (Z; B) → Z 0 , wenn, nach Ausführung des Befehls (des Programmstücks) B im Zustand Z der Zustand Z 0 danach hinterlassen wird. Zuweisung: (Z; x = s) → update(x, wert(s, Z), Z) Auch hier ist zu beachten, dass wir erst mal annehmen, dass s keine Seiteneffekte bewirkt. Mehrfachzuweisung: (Z; x1 , . . . , xn = s1 , . . . , sn ) → update(xn , wert(sn , Z), update(xn−1 , wert(sn−1 , Z), . . . update(x1 , wert(s1 , Z), Z) . . .)) Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 15.12.2004 19 Man erkennt, dass diese Zuweisung ein Potential zur Parallelisierbarkeit hat, aber nur unter der Annahme, dass die Auswertung der Ausdrücke keine Seiteneffekte bewirkt, und dass die Variablennamen der Zuweisung alle verschieden sind. Beispiel 3.4.6 Sei Z = ({x 7→ 3, y 7→ 4, z 7→ 5}, ∅). Die Auswertung von x,y = y,x im Zustand Z ergibt: Z 0 = update(y, wert(x, Z), update(x, wert(y, Z), Z)) = update(y, wert(x, Z), update(x, 4, Z)) = update(y, wert(x, ({x 7→ 3, y 7→ 4, z 7→ 5}, ∅)), ({x 7→ 4, y 7→ 4, z 7→ 5}, ∅)) = update(y, 3, ({x 7→ 4, y 7→ 4, z 7→ 5}, ∅)) = ({x 7→ 4, y 7→ 3, z 7→ 5}, ∅) Hier muss man beachten, dass Z in den Berechnungen immer die gleiche Umgebung referenziert. Beispiel 3.4.7 Sei Z = ({x 7→ 3, y 7→ 4, z 7→ 5}, ∅). Die Auswertung von x,x = y,0 im Zustand Z ergibt: Z 0 = update(x, wert(0, Z), update(x, wert(y, Z), Z)) = update(x, 0, update(x, 4, Z)) = update(x, 0, update(x, 4, ({x 7→ 3, y 7→ 4, z 7→ 5}, ∅))), = update(x, 0, ({x 7→ 4, y 7→ 4, z 7→ 5}, ∅), = ({x 7→ 0, y 7→ 4, z 7→ 5}, ∅), Auswertung der Sequenz Dies ist ein Konzept, das in Haskell unnötig ist, aber in imperativen Programmiersprachen wie Python notwendig ist. {c1 ; c2 } ist die Hintereinanderausführung der beiden Kommandos c1 , c2 . Die Regel zur operationalen Semantik ist: (Z; c1 ) → Z1 (Z1 ; c2 ) → Z2 (Z; {c1 ; c2 }) → Z2 Beispiel 3.4.8 Sei Z = ({x 7→ 3, y 7→ 4, z 7→ 5}, ∅). Die Auswertung von x = y; y = x im Zustand Z ergibt: 1. (Z, x = y) → update(x, wert(y, Z), Z) = update(x, 4, Z) = ({x 7→ 4, y 7→ 4, z 7→ 5}, ∅) =: Z1 2. (Z1 , y = x) → update(y, wert(x, Z1 ), Z1 ) = update(y, 4, Z1 ) = ({x 7→ 4, y 7→ 4, z 7→ 5}, ∅) = Z1 Fallunterscheidung Auswertungsregeln: Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 15.12.2004 20 wert(b, Z) 6= 0 (Z, c1 ) → Z1 (Z; if b : c1 else : c2 ) → Z1 wert(b, Z) = 0 (Z, c2 ) → Z2 (Z; if b : c1 else : c2 ) → Z2 Die Auswertung einer Fallunterscheidung ohne else-Fall oder mit weiteren elseif-Fällen ist jetzt eine einfache Verallgemeinerung. Bei der Abarbeitung muss man sich merken, dass zunächst nur die Bedingung ausgewertet wird, und abhängig von ihrem Wert entweder die Befehle des jaoder des nein-Falles. While-Schleife Bei der formalen Definition der Auswertung der while-Schleife muss man beachten, dass diese evtl. nicht stoppt. Die folgende Regel berücksichtigt das. Ebenso beachte man, dass die Regel rekursiv ist. wert(b, Z) = 0 (Z; while b : c) → Z wert(b, Z) 6= 0 (Z, c) → Z1 (Z1 ; while b : c) → Z2 (Z; while b : c) → Z2 Die erste Regel behandelt den Fall, dass die Bedingung falsch ist, und die while-Schleife nicht durchlaufen wird. In dem Fall bleibt die Umgebung erhalten. Die zweite Regel behandelt den Fall, dass Die Bedingung wahr ist, und die Schleife durchlaufen wird. In der Bedingung steht: wenn der Rumpf einmal durchlaufen wird, danach die While-Schleife und sich dann die Umgebung Z2 ergibt, dann auch nach der While-Abarbeitung alleine. Die for-Schleife werden wir noch behandeln. Beispiel 3.4.9 Sei die Umgebung vor der Auswertung der while-Schleife: Z = ({x 7→ 0, n 7→ 5}, ∅). Der Befehl sei while (n > 0): x = x+n; n = n-1 Wir gehen die Auswertung durch, entsprechend den Regeln: 1. wert(n > 0, Z) ergibt 1. 2. Wir wenden die zweite Regel an. Die zweite Voraussetzung ist (Z, c) → Z1 . c ist hierbei der Rumpf des while-Ausdrucks und Z1 die Umgebung nach einmaliger Ausführung. Es ergibt sich Z1 = ({x 7→ 5, n 7→ 4}, ∅). Um die dritte Voraussetzung zu erfüllen, muss man jetzt wieder einen while-Ausdruck, jetzt im Zustand Z1 auswerten. D.h. man muss wieder die volle Regel für while verwenden. Das kommt mehrfach vor und ergibt Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 15.12.2004 21 eine Folge von Umgebungen: ({x 7→ 9, n 7→ 3}, ∅). ({x 7→ 12, n 7→ 2}, ∅). ({x 7→ 14, n 7→ 1}, ∅). ({x 7→ 15, n 7→ 0}, ∅). 3. Danach muss man, um die letzte Voraussetzung zu erfüllen, die erste Regel anwenden, es ergibt sich, dass die Umgebung erhalten bleibt. 4. Jetzt kann man alle Voraussetzungen nach und nach erfüllen, die letzte Umgebung wird jeweils weitergegeben. Es ergibt sich am Ende Z2 = ({x 7→ 15, n 7→ 0}, ∅). 3.4.3 Anzahl der Schritte bei Auswertung Die Regeln der operationalen Semantik sind zwar nicht so formuliert, dass sie die Reihenfolge der Operationen genau festlegen, aber man kann an der Weitergabe der Umgebungen die notwendige Reihenfolge der Schritte ablesen. Aufbauend auf der operationalen Semantik können wir jetzt die Anzahl der Auswertungsschritte definieren und auch zählen. Wir vereinbaren: Bei Auswertung eines Ausdrucks werden folgende Auswertungsschritte gezählt: • Einzelauswertung einer Operation in einem Ausdruck • Auswertung eines Befehls • Zuweisung • Wertermittlung bei Variablen Nicht gezählt wird die implizite Initialisierung von Variablen. Bei Operationen wir += nehmen wir an, dass diese zunächst in eine normale Zuweisung transformiert wurden. Beispiel 3.4.10 Die Auswertung der Sequenz x = y; y = x ergibt: Auswertung von y Zuweisung auf x Auswertung von x Zuweisung auf y Das sind insgesamt 4 Auswertungsschritte. Beispiel 3.4.11 Auswertung einer while-Schleife, die fib (10) berechnet. Wir zählen die Schritte. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 15.12.2004 22 n = 10; a,b = 0,1; while n > 0: print b; a,b = b,a+b; n = n-1; Die Anzahl der Auswertungsschritte ergibt: 1 Zuweisung zu n 2 Mehrfachzuweisung 1 Aufruf von while 2 Bedingung 2 print 2 Mehrfachzuweisung a = b 4 Mehrfachzuweisung b = a+b; (a;b;+;=) 3 Zuweisung n=n-1 Ergebnis: 3 für Initialisierung 1 für while-Aufruf 14 pro while-Durchlauf (10 mal) 2 Bedingung zur While-Beendigung In der Summe ergibt das: 6 + 14 ∗ n Auswertungsschritte, d.h. O(n). 3.4.4 Auswertung unter Benutzung von Funktionsaufrufen und Seiteneffekten Hier erweitern wir die Auswertung um die Behandlung von Funktionsaufrufen und auch Seiteneffekten. Die (echten) Seiteneffekte können in unserer Modellierung nur von Funktionsanwendungen herrühren, in denen globale Variablen verändert werden. Bevor wir die Formalisierung durchführen, hier noch zwei Beispiele. Beispiel 3.4.12 Ein Beispiel einer Booleschen Funktion mit Seiteneffekt. Die Funktion schonmalda testet, ob der Wert 12345 schon einmal eingegeben wurde. schonmaldaKenn = False def schonmalda(x): global schonmaldaKenn; if (x == 12345): if schonmaldaKenn: return True else: schonmaldaKenn = True def InitKenn(): global schonmaldaKenn; schonmaldaKenn = False >>> kap3.schonmalda(3) False Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 15.12.2004 23 >>> kap3.schonmalda(12345) False >>> kap3.schonmalda(12345) True >>> kap3.InitKenn() >>> kap3.schonmalda(12345) False >>> kap3.schonmalda(12345) True >>> Beispiel 3.4.13 Die Funktion addToAkku addiert das Argument jeweils zu einem Akkumulator, der als globale Variable vereinbart ist. Akkumulator = 0 def addToAkku(x): global Akkumulator; Akkumulator = Akkumulator + x return Akkumulator def ShowAkku(): return Akkumulator >>> kap3.addToAkku(2) 2 >>> kap3.ShowAkku() 2 >>> kap3.addToAkku(2) * kap3.addToAkku(2) 24 >>> Die letzte Rechnung kommt von 4 ∗ 6 = 24, da der Akkumulator jeweils um 2 erhöht wird. Da Python keine explizite Variablendeklaration für lokale Variablen in Funktionen hat, aber doch eine implizite Variablendeklaration macht, müssen wir diese Variablen hier behandeln. Mit LV(f ) bezeichnen wir die lokalen Variablen von f : Das sind solche, die nicht als formale Parameter von f vereinbart sind, die aber auf der linken Seite von Zuweisungen innerhalb des Rumpfs vorkommen. Ein Funktionsaufruf in Python ist von der Form f (s1 , . . . , sn ). Um die Auswertung zu verstehen, muss man sich klarmachen, dass die si wieder Funktionsaufrufe enthalten können, und dass Argumente von links nach rechts ausgewertet werden. Da als Ergebnis einer Auswertung der Wert eines Funktionsaufrufs und der Zustand benötigt wird, schreiben wir (Z; s) →e (Z 0 ; v) 24 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 falls es sich um die Auswertung eines Ausdrucks (expressions) s im Zustand Z handelt, Z 0 der Zustand danach ist und v der return-Wert von s. Aufgrund der Regeln wird bei der Auswertung →e nur der globale Anteil in Z und Z 0 unterschiedlich sein, im Gegensatz zu Auswertung von Befehlen. Eine (kleine) Schwierigkeit bereitet die spezielle Methode der Rückgabe mittels return, die nicht nur einen Wert zurückgibt, sondern auch die Ausführung der Funktion beendet, und damit einen Rücksprung bewirkt. Das kann in unserer Methode nicht genau modelliert werden, statt eines Rücksprungs sind nach einem return innerhalb der Funktion alle Befehle ohne Wirkung. Wir erweitern deshalb den bisher benutzten Formalismus, indem wir im Umgebungsrahmen zwei spezielle Variablennamen zur Steuerung der Ausführung hinzufügen: rj enthält true bzw. false, wenn ein Rücksprung aktiv ist. rw enthält den Rückgabewert, wenn ein Rücksprung aktiv ist. Wir geben nacheinander die operationale Semantik der Befehle an, wenn Seiteneffekte erlaubt sind. Zunächst die einfachen Auswertungen: (Z, x) →e (Z, wert(x, Z)) (Z, v) →e (Z, v) wenn x Variable ist wenn v Basiswert ist Wenn ein Rücksprung aktiv ist, sollen Anweisungen c nichts tun: v 6= 0 (({. . . , rj 7→ v, . . .}, R), c) → ({. . . , rj 7→ v, . . .}, R) return (Z; s) →e (Z 0 ; w) (Z, return s) → update( rj, 1, update( rw, w, Z 0 )) Die operationale Semantik der Konstrukte muss jetzt so erweitert werden, dass diese nur unter der Bedingung ausgeführt werden, dass rj den Wert 0 hat. Es reicht aus, das jeweils in den Bedingungen aller Regeln mit aufzuführen. Wir lassen diese Vorbedingung bei der Notation unten weg, damit die Notation Regeln nicht zu kompliziert wird. Zuweisung (Z, s) →e (Z1 , v) (Z; x = s) → update(x, v, Z1 ) Mehrfachzuweisung: Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 25 (Z, s1 ) →e (Z1 , v1 ), (Z1 , s2 ) →e (Z2 , v2 ), . . . , (Zn−1 , sn ) →e (Zn , vn ) (Z; x1 , . . . , xn = s1 , . . . , sn ) → update(xn , vn , update(xn−1 , vn−1 , . . . update(x1 , v1 , Zn ) . . .)) Man erkennt, dass die Mehrfach-Zuweisung jetzt nicht mehr parallelisierbar ist, da die Umgebungen jeweils übergeben werden müssen. Fallunterscheidung: (Z, b) →e (Z1 , v) v 6= 0 (Z1 , c1 ) → Z2 (Z; if b : c1 else : c2 ) → Z2 (Z, b) →e (Z1 , v) v = 0 (Z1 , c2 ) → Z2 (Z; if b : c1 else : c2 ) → Z2 While-Schleife (Z, b) →e (Z1 , v) v = 0 (Z; while b : c) → Z1 (Z, b) →e (Z1 , v) v 6= 0 (Z1 , c) → Z2 (Z2 ; while b : c) → Z3 (Z; while b : c) → Z3 Auswertung eines Ausdrucks Wir müssen auch den Fall betrachten, dass eine Funktion ohne ein return beendet wird. Dann wird auch kein Wert zurückgegeben. Diesen “Nicht-Wert“ kann man durch einen eigenen Wert beschreiben, in Python ist es None. Er verhält sich ähnlich wie das Objekt () des unit-types “ in Haskell. ” Damit kann man die Auswertung eines Ausdrucks beschreiben. Hierbei gehen wir davon aus, dass f den Rumpf rumpf f hat, die formalen Parameter x1 , . . . , xn und die lokalen Variablen LV (f ) = {y1 , . . . , ym }. (Z, s1 ) →e (Z1 , v1 ), . . . , (Zn−1 , sn ) →e (Zn , vn ), R = {x1 7→ v1 , . . . , xn 7→ vn , y1 7→ ⊥, . . . , ym 7→ ⊥, rj 7→ 0, rw 7→ None} Zn = (R1 , R2 ) ((R, R2 ), rumpf f ) → ({. . . , rw 7→ w}, R20 ) (Z, f (s1 , . . . , sn )) →e ((R1 , R20 ), w) Das kann man so beschreiben: Zeile 1: Zuerst werden die Argumente von f von links nach rechts ausgewertet. Da man damit rechnen muss, dass die Auswertung Seiteneffekte in der lokalen Umgebung und in der globalen hat, darf man das nur sequentiell machen, und der veränderte Zustand muss jeweils mitübergeben werden. Das kann einen rekursiven Abstieg in die Auswertung von Unterargumenten bedeuten. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 26 Zeile 2: Wenn die Auswertung der Argumente beendet ist, kann man die lokale Umgebung R definieren, die die LV-Variablen initialisiert und den formalen Variablen die entsprechenden Werte zuweist. Zeile 3: aus Zn wird die globale Umgebung extrahiert. Zeile 4: Jetzt kann man den Rumpf der Funktion auswerten. Am Ende des Aufrufs ist der Rückgabe-Wert bekannt, falls alles terminiert. Wenn kein return gemacht wurde, wird als Wert das Objekt None zurückgegeben, das als Initialisierung verwendet wurde. Da in den Argumenten eines Ausdrucks ein return unzulässig ist 1 , braucht man nicht damit zu rechnen, dass die Zustände Zi einen Sprung erzwingen. Ergebnis am Ende ist der evtl. veränderte Zustand, und der berechnete Wert. Zu beachten ist, dass die Berechnung wert(s, Z) jetzt nur noch für Variablen s stimmt. Die Berechnung des Wertes für arithmetische (auch Boolesche) Ausdrücke muss angepasst werden und sieht jetzt so aus: (Z, s1 ) →e (Z1 , v1 ), (Z1 , s2 ) →e (Z2 , v2 ), v1 op v2 = v3 (Z, s1 op s2 ) →e (Z2 , v3 ) wobei op ein arithmetischer (bzw. Boolescher) Operator ist. Beispiel 3.4.14 Als Abkürzung verwenden wir in den Beispielen rj statt rj und rw statt rw def fakultaet(x): if x <= 1: return 1 else: return x*fakultaet(x-1) Die jeweiligen lokalen Umgebungen bei Auswertung von fakultaet(3) ohne die globale Umgebung sind in zeitlicher Reihenfolge wie folgt: Aufruf von fakultaet(3) im Zustand Z {x 7→ 3, rj 7→ 0, rw 7→ None} rekursives Auswerten von fakultaet (2) x 7→ 2, rj 7→ 0, rw 7→ None} rekursives Auswerten von fakultaet (1) {x 7→ 1, rj 7→ 0, rw 7→ None} Auswerten von return 1 {x 7→ 1, rj 7→ 1, rw 7→ 1} Auswerten von return 2 {x 7→ 2, rj 7→ 1, rw 7→ 2} Auswerten von return 6 {x 7→ 3, rj 7→ 1, rw 7→ 6} Resultat am Ende ist 6. Beispiel 3.4.15 Veränderung einer globalen Variablen durch eine Zuweisung. Hier kann man die dauerhafte Veränderung der Umgebung nachvollziehen. 1 Hier gibt es doch eine Typprüfung in Python 27 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 count = 0 def nreft(x): global count count = count + x return count Die Ausführung von nreft(1) ergibt: Aufruf von nreft(1) ({x 7→ 1, rj 7→ 0, rw 7→ None}, {count 7→ 0}) nach count = count + x ({x 7→ 1, rj 7→ 0, rw 7→ None}, {count 7→ 1}) nach return count ({x 7→ 1, rj 7→ 1, rw 7→ 1}, {count 7→ 1}) Die globale Umgebung nach dem nreft(1)-Aufruf ist {count 7→ 1} und der Rückgabewert ist = 1. Die Bedingung nreft(1) == nreft(1) kann jetzt nicht zu True auswerten, denn nach Auswertung des linken Ausdrucks hat count den Wert 1, nach Aufruf des zweiten hat count den Wert 2, das sind auch die jeweiligen Rückgabewerte, somit ergibt sich False. Beispiel 3.4.16 Wir zeigen die Zustände eulerzahl(0.0001) in zeitlicher Reihenfolge. bei Auswertung def eulerzahl(x): s = 1 e = 0 n = 1 while x < s: e = e + s s = 1.0 / (fakultaet(n)) n = n+1 return e Abfolge der Zustände, wobei wir die globale Umgebung weglassen. von Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 28 Aufruf von eulerzahl(0.0001) {x 7→ 0.0001, rj 7→ 0, rw 7→ None} Aufruf von eulerzahl {s 7→ ⊥, e 7→ ⊥, n 7→ ⊥, x 7→ 0.0001, rj 7→ 0, rw 7→ None} 3 Zuweisungen {s 7→ 1, e 7→ 0, n 7→ 1, x 7→ 0.0001, rj 7→ 0, rw 7→ None} 1. Zuweisungen im while {s 7→ 1, e 7→ 1, n 7→ 1, x 7→ 0.0001, rj 7→ 0, rw 7→ None} fakultaet im while, nur dessen lokale Umgebung {x 7→ 1, rj 7→ 0, rw 7→ None} nach Aufruf fakultaet und Zuweisung: {s 7→ 1, e 7→ 1, n 7→ 1, x 7→ 0.0001, rj 7→ 0, rw 7→ None)] nach n = n+1 {s 7→ 1, e 7→ 1, n 7→ 2, x 7→ 0.0001, rj 7→ 0, rw 7→ None)] nach e = e+s {s 7→ 1, e 7→ 2, n 7→ 2, x 7→ 0.0001, rj 7→ 0, rw 7→ None)] ... Bemerkung 3.4.17 Man sieht, dass Zuweisungen bzw. allein die Vermutung, dass welche erfolgen könnten, die Parallelisierbarkeit von Programmen verhindert. Die Reihenfolge der Auswertung in Python spielt eine große Rolle und kann nicht verändert werden, ohne die Bedeutung der Programme zu ändern. In Python wird das Ausmaß der Seiteneffekte etwas zurückgedrängt, auch wenn es noch ein normales Mittel der Programmierung ist. 3.4.5 Anzahl Auswertungsschritte mit Funktionsanwendungen und return Die Anzahl der Auswertungsschritte einer Auswertung kann man auf Programme mit Funktionsanwendung erweitern, wenn man vereinbart, dass return selbst als eine Auswertung zählt, während das Überspringen der nachfolgenden Anweisungen, bis die Funktion verlassen wird, nicht mitzählt. Ebenso zählt die Initialisierung von internen Variablen nicht mit. Beispiel 3.4.18 Anzahl der Schritte am Beispiel der rekursiven FibonacciFunktion. def fib(n): if n == 0: return 0 elif n == 1: return 1 else: return (fib(n-2) + fib(n-1)) Anzahl Schritte für fib(3): Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 Anzahl Schritte 1 1 2 1 2 1 1 2 1 1 2 1 2 1 2 1 ... 3.4.6 29 welche Auswertung fib(3)-Aufruf if- Aufruf n == 0 if- Aufruf n == 1 return- Aufruf +- Aufruf n-2-Auswertung fib-Aufruf (fib(1)) if- Aufruf (rekursiv in fib(1)) n == 0 if- Aufruf n == 1 return- Aufruf: fib(1) zu Ende n-2-Auswertung fib-Aufruf (fib(2)) ... Argumentieren mittels operationeller Semantik Man kann die Definition der operationellen Semantik verwenden, um Beweise über Python-Programme zu führen. Als Beispiel nehmen wir die Funktion, die die Fibonacci-Zahlen berechnet. def fibw (n): a,b = 0,1 while n > 0: print b, a,b = b,a+b n = n-1 Wir zeigen: fibw(n) druckt F ibn als letzten Wert, unter der Voraussetzung, dass n > 0 und kein Überlauf eintritt. Man braucht noch eine Klarstellung, die nicht ganz offensichtlich ist: Die Operationen, die das Programm ausführt, sind konzeptuell etwas anderes als die mathematischen Operationen. Wir können aber annehmen, dass nach einer Übersetzung der internen Datenstrukturen in mathematische Objekte sich die Python-Operatoren +, −, ∗, /, > wie die mathematischen Operationen verhalten, insbesondere da wir angenommen haben, dass kein Überlauf eintritt. Der Nachweis geschieht mit vollständiger Induktion nach Anzahl der Durchläufe des while-Rumpfs. Eigentlich muss man zunächst beweisen, dass die Schleife terminiert. Das ist aber einfach, da der Wert n immer kleiner wird. Man braucht eine Idee, was man beweisen will. I.a. braucht man dafür eine sogenannte Schleifeninvariante. Hier kann man diese leicht raten und erhält: wenn man den Zeitpunkt vor Durchlauf der while-Schleife nimmt, und mit m 30 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 den Wert der Eingabe bezeichnet: a = F ibm−n , b = F ibm−n+1 Diese Schleifeninvariante wird jetzt mit vollständiger Induktion nachgewiesen: Die Induktion nach Anzahl der Schleifendurchläufe ergibt jetzt: Basisfall m = n : a = F ibn−n = F ib0 = 0 stimmt. Und b = F ib1 = 1 stimmt. Induktionsfall: Wenn n > 0: Die Schleife wird durchlaufen. (Wir schreiben a, b, n für die alten Werte.) print b ändert den Zustand nicht. a,b = b,a+b ergibt: a = b, b = a + b n = n-1 ergibt n = n − 1. Vor der Schleife galt: a = F ibm−n , b = F ibm−n+1 Wir berechnen a = b = F ibm−n+1 = F ibm−n . Das ist der erste Teil der Schleifeninvariante. Die Berechnung für b ergibt: b = a + b = F ibm−n + F ibm−n+1 Einsetzen des veränderten Index n ergibt: b = F ibm−n−1 + F ibm−n . Nach der Definition der Fibonacci-Zahlen ist das gerade b = F ibm−n−1 + F ibm−n = F ibm−n+1 . Damit ist die Behauptung mit Induktion bewiesen. Wir müssen noch argumentieren, was das letzte gedruckte b ist. Offenbar ist dann n = 1. Es ergibt sich, dass b = F ibm−1+1 = F ibm gedruckt wurde. Beispiel 3.4.19 Dieses Beispiel soll zeigen, dass man auch in Python mit einem let doppelte Auswertungen vermeiden kann. Die Funktion f (x, y) := x(1 + xy)2 + y(1 − y) + (1 + xy)(1 − y) läßt sich effizienter berechnen, wenn man definiert: a b f (x, y) := := := 1 + xy 1−y xa2 + yb + ab Die entsprechende Definition sieht so aus: def polyf(x,y): a = 1 + x *y b = 1 - y return (x * a**2 + 3.4.7 y* b + a*b) Lokale Funktionen und geschachtelten Gültigkeitsbereiche Python erlaubt in der Version 2.3 geschachtelte Funktionsdefinitionen, d.h. solche, die innerhalb einer Funktionsdefinition gemacht werden und auf die lokalen Variablen der Ober-Funktion zugreifen dürfen. Hier gibt es Beschränkungen bei Zuweisungen: Externe Variablen (außer globale) dürfen nicht verändert werden, aber sie dürfen referenziert werden. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 31 Beispiel 3.4.20 def f(x): def g(y): return x+y; return g(11); Der Aufruf f(4)ergibt 15. Hier ist die textuelle Sichtbarkeit (lexikalischer Gültigkeits-Bereich) entscheidend. Der Aufruf f(11) hat als Rückgabewert den Wert von g(11). Im Rumpf von g wird x+y ausgewertet: Da g die Variable x von f sieht, ergibt sich 4+11, d.h. 15. Bei folgender unabhängiger Definition von f und g ergibt sich ein Fehler. def f(x): return g(11); def g(y): return x+y; Der Grund ist, dass g die lokale Variable x von f nicht sieht. Das Prinzip, das dahintersteht ist der lexikalische Gültigkeits-Bereich von Variablen. Die Semantik von Funktionen soll erhalten bleiben, wenn man die formalen und lokalen Parameter einer Funktion nur in dieser Funktion umbenennt. Würde der externe Zugriff auf x oben noch funktionieren, so wäre das nach Umbenennung nicht mehr möglich: def f(z): return g(11); def g(y): return x+y; Die allgemein verwendete Variante der Variablensichtbarkeit ist der lexikalische Gültigkeitsbereich, d.h., man darf nur Variablennamen verwenden, die im Programmtext auf derselben oder einer höheren Stufe verwendet werden. Die andere Methode, die oben im Beispiel nicht funktionierte, und die man in Ausnahmefällen (mit global-Definition) verwendet, allerdings so, dass man sie wieder als Variante der lexikalische Sichtbarkeit sehen kann, nennt man dynamischen Gültigkeitsbereich der Variablen (hier x). Die Funktion g sieht x in der Ablaufumgebung und versucht diese zu ändern. In Python wird das Prinzip für globale Variablen verwendet, nachdem sie als solche deklariert wurden. Diese Methode hat ohne extra Deklaration schwerwiegende Nachteile, da sie die theoretische Behandlung der Bedeutung von Programmen sehr schwierig macht und zudem Namensabhängigkeiten einführt. Der Effekt der Auswertung in Python bei geschachtelten Funktionsdefinitionen kann so beschrieben werden: • Wird ein undeklarierter Name referenziert, d.h. der Wert abgefragt, dann wird im nächsthöheren Gültigkeitsbereich gesucht. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 32 • Wird im innersten Gültigkeitsbereich ein Variablenname definiert, kann man nicht mehr auf den gleichen Variablennamen im höheren Gültigkeitsbereich zugreifen, auch wenn man den ganz unten definierten Variablennamen wieder löscht (mit del) • Variablennamen aus höheren Gültigkeitsbereichen können nicht gelöscht werden (mit del) und sie können auch nicht neu gebunden werden. (mit x = ...), ein solches Assignment führt dazu, dass eine neue lokale Deklaration angelegt, und gleichzeitig auch dazu, dass sämtliche Anweisungen im selben Gültigkeitsbereich, die x referenzieren, sich nur auf diesen Gültigkeitsbereich beziehen Wir betrachten noch ein Beispiel: Beispiel 3.4.21 def fff(): v = 2; def ggg(): v = 3; return hhh (); def hhh(): return v return ggg(); Hier ist die Frage, welchen Wert fff()zurückgibt: 2 oder 3? Analyse ergibt, dass der lexikalische Gültigkeitsbereich der lokalen Variablen v der Rumpf von fff ist außer dem Rumpf von ggg, ebenso der Rumpf von hhh. Damit ergibt sich 2 als Wert. Betrachten wir das Prinzip der Umbenennung und das Python-Prinzip der Lokalisierung aller veränderbaren Variablen, dann ist das v in ggg lokal in ggg, also kann man folgendermaßen umbenennen: def fff(): v = 2; def ggg(): w = 3; return hhh (); def hhh(): return v return ggg(); Dann ergibt fff() immer noch 2. 3.5 Listenverarbeitung in Python Es gibt Datentypen, die Sequenzen von Objekten implementieren: Tupel, Listen und Strings. Tupel und Listen werden analog zu Haskell dargestellt. Z.B. (1, 2, 3) 33 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 ist das 3-Tupel aus den Zahlen 1,2,3, und [1, 2, 3] die Liste dazu. Tupel sind in der Länge festgelegt, während Listen ihre Länge ändern können. Es gibt in Python die Listenfunktionen map, +, filter, und reduce, die vordefiniert sind. Sie haben analoge Ergebnisse und Funktionalitt wie die Funktionen map, ++, filter, und foldl in Haskell. Beachte: Die Listenfunktionen in Prfixschreibweise verndern die Liste nicht, whrend die Listenfunktionen in Attributschreibweise die Liste verndern knnen. Hier ein Auszug aus einer Dokumentation zu Python. Operationen auf allen Arten von Folgen (Listen, Tupel, Strings) 2 Operation Resultat x in s True wenn ein Eintrag von s = x, sonst False x not in s False wenn ein Eintrag von s gleich x, sonst True s+t Konkatenation von s und t s∗n n Kopien von s aneinander gehängt s[i] i-es Element von s, Start mit 0 s[i : j] Teilstück s ab i (incl.) bis j (excl.) len(s) Länge von s min(s) Kleinstes Element von s max(s) Größtes Element von s Bemerkungen Bem. (1) (1), (2) (1) Wenn i oder j negativ ist, dann ist der Index relativ zum Ende des String, d.h. len(s)+i oder len(s)+j wird eingesetzt. Beachte, dass -0 = 0. (2) Das Teilstück von s von i bis j wird definiert als die Folge von Elementen mit Index k mit i <= k < j. Wenn i oder j größer als len(s) sind, nehme len(s). Wenn i weggelassen wurde, nehme len(s). Wenn i ≥ j, dann ist das Teilstück leer. Operation auf Listen (mutable sequences)3 Operation s[i] = x s[i : j] = t del s[i : j] s.append(x) s.extend(x) s.count(x) s.index(x) s.insert(i, x) s.remove(x) s.pop([i]) s.reverse() s.sort([cmpF ct]) 2 Aus 3 Aus Resultat item i von s ersetzt durch x Unterliste s von i bis j ersetzt durch t dasselbe wie s[i : j] = [] dasselbe wie s[len(s) : len(s)] = [x] dasselbe wie s[len(s) : len(s)] = x Return: Anzahl der i mit s[i] == x Return: kleinstes i mit s[i] == x dasselbe wie s[i : i] = [x] wenn i >= 0 dasselbe wie del s[s.index(x)] dasselbe wie x = s[i]; del s[i]; return x Umdrehen von s in place Sortiere s in place Quick-Reference http://rgruet.free.fr/#QuickRef Quick-Reference http://rgruet.free.fr/#QuickRef Bemerkungen (5) (1) (1) (4) (3) (2), (3) Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 34 Kommentare: (1) Ergibt eine ValueError-Exception, wenn x nicht in s, d.h. wenn der Index außerhalb des gültigen Bereichs ist. (2) Die sort()-Methode nimmt ein optionales Argument, das eine Vergleichsfunktion für 2 Argumente ist, die -1, 0, or 1 als Wert ergeben muss, abhängig davon, ob das erste Elemente kleiner als, gleich oder größer als das zweite Argument sein soll. Beachte dass dadurch das Sortieren sehr viel langsamer wird. (3) Die sort() und reverse()-Methoden ändern die Liste in-place. Es gibt keinen Rückgabewert zur Warnung vor diesem Seiteneffekt. (4) Die pop()-Methode wird nur für Listen unterstützt. Das optionale Argument i wird automatisch als -1 definiert, so dass normalerweise das letzte Element entfernt und zurückgegeben wird. (5) Ergibt eine TypeError-Exception, wenn x kein Listenobjekt ist. Weitere Funktionen, die nicht in Attributschreibweise angewendet werden: Operation Resultat map(f,s) Liste [f s[0], f s[1], . . . ] filter(p,s) Liste der Elemente mit p(s[i]) = True reduce(op,s) s[0] op s[1] op . . . reduce(op,s,init) Dasselbe wie init op (reduce(op,s)) zip(s,t) Liste der Paare der Elemente von s,t. Der Rückgabewert (return . . . ) der Funktionen entspricht im allgemeinen dem der Haskellfunktionen. Bei Seiteneffekten haben die Listen-Funktionen von Python ein ganz anderes Verhalten als in Haskell. Haskell ist referentiell transparent, was bewirkt, dass die Listenargumente nach einer Funktionsanwendung unverändert sind, während eine Funktionsanwendung bzw. Operatoranwendung in Python oft eine Veränderung der Listenstruktur, und damit der Werte von Argumentvariablen nach sich zieht. Einige Funktionen sind nur dazu gemacht, genau dies zu bewirken. Z.B. reverse dreht die Python-Liste um. Wir geben zur Listenverarbeitung in Python einige Beispiele an: >>> range(20) [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] >>> range(5,10) [5, 6, 7, 8, 9] >>> len(range(10)) 10 >>> len(range(1000000)) Traceback (most recent call last): File "<input>", line 1, in ? MemoryError Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 35 >>>len(xrange(1000000)) 1000000 >>> a = [’a’, ’b’,’c’] >>> a [’a’, ’b’, ’c’] >>> b = [3,4,5] >>> a.extend(b) >>> a [’a’, ’b’, ’c’, 3, 4, 5] ## a ist modifiziert >>> b [3, 4, 5] >>> a.append(b) >>> a [’a’, ’b’, ’c’, 3, 4, 5, [3, 4, 5]] >>> a.reverse() >>> a [[3, 4, 5], 5, 4, 3, ’c’, ’b’, ’a’] >>> b [3, 4, 5] ## b bleibt >>> b.reverse() >>> a [[5, 4, 3], 5, 4, 3, ’c’, ’b’, ’a’] ## a ist modifiziert! Variablen, die in einem Aufruf f (t1 , . . . , tn ) nicht erwähnt werden, können trotzdem nach der Auswertung dieses Aufrufs andere Werte haben. Das passiert durch sogenanntes aliasing: der gleiche Speicherbereich hat verschiedene Namen. Schleifen in Python Schleifen kann man in Python programmieren mittels while oder for: Hier ein Beispiel für for: >>> for i in range(1,11): ... print(i) ... 1 2 3 4 5 6 7 8 9 10 >>> Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 36 Beispiel zu vordefinierten Listenfunktionen Beispiele zu den Listenfunktionen map, +, filter, und reduce, die analog zu map, ++, filter, und foldl in Haskell sind. Folgende Funktion listcopy erzeugt eine Kopie einer Liste: def id(x): return x def listcopy(x): return map(id, x) def geradeq(n): return n%2 == 0 >>> filter(geradeq,range(20)) [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] >>> map(quadrat,range(1,10)) [1, 4, 9, 16, 25, 36, 49, 64, 81] >>>[1,2,3]+[11,12,13] >>>[1,2,3,11,12,13] Die Summe aller Elemente einer Liste kann man ebenfalls analog zu den Haskell-Methoden berechnen: def add(x,y): return x+y >>> reduce(add,range(100)) 4950 def mal(x,y): return x*y def fak(n): return reduce(mal,range(1,n+1)) Alias-Namens-Problematik Alias-Namens-Problematik (Aliasing): Der gleiche Speicherbereich wird von mehreren Variablen (-Namen) referenziert. Effekte: Der Wert (bzw. das Datenobjekt) zu einer Variablen x kann sich im Laufe der Programmbearbeitung verändern, ohne dass diese Variable im Programm in einem Befehl, der den Speicher verändert, vorkommt. Allgemeiner kann auch ein Objekt unter dem Namen A erreichbar sein, und Teile des Objekts unter anderen Namen. Das bedeutet, dass der einfache Schluss: B wurde im Aufruf nicht erwähnt, also ist es nach der Auswertung des Aufrufs nicht verändert! falsch sein kann. Ein weiteres Beispiel für die Effekte der Alias-Namens-Problematik: 37 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 >>> >>> >>> [3, >>> [3, >>> >>> [1, >>> [1, a = [3,2,1] b = a a 2, 1] b 2, 1] a.sort() a 2, 3] b 2, 3] Um sich ein Bild von verketteten Listen zu machen, ist folgendes Darstellungsweise von Listen in einem Diagramm hilfreich, das die Situation nach der Zuweisung a = [1,2,3]; b = a repräsentiert. • Pfeile ausgehend von Namen bedeuten Bindungen: a 7→ . . .. Andere Pfeile kann man ebenfalls als Bindung ansehen, nur sind die Namen implizit. repräsentiert ein Paar von zwei impliziten Namen. In einer • Eine Box Implementierung wird man das als Adressen (Zeiger) darstellen. Der erste Pfeil von der linken Zelle einer Box ausgehend ist die Bindung des Listenelementes. Der zweite Pfeil ist die Bindung an die Restliste (Liste ohne das erste Element). • Die zweite Zelle der letzten Box symbolisiert einen Zeiger auf die leere Liste (Nil). b a Nil 1 2 3 Es ist zu beachten, dass bei a.extend(b) eine Kopie der Liste b an a angehängt wird. Bei append wird nur der Verweis genommen: >>> >>> >>> >>> [1, >>> a = [1,2,3] b = [4,5,6] a.append(b) a 2, 3, [4, 5, 6]] b.reverse() 38 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 >>> a [1, 2, 3, [6, 5, 4]] >>> Bild des Speichers nach den obigen Befehlen: a Nil 2 1 3 b Nil 6 5 4 Python nutzt die Flexibilität der Listendarstellung nicht aus, sondern es wird intern optimiert. D.h. Man hat Listenobjekte, die keine Teillisten haben, so dass man eigentlich ein anderes Diagramm verwenden könnte: >>> a = [1,2,3] a Nil 1 >>> >>> >>> [1, >>> >>> 2 b = [4,5,6] a.append(b) a 2, 3, [4, 5, 6]] b.reverse() a 3 39 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 [1, 2, 3, [6, 5, 4]] >>> a Nil 1 2 3 b Nil 6 5 4 Stacks und Queues Ein Stack (bzw. Keller, Stapel) ist eine oft verwendete Datenstruktur. In Python kann man einen Stack leicht implementieren, man kann sogar eine Liste von beiden Seiten als Stack verwenden: push e auf Stack a: a.insert(0,e) Benutzung der Listen von links: a.insert(0,b) a.pop(0) ##push(b) Benutzung der Listen von rechts: a.append(b) a.pop() Es tritt ein Fehler auf, wenn man vom leeren Stapel etwas herunternehmen will. Beispiel 3.5.1 Ein Beispiel, das sich eines Stacks bedient, ist die Verwendung eines Umgebungsstacks zur Auswertung von (Python-)Ausdrücken. Das Umdrehen einer Liste wurde schon im Haskell-Kapitel besprochen. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 07.01.2005 40 Eine Schlange, Queue ist eine Datenstruktur, die sich wie eine Warteschlange verhält: Vorne wird bedient (abgearbeitet), und hinten stellen sich Leute an (first-in first out, fifo). In Python kann man das effizient implementieren mit einer der folgenden Kombination von Operationen. a.insert(0,b), a.pop() oder a.append(b), a.pop(0). 3.5.1 Auswertungsregeln für Listenfunktionen in Python: operationale Semantik Dieser Paragraph soll verdeutlichen, dass man die operationale Semantik der Listenverarbeitung in einer imperativen Programmiersprache wie Python formal darstellen kann, wobei man den Speicher nur als abstrakte Schnittstelle benötigt, Natürlich wird dies dann letztlich doch auf einen adressierbaren Speicher abgebildet, aber man kann danach konzeptuell trennen zwischen den minimalen Erfordernissen der Auswertung und den zufälligen Eigenschaften der Implementierung. Z.B. ist die Größe der Adressen oder die Lokalität eher eine Eigenschaft der Implementierung auf einem Rechner mit linearem Speicher. Der Heap (Halde) dient dazu, Daten mit Struktur, die vom Programm modelliert werden, darzustellen, und deren Veränderung korrekt zu beschreiben. Dazu werden Heap-Variablen verwendet, deren Zweck es ist, Zeiger abstrakt zu modellieren und Objekte bereitzustellen, die vom Programm aus referenziert werden können. 4 . Um auch die Problematik der nicht mehr erreichbaren Speicherteile korrekt zu behandeln, führen wir zusätzlich noch den Begriff der Wurzelvariablen ein. Die Wurzelvariablen haben nur den Zweck, den garbagecollector zu unterstützen. Definition 3.5.2 Eine Halde (Heap) besteht aus folgenden Komponenten: • Einer Menge VH von Heap-Variablen, d.h. einer Menge von Namen. • Einer Menge von HH-Bindungen an Heapvariablen, wobei zwei Arten von HH-Bindungen vorkommen: – x 7→ v: eine HH-Bindung eines Wertes v (Zahl, Character oder Nil) an die Heap-Variable x, oder x 7→ ⊥ , x 7→ None. – x 7→ [x1 |x2 ]: eine HH-Bindung eines Paares (einer Box) aus zwei Heap-Variablen [x1 |x2 ] an die Heap-Variable x. Folgende Zusatzbedingungen müssen erfüllt sein: Es darf pro Heap-Variable nur eine HH-Bindung im Heap sein. Jede Heap-Variable, die in einer Box vorkommt, muss auch eine HH-Bindung im Heap haben. Meist werden einige Heapvariablen als Wurzel-Variablen markiert. Die Menge der Wurzelvariablen nennen wir VW ; wobei VW ⊆ VH gelten muss. 4 Wie bei anderen Namen ist die Implementierung sinnvoll als Zeiger. 41 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 Die beiden Formen der HH-Bindung kann man folgendermaßen darstellen: x x v x1 x2 Jede Heapvariable repräsentiert ein komplexes Objekt, das man als gerichteten Graphen sehen kann, wobei nur binäre Verzweigungen vorkommen und die Daten an den Blättern sind. Eine Programmvariable z wird dann durch die (PH-) Bindung {z 7→ x}, an ein Objekt gebunden, wobei x die Heapvariable ist, die das Objekt repräsentiert. Wir wiederholen die Definition der Bindungen und Umgebung zu Programmen (siehe Def. 3.4.1), wobei statt Zahlen und Zeichen auch Objekte aus einer Halde verwendet werden. Definition 3.5.3 Gegeben eine Halde H. Eine (Programmvariablen- bzw. PH) Bindung (bzgl. der Halde H) ist ein Paar x 7→ y, wobei x eine ProgrammVariable und y eine Heap-Variable ist. Ein Umgebungsrahmen (Frame) zu einer Halde H ist eine endliche Menge R von PH-Bindungen zur Halde H. Pro Programm-Variablennamen kann nur eine PH-Bindung in R enthalten sein. Eine Umgebung (Variablenumgebung, Zustand) ist ein Tripel (R1 , R2 , H) von zwei Umgebungsrahmen, des lokalen Umgebungsrahmens R1 bezüglich der Halde H, und des globalen Umgebungsrahmens R2 bezüglich der Halde H; und der Halde H. Wir nehmen an, dass die Wurzel-Variablen von H mindestens die HeapVariablen sind, die in den Variablenumgebungen R1 , R2 als Ziele vorkommen. Oft ist die Umgebung als Stack organisiert, der die Auswertung von Ausdrücken unterstützt. Unsere Modellierung erlaubt keine Werte direkt auf dem Stack. In realen Implementierung werden zur Optimierung oft Werte direkt auf dem Auswertungsstack abgelegt und verarbeitet, da dies effizienter ist. Beispiel 3.5.4 Die Darstellung der Liste [100] nach dem Aufruf z1 = [100] mit Umgebung und Halde ist folgendermaßen: lokaler Umgebungsrahmen {z1 7→ z2} globaler Umgebungsrahmen ∅ Halde {z2 7→ [z3|z4] z3 7→ 100 z4 7→ Nil} 42 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 z2 z1 z3 z4 100 Nil Beispiel 3.5.5 Darstellung einer zyklischen Liste, wobei man diese nur so drucken kann: [[[[[...]]]]]] AA AB Nil Die Halde dazu sieht so aus: {AA 7→ [AA|AB], AB 7→ Nil} H,∗ Definition 3.5.6 Die Erreichbarkeitsrelation −→ des Heaps H kann man folgendermaßen definieren: Eine Heap-Variable y ist von der Heap-Variablen x aus erreichbar, Notation H,∗ x −→ y, wenn x = y, oder wenn es eine endliche Folge von Variablen x1 , . . . , xn gibt, so dass x = x1 und y = xn und für alle i = 1, . . . n − 1 entweder xi 7→ [xi+1 |x0i+1 ] ∈ H oder xi 7→ [x0i+1 |xi+1 ] ∈ H wobei x0i+1 irgendwelche Heap-Variablen sein dürfen. Eine Heap-Variable x ist erreichbar (aktiv), wenn es eine Wurzelvariable x0 H,∗ gibt mit x0 −→ x, andernfalls heißt sie unerreichbar (redundant). H,∗ Bemerkung 3.5.7 Die Erreichbarkeitsrelation −→ des Heaps H kann man auch als die reflexiv-transitive Hülle der folgenden Relation definieren: H,∗ H,∗ Wenn x 7→ [x1 |x2 ] ∈ H, dann gilt x −→ x1 und x −→ x2 . Die Bindungen von unerreichbaren Variablen kann man aus dem Heap entfernen. In einer Implementierung nennt man das garbage collection. Wurzelvariablen sind i.a. die Heap-Variablen, auf die vom Stack aus referenziert wird und die Heap-Variablen, auf die das Programm referenziert. 43 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 Jede Variable der Halde repräsentiert einen Basiswert oder eine komplexere Datenstruktur. z.B.: In der Halde H = {x 7→ [x1 |x2 ], x1 7→ 1, x2 7→ Nil)} repräsentiert die Halden-Variable x die Liste [1] und x2 die leere Liste. x x1 x2 Nil 1 Regeln der (erweiterten) operationalen Semantik Der Aufwand dafür ist etwas höher als bei der ersten Variante der operationalen Semantik von Python, die nur mit Zahlen umgehen konnte, da auch zyklische Strukturen beschreibbar sein sollen. Der Zustand besteht aus Bindungsumgebung und Heap. Die übliche Umgebung wird so verallgemeinert, dass jeder Programmvariablen eine Heapvariable zugeordnet wird, die ihrerseits auf einen (evtl. komplexeren) Wert im Heap verweist. Jetzt können wir die Auswertung von Listenausdrücken und Listenfunktionen in Python mittels dieser Halden-Modellierung beschreiben: Wir verwenden die Klammer [[.]], um syntaktische Konstrukte einzuklammern, damit diese von den Operationen deutlich getrennt werden, und verwenden die Notation (Z, s) →e (Z 0 , x) um eine Auswertung des Ausdrucks s im Zustand Z zu bezeichnen, wobei x die Heapvariable ist, die den zurückgegebenen Wert repräsentiert und Z 0 der neue Zustand ist. Definition 3.5.8 Eine Auswertung des Listenausdrucks [a1 , . . . , an ] in einer Halde H folgt der Regel (n ≥ 1): (Z; [[a1 ]]) →e (Z1 ; x1 ) (Z1 ; [[[a2 , . . . , an ]]]) →e (Z2 ; x2 ) Z2 = (R2,1 , R2,2 , H2 ) (Z; [[[a1 , . . . , an ]]]) →e (R2,1 , R2,2 , H2 ∪ {x 7→ [x1 |x2 ]}); x Am Ende der Listen verwenden wir die spezielle Konstante Nil: ((R1 , R2 , H); [[[]]]) →e ((R1 , R2 , H ∪ {x 7→ Nil}); x) Die Auswertung des Listenausdrucks [a1 , . . . , an ] kann man so umschreiben: Zuerst wird a1 ausgewertet, danach a2 usw., bis an . Der Zustand am Ende ist der letzte Zustand nach dieser Auswertung. In der Halde repräsentiert die Haldenvariable x die ausgewertete Liste. 44 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 Die Alias-Namens-Problematik tritt jetzt auch bei Zuweisung von Listenvariablen auf. Sei die Folge der Befehle gegeben: a = [1,2] b = a Dann entsteht folgende Struktur in der Umgebung und in der Halde: Nach a = [1,2]: Bindungen: . . . , a 7→ u1 , . . . Heap: {u1 7→ [u2 |u3 ], u2 7→ 1, u3 7→ [u4 |u5 ], u4 7→ 2, u5 7→ Nil, . . .} Nach der weiteren Zuweisung b = a entsteht der Zustand Bindungen: . . . , a 7→ u1 , . . . , b 7→ u1 , . . . Heap: {u1 7→ [u2 |u3 ], u2 7→ 1, u3 7→ [u4 |u5 ], u4 7→ 2, u5 7→ Nil, . . .} Wenn jetzt innerhalb der Liste zu a etwas verändert wird, dann ändert sich automatisch etwas in der Liste, auf die b zeigt. a u1 b u2 1 u3 u4 u5 2 Jetzt können wir auch die Auswertung von Funktionen wie len, append, insert, . . . angeben. Für die folgende Regel nehmen wir der Einfachheit halber an, dass a, b Programm-Variablen sind. ((R1 , R2 , H); [[a.append(b)]]) →e ((R1 , R2 , H 0 ); None) Wobei H = H0 ∪ {x 7→ Nil} der Heap vor der Auswertung ist. x sei letzte Heap-Variable der Liste zu a die auf Nil zeigt, b 7→ x1 ist Bindung in der Umgebung R1 , und H 0 = H0 ∪ {x 7→ [x1 |x2 ], x2 7→ Nil} mit der neu erzeugten HeapVariablen x2 . D.h. die x-Bindung {x 7→ Nil} im Heap wird ersetzt durch {x 7→ [x1 | x2 ]}, wobei x2 neu eingeführt wird. Die Auswertung von a.append(b) kann man sich so veranschaulichen: Nil 45 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 Vorher: a u1 b x x1 Nil w Nachher: a u1 b x1 x2 x x1 Nil w Dadurch wird (die Liste) b als letztes Element an die Liste angehängt. Durch append-Anwendungen kann auch eine Liste entstehen, die unendlich tiefe Verschachtelung hat, indem man z.B. a.append(a) ausführt. Die zugehörige Halde für die Liste a = [1, 2] sieht so aus: {u1 7→ [u2 |u3 ], u2 7→ 1, u3 7→ [u4 |u5 ], u4 7→ 2, u5 7→ [u1 |u7 ], u7 7→ Nil, . . .} Diese Halde ist ein Graph, der Zyklen hat. Man sieht auch, dass im Diagramm u1 zweimal vorkommt, aber die zwei Pfeile der gleichen Bindung entsprechen. a u1 u2 u3 1 u4 u5 u1 u7 2 Als weiteres Beispiel die operationelle Semantik der Funktion insert. Für diese Regel nehmen wir ebenfalls an, dass a, b Programm-Variablen sind. ((R1 , R2 , H); [[a.insert(i, b)]]) →e ((R1 , R2 , H 0 ); None) wobei xi die Heap-Variable zum i-ten Listen-Tail ist. H = H0 ∪ {xi 7→ [ei+1 |xi+1 ]}, b 7→ y1 ∈ R1 , und H 0 = H0 ∪ {xi 7→ [y1 |y2 ], y2 7→ [ei+1 |xi+1 ]}, wobei y2 neue Heap-Variable ist. Nil 46 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 vorher: ei xi ei+1 ... xi+1 nach insert: ei xi ei+1 y1 xi+1 y2 Der Fall, dass die Liste zu Ende ist, geht analog: H = H0 ∪ {xi 7→ Nil}, b 7→ z1 ∈ S, und H 0 = H0 ∪ {xi 7→ [z1 |z2 ], z2 7→ Nil}. Die operationelle Semantik von map kann damit ebenfalls beschrieben werden, allerdings ist diese Funktion aus kleineren Operationen zusammengesetzt, so dass es einfacher wäre, für die Implementierung der Funktion map die operationale Semantik der kleineren Operationen zu beschreiben. Zum Beispiel, indem man ersatzweise die folgende Funktion betrachtet. def map_demo(f,xs): if len(xs) == 0: return []; else: wert = map_demo(f, xs[1:len(xs)]); wert.insert(0,f(xs[0])); ## Seiteneffekt return wert; Den Inhalt des Elements mit Index i der Liste a kann man mittels a[i] = b verändern. Wir geben hierfür die operationelle Semantik an, wobei wir annehmen, dass b eine Programmvariable ist, und bei der Auswertung von Anweisungen rechts nur den veränderten Zustand angeben. ((R1 , R2 , H); [[a[i] = b]]) → (R1 , R2 , H 0 ) wobei H = H0 ∪ {xi 7→ [y1 |y2 ]}, xi ist die Heap-Variable zum i-ten Listen-Tail, b 7→ z ∈ R1 , und H 0 = H0 ∪ {xi 7→ [z|y2 ]}. ... 47 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 Indirektionen Diese könnte man auch in der Halde erlauben. Es sind Bindungen von der Art x1 7→ x2 , wobei x1 , x2 beides Heap-Variablen sind. Deren Bedeutung ist, dass der Wert von x1 erst über die Referenz x2 und evtl. weitere Indirektionen gefunden werden kann, wobei die Indirektionen für die Programmiersprache nicht sichtbar sind. Das formale Modell würde dadurch etwas komplizierter, da man den Wert nicht direkt finden kann. Man muss auch Indirektionszyklen beachten, die eine Nichtterminierung des Wertesuchens bewirken könnten. In Implementierungen werden Indirektionen z.T. verwendet, aus verschiedenen Gründen: manche Operationen lassen sich leichter implementieren bzw. formulieren, und manchmal ist es effizienter, Indirektionen zu verwenden. 3.5.2 Implementierung der Halde in einem Speichermodell Zunächst das Speichermodell in dem die Halde implementiert werden soll. Definition 3.5.9 Einen RAM-Speicher S kann man definieren als endliche Folge von Bytes (1 Byte = 8 Bit), (alternativ: von Zahlen zwischen 0 und 28 − 1 = 255), nummeriert mit Indices 0, . . . , L − 1. L ist die Größe des Speichers. Der gespeicherte Wert unter der Adresse i ist das Byte S(i), d.h. das i − te Element der Folge, wenn die Zählung mit 0 beginnt. Die benötigte Adresslänge in Bits ist dlog2 (L)e. Die Zugriffsfunktion get(S, i, l) hat als Ergebnis die Subfolge von S der Länge l ab i. 00101001 0 1 2 3 4 5 Eine Implementierung von Adressen eines Speichers mit Adresslänge 32 Bit kann man durchführen, indem man eine Binärzahl, die durch eine Folge von 4 Bytes repräsentiert wird, als Adresse im gleichen Speicher interpretiert. Heapvariablen kann man dann implementieren als Folge von 4 Bytes (auch Wort genannt), die im Speicher direkt hintereinanderliegen, d.h. S(i), S(i + 1), S(i + 2), S(i + 3). Diese werden als Adressen interpretiert. Ein Paar [x1 |x2 ] von zwei Heapvariablen kann man als hintereinanderliegende Folge von 8 Bytes implementieren, d.h. S(i), . . . , S(i + 7). Eine Zahl kann man implementieren wie eine Adresse, nur wird der Wert als die binäre Zahl selbst interpretiert. 48 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 Beispiel 3.5.10 Hat man z.B. (mit Programmvariable b, Heapvariable x) eine Bindung b 7→ x und x 7→ 4, so kann man x als 4-Byte Adresse implementieren. Wenn an der Stelle mit der Adresse x die Adresse i steht, dann ist der Wert der Heapvariablen x ist dann der Wert unter dieser Adresse, d.h. S(i). Hat x die Adresse 100, dann erhält man durch a = get(H, 100, 4) die Adresse i, die zu x assoziiert ist, get(H, i, 1) ergibt dann den Wert, der bei korrekter Implementierung dann 4 ist. 200 100 4 x b 100 200 In dieser Implementierung geht die Information verloren, welcher Typ von Daten gemeint ist. Das sieht man daran, dass man damit Indirektionen, Paare, Nil, und Zahlen nicht unterscheiden kann. Je nach Implementierung benötigt man daher i.a. 5 noch eine Markierung (englisch: Tag), die diese verschiedenen Daten im Speicher voneinander unterscheidet. D.h. man könnte vereinbaren: Adresse: Paar: Ganze Zahl: Nil: kein Wert: 1 1 1 1 1 Byte Byte Byte Byte Byte Markierung Markierung Markierung Markierung Markierung (z.B. (z.B. (z.B. (z.B. (z.B. binär binär binär binär binär 1), und 4 Byte Adresse. 2), und 8 Byte Adressen. 3), und 4 Byte Zahl. 4). 5). Durch diese Haldenimplementierung haben wir eine abstrakte Schnittstelle angedeutet, die auch anders implementiert sein kann, ohne dass sich die Funktionalität der Halde bzw. der Umgebung ändert. Z.B. braucht folgendes nicht zu gelten: • die Adressen eines Adresspaares (einer Box) [x1 |x2 ] liegen direkt nebeneinander. • Adressen sind als aufsteigende Folge von Bytes repräsentiert. • Der nächste Eintrag im Array (Feld) hat Adresse des aktuellen Feldes +4. Auch die Länge der Adressen ist nicht festgelegt. 5 Diese soll. Markierung wird nicht benötigt, wenn ein Programm vorher weiß, was dort stehen Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 3.6 49 Felder, Arrays in Python In Python sind die Listen (und Tupel) gleichzeitig eindimensionale Felder, die auch heterogen (d.h. mit Elementen unterschiedlichen Typs) sein dürfen. Mehrdimensionale Arrays und Matrizen kann man mit einem Array von Arrays, d.h. mit Listen von Listen modellieren. Die interne Implementierung eines Feldes macht es möglich, eine (praktisch) konstante Zugriffszeit zu realisieren, wenn der Index bekannt ist. Allerdings ist die Handhabung eines Feldes im Programm etwas aufwändiger, da man bei Zugriffen stets den Index berechnen muss und da man im allgemeinen ein Feld nicht so ohne weiteres verkleinern oder erweitern kann. Folgend einige Beispiele: Beispiel 3.6.1 Transposition einer 2-dimensionalen quadratischen Matrix in Python def transpose(a,n): for i in range(n): for j in range(i+1,n): a[i][j], a[j][i] = a[j][i] , a[i][j] return a def testtranspose(n): b = range(n) for i in range(n): b[i] = range(n) for i in range(n): print b[i]; c = transpose(b,n) print " "; print "Nach Transposition:" for i in range(n): print c[i] Beispiel 3.6.2 Matrixmultiplikation für quadratische Matrizen und Determinante einer Matrix def matrixMult(a,b): leng = len(a); c = range(leng); for i in range(leng): c[i] = range(leng); for j in range(leng): c[i] [j] = 0; for i in range(leng): for j in range(leng): sum = 0 Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 50 for k in range(leng): sum = sum + a[i][k]*b[k][j] c[i][j] = sum return c def testmatmult(leng): a = range(leng); b = range(leng); print a; print b; for i in range(leng): a[i] = range(leng); b[i] = range(leng); for j in range(leng): a[i] [j] = i; b[i] [j] = j; print a; print b; return (matrixMult(a,b)) ###Determinante einer Matrix: rekursiv, exponentiell \begin{verbatim} def determinante(a): n = len(a); if n <= 0: return 1; else: if n == 1: return a[0][0] else: sum = 0; flip = -1; for i in range(n): flip = (-1)*flip; b = matrixcopy(a); for j in range(n): b[j].pop(0); b.pop(i); sum = sum + flip*a[i] [0] * determinante(b); return(sum); Beachte, dass es unter Benutzung der Gauß-Elimination einen ALgorithmus mit Laufzeit O(n3 ) gibt. Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 3.6.1 51 Operationale Semantik: C C ist eine wichtige und verbreitete Programmiersprache für maschinennahes Programmieren, insbesondere Betriebssysteme. C-Compiler ergeben effizienten Code. Der Abstraktionsgrad von C bzw. der C-Programme ist zwar höher als der von Assembler-Sprachen, aber nicht so hoch wie der von Python oder Haskell. Wir untersuchen die Eigenschaften der operationalen Semantik von C anhand eines Konstruktes. Aus Kernighan/Ritchie: Programmieren in C: Diese Regeln sind außerordentlich kompliziert, denn sie müssen mit ” einer Mischung von Funktionen alten und neuen Stils fertigwerden. Wenn möglich, sollten Mischungen vermieden werden.“ Die Reihenfolge, in der Argumente bewertet werden, ist nicht fest” gelegt; man beachte, dass sich verschiedene Übersetzer hierin unterscheiden. Die Argumente und Funktionsbezeichner werden jedoch vollständig bewertet [d.h. ausgewertet], mit allen Nebenwirkungen, bevor die Ausführung der Funktion beginnt...“ Ein anderes Zitat zum Versuch einer Andeutung der (funktionalen) operationellen Semantik von C: . . . In anderen Worten, wenn das n-te Kode-Fragment durch die ” Funktion fn repräsentiert wird, und s0 ist der Anfangszustand, dann ist der Endzustand definiert durch sn = fn (fn−1 (. . . f1 (f0 (s0 )) . . .)) Die Ausführung der einzelnen Funktionen erlaubt . . . Parallelismus.“ Wir wollen hier mal explizit ein Code-Fragment untersuchen, dass wir im Prinzip ala Python analysieren können. Dazu schnell die Erklärung eines COperators: i++ bewirkt, wenn ausgewertet, den Seiteneffekt i := i+1 wobei i Programmvariable sein muss. Die Nebenbedingung ist: Wenn es in einem Ausdruck vorkommt, dann ist der Return-Wert i, und der Wert danach ist i+1. Um die operationale Semantik hinzuschreiben, nehmen wir mal an, dass das auch in Python geht. Die operationale Semantik ist dann: v = wert(i, Z) w = v + 1 Z 0 = update(i, w, Z) (Z; [[i++]]) →e (Z 0 ; v) Zum Vergleich hier die operationale Semantik des Ausdrucks ++i, der sich von i++ nur dadurch unterscheidet, dass der Returnwert bereits i+1 ist: v = wert(i, Z) w = v + 1 Z 0 = update(i, w, Z) (Z; [[++i]]) →e (Z 0 ; w) Als Beispiel für i++ betrachte das C-Code-Fragment Praktische Informatik 1, WS 2004/05, Kapitel 3, vom 11.01.2005 52 i = 7; j = i++ * i++ Informelle Begründung für den erwarteten Zustand danach: Die Multiplikation wertet i++ zweimal aus: Der Wert des linken Argument ist 7, des rechten Argument 8, da es um 1 erhöht wurde, danach hat i den Wert 9. Der Wert von j ist 7 ∗ 8 = 56. Zunächst mal eine Überraschung beim Ausprobieren (aus Dissertation Ronald Moore). Der C-Code i = 7; j = i++ * i++; kompiliert in gcc hinterlässt den Zustand j = 49, i = 8 statt j = 56, i = 9! Erklärungsversuch: der Compiler versucht, die Doppelauswertung von i++ zu vermeiden und ändert daher das Programm ab zu: i = 7; j = let x = i++ in x*x; bzw. in let-freier Schreibweise: i = 7; x = i++; j = x*x; Die operationale Semantik ergibt jetzt: Nach Auswertung von x: {x 7→ 7, i 7→ 8, . . .}. Danach wertet man x*x aus und erhält 49, wie vom gcc berechnet. Fazit: Die Verwirrung, die beim Programmierer entsteht, wird bewirkt durch eine (optimierende) Programmtransformation, die die operationale Semantik nur erhält, wenn die Unterausdrücke keine Seiteneffekte bewirken; im Fall, dass die Unterausdrücke Seiteneffekte bewirken, wird die operationale Semantik nicht erhalten.