Rezepte

Programmverifikation

Inhaltsverzeichnis

  1. Zusicherungen
  2. Korrektheit
  3. Schlussregeln
  4. Beispiel 1
  5. Probe zu Beispiel 1
  6. Beispiel 2
  7. Probe zu Beispiel 2

Zusicherungen

Dieses Kapitel beschäftigt sich damit, wie man die Korrektheit von Programmen beweist. Das grundlegende Hilfsmittel hierfür ist der Hoare-Kalkül, der es erlaubt, Beweise systematisch aus schrittweisen Deduktionen zu konstruieren.

Der Hoare-Kalkül ist ein Beweisverfahren, bei dem Algorithmen Zusicherungen, engl. assertions, bekommen. Das sind logische Aussagen, welche das Programm nicht verändern, aber beim Ablauf des Programms an der jeweiligen Stelle erfüllt sein müssen. Es läge nahe, diese Zusicherungen einfach in das Programm einzufügen und zu schauen wie es sich beim Durchlaufen verhält. Der springende Punkt liegt allerdings nicht in der Erfüllung der Zusicherung für eine bestimmte Belegung der Argumente, sondern darin, dass die Zusicherung für alle möglichen erlaubten Belegungen erfüllt ist.

Zusicherungen befinden sich vor und hinter jeder Anweisung, infolge auch vor und hinter jeder Sequenz von Anweisungen. Darin eingeschlossen sind Kontrollanweisungen. Man betrachtet nun eine Sequenz S für sich allein. Diese kann auch aus einer einzigen Anweisung bestehen. Der wesentliche Gegenstand des Kalküls ist das Hoare-Tripel

{P} S {Q}.

Hierbei sind P und Q Zusicherungen. Die Zusicherung P ist die Vorbedingung, engl. precondition, und Q ist die Nachbedingung, engl. postcondition. Es handelt sich um die logische Aussage, dass nach Ausführung der Sequenz die Nachbedingung erfüllt ist, sofern vor der Ausführung die Vorbedingung galt.

Eine Vorbedingung muss nicht in jedem Fall vorhanden sein. Entfällt die Vorbedingung P, schreibt man P = true. Für eine Ganzzahl x gilt bspw.

{true} y := x*x; {y ≥ 0}.

Korrektheit

Beweisen will man, dass eine Funktion das Ergebnis liefert, was man haben möchte. Man sagt, die Korrektheit des Programms ist zu verifizieren. Zunächst muss erst einmal notiert werden, was gewünscht ist. Hierfür wird der Signatur einer Funktion eine Nachbedingung und ggf. eine Vorbedingung hinzugefügt. Man spricht von einer Spezifikation der Funktion.

Man unterscheidet zwischen zwei unterschiedlich strengen Formen der Korrektheit:

Partielle Korrektheit
Ein partiell korrektes Programm liefert das gewünschte Ergebnis, falls es terminiert.
Totale Korrektheit
Ein total korrektes Programm ist ein partiell korrektes, welches immer terminiert.

Ein Programm terminiert, wenn es sich nicht in einer Endlosschleife verfängt und keine divergierenden Befehle wie panic!() aufgerufen werden.

Im Hoare-Kalkül beweist man zunächst die partielle Korrektheit. Die totale Korrektheit muss gesondert gezeigt werden. Es gibt aber auch eine Adaption des Kalküls, bei der zusätzlich die Terminierung von Schleifen bewiesen wird.

Schlussregeln

Zum Beweis der Gültigkeit der Zusicherungen muss man deduktive Schlussfolgerungen machen. Diese Schlussfolgerungen wurden in Form von Schlussregeln formalisiert. Eine Schlussregel beschreibt, welche Schlussfolgerung (Konklusion) aus einer bestimmte Gestalt von Voraussetzung (Prämisse) abgeleitet werden darf. Notiert sind Schlussregeln üblicherweise in dieser Form:

 Prämisse
──────────
Konklusion

