Dieses Kapitel hat Clemens Hintze geschrieben, ein europäischer
Ruby-Fan der ersten Stunde (sein Name taucht schon in der ersten
Nachricht auf der ruby-talk-Mailingliste auf).
Das Konzept der Continuations ist so verschieden von den
herkömlichen Programmstrukturen wie Schleifen und Verzweigungen, dass
man ihr Anwendungspotenzial nicht immer sofort erkennt. Dazu gehört
einiges an Erfahrung und der Mut, Continuations immer als Möglichkeit
in Erwägung zu ziehen.
In Ruby lassen sich ihre Eigenschaften und ihre daraus resultierenden
Verwendungsmöglichkeiten mit nur einem Wort treffend beschreiben:
Magie!
Continuations sind keine Erfindung von Ruby, es gibt sie zum Beispiel auch in Scheme. Da die Eigenschaften von Continuations nicht in allen Sprachen gleich sind, betonen wir ausdrücklich, dass es in diesem Abschnitt nur um die Ruby-Variante von Continuations geht.
Was
sind
Continuations? Die Übersetzung des englischen Begriffs gibt schon eine
vage Vorstellung - Continuation bedeutet Fortsetzung, in unserem
Zusammenhang ist der Begriff Fortsetzungspunkt besser. Um das
eigentlich einfache Konzept der Continuations erfassen zu können, muss
man zuerst verstehen, was bei der Abarbeitung eines Programms im
Grunde passiert.
Prinzipiell besteht ein Programm aus Aufrufen von Unterprogrammen (Funktionen in C, Methoden oder Codeblöcke in Ruby), die wiederum Unterprogramme aufrufen, bis die unterste Ebene erreicht ist - die Ebene, in der die eigentliche Arbeit in einer Folge von Maschinenbefehlen getan wird.
Wenn
nun
eine Methode ausgeführt wird, läuft sie in ihrem Aktivierungskontext
ab. Dort befinden sich unter anderem ihre lokalen Variablen
(Datenkontext) und die Aufrufkette (Callchain), die zur Aktivierung
der Methode geführt hat. Hat die Methode ihre Arbeit beendet, wird der
Kontext wieder zerstört.
Ruby erlaubt es nun, auf den gerade aktuell gültigen Kontext zuzugreifen und ihn vor der Zerstörung zu bewahren.
Die
Kernel-Methode
binding kapselt den Datenkontext in einer Instanz der Klasse
Binding und ermöglicht so die Weiterverwendung auch nach dem
Verlassen der Methode.
cmd = %q( puts "n=#{n}, s='#{s}'" )
def foo(n)
s = "foo mit n+1=#{n+1}"
binding
end
b1 = foo(12)
b2 = foo(14)
b1.class #-> Binding
eval(cmd, b1) #-> n=12, s='foo mit n+1=13'
eval(cmd, b2) #-> n=14, s='foo mit n+1=15'
Nachdem der Datenkontext der Methode foo jeweils in b1
und b2 konserviert wurde, wird die in cmd gespeicherte
puts-Anweisung im jeweiligen entsprechenden Kontext ausgeführt.
Die
Kernel-Methode caller liefert ein Array von Strings zurück,
welches die Aufrufkette beschreibt. Dieser Teil des Kontextes kann nur
zur Visualisierung verwendet werden, er steht also nach dem Verlassen
der Methode nicht mehr zur Verfügung.
def foo1; foo2; end
def foo2; puts caller(0); end
foo1 #-> -:2:in `foo2'
#-> -:1:in `foo1'
Von dem bisher Gesagten
ist es nur noch ein winziger Schritt zu den
Continuations. Wir haben eine Methode, um den Datenanteil des Kontextes
zu archivieren, und wir haben eine Methode, um die Aufrufkette zu
visualisieren. Was uns noch für eine Continuation fehlt, ist, einen
kompletten Kontext zwecks späterer Verwendung zu speichern.
Die Kernel-Methode callcc erzeugt eine
Instanz der Klasse Continuation. Diese Methode
erwartet einen einmalig auszuführenden Codeblock, dem die
Continuation als Argument übergeben wird.
callcc
kann mehr als einmal zurückkehren oder auch gar nicht! Wenn der von
callcc ausgeführte Block beendet wird, gibt callcc
den Wert seines letzten Ausdruckes zurück. Wird der konservierte
Kontext mittels call reaktiviert, wird der Block von
callcc nicht nochmals ausgeführt: callcc gibt nur die
Argumente zurück, die beim aktuellen Aufruf von call angegeben
wurden, nil falls keine Argumente verwendet wurden.
Der Kontext wird nahezu vollständig reaktiviert: Es wird
sozusagen eine Zeitreise zurück angetreten und das Programm findet
sich an der Stelle des callcc-Aufrufs wieder, allerdings schon
um einige Erfahrungen reicher.
Ein paar Beispiele machen dies deutlicher:
i = 1
callcc { |$cc| puts "Höre ich mehr?"}
puts "zum #{i}-ten "
i += 1
$cc.call if i <= 3
puts "... und verkauft!"
Effektiv werden folgende Anweisungen abgearbeitet:
i = 1
$cc = ... # Speicherung des Kontexts
puts "Höre ich mehr?"
puts "zum #{i}-ten " # i == 1
i += 1
puts "zum #{i}-ten " # i == 2
i += 1
puts "zum #{i}-ten " # i == 3
puts "... und verkauft!"
Wir sehen, dass die Continuation $cc die Bindung zur Variablen
i enthält, und nicht nur den zum Zeitpunkt der Definition
aktuellen Wert. Wird i an ein anderes Objekt gebunden, ist dies
auch im gespeicherten Kontext der Continuation sichtbar.
Nun
könnte man das obige Beispiel
auch gut mittels goto realisieren, wenn Ruby so etwas hätte.
Während aber goto üblicherweise nur eine Sprunganweisung ist,
wird mit Continuations ein vorheriger, vielleicht sogar bereits
abgeschlossener Kontext wiederhergestellt, in dem der Code ausgeführt
wird. Als Nebeneffekt findet man sich dann auch an einer anderen
Stelle im Code wieder - der Stelle, an der die Continuation kreiert
wurde. So kann man zum Beispiel sogar in eine eigentlich schon
abgeschlossene Schleife innerhalb einer beendeten Methode zurückspringen!
class Fixnum
def urlaubstage_in(ort, &tuwas)
puts "Koffer packen und ab nach #{ort}"
(1..self).each &tuwas
puts "jetzt geht's wieder heim"
end
end
# nur einmal nach Tahiti ...
5.urlaubstage_in 'Tahiti' do |tag|
puts " #{tag}. Tag: baden ..."
end
Wir können nun für fünf Tage Urlaub nehmen und an jedem Tag der
Tätigkeit nachgehen (Codeblock), die wir mögen. Leider ist der Urlaub
bald zu Ende und unser täglich Einerlei beginnt aufs Neue ... oder
nicht? Ruby macht es möglich,
immer wieder zum ersten Urlaubstag zurückzukehren:
urlaub = nil
5.urlaubstage_in 'Tahiti' do |tag|
callcc { |urlaub| } unless urlaub # (1)
puts "\t\t#{tag}. Tag: baden ..."
end
urlaub.call # (2)
Bemerkenswert ist, wie wir uns in einer Zeitschleife von (1) zu (2) zu (1) und so weiter bewegen. Gerade noch zu Hause angekommen, befinden wir uns in der nächsten Sekunde wieder auf Tahiti und genießen weitere fünf lange Tage am Strand, um wieder nach Hause zu fliegen und in der nächsten Sekunde ...
Mittels urlaub.call (2) gelangten wir nach (1) zurück, zu
dem Zeitpunkt, als der Block unter Ausführung von
Range#each stand, das seinerseits gerade von
Fixnum#urlaubstage_in ausgeführt wurde.
Abschließend
noch ein reales
und nützliches Beispiel - Koroutinen.
Einige Sprachen, wie z.B. Modula 2, kennen das Konzept von
Koroutinen. Als Koroutinen werden dort Unterprogramme (Procedure)
verstanden, die in einem eigenen Stackkontext ablaufen. Einmal
gestartet
, laufen sie
so lange, bis sie die Flusskontrolle freiwillig an eine andere
bestimmte Koroutine abgeben - eine Art kooperatives Multithreading.
Jedoch kann eine Koroutine die Kontrolle auch nur unter der Auflage abgeben,
dass sie im Falle
des
Eintreffens eines bestimmten Signals die Kontrolle sofort und bedingungslos
zurückerhält. Es sei denn, eine andere Koroutine hat
sich ebenfalls für dieses Signal angemeldet:
Wer zuletzt kommt, wird bedient.
Wozu Koroutinen, wenn Ruby doch Threads hat? Koroutinen verursachen weniger Synchronisationsaufwand, und wenn man will, kann man ein eigenes Multithreading mittels Koroutinen realisieren und dabei verschiedene Schedulerstrategien ausprobieren.
Da es um Speichern und Wiederherstellen von Kontexten geht, ist auch dieses Problem ein klarer Fall für Continuations. Für unser Beispiel wählen wir eine OO-Lösung und definieren eine Klasse Coroutine mit folgenden öffentlichen Methoden:
transfer_to sichert den Kontext
des Hauptprogramms und übergibt die Kontrolle
an die mitgegebene Koroutine.
transfer_to gibt die Kontrolle von einer
Koroutine an eine andere weiter. Dabei wird der aktuelle
Kontext im Empfänger gespeichert und der Kontext der
übergebenen Koroutine aktiviert.
suspend speichert den aktuellen Kontext im Empfänger
und stellt den Kontext des Hauptprogramms wieder her.
Den transfer_to-Methoden kann optional eine
Signalkennzeichnung mitgegeben werden, so dass beim
Auftreten dieses Signals der vorgenommene Transfer
rückgängig gemacht wird.
Und
hier die Implementation der
Klasse:
class Coroutine
class Error < Exception; end
@@main = nil
def initialize(&block)
@block = block || method(:run)
end
def transfer_to(to, signal=nil)
if signal
trap(signal) do
trap(signal, "DEFAULT")
to.transfer_to self
end
end
to.activate if self.saved?
end
def suspend
self.transfer_to @@main
end
def Coroutine.transfer_to(to, signal=nil)
@@main = Coroutine.new {}
@@main.transfer_to(to, signal)
end
protected
def activate
if @cont then
@cont.call false
else
@block.call
raise Error, "coroutine block was left!"
end
end
def saved?
callcc { |@cont| true }
end
end
Die eigentliche Arbeit wird von den beiden als protected
deklarierten Methoden activate und saved? geleistet,
die von den transfer_to-Methoden verwendet werden.
Mit activate wird der gespeicherte Kontext der Koroutine reaktiviert
oder gestartet. saved? speichert den aktuellen Kontext der Koroutine und
kehrt mit true zurück, wenn die Konservierung erfolgreich war.
saved? kehrt bei Reaktivierungen mit false zurück, da
keine Speicherung des Kontextes erfolgte.
Es gibt zwei Möglichkeiten, eine Koroutine zu kreieren:
Coroutine.new wird ein Block beigestellt.
Coroutine, welche
dann die Methode run implementieren muss. Diese Methode
wird bei der ersten Aktivierung gestartet.
Hier
ein Beispiel zweier Koroutinen, die
sich gegenseitig aktivieren, bis die Sequenz mit Strg+C abgebrochen wird.
require "coroutine"
class Foo < Coroutine
attr_accessor :next
def initialize(msg)
super() # Wichtig!
@msg = msg
end
def run
loop do
puts @msg
transfer_to @next
end
end
end
foo = Foo.new("Foo")
bar = Coroutine.new do
loop { puts "bar"; bar.transfer_to foo }
end
foo.next = bar
Coroutine.transfer_to foo, "SIGINT"
puts "User requested abort!"
Und
noch ein weiteres Beispiel, in dem wir ein
einfaches Multithreading implementieren. Koroutinen werden kreiert und bei
einem Scheduler angemeldet, der bei jedem Strg+C zur nächsten Koroutine
umschaltet. Der Scheduler bricht ab, nachdem zweimal durch alle Koroutinen
durchgeschaltet wurde.
require "coroutine"
class Scheduler
def initialize
@tasks = Array.new
end
def add(&block)
@tasks << Coroutine.new(&block)
end
def start
2.times do
@tasks.each do |task|
Coroutine.transfer_to task, "SIGINT"
end
end
end
end
sched = Scheduler.new
sched.add { loop { puts "Foo" } }
sched.add { loop { puts "Bar" } }
sched.add { loop { puts "FooBar" } }
sched.start
callcc