musikinformatik-wise-24.org
Evaluationszeit (compile-time vs. run-time)
Für Common Lisp Systeme ich charakteristisch, dass während einer Session in einem laufenden Programm einzelne Komponenten (Funktionen, etc.) des Programms neu kompiliert werden. Das ist in starkem Kontrast zu den meisten anderen Programmiersprachen, die den Kompiliervorgang komplett abschließen, bevor man das Programm startet. Änderungen an Bestandteilen des Programms müssen dann in einem separaten Prozess neu kompiliert werden und führen zu einem neuen, ausführbaren Programm, das dann komplett neu gestartet werden muss1.
Diese Eigenschaft von Common Lisp hat zur Konsequenz, dass man zwischen verschiedenen Zeiten unterscheiden muss: Der sogenannten compile-time und der run-time. Es ist sehr wichtig, die Konsequenzen davon zu verstehen, da sie selbst bei erfahreneren Lisp Programmierer*Innen immer wieder zu Irritationen führen.
Dazu einige Beispiele, die die Unterschiede verdeutlichen. Der Zeitpunkt, an dem die untenstehenden Ausdrücke evaluiert oder kompiliert werden, ist die compile-time. Die Ausdrücke schreiben Befehle in den scheduler, deren Ausführung mit zeitlicher Verzögerung erfolgt. Der Zeitpunkt, zu dem die Befehle dann ausgeführt werden, ist die run-time.
In dem folgenden Ausdruck wird zur compile-time die komplette Iteration des loops durchgeführt. Dies kann man an der Ausgabe in der REPL sehen, wenn man ihn evaluiert:
(loop for x below 10 do (at (+ (now) x) #'output (progn (msg :warn "~,2f: ~a" (now) x) (new sfz :keynum (+ 60 x))))) ;; => nil ;; Direkte Ausgabe in der REPL: ;; warn: 9545.49: 0 ;; warn: 9545.49: 1 ;; warn: 9545.49: 2 ;; warn: 9545.49: 3 ;; warn: 9545.49: 4 ;; warn: 9545.49: 5 ;; warn: 9545.49: 6 ;; warn: 9545.49: 7 ;; warn: 9545.49: 8 ;; warn: 9545.49: 9
Wie man an den Zeiten sehen kann, die hinter dem warn: stehen,
werden sämtliche Zeilen in der repl direkt zur compile-time (dem
Zeitpunkt, den (now) zur compile-time hat) ausgegeben, während
die Töne entsprechend der Zeitpunkte, die das erste Argument von
#'at
zur compile-time bilden (also (+ (now) x)), gespielt
werden.
Mit anderen Worten werden die Argumente der Funktion #'output
zur
compile-time evaluiert und das evaluierte Ergebnis der (progn…)
Form direkt in den Scheduler als Argument für den zeitverzögerten
Aufruf der Funktion #'output
geschrieben.
Anders verhält es sich im folgenden Beispiel:
(loop for x below 10 do (at (+ (now) x) (lambda () (msg :warn "~,2f: ~a~%" (now) x) (output (new sfz :keynum (+ 60 x)))))) ;; => nil ;; Zeitlich um jeweils 1 Sekunde versetzte Ausgabe in der REPL: ;; warn: 10563.75: 10 ;; warn: 10564.75: 10 ;; warn: 10565.75: 10 ;; warn: 10566.75: 10 ;; warn: 10567.75: 10 ;; warn: 10568.75: 10 ;; warn: 10569.75: 10 ;; warn: 10570.75: 10 ;; warn: 10571.75: 10 ;; warn: 10572.75: 10
Zur compile-time wird der Lambda Ausdruck kompiliert, aber nicht ausgeführt. Die kompilierte Funktion wird zur compile-time in den Scheduler geschrieben und zeitverzögert zur run-time ausgeführt.
Daher passiert die Ausgabe der #'msg
Funktion in der REPL zur
run-time, wie man am Zeitpunkt der Ausgabe in der REPL, sowie den
ausgegebenen Zeiten hinter dem warn:
erkennen kann.
Irritierend ist, dass die Tonhöhe im Unterschied zum vorhergehenden Beispiel konstant auf der Midi Keynum 70 bleibt und in der REPL bei jeder Zeile am Ende die Zahl 10 ausgegeben wird, obwohl x in dem loop eigentlich von 0-9 iteriert.
Der Grund dafür besteht darin, dass sich der Lambda Ausdruck, der zur run-time evaluiert wird, auf die Variable x bezieht. Die Variable x wurde zur compile-time iteriert und ist am Ende der Iteration auf dem Wert 10, was die Abbruchbedingung des loops bildete. Daher ist der Wert von x zur run-time 10 und er verändert sich auch nicht mehr.
Wenn man dafür sorgen möchte, dass der Wert von zur run-time dem Wert entspricht, den x zur compile-time in der Iteration hatte, gibt es verschiedene Möglichkeiten:
Eine Möglichkeit besteht darin, den Wert für x als Argument für den Lambda Ausdruck zu verwenden und diesen Wert bereits zur compile-time zu evaluieren:
(loop for x below 10 do (at (+ (now) x) (lambda (x) (output (new sfz :keynum (+ 60 x)))) x))
Diese Lösung ist ähnlich zum ersten Beispiel, bei dem der
#'output
Funktion die Instanz des sfz Events übergeben wird, die
zur compile-time mit den "richtigen" keynums erzeugt wird.
Der Unterschied zum ersten Beispiel besteht darin, dass das sfz event erst zur run-time erzeugt wird und die keynum über das Argument des Lambda Ausdrucks "richtig" evaluiert wird.
Eine andere Möglichkeit, das Problem zu lösen, besteht darin, eine neue Variable zu verwenden, die zur compile Zeit in der Iteration des loops an den momentanen Wert von x gebunden wird:
(loop for x below 10 do (let ((y x)) ;; Bindung einer neuen Variable y zur compile-time (at (+ (now) x) (lambda () (output (new sfz :keynum (+ 60 y)))))))
HINWEIS: In dem Beispiel könnte man im Ausdruck des ersten
Arguments von #'at
auch das Symbol y verwenden. Das Ergebnis
wäre gleich, da dieses Argument zur compile-time ausgewertet
wird. Lediglich innerhalb des Lambda Ausdrucks muss das y
verwendet werden, damit dessen zur compile-time gebundener Wert zur
run-time vorliegt.
Man beachte, dass bei jeder Iteration im loop eine neue Lambda Funktion erzeugt wird, die verschieden von allen anderen Lambda Ausdrücken ist, die in der Iteration erzeugt werden. Diese Funktionen haben alle einen individuellen lexikalischen Kontext, der mit dem (let…) etaliert wird. Das bedeutet, auch das Symbol y ist jedesmal ein anderes.
Es ist in Lisp nicht ungewöhnlich, für den neu etablierten Kontext sogar den gleichen Symbolnamen zu verwenden. Das funktioniert, da das neue Symbol die Bindung des alten Symbols für den jeweiligen lexikalischen Kontext überschreibt. Daher ist das folgende Beispiel äquivalent zum vorhergehenden:
(loop for x below 10 do (let ((x x)) ;; Bindung einer neuen Variable x zur compile-time (at (+ (now) x) (lambda () (output (new sfz :keynum (+ 60 x)))))))
Die beiden letzten Besipiele sind sogenannte Closures, die in Lisp und zunehmend auch in moderneren Sprachen eine große Rolle spielen. Siehe hierzu auch die Exkursion zu Clousers in der Clamps Dokumentation bei cl-midictl->MIDI Controllers->Excursion: Closures.
Fußnoten:
Das Konzept dynamischer Kompilierung ist zwar mittlerweile auch in andere Sprachen, wie bespielsweise Python, aufgenommen worden, dennoch ist die Programmierpraxis sehr häufig unverändert.
Created: 2025-02-12 Mi 20:35