Bedarf es für die Konklusion mehrerer Prämissen, schreibt man diese untereinander oder getrennt nebeneinander:

Prämisse1
Prämisse2
──────────
Konklusion
Prämisse1, Prämisse2
────────────────────
     Konklusion

Zuweisungsaxiom

Mit der Notation P[v := e] drücken wir aus, dass in der Formel P jedes Vorkommen der Variable v durch den Ausdruck e ersetzt wird. Gemäß dem Zuweisungsaxiom ist die Zusicherung P nach einer Zuweisung v := e; gültig, sofern P[v := e] vor der Zuweisung erfüllt war. Diese Konklusion benötigt keine Prämissen:

─────────────────────────
 {P[v := e]} v := e; {P}

Verzweigungsregel

Kann man für beide Zweige einer Verzweigung zeigen, dass die Zusicherung Q hinter dem jeweiligen Zweig erfüllt ist, falls P davor galt, so muss Q auch nach dem Ende der Verzweigung erfüllt sein, falls P vor der Verzweigung galt.

         {P ∧  B} S1 {Q}
         {P ∧ ¬B} S2 {Q}
──────────────────────────────────
 {P} if B then S1 else S2 end {Q}

Kompositionsregel

Die Kompositionsregel besagt wie bei Sequenzen von Anweisungen schrittweise geschlussfolgert werden darf. Ohne diese Regel ergäbe das Unterfangen keinen Sinn.

Ein Programm bestehe aus zwei Teilsequenzen S1 und S2. Die Sequnez S1 ziehe die Zusicherung Q nach sich, falls P galt. Die Sequenz S2 ziehe R nach sich, falls Q galt. Dann muss die Gesamtsequenz S1; S2 die Zusicherung R nach sich ziehen, falls P galt.

   {P} S1 {Q}
   {Q} S2 {R} 
────────────────
 {P} S1; S2 {R}

While-Regel

Sei B die Schleifenbedingung und S der Schleifenrumpf. Prämisse: Man kann die Gültigkeit einer Zusicherung I hinter dem Schleifenrumpf folgern, sofern I und B vor dem Schleifenrumpf gültig sind. Konklusion: Ist I vor der Schleife gültig, muss hinter der Schleife I und ¬B gelten. Man bezeichnet I in diesem Zusammenhang als Schleifeninvariante, kurz Invariante.

        {I ∧ B} S {I}
───────────────────────────────
 {I} while B do S end {I ∧ ¬B}

Die Auffindung einer Schleifeninvariante ist nicht immer einfach.

Beispiel 1

Betrachten wir die rekursive Definition der Potenzierung. Für eine ganze Zahl x und eine nichtnegative Zahl n ist die Potenz xn definiert gemäß

x0 := 1,
xn := xxn−1.

Per Definition berechnet das Programm

def pow_rec(x, n):
    return 1 if n == 0 else x*pow_rec(x, n - 1)

die Potenz also in korrekter Weise.

Es wird nun gezeigt, wie man mithilfe des Hoare-Kalküls die partielle Korrektheit des iterativen Berechnungsverfahrens

def pow(x, n):
    y = 1
    for i in range(0, n): y *= x
    return y

zeigt. Zunächst müssen wir die for-Schleife in eine äquivalente while-Schleife transformieren. Dies resultiert in:

def pow(x, n):
    y = 1
    i = 0
    while i != n:
        y = x*y
        i = i + 1
    return y

Das Ende der Berechnung liegt direkt hinter der Schleife, womit dort i==n ist. Außerdem soll dort y das Ergebnis der Berechnung sein, das heißt y==xn. Demnach gilt dort y==xi. Weil sich in der Schleife sowohl i als auch y verändern, können wir vermuten, dass es sich bei der letzten Gleichung um eine Schleifeninvariante handelt. Wie sich herausstellt, ist diese Vermutung richtig und zielführend.

Nun werden zum Algorithmus die Zusicherungen annotiert.

