Bei imperativen (= befehlsorientierten) Programmiersprachen wie Python, Ruby, Java oder C# herrscht das "Gesetz des nächsten Befehls" -- so nenne ich das jedenfalls gerne. Nach der Ausführung eines Befehls wird unweigerlich der nächste Befehl abgearbeitet, dann der übernächste, dann der überübernächste usw. Die Anweisungen werden schlicht in Reihe (sequentiell) abgearbeitet. Eine Ausnahme davon bilden Sprünge. Eine Sprunganweisung erlaubt es, mehrere Anweisungen vor oder zurück zu springen. Ist die Sprunganweisung an eine Bedingung geknüpft, dann kann bedarfsweise gesprungen werden. Ohne bedingte Sprünge wäre das Programmierleben ziemlich trist. Es gäbe sonst nur Programme, die Befehle "von oben nach unten" abarbeiteten und schlimmstenfalls in einer Endlosschleife gefangen blieben.
An dem "Gesetz des nächsten Befehls" ist nicht zu rütteln. Vordergründig ist ein Programm in einer imperativen Sprache als Kette von Anweisungen zu schreiben und zu verstehen. Der Kontrollfluss, der thread of control, folgt den Anweisungen. Sobald ein Programmierer aber irgendetwas Interessantes machen möchte, ist er zwar gezwungen dieses vordergründige Ausführungsschema beizubehalten, tatsächlich aber wird er in die "Trickkiste" greifen, um sich aus dieser Abhängigkeit zu lösen. Zwei dieser wesentlichen "Tricks" möchte ich Ihnen heute vorstellen: verzögerter Aufruf und verzögerte Ausführung. Statt "verzögerter Aufruf" könnte man auch "verzögerte Kommunikation" sagen, da in imperativen Sprachen Aufrufe das Mittel zur Kommunikation zwischen Programmteilen sind. Als Programmierer werden Ihnen beide Techniken bekannt sein, nur vielleicht nicht unter diesem Namen. Und vielleicht haben Sie beide Techniken auch noch nicht auf diese Weise in "Reinkultur" betrachtet.
Verzögerter Aufruf
Nehmen wir folgendes Programmfragment in der Sprache Python. Zunächst binden wir die Bibliothek "math" ein, dann definieren wir die Funktion "calc". Die Funktion nimmt eine Zahl entgegen, ruft die "Wurzelzieh"-Funktion aus der Mathe-Bibliothek aus, addiert Eins dazu und liefert das Ergebnis zurück.
Wenn eine andere Funktion aufgerufen wird, hier "math.sqrt", dann folgt der Kontrollfluss dem Aufruf. Es handelt sich also um einen unbedingten (nicht von einer Bedingung abhängigen) Sprung! Bevor der Sprung zu der anderen Funktion ausgeführt wird, merkt sich die Python-Ausführungsmaschine zum einen den aktuellen Wert von Variablen in "calc", hier den Wert von "n". Zum anderen wird sich gemerkt, an welcher Stelle die Ausführung nach dem Aufruf von "math.sqrt" fortzusetzen ist, d.h. an welche Stelle nach der Ausführung von "math.sqrt" zurück zu springen ist. Diese Information wird auf einem sogenannten Call-Stack abgelegt.
Was können wir tun, wenn wir den Aufruf (call) "math.sqrt(n)" nicht sofort ausführen, sondern vorläufig zurückstellen wollen?
Statt des Aufrufs kapseln wir alle zum Aufruf benötigten Informationen in einer Datenklasse "DelayedCall" (verzögerter Aufruf):
Auch wenn Sie den Konstruktor ("__init__") von DelayedCall in Python nicht gleich verstehen, ist es ganz einfach, was hier getan wird. Eine beliebige Anzahl übergebener Argumente wird als Liste gespeichert in "self.args". Benamte Argumente werden als Dictionary (HashMap) in "self.kw" abgelegt.
Der zurückgestellte Aufruf verändert unser Programmfragment wie folgt:
Nun hält "x" nicht mehr das Ergebnis eines Wurzelaufrufs, sondern einen zurückgestellten Aufruf! Der Aufruf kann jetzt jederzeit nachgeholt werden via
Allerdings haben wir jetzt ein neues Problem. Wir können nicht einfach mit "return x + 1" fortfahren. Die Addition arbeitet mit Zahlen und nicht mit einem DelayedCall-Objekt.
Verzögerte Ausführung
Stellen wir doch die Ausführung von Anweisungen nach einem verzögerten Aufruf ebenfalls zurück! Auch das ist ganz einfach: Wir kapseln jetzt die ausstehenden Anweisungen in einer Funktion, die wir "callback" nennen. Als Input verarbeite die Funktion das Ergebnis des zurückgestellten "math.sqrt"-Aufrufs.
Der Ursprungscode für "calc" lässt sich noch erkennen, aber es hat sich einiges verändert. Die Funktion "calc" führt weder den Aufruf, noch die darauf folgenden Anweisungen aus und gibt auch keine Zahl mehr als Ergebnis zurück. Stattdessen gibt "calc" das Objekt mit dem gekapselten Aufrufparametern und eine Funktion namens "callback" zurück. Nun ist es an jemand anderem, (z.B dem Aufrufer von "calc") Aufruf und Ausführung nachzuholen.
Die Bedeutung von "calc" hat sich vollkommen verändert. Es ist keine Funktion mehr, die eine Wurzel berechnet und das um Eins erhöhte Ergebnis zurück liefert. "calc" ist zum Konstruktor geworden für "Aktionskapseln", die zwar ebendieses tun, aber eben zu einem späteren Zeitpunkt.
Dieses Ablösung dessen, was das "Gesetz des nächsten Befehls" verlangt und was ein Programmierer durch solche Techniken intendiert (beabsichtigt), ist der Grund dafür, warum Programme im imperativen Stil oft so schwer zu durchschauen sind. Man muss erst herausfinden, was der Programmierer da eigentlich treibt, bevor man zur eigentlichen Programmlogik vordringt.
Und noch eines lässt sich an diesem Beispiel sehr schön zeigen: Diese Techniken greifen auf der Code-Ebene und zielen darauf ab, den Kontrollfluss zu manipulieren. Die UML (Unified Modeling Language) ist eigentlich nicht dafür geeignet, solche Details abzubilden. Dabei sind diese Details enorm wichtig, da sie die Code-Ausführung erheblich beeinflussen und damit die Semantik eines Programms verändern.
An dem "Gesetz des nächsten Befehls" ist nicht zu rütteln. Vordergründig ist ein Programm in einer imperativen Sprache als Kette von Anweisungen zu schreiben und zu verstehen. Der Kontrollfluss, der thread of control, folgt den Anweisungen. Sobald ein Programmierer aber irgendetwas Interessantes machen möchte, ist er zwar gezwungen dieses vordergründige Ausführungsschema beizubehalten, tatsächlich aber wird er in die "Trickkiste" greifen, um sich aus dieser Abhängigkeit zu lösen. Zwei dieser wesentlichen "Tricks" möchte ich Ihnen heute vorstellen: verzögerter Aufruf und verzögerte Ausführung. Statt "verzögerter Aufruf" könnte man auch "verzögerte Kommunikation" sagen, da in imperativen Sprachen Aufrufe das Mittel zur Kommunikation zwischen Programmteilen sind. Als Programmierer werden Ihnen beide Techniken bekannt sein, nur vielleicht nicht unter diesem Namen. Und vielleicht haben Sie beide Techniken auch noch nicht auf diese Weise in "Reinkultur" betrachtet.
Verzögerter Aufruf
Nehmen wir folgendes Programmfragment in der Sprache Python. Zunächst binden wir die Bibliothek "math" ein, dann definieren wir die Funktion "calc". Die Funktion nimmt eine Zahl entgegen, ruft die "Wurzelzieh"-Funktion aus der Mathe-Bibliothek aus, addiert Eins dazu und liefert das Ergebnis zurück.
import math
def calc(n):
x = math.sqrt(n)
return x + 1
Wenn eine andere Funktion aufgerufen wird, hier "math.sqrt", dann folgt der Kontrollfluss dem Aufruf. Es handelt sich also um einen unbedingten (nicht von einer Bedingung abhängigen) Sprung! Bevor der Sprung zu der anderen Funktion ausgeführt wird, merkt sich die Python-Ausführungsmaschine zum einen den aktuellen Wert von Variablen in "calc", hier den Wert von "n". Zum anderen wird sich gemerkt, an welcher Stelle die Ausführung nach dem Aufruf von "math.sqrt" fortzusetzen ist, d.h. an welche Stelle nach der Ausführung von "math.sqrt" zurück zu springen ist. Diese Information wird auf einem sogenannten Call-Stack abgelegt.
Was können wir tun, wenn wir den Aufruf (call) "math.sqrt(n)" nicht sofort ausführen, sondern vorläufig zurückstellen wollen?
Statt des Aufrufs kapseln wir alle zum Aufruf benötigten Informationen in einer Datenklasse "DelayedCall" (verzögerter Aufruf):
class DelayedCall(object):
def __init__(self,func,*args,**kw):
self.func, self.args, self.kw = func, args, kw
Auch wenn Sie den Konstruktor ("__init__") von DelayedCall in Python nicht gleich verstehen, ist es ganz einfach, was hier getan wird. Eine beliebige Anzahl übergebener Argumente wird als Liste gespeichert in "self.args". Benamte Argumente werden als Dictionary (HashMap) in "self.kw" abgelegt.
Der zurückgestellte Aufruf verändert unser Programmfragment wie folgt:
def calc(n):
x = DelayedCall(math.sqrt,n)
return x + 1
Nun hält "x" nicht mehr das Ergebnis eines Wurzelaufrufs, sondern einen zurückgestellten Aufruf! Der Aufruf kann jetzt jederzeit nachgeholt werden via
x.func(*x.args,**x.kw)
Allerdings haben wir jetzt ein neues Problem. Wir können nicht einfach mit "return x + 1" fortfahren. Die Addition arbeitet mit Zahlen und nicht mit einem DelayedCall-Objekt.
Verzögerte Ausführung
Stellen wir doch die Ausführung von Anweisungen nach einem verzögerten Aufruf ebenfalls zurück! Auch das ist ganz einfach: Wir kapseln jetzt die ausstehenden Anweisungen in einer Funktion, die wir "callback" nennen. Als Input verarbeite die Funktion das Ergebnis des zurückgestellten "math.sqrt"-Aufrufs.
def calc(n):
x = DelayedCall(math.sqrt,n)
def callback(x):
return x + 1
return x, callback
Der Ursprungscode für "calc" lässt sich noch erkennen, aber es hat sich einiges verändert. Die Funktion "calc" führt weder den Aufruf, noch die darauf folgenden Anweisungen aus und gibt auch keine Zahl mehr als Ergebnis zurück. Stattdessen gibt "calc" das Objekt mit dem gekapselten Aufrufparametern und eine Funktion namens "callback" zurück. Nun ist es an jemand anderem, (z.B dem Aufrufer von "calc") Aufruf und Ausführung nachzuholen.
callback(x.func(*x.args,**x.kw))
Die Bedeutung von "calc" hat sich vollkommen verändert. Es ist keine Funktion mehr, die eine Wurzel berechnet und das um Eins erhöhte Ergebnis zurück liefert. "calc" ist zum Konstruktor geworden für "Aktionskapseln", die zwar ebendieses tun, aber eben zu einem späteren Zeitpunkt.
Dieses Ablösung dessen, was das "Gesetz des nächsten Befehls" verlangt und was ein Programmierer durch solche Techniken intendiert (beabsichtigt), ist der Grund dafür, warum Programme im imperativen Stil oft so schwer zu durchschauen sind. Man muss erst herausfinden, was der Programmierer da eigentlich treibt, bevor man zur eigentlichen Programmlogik vordringt.
Und noch eines lässt sich an diesem Beispiel sehr schön zeigen: Diese Techniken greifen auf der Code-Ebene und zielen darauf ab, den Kontrollfluss zu manipulieren. Die UML (Unified Modeling Language) ist eigentlich nicht dafür geeignet, solche Details abzubilden. Dabei sind diese Details enorm wichtig, da sie die Code-Ausführung erheblich beeinflussen und damit die Semantik eines Programms verändern.