Algorithmen und Datenstrukturen Fachhochschule Regensburg 3. Übungsblatt Korrektheit, Verifikation, Zusicherungen, partielle und totale Korrektheit, Hoare-Kalkül Algorithmen und Datenstrukturen Name: ________________________ Lehrbeauftragter: Prof. Sauer Vorname: _____________________ Partielle Korrektheit Falls ein Programm terminiert und die Spezifikation erfüllt, heißt es partiell korrekt Die Hoare-Formel {P}S{Q} ist gültig, wenn S partiell korrekt ist bzgl. Vorbedinging P und Nachbedingung Q. S Anfangszustand Endzustand Ausführung P gilt Q gilt, wenn S terminiert d.h.: Wenn P im Anfangszustand von S gilt und wenn S terminiert, dann gilt Q nach Ausführung von S Terminierung kann man allgemein nicht entscheiden, d.h. es gibt kein Verfahren, welches für einen beliebigen Algorithmus feststellt, ob dieser terminiert oder nicht. Totale Korrektheit = Partielle Korrektheit und Terminierung Ein Programm, das immer terminiert und partiell korrekt ist, heißt total korrekt. Die Hoare-Formel {P}S{Q} ist gültig, wenn S total korrekt ist bzgl. Vorbedinging P und Nachbedingung Q S Anfangszustand Endzustand Ausführung P gilt Q gilt und S terminiert Bsp.: 1. Partielle Korrektheit nicht aber totale Korrektheit zeigt: {true} while (x!= 0) x = x-1;{x == 0}, da keine Terminierung bzgl. x < 0. 2. Die Hoare Formel {x>0} while (x > 0) x = x + 1; {false} terminiert nie. Sie ist partiell korrekt, aber nicht total total korrekt. Generell drückt die Gültigkeit von {P} S {false} Nichtterminierung aus, d.h. {P} S{false} ist partiell korrekt, S terminiert aber nicht für alle Anfangszustände, die P erfüllen. Halteproblem Gibt es ein Programm, das für ein beliebiges anderes Programm entscheidet, ob es für eine bestimmte Eingabe in eine Endlosschleife gerät oder nicht? Das allgemeine Halteproblem dückt sich offenbar in folgender Frage aus: „Hält Algorithmus x bei der Eingabe von y?“ und ist nicht entscheidbar. Anschaulicher Beweis der Unentscheidbarkeit des Halteproblems 1 Algorithmen und Datenstrukturen Annahme: Es gibt eine Maschine (Algorithmus) STOP mit 2 Eingaben: Algorithmentext xund eine Eingabe y für 2 Ausgaben („JA: stoppt bei der Eingabe von y“, „NEIN“ stoppt nicht bei der Eingabe von y“) x JA STOP y NEIN Mit dieser Maschine STOP kann man eine neue Maschine SELTSAM konstruieren: x JA x STOP OK x NEIN Bei Eingabe von x wird getestet, ob x bei Eingabe von x stoppt. Im JA-Fall wird in eine Endlosschleife gegangen, die nie anhält. Im NEIN-Fall hält SELTSAM an der Anzeige OK. Es erfolgt nun die Eingabe von SELTSAM (für sich selbst) mit der Frage: „Hält SELTSAM bei der Eingabe von SELTSAM? 1. Wenn JA, wird der JA-Ausgang von STOP angelaufen und SELTSAM gerät in eine Endlosschleife, hält also nicht. Widerspruch! 2. Wenn NEIN, so wird der NEIN-Ausgang von STOP angelaufen und SELTSAM stoppt mit OK. Widerspruch! Der Widerspruch folgt aus der Annahme, dass eine STOP-Maschine existiert, was verneint werden muß. Nicht entscheidbare Probleme Das Theorem von Rice: Alle nicht trivialen (nicht einfachen) Eigenschaften von Algorithmen sind unentscheidbar Als trivial gelten bspw. folgende Eigenschaften: - Der Algorithmus hat eine bestimmte Länge - Der Algorithmus enthält eine bestimmte Zeichenkette Nicht trivial sind hingegen die Eigenschaften, ob er eine bestimmte Ausgabe erzeugt bzw. ob eine bestimmte Anweisung ausgeführt wird etc. 2 Algorithmen und Datenstrukturen Hoare Kalkül Bei dem Hoare-Kalkül handelt es sich um eine Menge von Regeln, die sich aus Prämissen und Schlussfolgerungen zusammensetzen: Prämisse1 Prämisse2 ... Prämissen --------------Konklusion Wenn die Prämissen 1 bis n bewiesen sind, dann ist auch die Konklusion bewiesen. Manche Regeln haben zusätzliche Bedingungen, die festlegen, wann sie angewendet dürfen. Die Anwendung der Regeln des Hoare-Kalküls führt auf das Hoare Tripel, d.h. auf Gruppen von 3 Elemneten folgender Art: {P}S{Q} 1 . Hier werden P und Q Zusicherungen genannt. S steht für eine Anweisung (ein Statement) in einer Programmiersprache Mit dem Hoare-Kalkül kann die partielle Korrektheit eines Programms nachgewiesen werden: - - Zerlege den Algorithmus in seine einzelne Anweisungen und füge vor (und nach) jeder Anweisung geeignete Vor- und Nachbedingungen ein. Zeige, dass die einzelnen Anweisungen bzgl. dieser Bedingungen (partiell) korrekt sind. Beweise die Korrektheit des gesamten Algorithmus aus der Korrektheit der einzelnen Aussagen. Der Nachweis der Korrektheit erfolgt nach dem Schema: {P}( s1 ;...; s n ){Q} . Der Korektheitsbeweis geht von hinten nach vorne vor, und zwar nach folgendem Schema: - Finde eine Zwischenbedingung {P ' } für s n und spalte den Beweis in {P}( s1 ;...; s n−1 ){P '} und {P '}( s n ){Q} . Die Weiterverarbeitung von {P '}( s n ){Q} hängt von der Art der Anweisung s n - ab. Für jede Anweisungsart benötigt man eine Extraregel. Diese Regeln geben auch z.T. Hinweise, was die richtige Zwischenbedingung ist. Nach dem gleichen Schema werden die Anweisungen s n−1 ;...; s1 behandelt, bis man schließlich eine Situation erreicht {P}(){Pn '} erreicht, wo kein Programmstück zwischen den Bedingungen mehr vorhanden ist. Partielle Korrektheit ist bewiesen, wenn man in dieser Situation {P} ⇒ {Pn '} mit rein mathematischen Mitteln erreichen kann. Verifikationsregeln schreibt man folgendermaßen: {P1 }s1{Q1 },..., {Pn }s n {Qn } . Sie hat die Bedeutung: {P}s{Q} Wenn Aussagen oberhalb der Trennlinie wahr sind, dann ist auch die Aussage unterhalb wahr. Eine Voraussetzung (oder ein Effekt) {P} wird abgeschwächt zu {P ' } , wenn gilt {P} ⇒ {P ' } . 1 Hoare-Formel 3 Algorithmen und Datenstrukturen Die Regeln nach C. A. R. Hoare R0: Axiom der leeren Anweisung (Skip-Regel) {P}NOP{P} R1: Zuweisung (Zuweisungsaxiom), Regel für die Ergibt-Anweisung {P[ x / t ]} x = t ;{P} Dies ist neben der Skip-Regel die einzige Regel, die ohne vorher zu beweisende Elemente auskommt. Für Korrektheitsbeweise bildet sie den Basisfall, auf dem die anderen Regeln aufbauen können. Die Regel ist so zu lesen: Die Vorbedingung einer Zuweisung x = t ist genau die Formel, die entsteht, wenn man die Nachbedingung alle Vorkommen von x durch t ersetzt. R2: Abschwächung P1 ⇒ P, {P}S{Q}, Q ⇒ Q1 {P1}S{Q1} Abschwächung wird benötigt, um aus speziellen Vorbedingungen, wie sie z.B. durch die Zuweisungsregel entstehen, auf allgemeinere Bedingungen schließen zu können. Bsp.: n == 3 ⇒ 2n >= 6, {2n >= 6}n = 2 * n;{n >= 6}, n >= 6 ⇒ n > 5 {n == 3}n = 2 * n;{n > 5} R3: Regel für Anweisungsfolgen, Regel der sequentiellen Komposition, Sequenzregel {P}S1{Q};{Q}S 2{R} {P}S1; S 2{R} Bsp.: {true} {5 == 5} x = 5; {x == 5} {x * x + 6 == 31} res = x * x + 6; {res == 31} {true} x = 5; res = x * x + 6; {res == 31} R4a: Regel der bedingten Fallunterscheidung, Regel der bedingten Anweisung {P ∧ B}S1{Q} {P ∧ ¬B}S 2 {Q} {P}if B then S1 else S 2 {Q} Die Fallunterscheidung besagt, dass sowohl der then- als auch der else-Zweig, wenn sie mit einer gültigen Vorbedingung abgearbeitet werden, die gleiche Nachbedingung erfüllen. B muß seiteneffektfrei sein, d.h. der Zustand des Programms darf bei der Auswertung von B nicht verändert werden Bsp. 4 Algorithmen und Datenstrukturen { true } if (x < 0) res = -x; else res = x; { res =|x|} denn {x < 0 } {-x == |x|} und {!x < 0} {x == |x| R4b: Regel der bedingten Fallunterscheidung, Regel der bedingten Anweisung. Diese Variante von dient R4a dient der Verifikation von Verzweigungen ohne Alternative (den "else"-Teil). {P ∧ B}S{Q} {P ∧ ¬B} ⇒ {Q} {P}if B then S Allgemein kann man hier die Regel 4a anwenden und den Quelltext unter S2 als NOP ansehen. Bsp.: {true} {true} {y == y } res = y; res = y; if (x > y) res = x; {res == y} {res == max(x,y)} if (x > y) res = x; {res == max(x,y)} R5a : Iteration Regel Iterationpartiell 2 {I ∧ B}S{I } {I }while B do S {I ∧ ¬B} Regel Iterationtotal 3 {I ∧ B}S{I } {I ∧ B ∧ t = z}S{t < z} {I }while( B ) do S{I ∧ ¬B} Bei while-Schleifen wird der Rumpf S solange wiederholt, bis die Wiederholungsanweisung B nicht mehr erfüllt ist (also ¬ B gilt). Zur Verifikation von Schleifen ist es nötig, eine so genannte Invariante zu finden. Invarianten gelten nach jedem Schleifendurchlauf und beschreiben innerhalb der Schleifendynamik das Gleichbleibende. Das Finden von Invarianten ist eine der Schwierigkeiten des Hoare-Kalküls. Einen eindeutigen und sicheren Weg zur Bestimmung von Invarianten gibt es nicht. z ist eine logische Variable, die frei ist. Das bedeutet: Sie darf in I , B, S , t nicht vorkommen und dient dazu, den alten Wert von t zwischenzuspeichern). Offensichtlich ist die Regel für die totale Korrektheit eine Erweiterung der Regel für die partielle Korrektheit. Es wird hier eine fundierte Ordnung verwendet: S muss einen Integer-Ausdruck t echt kleiner werden lassen, während die Invariante garantiert, dass der Ausdruck den kleinsten Wert nicht unterschreitet. Bsp.: - partielle Korrektheit: I ≡ ( n <= end + 1) 2 3 Wenn B seiteneffektfrei Wenn B seiteneffektfrei, I ⇒ t ≥ 0 und z ist freie logische Variable 5 Algorithmen und Datenstrukturen {n <= end & &n <= end + 1}n = n + 1;{n <= end + 1} {n <= end + 1 & &!(n <= end )} - totale Korrektheit: t ≡ end + 1 − n {n <= end & &n <= end + 1}n = n + 1;{n <= end + 1} {n <= end & &n <= end + 1 & &(end + 1 − n) == z}n = n + 1;{(end + 1 − n < z} {n <= end + 1}while(n <= end )n = n + 1;{n <= end + 1 & &!(n <= end )} R5b : {I }S{R} {R ∧ ¬B}S 2 {R} {I }repeatS _ until _ B{R ∧ B} R6 {P ∧ ( B = w1 }S i {R} __(i = 1(1)n) {P}case _ B _ of _ w1 : S1 ;...wn : S n {R} R7a : Implikationsregel, Konsequenzregel 1 (stärkere Vorbedingung) P ⇒ {R}, {R}S {Q} {P}S{Q} Wenn aus einem Zustand P ein Zustand R folgt, aus dem über den Quelltext ein Zustand Q angenommen wird, dann folgt Q bereits aus P. Bsp.: { true } x = 5; {x == 5}, denn true ⇒ 5 == 5,{5 == 5}x = 5;{x == 5} {true}x = 5;{x == 5} R7b : Implikationsregel, Konsequenzregel 2 (stärkere Nachbedingung) {P}S {Q}, Q ⇒ {R} {P}S{R} {true}x = 5;{x == 5}, x == 5 ⇒ x >= 5 Bsp.: { true } x = 5; {x >= 5}, denn {true}x = 5;{x >= 5} Das allgemeine Schema des partiellen Korrektheitsbeweises Da Vor- und Nachbedingungen i.a. gegeben sind, liegt folgende Situation vor: wobei {P}S1 ; S 2 ;.....; S n {Q}, S1 ; S 2 ;....; S n die einzelnen Anweisungen des Programms sind. Der Korrektheitsbeweis geht von hinten nach vorne vor, und zwar nach folgendem Schema: 1. Finde eine Zwischenbedingung {α 1 }S n {Q} . 4 α1 für S n und spalte den Beweis in {P}S1 ; S 2 ;.....; S n−1 {a1 } und 4 Für n = 1 hat der erste Teil die Gestalt {P}{α 1 } , was im nächsten Fall behandelt wird. 6 Algorithmen und Datenstrukturen Die Weiterverarbeitung von {α 1 }S n {Q} hängt von der Art der Anweisung S n ab (Wertzuweisung, if (…) … else … oder while(…) …). Für jede Anweisungsart benötigt man eine extra Regel. Diese Regeln geben auch z.T Hinweise, was die richtige Zwischenbedingung α 1 ist. 2. Nach dem gleichen Schema werden die Anweisungen schließlich die Situation S n −1 ; S n − 22 ;.....; S1 behandelt, bis man {P}{a n } erreicht, wo kein Programmstück zwischen den Bedingungen mehr vorhanden ist. Partielle Korrektheit ist bewiesen, wenn man in dieser Situation α n , mit rein mathematischen Mitteln beweisen kann 5 P ⇒ α n , d.h. aus P folgt . Die Anwendung der einzelnen Verifikationsregeln 1. Zuweisung 2. if (….) …. else … 3. while (…..) …… 6 Ausgangssituation: {P}s1 ; s 2 ;...; s n−1 ; while( B) s{Q}, wobei B die Testbedingung ist und s ein Programmstück (Anweisungsfolge) ist. Die while-Schleife transformiert dieses Problem in folgende einzelne Probleme: 1. Finde eine geeignete Schleifeninvariante 2. Finde eine geeignete Zwischenbedingung α1 als neue Vorbedingung für die while-Anweisung, so dass α 1 ⇒ I gilt, d.h. die Zwischenbedingung α 1 ist die spezielle Form der Schleifeninvariante, die vor Eintritt in die Schleife gilt. 3. Verifiziere den Erhalt der Schleifeninvariante: I ∧ B s I . Dies bestätigt, dass die { }{} Schleifeninvariante so lange gültig bleibt, wie die Bedingung B gilt. 4. Weise nach, dass die Schleifeninvariante stark genug ist, dass sie die Nachbedingung Q erzwingt: (I ∧ ¬B ⇒ Q ) , d.h. nachdem die Bedingung B falsch geworden ist, und die Schleife verlassen wurde, muß Q folgen. 5. Mache weiter mit den restlichen Anweisungen vor der Schleife für die Nachbedingung {P}s1 ; s 2 ;...; s n−1 ; {α 1 } Beispiele 5 6 dies entspricht der Abschwächungsregel ohne break- und continue-Anweisungen 7 α1 : Algorithmen und Datenstrukturen Assertions (Zusicherungen) Aufgabe Assertions sind sinnvoll, wenn - - der Programmierer überprüfen will, ob sein Programm an entscheidenden Stellen die Ergebnisse liefert, die zu erwarten wären. Sonderformen dieser Korrektheits-Checks sind: Preconditions (Vorbedingungen) und Postconditions (Nachbedingungen). Preconditions sichern korrekte Übergabewerte ab, Postconditions überprüfen am Ende der Methode, ob das Ergebnis sinnig ist und den Erwartungen entspricht. Vor- und Nachbedingungen einzelner Programmteile können zum Nachweis der Korrektheit eines Programms benutzt werden. Andere Anwendungen erschließen sich bei komplexen, undurchsichtigen Schleifen, wo jeder Durchlauf kontrolliert wird. man strikt die Überprüfung nach Korrektheit (Assertions) vom Programmfluß trennen (if-else) trennen will. man sichergehen will, dass bestimmte Bereiche beim normalen Betrieb nicht durchlaufen werden. Assertions sollten nicht verwendet werden, wenn - wenn Eingaben von Benutzern oder Dateien, Datenbanken etc. überprüft werden sollen. Bei Assertions wird davon ausgegangen, dass der "boolesche Ausdruck" immer erfüllt ist, solange das Programm korrekt läuft. Bei Benutzereingaben kann man aber zu keiner Zeit davon ausgehen. Sie können zu jeder Zeit falsch bzw. unsinnig sein. Das assert-Makro in C++ zum Verifizieren von Vor- und Nachbedingungen Zusicherungen werden in C++ mit dem Header <cassert> eingebunden, z.B.: #include <cassert> // enthaelt Makrodefinition const int grenze = 100; int Iindex; // .. Berechnung des Index // .. Test auf Einhalten der Grenzen assert(index >= 0 && index < grenze) ... Falls die Annahme (index >= 0 && index < grenze) nicht stimmen sollte, wird das Programm mit einem Fehler abgebrochen, die die zu verifizierende logische Annahme, die Datei und die Nummer der Zeile enthält, in der der Fehler aufgetreten ist. Assertions in Java Java kennt eine Anweisung assert BoolescherAusdruck, mit der zur Laufzeit sichergestellt werden kann, dass Ausnahmen über den Programmzustand eingehalten werden. Die Anweisung bewirkt, dass der Boolesche Ausdruck ausgewertet wird. Hat er den Wert true, dann passiert nichts weiter. Hat er den Wert false, wird ein Fehler ausgelöst. Man könnte die gleiche Wirkung auch mit if erzielen, z.B. könnte man statt assert 0 <=a; schreiben if (!(0 <= a)) throw new AssertionError();. Assertions werden im Java-Quellcode folgendermaßen genutzt: - assert Ausdruck assert Ausdruck1 : Ausdruck2 Der Ausdruck hinter dem Schlüsselwort assert muß in jedem Fall einen booleschen Wert zurückliefern. In der Regel wird der Ausdruck so gewählt, dass er bei korrekter Arbeitsweise des Programms immer 8 Algorithmen und Datenstrukturen true ergibt. Man spricht deshalb auch von Invarianten. Arbeitet das Programm nicht korrekt und liefert der Ausdruck false zurück, dann wird ein AssertionError ausgelöst. Die zweite Variante erlaubt es, hinter dem Doppelpunkt eine zweite Anweisung an die assert-Anweisung zu übergeben: diese setzt den Fehlertext. Bsp.: Eine Methode div() muß u.a. mit einer Zahl ungleich 0 versorgt werden. Sollte irgendein Programmteil fehlerhaft sein und den Divisor doch mit 0 belegen, muß ein Assert-Error erfolgen. public class Assertiontest { public static int div(int divident,int divisor) { assert divisor != 0 : "Bitte keine Zahl durch 0 teilen."; return divident / divisor; } public static void main(String args[]) { System.out.println("Quotient ist " + div(10,2)); System.out.println("Quotient ist " + div(10,0)); } } Damit das Programm kompiliert und ausgeführt werden kann, muß bei Compiler und Laufzeitumgebung noch ein Schalter gesetzt werden, z.B. javac –source 1.4 AssertionTest.java Die Überprüfung von Assertions ist zur Laufzeit standardmäßig abgeschaltet. Dadurch entsteht kein Geschwindigkeitsverlust bei der Ausführung der Programme. Zur Aktivierung von Assertions muß die Laufzeitumgebung mit dem Schalter –ea (enable assertions) gestartet werden, z.B.: java –ea AssertionTest 9