def pow(x, n):
    y = 1
    i = 0
    # {y == 1 and i == 0}
    # {y == xi}
    while i != n:
        # {y == xi}
        y = x*y
        # {y == xi+1}
        i = i + 1
        # {y == xi}
    # {y == xi and not i != n}
    # {y == xn}
    return y

Rechnen wir das durch. Die Invariante ist vor der Schleife erfüllt, denn laut der vorherigen Zusicherung darf y==1 und i==0 in die Gleichung eingesetzt werden. Das macht 1==x0, und laut Definition der Potenzierung stimmt dies.

Nun nehmen wir an, die Invariante sei innerhalb der Schleife vor dem Schleifenrumpf erfüllt. Nach der Zuweisung y := x*y gilt dann y==x*xi, und laut Definition der Potenzierung ist x*xi das Gleiche wie xi+1. Die Inkrementation i := i+1 bewirkt, dass i in der Zusicherung dekrementiert werden muss. Formal ausgedrückt gilt

{y == xi}[i := i+1] i := i+1; {y == xi}
laut Zuweisungsaxiom, was nach Vereinfachung die gewünschte Form
{y == xi+1} i := i+1; {y == xi}

annimmt. Daher ist die Invariante auch hinter dem Schleifenrumpf erfüllt. Laut der while-Regel gilt hinter der Schleife folglich die Invariante und die Negation der Bedingung i!=n. Die Negation von i!=n ist i==n. Am Ende gilt hiermit schließlich y==xn, was zu beweisen war.

Diese Technik ist bedeutungsschwer, – vergleichbar wichtig wie das Verfahren der vollständigen Induktion. Wir können nun der Reihe nach unsere Algorithmen beweisen. Zumindest erst einmal die, deren Komplexität nicht zu hoch ist. Letztendlich hilft uns die Verifikation im Hoare-Kalkül, einen Algorithmus besser zu verstehen.

Wohlgemerkt wurde lediglich die partielle Korrektheit gezeigt. Die totale Korrektheit gilt eigentlich ohnehin nicht, denn für zu große Argumente würde das Programm mehr Speicher benötigen als im Computer zur Verfügung steht.

Aber angenommen, dieser Speicher stünde immer zur Verfügung. Der Beweis zur Terminierung der Schleife ist dann recht evident. Weil vor der Schleife i ≤ n ist und i in jedem Durchlauf inkrementiert wird, muss irgendwann i==n gelten.

Probe zu Beispiel 1

Nun kann es vorkommen, dass der Beweis selbst mit Fehlern behaftet ist. Mithilfe der Anweisung assert können wir die Zusicherungen für konkrete Belegungen der Parameter x und n zur Laufzeit prüfen lassen. Zwar liefert das keine Garantie für die Korrektheit des Beweises, jedoch sind solche Tests nützlich, um Fehler aufzuspüren.

def pow(x, n):
    y = 1
    i = 0
    assert y == 1 and i == 0
    assert y == pow_rec(x, i)
    while i != n:
        assert y == pow_rec(x, i)
        y = x*y
        assert y == pow_rec(x, i + 1)
        i = i + 1
        assert y == pow_rec(x, i)
    assert y == pow_rec(x, i) and not i != n
    assert y == pow_rec(x, n)
    return y

def test():
    for x in range(-9, 10):
        for n in range(0, 10): pow(x, n)

test()

Beispiel 2

Ein weiterer Algorithmus zur Berechnung von Potenzen ist die schnelle Potenzierung.

def pow(x, n):
    y = 1
    while n > 1:
        if n%2 == 1: y = y*x
        n //= 2; x = x*x
    if n == 1: y = y*x
    return y

Einführung von Hilfsvariablen sorgt dafür, dass die ursprünglichen Argumente konstant bleiben. Das ist notwendig, weil diese in die Überlegung einbezogen werden müssen. Der Algorithmus wird damit transformiert zu:

def pow(x, n):
    y = 1
    a = x
    i = n
    while i > 1:
        if i%2 == 1: y = y*a
        i //= 2
        a = a*a
    if i == 1: y = y*a
    return y

