Rolf B: Funtion in Funktion Unterschiede Closures

Beitrag lesen

problematische Seite

Hallo Henry,

also grundsätzlich hat die Ulrike aus Moers schon recht. Closures sind Mehraufwand. Aber schauen wir uns das mal näher an.

Im folgenden Beispiel gibt es drei Scopes:

  • den globalen Scope
  • den Scope von createAdder
  • den Scope von doAdd
function createAdder(x) {
   let garbage = new Array(1000);
   garbage.fill(4711);

   function doAdd(y) {
      return x+y;
   }
   return doAdd;
}

let add7 = createAdder(7);
let add11 = createAdder(11);
let add99 = createAdder(99);

console.log("4+7 = " + add7(4));

Jeder dieser Scopes wird beim Aufruf seines Scope-Trägers erzeugt und lebt so lange, wie eine lebendige Referenz auf ihn existiert. Bei globalen Scope ist der Träger die JS-Engine selbst. Der Scope von createAdder wird beim Aufruf der Funktion erzeugt. Er enthält vier Einträge:

  • eine Referenz auf seinen Besitzer, das Funktionsobject createAdder
  • den Parameter x
  • das Array garbage
  • ein Funktionsobjekt mit dem Namen doAdd.

Zu beachten ist hier, dass bei jedem Aufruf von createAdder das doAdd Funktionsobjekt neu entsteht. Die JS-Engine mag es fertigbringen, den ausführbaren Code für doAdd herauszufaktorieren und nur einmal zu speichern, aber das Funktionsobjekt an sich entsteht jedes Mal neu. Deswegen kann man createAdder mehrfach aufrufen und drei Funktionen bekommen, die 7, 11 und 99 addieren.

Und deswegen hat Ulrike recht, wenn sie sagt, dass das langsamer ist als eine statische Funktion.

Wenn das Funktionsobjekt für doAdd angelegt wird, speichert es sich eine Referenz auf den Scope, in dem es erzeugt wurde. Und wenn createAdder das doAdd Objekt zurückgibt, dann nimmt es diese Scope-Referenz mit. Eine Referenz auf den ganzen Scope, nicht nur auf den Parameter x!

Die Rückgabe von doAdd aus createAdder bewirkt, dass der Scope des createAdder Aufrufs nach Ende des Funktionsaufrufs noch eine lebendige Referenz hat. Deshalb bleibt er erhalten und wird nicht vom Garbage Collector entsorgt. Rein formal bleibt deshalb auch das garbage-Array erhalten, und belegt Speicher. Eine gute JS-Engine könnte feststellen, dass nur x benötigt wird, und den Scope teilen. Ich weiß aber nicht, ob Spidermonkey oder V8 dazu im Stande sind.

In PHP ist das übrigens anders gelöst. Wenn ich da eine Closure bilden will, muss ich das explizit mit der use-Klausel tun. Auf diese Weise werden nur die Daten in der Closure gebunden, die unbedingt gebraucht werden.

Wird nun beispielsweise add7 aufgerufen, so wird wieder ein neuer Scope erzeugt. Er enthält:

  • eine Referenz auf seinen Besitzer, dass Funktionsobjekt in add7
  • den Parameter y

Beim Ausführen der Funktion wird die Variable x benötigt. Und nun wird es spannend, denn hier besteht viel Optimierungspotenzial.

Ein dummer Interpreter würde nun die Scope-Chain nach einer Variablen mit dem Namen "x" absuchen. Er beginnt im aktuellen Scope, nicht gefunden. Er geht zum Parent-Scope, was zwei Schritte braucht: (1) hol Dir Deinen Besitzer und (2) frag deinen Besitzer nach dem Scope, in dem er liegt. Im Parent-Scope wird nun wieder eine Variable mit dem Namen "x" gesucht, gefunden.

Und deswegen hat Ulrike recht, wenn sie sagt, dass der Zugriff auf eine Variable im Elternscope langsamer ist als auf eine Variable im eigenen Scope. Sie hat auch recht mit dem Memory Leaks. Ein Scope, der nicht entsorgt wird, belegt Speicher. Eine Eventhandler-Funktion, an der eine fette Scope-Kette hängt, verhindert ggf. das Abräumen einer Menge von nicht mehr benutzten Daten. Man muss da schon aufpassen.

Aber um wieviel langsamer ist das?

Ein klügerer Interpreter compiliert seinen Code zunächst. Und sei es auch nur in einen Bytecode, wie PHP. V8 dagegen enthält einen JIT-Compiler, der echten Maschinencode erzeugt. Ein klügerer Interpreter stellt da schon fest, dass x nicht im eigenen Scope liegt. Da die Schachtelung der Scopes rein lexikalisch erfolgt, kann er beim Compileren bereits feststellen, dass y im aktuellen Scope (Scope 0) und x im direkten Elternscope (Scope +1) liegen, und kann Bytecode generieren, der x direkt aus Scope +1 und y aus Scope 0 holt. Er kann bei längeren Scope-Ketten auch Code generieren, der die Referenzen auf diese Scopes zu Beginn der Funktionsausführung ermittelt und sie im Scope 0 ablegt (und er kann es lassen, wenn er feststellt, dass sich das nicht lohnt). Ein kluger Interpreter muss auch nicht namentlich nach Variablen suchen. Statt dessen weiß er, dass mit let, const und var ein bestimmter Satz von Variablen angelegt wurde. Er weiß, dass x im Scope +1 der zweite Eintrag ist und dass y im Scope 0 ebenfalls der zweite Eintrag ist. Er generiert also solchen Bytecode:

  • push (+1, 2)
  • push (0, 2)
  • add

Heißt (diese Bytecodes arbeiten stackorientiert):

  • Schiebe die 2. Variable aus Scope +1 auf den Stack
  • Schiebe die 2. Variable aus Scope 0 auf den Stack
  • Addiere die beiden obersten Werte auf dem Stack

Und das ist überhaupt nicht mehr langsam. Es ist langsamer, aber nur um ein paar Nanosekunden. Das fällt auf, wenn solcher Code in Schleifen mit hoher Ausführungszahl abläuft. In normalem eventverarbeitenden Code, was JavaScript im Browser ist, merkt das kein Mensch. Das sieht anders aus, wenn man mit JavaScript hochperformanten Code erzeugen muss, z.B. für einen stark belasteten node.js Server. In Code-Hotspots Closures einzusetzen kann da bedeuten, dass der Server nicht mehr 1000 Requests pro Sekunde schafft, sondern nur noch 900 oder noch weniger. Je nachdem, wie hot der spot ist und wieviel I/O der Code nebenbei noch macht.

Mit dem Memory Leaks ist es auch im Browser kritisch. Mein garbage-Array wäre ein echtes Problem. Oder anders: Wenn ich 7 Funktionen ineinander schachtele und ganz tief drinnen fleißig Eventhandler registriere, baumeln da eine Menge Scopes dran.

Rolf

--
sumpsi - posui - obstruxi