Deep JavaScript
Bitte unterstützen Sie dieses Buch: kaufen Sie es oder spenden Sie
(Werbung, bitte nicht blockieren.)

4 Umgebungen: hinter den Kulissen von Variablen



In diesem Kapitel untersuchen wir genauer, wie die ECMAScript-Sprachspezifikation mit Variablen umgeht.

4.1 Umgebung: Datenstruktur zur Verwaltung von Variablen

Eine Umgebung ist die Datenstruktur, die die ECMAScript-Spezifikation zur Verwaltung von Variablen verwendet. Es handelt sich um ein Wörterbuch, dessen Schlüssel Variablennamen und dessen Werte die Werte dieser Variablen sind. Jeder Scope hat seine zugehörige Umgebung. Umgebungen müssen die folgenden Phänomene im Zusammenhang mit Variablen unterstützen können:

Wir werden anhand von Beispielen veranschaulichen, wie dies für jedes Phänomen geschieht.

4.2 Rekursion über Umgebungen

Wir beginnen mit der Rekursion. Betrachten Sie den folgenden Code:

function f(x) {
  return x * 2;
}
function g(y) {
  const tmp = y + 1;
  return f(tmp);
}
assert.equal(g(3), 8);

Für jeden Funktionsaufruf benötigen Sie frischen Speicherplatz für die Variablen (Parameter und lokale Variablen) der aufgerufenen Funktion. Dies wird über einen Stack von sogenannten Ausführungskontexten verwaltet, die (für den Zweck dieses Kapitels) Verweise auf Umgebungen sind. Die Umgebungen selbst werden auf dem Heap gespeichert. Das ist notwendig, weil sie gelegentlich länger leben, nachdem die Ausführung ihre Scopes verlassen hat (das sehen wir bei der Untersuchung von Closures). Daher können sie selbst nicht über einen Stack verwaltet werden.

4.2.1 Ausführung des Codes

Bei der Ausführung des Codes machen wir folgende Pausen:

function f(x) {
  // Pause 3
  return x * 2;
}
function g(y) {
  const tmp = y + 1;
  // Pause 2
  return f(tmp);
}
// Pause 1
assert.equal(g(3), 8);

Dies ist, was passiert

Abbildung 1: Rekursion, Pause 1 – vor dem Aufruf von g(): Der Ausführungskontext-Stack hat einen Eintrag, der auf die Top-Level-Umgebung verweist. In dieser Umgebung gibt es zwei Einträge; einen für f() und einen für g().
Abbildung 2: Rekursion, Pause 2 – während der Ausführung von g(): Die Spitze des Ausführungskontext-Stacks verweist auf die für g() erstellte Umgebung. Diese Umgebung enthält Einträge für das Argument y und für die lokale Variable tmp.
Abbildung 3: Rekursion, Pause 3 – während der Ausführung von f(): Der oberste Ausführungskontext verweist nun auf die Umgebung für f().

4.3 Verschachtelte Scopes über Umgebungen

Wir verwenden den folgenden Code, um zu untersuchen, wie verschachtelte Scopes über Umgebungen implementiert werden.

function f(x) {
  function square() {
    const result = x * x;
    return result;
  }
  return square();
}
assert.equal(f(6), 36);

Hier haben wir drei verschachtelte Scopes: den Top-Level-Scope, den Scope von f() und den Scope von square(). Beobachtungen:

Daher verweist die Umgebung jedes Scopes über ein Feld namens outer auf die Umgebung des umgebenden Scopes. Wenn wir den Wert einer Variablen nachschlagen, suchen wir zuerst nach ihrem Namen in der aktuellen Umgebung, dann in der äußeren Umgebung, dann in der äußeren Umgebung ihrer äußeren Umgebung usw. Die gesamte Kette von äußeren Umgebungen enthält alle Variablen, auf die aktuell zugegriffen werden kann (abzüglich überschatteter Variablen).

Wenn Sie einen Funktionsaufruf tätigen, erstellen Sie eine neue Umgebung. Die äußere Umgebung dieser Umgebung ist die Umgebung, in der die Funktion erstellt wurde. Um bei der Einrichtung des Feldes outer von über Funktionsaufrufe erstellten Umgebungen zu helfen, hat jede Funktion eine interne Eigenschaft namens [[Scope]], die auf ihre "Geburtsumgebung" verweist.

4.3.1 Ausführung des Codes

Dies sind die Pausen, die wir bei der Ausführung des Codes machen.

function f(x) {
  function square() {
    const result = x * x;
    // Pause 3
    return result;
  }
  // Pause 2
  return square();
}
// Pause 1
assert.equal(f(6), 36);

Dies ist, was passiert