Der Ansatz ist wieder eine Rückwärtsbetrachtung des Programmablaufs. Der Rückgabewert am Ende muss y==xn sein, so wie das auch im einfachen Algorithmus war. Zunächst findet man:

# {y == xn ∧ i == 0 ∨ y*a == xn ∧ i == 1}
if i == 1: y = y*a
# {y == xn}

Im Fall i==1 reduziert sich die Vorbedingung nämlich zu y*a==xn, die bedingte Anweisung wird ausgeführt und das Resultat ist y==xn laut Zuweisungsaxiom. Im Fall i!=1 muss i==0 sein, denn hinter der Schleife gilt 0≤i≤1. In diesem Fall reduziert sich die Vorbedingung zu y==xn, die bedingte Anweisung wird nicht ausgeführt und das Resultat ist ebenfalls y==xn.

Nun kommt ein kritischer Schritt, dessen Auffindung ohne Übung schwierig sein mag. Vor der Schleife gilt ai==xn, was etwas mit der Schleifeninvariante zu tun haben könnte. Betrachten wir nun die Vorbedingung

{y == xn ∧ i == 0 ∨ y*a == xn ∧ i == 1}.

Ist ai==xn so modifizierbar, dass diese gilt? Ja, nämlich zu y*ai==xn, weil

Fall i == 0: y*ai == y*a0 == y,
Fall i == 1: y*ai == y*a1 == y*a.

Die Gleichung y*ai==xn gilt auch vor der Schleife, denn dort ist y==1. Zudem kommen in der Gleichung alle drei Variablen vor, die sich in der Schleife verändern. Tatsächlich ist die Gleichung eine günstige Schleifeninvariante.

Nun zum Schleifenrumpf. Das Tripel zur ersten Anweisung ist:

# {y*ai == xn}
if i%2 == 1: y = y*a
# {y*ai-1 == xn ∧ i%2 == 1 ∨ y*ai == xn ∧ i%2 == 0}

Die lange Nachbedingung kann man kompakter schreiben als

{y*ai-i%2 == xn}.

Außerdem gilt

i - i%2 == 2*(i//2).

Mit dieser Vorbereitung ist der Rest ein Kinderspiel. Gemäß Zuweisungsaxiom sind die Tripel

# {y*a2*(i//2) == xn}
i //= 2;
# {y*a2i == xn}

und

# {y*a2i == xn}
a = a*a;
# {y*ai == xn}

erfüllt. Damit ist die Invariante bewiesen. Weil nichts weiter zu beweisen verbleibt, ist auch der Algorithmus bewiesen. Der Algorithmus mit sämtlichen Zusicherungen:

def pow(x, n):
    y = 1
    a = x
    i = n
    # {y*ai == xn}
    while i > 1:
        # {y*ai == xn}
        if i%2 == 1: y = y*a
        # {y*ai-i%2 == y*a2*(i//2) == xn}
        i //= 2
        # {y*a2i == xn}
        a = a*a
        # {y*ai == xn}
    # {y*ai == xn ∧ 0≤i≤1}
    if i == 1: y = y*a
    # {y == xn}
    return y

Probe zu Beispiel 2

So schön die Beweise anmuten, können auch diese von Fehlern unterlaufen sein. Ein naheliegender Weg zur Vermeidung sind Proben zur Laufzeit, denn die Zusicherungen lassen sich ja für konkrete Variablenbelegungen zur Laufzeit ausführen.

def pow_iter(x, n):
    y = 1; a = x; i = n
    p = pow_rec(x, n)
    assert y*pow_rec(a, i) == p
    while i > 1:
        assert y*pow_rec(a, i) == p
        if i%2 == 1: y = y*a
        assert y*pow_rec(a, i-i%2) == p
        i //= 2
        assert y*pow_rec(a, 2*i) == p
        a = a*a
        assert y*pow_rec(a, i) == p
    assert y*pow_rec(a, i) == p and i<=1
    if i == 1: y = y*a
    assert y == p
    return y