Abbildung 4: Verschachtelte Scopes, Pause 1 – vor dem Aufruf von f(): Die Top-Level-Umgebung hat einen einzelnen Eintrag für f(). Die Geburtsumgebung von f() ist die Top-Level-Umgebung. Daher verweist f's [[Scope]] darauf.
Abbildung 5: Verschachtelte Scopes, Pause 2 – während der Ausführung von f(): Es gibt jetzt eine Umgebung für den Funktionsaufruf f(6). Die äußere Umgebung dieser Umgebung ist die Geburtsumgebung von f() (die Top-Level-Umgebung an Index 0). Wir sehen, dass das Feld outer auf den Wert von f's [[Scope]] gesetzt wurde. Darüber hinaus ist das [[Scope]] der neuen Funktion square() die gerade erstellte Umgebung.
Abbildung 6: Verschachtelte Scopes, Pause 3 – während der Ausführung von square(): Das vorherige Muster wurde wiederholt: das outer der neuesten Umgebung wurde über das [[Scope]] der gerade aufgerufenen Funktion eingerichtet. Die Kette von Scopes, die über outer erstellt wurden, enthält alle Variablen, die gerade aktiv sind. Wir können zum Beispiel auf result, square und f zugreifen, wenn wir möchten. Umgebungen spiegeln zwei Aspekte von Variablen wider. Erstens spiegelt die Kette von äußeren Umgebungen die verschachtelten statischen Scopes wider. Zweitens spiegelt der Stack von Ausführungskontexten wider, welche Funktionsaufrufe dynamisch getätigt wurden.

4.4 Closures und Umgebungen

Um zu sehen, wie Umgebungen zur Implementierung von Closures verwendet werden, verwenden wir das folgende Beispiel:

function add(x) {
  return (y) => { // (A)
    return x + y;
  };
}
assert.equal(add(3)(1), 4); // (B)

Was passiert hier? add() ist eine Funktion, die eine Funktion zurückgibt. Wenn wir den verschachtelten Funktionsaufruf add(3)(1) in Zeile B machen, ist der erste Parameter für add(), der zweite Parameter für die von ihr zurückgegebene Funktion. Das funktioniert, weil die in Zeile A erstellte Funktion die Verbindung zu ihrem Geburts-Scope nicht verliert, wenn sie diesen Scope verlässt. Die zugehörige Umgebung wird durch diese Verbindung am Leben gehalten und die Funktion hat weiterhin Zugriff auf die Variable x in dieser Umgebung (x ist innerhalb der Funktion frei).

Diese verschachtelte Art, add() aufzurufen, hat einen Vorteil: Wenn Sie nur den ersten Funktionsaufruf tätigen, erhalten Sie eine Version von add(), bei der der Parameter x bereits ausgefüllt ist.

const plus2 = add(2);
assert.equal(plus2(5), 7);

Die Umwandlung einer Funktion mit zwei Parametern in zwei verschachtelte Funktionen mit jeweils einem Parameter wird als Currying bezeichnet. add() ist eine gecurriede Funktion.

Nur einige der Parameter einer Funktion auszufüllen, wird als partielle Anwendung bezeichnet (die Funktion wurde noch nicht vollständig angewendet). Die Methode .bind() von Funktionen führt eine partielle Anwendung durch. Im vorherigen Beispiel sehen wir, dass die partielle Anwendung einfach ist, wenn eine Funktion gecurried ist.

4.4.0.1 Ausführung des Codes

Während wir den folgenden Code ausführen, machen wir drei Pausen:

function add(x) {
  return (y) => {
    // Pause 3: plus2(5)
    return x + y;
  }; // Pause 1: add(2)
}
const plus2 = add(2);
// Pause 2
assert.equal(plus2(5), 7);

Dies ist, was passiert

Abbildung 7: Closures, Pause 1 – während der Ausführung von add(2): Wir sehen, dass die von add() zurückgegebene Funktion bereits existiert (siehe unten rechts) und dass sie über ihre interne Eigenschaft [[Scope]] auf ihre Geburtsumgebung verweist. Beachten Sie, dass sich plus2 noch in seiner temporären toten Zone befindet und uninitialisiert ist.
Abbildung 8: Closures, Pause 2 – nach der Ausführung von add(2): plus2 verweist nun auf die von add(2) zurückgegebene Funktion. Diese Funktion hält ihre Geburtsumgebung (die Umgebung von add(2)) über ihr [[Scope]] am Leben.
Abbildung 9: Closures, Pause 3 – während der Ausführung von plus2(5): Das [[Scope]] von plus2 wird verwendet, um das outer der neuen Umgebung einzurichten. So erhält die aktuelle Funktion Zugriff auf x.