molily Mastodon Twitter

Die Grundlagen von JavaScript:
Zweite Ausgabe

Deutsche Übersetzung des Artikels JavaScript. The Core: 2nd Edition
von Dmitry Soshnikov

Dies ist die zweite Ausgabe des Übersichtsartikels JavaScript. The Core, der sich der Programmiersprache ECMAScript und den Kernkomponenten ihrer Laufzeitumgebung widmet.

Die Zielgruppe dieses Artikels sind erfahrene ProgrammiererInnen und ExpertInnen.

Die erste Ausgabe dieses Artikels (deutsche Übersetzung) behandelt die allgemeinen Aspekte der Sprache JavaScript unter Bezugnahme auf Konzepte der veralteten ECMAScript-3-Spezifikation mit ein paar Verweisen auf Änderungen in ECMAScript 5 und 6 (auch ES2015 genannt).

Mit ES2015 änderten sich die Beschreibungen und Strukturen mancher Kernkomponenten. Neue Modelle wurden eingeführt. In dieser Ausgabe richten wir unser Augenmerk auf die neueren Konzepte und die aktualisierte Terminologie. Dabei behalten wir die fundamentalen JavaScript-Strukturen bei, die durch die Versionen gleich geblieben sind.

Dieser Artikel deckt die Laufzeitumgebung von ECMAScript 2017 und neuer ab.

Hinweis: Die neueste Version der ECMAScript-Spezifikation finden Sie auf der Website der TC-39-Arbeitsgruppe.

Wir beginnen die Abhandlung mit dem Konzept des Objekts, denn es ist fundamental für ECMAScript.

Objekt

ECMAScript ist eine objektorientierte Programmiersprache mit einem prototyp-basierten Aufbau. Dabei ist das Konzept des Objekts die grundlegende Abstraktion.

Definition 1: Objekt: Ein Objekt ist eine Sammlung von Eigenschaften. Es besitzt ein einziges Prototyp-Objekt. Der Prototyp ist entweder ein Objekt oder der Wert null.

Schauen wir uns ein einfaches Beispielobjekt an. Der Prototyp eines Objekts ist in der internen Eigenschaft [[Prototype]] gespeichert. Beim Programmieren ist dieser Verweis über die Eigenschaft __proto__ zugänglich.

Der Beispielcode:

let point = {
  x: 10,
  y: 20,
};

Dieses Objekt ist so aufgebaut, dass es zwei ausdrückliche, eigene Eigenschaften und eine implizite Eigenschaft besitzt. Letztere ist der Verweis auf den Prototyp von point:

Abbildung 1. Ein einfaches Objekt mit einem Prototyp.

Abbildung 1. Ein einfaches Objekt mit einem Prototyp.

Legende
some propertieseinige Eigenschaften

Hinweis: Objekte können auch Symbole speichern. Sie finden mehr Informationen zu Symbolen in dieser Dokumentation.

Prototypen-Objekte dienen dazu, um Vererbung mithilfe der dynamischen Bindung (dynamic dispatch) umzusetzen. Schauen wir uns das Konzept der Prototypen-Kette an, um diesen Mechanismus zu verstehen.

Prototyp

Jedes Objekt bekommt in dem Moment, in dem erzeugt wird, einen Prototypen zugewiesen. Falls der Prototyp nicht ausdrücklich gesetzt wird, so erbt das Objekt vom Standard-Prototypen.

Definition 2: Prototyp: Ein Prototyp ist ein Stellvertreter-Objekt, an das delegiert wird. Mithilfe dieser Delegation wird eine Prototypen-basierte Vererbung umgesetzt.

Der Prototyp kann entweder ausdrücklich über die Eigenschaft __proto__ gesetzt werden oder über die Methode Object.create:

// Basis-Objekt.
let point = {
  x: 10,
  y: 20,
};

// Erbe vom Objekt `point`.
let point3D = {
  z: 30,
  __proto__: point,
};

console.log(
  point3D.x, // 10, vererbte Eigenschaft
  point3D.y, // 20, vererbte Eigenschaft
  point3D.z  // 30, eigene Eigenschaft
);

Hinweis: Standardmäßig erben Objekte von Object.prototype.

Jedes Objekt kann als Prototyp eines anderen Objekts dienen. Der Prototyp wiederum kann einen eigenen Prototyp haben. Wenn ein Prototyp ein Verweis auf einen weiteren Prototyp hat und dieser nicht null ist, so spricht man von einer Prototypen-Kette.

Definition 3: Prototypen-Kette: Eine Prototypen-Kette ist eine endliche Kette von Objekten. Sie dient dazu, Vererbung und gemeinsame Eigenschaften umzusetzen.

Figure 2. Eine Prototypen-Kette.

Abbildung 2. Eine Prototypen-Kette.

Anmerkung der Übersetzung: Die Illustration zeigt die Prototypen-Kette für den obigen Beispielcode. Die Kette beginnt mit dem Objekt point3D auf der linken Seite. Es hat das Objekt point als Prototypen, daher zeigt ein Verweis-Pfeil darauf. point wiederum besitzt den Standard-Prototypen Object.prototype, daher ein weiterer Verweis-Pfeil. Object.prototype hat schließlich keinen Prototypen mehr (null).

Die Funktionsweise ist sehr einfach: Wenn eine Eigenschaft am Objekt selbst nicht gefunden wird, so wird versucht, sie am Prototyp aufzulösen, dann an dessen Prototyp und so weiter – bis die gesamte Prototypen-Kette abgesucht wurde.

Der Fachbegriff für diesen Mechanismus lautet dynamische Bindung oder auch Delegation.

Definition 4: Delegation: Ein Mechanismus zur Auflösung einer Eigenschaft entlang der Vererbungskette. Dieser Prozess findet zur Laufzeit statt, daher wird er auch dynamische Bindung (dynamic dispatch) genannt.

Hinweis: Bei der statischen Bindung werden Verweise schon zur Übersetzungszeit (zum Zeitpunkt des Kompilierens) aufgelöst. Bei der dynamischen Bindung hingegen werden Verweise erst zur Laufzeit aufgelöst.

Wenn eine Eigenschaft in der Prototypen-Kette nicht gefunden wurde, wird der Wert undefined zurückgegeben:

// Ein »leeres« Objekt.
let empty = {};

console.log(

  // Eine Funktion vom Standard-Prototypen
  empty.toString,

  // undefined
  empty.x,

);

Wie wir sehen, ist ein einfaches Objekt tatsächlich niemals leer – es erbt immer etwas von Object.prototype. Wenn wir ein Assoziatives Datenfeld (auch Map oder Dictionary genannt) benötigen, so müssen wir ein Objekt ohne Prototyp erzeugen. Dazu müssen wir ausdrücklich den Prototyp auf null setzen:

// Dieses Objekt erbt keine Eigenschaften.
let dict = Object.create(null);

console.log(dict.toString); // undefined

Die dynamische Bindung ermöglicht das uneingeschränkte Ändern der Vererbungskette. Sie erlaubt es, das Objekt zu ändern, an das delegiert wird:

let protoA = {x: 10};
let protoB = {x: 20};

// Dies ist gleichwertig mit `let objectC = {__proto__: protoA};`:
let objectC = Object.create(protoA);
console.log(objectC.x); // 10

// Ändern des Prototyps (das Objekt, an das delegiert wird):
Object.setPrototypeOf(objectC, protoB);
console.log(objectC.x); // 20

Hinweis: Die Eigenschaft __proto__ ist zwar mittlerweile standardisiert und eignet sich besser für Erklärungen wie die obige. In der Praxis sollten Sie jedoch die API-Methoden verwenden, die für die Änderung des Prototyps konzipiert wurden. Das sind Object.create, Object.getPrototypeOf, Object.setPrototypeOf und ähnliche am Reflect-Modul.

Am Beispiel von Object.prototype zeigt sich, dass sich viele Objekte denselben Prototyp teilen können. Auf diesem Prinzip basiert die klassen-basierte Vererbung in ECMAScript. Schauen wir uns anhand eines Beispiels unter die Haube der Abstraktion der »Klasse«.

Klasse

Wenn mehrere Objekte denselben Zustand und dasselbe Verhalten haben, so bilden sie eine Klassifikation.

Definition 5: Klasse: Eine Klasse ist eine formale und abstrakte Menge, die den anfänglichen Zustand sowie das Verhalten ihrer Objekte definiert.

Wenn wir möchten, dass mehrere Objekte vom selben Prototyp erben, so könnten wir natürlich diesen Prototyp erzeugen und dann neue Objekte ausdrücklich von ihm erben lassen:

// Allgemeiner Prototyp für alle Buchstaben.
let letter = {
  getNumber() {
    return this.number;
  }
};

let a = {number: 1, __proto__: letter};
let b = {number: 2, __proto__: letter};
// ...
let z = {number: 26, __proto__: letter};

console.log(
  a.getNumber(), // 1
  b.getNumber(), // 2
  z.getNumber(), // 26
);

Wir können die Beziehungen zwischen den Objekten folgendermaßen veranschaulichen:

Abbildung 3. Ein geteilter Prototyp.

Abbildung 3. Ein geteilter Prototyp.

Legende
built-insEingebaute Eigenschaften der ECMAScript-Kernobjekte

Allerdings ist das offensichtlich mühsam. Die Abstraktion der Klasse hilft uns hier weiter, denn sie dient genau einem Zweck: Sie ist »Syntaxzucker« für das komfortable Erzeugen mehrerer solcher Objekte. (»Syntaxzucker« ist ein Sprachkonstrukt, das semantisch dasselbe tut, allerdings in einer schöneren syntaktischen Form.)

class Letter {
  constructor(number) {
    this.number = number;
  }

  getNumber() {
    return this.number;
  }
}

let a = new Letter(1);
let b = new Letter(2);
// ...
let z = new Letter(26);

console.log(
  a.getNumber(), // 1
  b.getNumber(), // 2
  z.getNumber(), // 26
);

Hinweis: Klassen-basierte Vererbung in ECMAScript ist auf Basis der Prototyp-basierten Delegation umgesetzt.

Hinweis: Eine »Klasse« ist lediglich eine theoretische Abstraktion. Technisch gesehen kann sie mittels statischer Bindung umgesetzt werden, wie in Java oder C++, oder mittels dynamischer Bindung (Delegation), wie in JavaScript, Python, Ruby usw.

Intern wird eine »Klasse« durch zwei Bestandteile abgebildet: eine Konstruktorfunktion und ein Prototyp. Die Konstruktorfunktion erzeugt Objekte. Außerdem setzt sie automatisch den Prototyp der von ihr erzeugten Instanzen. Dieser Prototyp ist in der Eigenschaft <Konstruktorfunktion>.prototype gespeichert.

Definition 6: Konstruktor: Ein Konstruktor ist eine Funktion zum Erzeugen von Instanzen. Sie setzt automatisch deren Prototyp.

Es ist durchaus möglich, eine Konstruktorfunktion direkt zu definieren. Bevor die Klassen-Abstraktion eingeführt wurde, haben JavaScript-EntwicklerInnen dies in Ermangelung einer besseren Alternative lange getan. Wir finden immer noch eine Menge dieser Hinterlassenschaften im Internet verstreut:

function Letter(number) {
  this.number = number;
}

Letter.prototype.getNumber = function() {
  return this.number;
};

let a = new Letter(1);
let b = new Letter(2);
// ...
let z = new Letter(26);

console.log(
  a.getNumber(), // 1
  b.getNumber(), // 2
  z.getNumber(), // 26
);

Einen losgelösten Konstruktor so zu notieren war noch recht einfach. Eine Vererbungsstruktur hingegen benötigte einen langen Codebaustein. Heute ist dieser Code versteckt und nur noch ein Implementierungsdetail. Doch unter der Haube passiert nach wie vor dasselbe, wenn wir in JavaScript eine Klasse erzeugen.

Hinweis: Konstruktorfunktionen sind ein Implementierungsdetail der Klassen-basierten Vererbung.

Schauen wir uns die Beziehungen zwischen den Objekten und ihrer Klasse an:

Abbildung 4. Die Beziehungen zwischen Objekten und dem Konstruktor.

Abbildung 4. Die Beziehungen zwischen Objekten und dem Konstruktor.

Legende
built-insEingebaute Eigenschaften der ECMAScript-Kernobjekte

Die Abbildung zeigt, dass jedes Objekt mit einem Prototypen verbunden ist. Sogar die Konstruktorfunktion (die Klasse) Letter hat ihren eigenen Prototyp, nämlich Function.prototype. Beachten Sie, dass Letter.prototype der Prototyp aller Letter-Instanzen ist. Das sind a, b und z.

Hinweis: Der tatsächliche Prototyp eines jeden Objekts steckt immer in der Eigenschaft __proto__. Die ausdrückliche Eigenschaft prototype der Konstruktorfunktion verweist auf den Prototyp all ihrer Instanzen. Die Instanzen verweisen auf den Prototyp nach wie vor mit der Eigenschaft __proto__. Genaues finden Sie in diesem Artikel.

Sie finden eine detaillierte Erörterung der allgemeinen OOP-Konzepte einschließlich einer Beschreibung von Klassen-Basierung, Prototypen-Basierung usw. im Artikel ES3. 7.1 OOP: The general theory.

Nachdem wir uns die grundlegenden Verbindungen zwischen ECMAScript-Objekten angesehen haben, werfen wir einen genaueren Blick auf die JavaScript-Laufzeitumgebung. Wie wir später sehen werden, kann fast alles darin ebenfalls als Objekt beschrieben werden.

Ausführungskontext

Um JavaScript-Code auszuführen und den Überblick über die Auswertung zur Laufzeit zu behalten, definiert die ECMAScript-Spezifikation das Konzept des Ausführungskontexts. Diese Ausführungskontexte werden in einem Stapelspeicher (Stack) vorgehalten. Wir kommen bald auf diesen Stapel von Ausführungskontexten zu sprechen. Er entspricht dem allgemeinen Konzept des Aufrufstapels.

Definition 7: Ausführungskontext: Ein Ausführungskontext (execution context) ist das Mittel der ECMAScript-Spezifikation, um die Auswertung des Codes zur Laufzeit zu protokollieren.

Es gibt verschiedene Arten von ECMAScript-Code: globalen Code, Funktionscode, eval-Code und Modul-Code. Jede dieser Codearten wird in einem eigenen Kontext ausgewertet. Verschiedene Arten von Code und ihre jeweiligen Objekte beeinflussen die Struktur des Ausführungskontexts. Zum Beispiel speichern Generator-Funktionen ihr Generator-Objekt im Ausführungskontext.

Schauen wir uns einen rekursiven Funktionsaufruf an:

function recursive(flag) {

  // Abbruchbedingung
  if (flag === 2) {
    return;
  }

  // Rekursiver Aufruf.
  recursive(++flag);
}

// Los!
recursive(0);

Wenn eine Funktion aufgerufen wird, so wird ein neuer Ausführungskontext erzeugt und auf den Stapel gelegt (Push). Damit wird er zum aktiven Ausführungskontext. Nachdem sich eine Funktion beendet, wird der Kontext wieder vom Stapel genommen (Pop).

Der Kontext, der einen anderen Kontext aufruft, wird Aufrufer (caller) genannt. Der aufgerufene Kontext wird dementsprechend Aufgerufener (callee) genannt. Im obigen Beispiel spielt die rekursive Funktion beide Rollen: Die des Aufgerufenen und die des Aufrufers, wenn sie sich selbst rekursiv aufruft.

Definition 8: Stapel von Ausführungskontexten: Der Stapel von Ausführungskontexten (execution context stack) ist eine Datenstruktur der Sorte Last In First out (LIFO), mit der der Kontrollfluss und die Ausführungsreihenfolge gesteuert wird.

In unserem Beispiel finden folgende Push- und Pop-Änderungen am Stack statt:

Abbildung 5. Ein Stapel von Ausführungskontexten.

Abbildung 5. Ein Stapel von Ausführungskontexten.

Legende
EC stackStapel von Ausführungskontexten
Global contextGlobaler Kontext

Anmerkung der Übersetzung: Die Pfeile zeigen die Reihenfolge der Änderungen am Stapel an. Zunächst liegt nur der globale Kontext auf dem Stapel, dann werden nach und nach Kontexte auf den Stapel gelegt (entlang der Pfeile nach rechts). Schließlich werden nach und nach Kontexte wieder vom Stapel genommen (entlang der Pfeile nach links). Der Stapel wird wieder abgebaut.

Wir können in der Illustration sehen, dass der globale Kontext immer zuunterst im Stapel liegt. Er wird erzeugt, bevor alle anderen Kontexte ausgeführt werden.

Mehr Informationen zu Ausführungskontexten in ECMAScript finden Sie im entsprechenden Kapitel.

Normalerweise läuft der Code in einem Kontext bis zu seinem Ende. Wie wir bereits angemerkt haben, verstoßen manche Objekte gegen die LIFO-Regel des Stapelspeichers – zum Beispiel Generatoren. Eine Generator-Funktion kann ihren aktiven Kontext vorübergehend schlafen legen und ihn vorzeitig vom Stapel nehmen, noch bevor der Code darin vollständig ausgeführt wurde. Sobald der Generator wieder aktiviert wird, wird der Kontext wieder aufgeweckt und auf den Stapel gelegt:

function *gen() {
  yield 1;
  return 2;
}

let g = gen();

console.log(
  g.next().value, // 1
  g.next().value, // 2
);

Das yield-Statement gibt einen Wert an den Aufrufer zurück und nimmt den Kontext vom Stapel. Beim zweiten Aufruf der next-Methode wird eben dieser Kontext wieder auf den Stapel gelegt und die Ausführung wird wiederaufgenommen. Ein solcher Kontext kann länger leben als der Aufrufer, der ihn erzeugt hat. Dies ist der besagte Verstoß gegen die LIFO-Datenstruktur.

Hinweis: Mehr über Generatoren und Iteratoren finden Sie in dieser Dokumentation.

Wir schauen uns nun die wichtigen Teile eines Ausführungskontexts an. Insbesondere werden wir erfahren, wie die ECMAScript-Laufzeitumgebung die Speicherung von Variablen bewerkstellt und wie verschachtelte Code-Blöcke Geltungsbereiche erzeugen. Das dahinterstehende Konzept heißt lexikalische Umgebung. Es wird in JavaScript genutzt, um Daten zu speichern und das »Funarg-Problem« mithilfe von Closures zu lösen.

Umgebung

Jeder Ausführungskontext hat eine zugehörige lexikalische Umgebung.

Definition 9: Lexikalische Umgebung: Die lexikalische Umgebung (lexical environment) ist eine Datenstruktur, in der Verbindungen zwischen den Bezeichnern (identifiers) eines Kontexts und ihren Werten gespeichert werden. Jede Umgebung kann einen Verweis auf eine Eltern-Umgebung besitzen.

Eine Umgebung ist also ein Speicher mit allen Variablen, Funktionen und Klassen, die in einem Geltungsbereich definiert sind.

Technisch gesehen besteht eine Umgebung aus zwei Teilen: Zum einen das Umgebungsregister (environment record). Dies ist die tatsächliche Speichertabelle, die Bezeichner auf ihre Werte abbildet. Zum anderen der Verweis auf die Elternumgebung, der null sein kann.

Untersuchen wir folgenden Code:

let x = 10;
let y = 20;

function foo(z) {
  let x = 100;
  return x + y + z;
}

foo(30); // 150

Die Umgebungen des globalen Kontexts und des Kontexts der Funktion foo sehen wie folgt aus:

Abbildung 6. Eine Kette von Umgebungen

Abbildung 6. Eine Kette von Umgebungen

Legende
foo envUmgebung der Funktion foo
Foo EnvRecUmgebungsregister der Funktion foo
Global envGlobale Umgebung
Global EnvRecGlobales Umgebungsregister

Dieser Aufbau erinnert uns stark an die Prototypen-Kette, die wir oben kennengelernt haben. Und tatsächlich funktioniert die Auflösung von Bezeichnern (identifier resolution) sehr ähnlich: Wenn eine Variable in der eigenen Umgebung nicht gefunden wird, so wird versucht, sie in der Elternumgebung nachzuschlagen, dann in deren Elternumgebung und so weiter – bis die gesamte Kette von Umgebungen abgesucht wurde.

Definition 10: Auflösung von Bezeichnern: Der Vorgang des Auflösens einer Variable (einer Verbindung zwischen Name und Wert, Englisch binding) entlang der Kette von Umgebungen. Wenn ein Verbindung nicht aufgelöst werden kann, so tritt ein ReferenceError auf.

Dies erklärt, warum im Beispiel die Variable x zum Wert 100 aufgelöst wird und nicht etwa zum Wert 10 – die Variable wird direkt in der eigenen Umgebung der Funktion foo gefunden. Dies erklärt auch, warum wir auf den Funktionsparameter z zugreifen können – der Parameter wird ebenfalls in der Aktivierungsumgebung gespeichert. Schließlich verstehen wir nun, warum wir auf die Variable y zugreifen können – sie wird in der Elternumgebung gefunden.

Ähnlich wie bei Prototypen können sich verschiedene Kindumgebung ein und dieselbe Elternumgebung teilen: Beispielsweise teilen sich zwei globale Funktionen dieselbe globale Umgebung.

Hinweis: Mehr Informationen über lexikalische Umgebungen finden Sie in diesem Artikel.

Der Aufbau eines Umgebungsregisters hängt vom Typ der Umgebung ab. Es gibt Objekt-Umgebungsregister und deklarative Umgebungsregister. Zwei Untertypen des deklarativen Registers sind Funktions- und Modul-Umgebungsregister. Jeder Registertyp bringt eigentümliche Eigenschaften mit sich. Der generelle Mechanismus der Auflösung von Bezeichnern hingegen ist bei allen Umgebungen gleich, er hängt nicht vom Registertyp ab.

Ein Beispiel für ein Objekt-Umgebungsregister ist das Register der globalen Umgebung. Dieses Register besitzt ein zugehöriges Verbindungsobjekt (binding object). Das Verbindungsobjekt kann einige Eigenschaften des Registers speichern, aber nicht alle. Dasselbe gilt umgekehrt. Das Verbindungsobjekt kann außerdem als this-Wert dienen.

// Herkömmliche Variablen mit `var`.
var x = 10;

// Moderne Variablen mit `let`.
let y = 20;

// Beide werden dem Umgebungsregister hinzugefügt:
console.log(
  x, // 10
  y, // 20
);

// Lediglich `x` wird zum »Verbindungsobjekt« hinzugefügt.
// Das Verbindungsobjekt der globalen Umgebung ist
// das globale Objekt, das auch über `this` verfügbar ist:

console.log(
  this.x, // 10
  this.y, // undefined!
);

// Das Verbindungsobjekt kann einen Namen speichern,
// der nicht im Umgebungsregister steht,
// weil er kein gültiger Bezeichner ist:

this['not valid ID'] = 30;

console.log(
  this['not valid ID'], // 30
);

Dies wird veranschaulicht in der folgenden Abbildung:

Abbildung 7. Ein Verbindungsobjekt.

Abbildung 7. Ein Verbindungsobjekt.

Legende
Global envGlobale Umgebung
Global EnvRecGlobales Umgebungsregister
Global EnvRec BindingObjectVerbindungsobjekt des globalen Umgebungsregisters

Beachten Sie, dass das Verbindungsobjekt bloß existiert, um veraltete Sprachkonstrukte abzudecken. Das sind beispielsweise die var-Deklaration und das with-Statement, das ein Objekt als Verbindungsobjekt zur Verfügung stellt. Aus historischen Gründen wurde eine Umgebung als einfaches Objekt modelliert. Heute hingegen ist das Modell der Umgebungen weit ausgereifter. Dies hat jedoch dazu geführt, dass wir auf Verbindungen nicht mehr als Eigenschaften zugreifen können.

Wir haben gelernt, dass Umgebungen miteinander über den Eltern-Verweis verknüpft sind. Nun werden wir sehen, wie eine Umgebung länger leben kann als der Kontext, der sie erzeugt hat. Dies ist nämlich die Voraussetzung für den Mechanismus der Closure, den wir nun erörtern werden.

Closure

Funktionen in JavaScript sind Objekte erster Klasse. Dieses Konzept liegt der funktionalen Programmierung zugrunde. JavaScript unterstützt einige Aspekte der funktionalen Programmierung.

Definition 11: Funktion erster Klasse (first-class function): Eine Funktion, die sich wie normale Daten verhält: Sie kann in einer Variable gespeichert werden, als Parameter übergeben werden oder als Wert von einer anderen Funktion zurückgegeben werden.

Eng verknüpft mit dem Konzept der Funktionen erster Klasse ist das sogenannte »Funarg problem« (das Problem des funktionalen Parameters). Dieses Problem tritt auf, wenn eine Funktion auf freie Variablen zugreifen muss.

Definition 12: Freie Variable: eine Variable, die weder ein Parameter noch eine lokale Variable der jeweiligen Funktion ist.

Schauen wir uns das Funarg-Problem und seine Lösung in ECMAScript an.

Untersuchen wir folgenden Code-Schnipsel:

let x = 10;

function foo() {
  console.log(x);
}

function bar(funArg) {
  let x = 20;
  funArg(); // 10, not 20!
}

// Übergebe `foo` als Parameter an `bar`.
bar(foo);

Aus Sicht der Funktion foo ist die Variable x eine freie Variable. Wenn die Funktion foo aufgerufen wird (als Parameter funArg), so muss die Verbindung für x aufgelöst werden. Woher kommt nun diese Verbindung? Aus dem äußeren Geltungsbereich, in dem die Funktion erzeugt wurde? Oder aus dem Geltungsbereich des Aufrufers, der die Funktion schließlich aufruft? Wie wir im Beispiel sehen, stellt der Aufrufer, das ist die Funktion bar, ebenfalls eine Verbindung zwischen x und dem Wert 20 bereit.

Der obige Anwendungsfall ist als Abwärts-Funarg-Problem (downward funarg problem) bekannt. Das Problem besteht in einer Mehrdeutigkeit beim Bestimmen der korrekten Umgebung einer Verbindung: Wird die Umgebung zum Zeitpunkts der Erzeugung verwendet oder die Umgebung zum Zeitpunkts des Aufrufs?

ECMAScript löst das Problem, indem man sich auf einen statischen, das heißt unveränderlichen Geltungsbereich geeinigt hat. Das ist der Geltungsbereich zum Zeitpunkt der Erzeugung.

Definition 13: Statischer Geltungsbereich: Eine Sprache arbeitet mit einem statischen Geltungsbereich (static scope), wenn alleine durch das Anschauen des Quellcodes bestimmt werden kann, in welcher Umgebung eine Verbindung aufgelöst wird.

Der statische Geltungsbereich wird auch manchmal lexikalischer Geltungsbereich (lexical scope) genannt. Daher stammt die Bezeichnung für die lexikalischen Umgebungen.

Intern wird der statische Geltungsbereich folgendermaßen umgesetzt: Zum Zeitpunkt der Erzeugung einer Funktion wird deren Umgebung festgehalten und gespeichert.

Hinweis: Erfahren Sie mehr über statische und dynamische Geltungsbereiche in diesem Artikel.

Im obigen Beispiel hält die Funktion foo die globale Umgebung fest:

Abbildung 8. Eine Closure.

Abbildung 8. Eine Closure.

Legende
Global envGlobale Umgebung
Global EnvRecGlobales Umgebungsregister

Wir sehen hier eine Umgebung, die auf eine Funktion verweist, welche wiederum zurück auf die Umgebung verweist.

Definition 14: Closure: Eine Closure (zu Deutsch auch Funktionsabschluss) ist eine Funktion, die diejenige Umgebung einschließt, in der sie definiert ist. Im Weiteren wird die Umgebung zur Auflösung von Bezeichnern genutzt.

Hinweis: Wird eine Funktion aufgerufen, so wird eine neue, leere Aktivierungsumgebung erzeugt. Darin werden lokale Variablen und die Funktionsparameter gespeichert. Als Eltern-Umgebung dieser Aktivierungsumgebung wird nun die eingeschlossene Umgebung der Funktion auserkoren. So wird die Semantik des lexikalischen Geltungsbereiches erreicht.

Der zweite Untertyp des Funarg-Problems ist das Aufwärts-Funarg-Problem. Der einzige Unterschied hier ist, dass die einschließende Umgebung länger lebt als der Kontext, der sie erzeugt.

Ein Beispiel:

function foo() {
  let x = 10;

  // Closure, die die Umgebung von `foo` einschließt
  function bar() {
    return x;
  }

  // Funktionaler Parameter »aufwärts«
  return bar;
}

let x = 20;

// Der Aufruf von `foo` gibt die Closure `bar` zurück.
let bar = foo();

bar(); // 10, nicht 20!

Technisch gesehen haben wir es mit dem gleichen Mechanismus zu tun, der die Umgebung festhält, in dem eine Funktion definiert wurde. In diesem Fall jedoch würde die Aktivierungsumgebung von foo zerstört werden, wenn es die Closure nicht gäbe. Aber wir haben sie festgehalten, sodass sie nicht aus dem Speicher geräumt (dealloziert) wird. Daher bleibt die Aktivierungsumgebung erhalten, um das Verhalten des statischen Geltungsbereichs zu ermöglichen.

Das Verständnis von Closures ist oftmals unvollständig: Meist denken EntwicklerInnen nur an das Aufwärts-Funarg-Problem, wenn es um Closures geht. Tatsächlich tritt dieser Fall in der Praxis häufiger auf. Allerdings ist die technische Lösung des Abwärts-Problems genau dieselbe wie die des Aufwärts-Problems, nämlich der statische Geltungsbereich.

Wie gesagt können sich mehrere Closures ein und dieselbe Elternumgebung teilen, ähnlich wie es bei Prototypen der Fall ist. Dies ermöglicht es, auf die geteilten Daten zuzugreifen und diese auch zu ändern:

function createCounter() {
  let count = 0;

  return {
    increment() { count++; return count; },
    decrement() { count--; return count; },
  };
}

let counter = createCounter();

console.log(
  counter.increment(), // 1
  counter.decrement(), // 0
  counter.increment(), // 1
);

Beide Closures, increment und decrement, werden im Geltungsbereich erzeugt, der auch die Variable count enthält. Sie teilen sich daher den Eltern-Geltungsbereich. Das Festhalten erfolgt durch das Anlegen eines Verweises (by reference). Es wird also ein Verweis auf die Elternumgebung als solche gespeichert.

Das folgende Bild illustriert dies:

Abbildung 9. Eine geteilte Umgebung.

Abbildung 9. Eine geteilte Umgebung.

Legende
createCounter envUmgebung von createCounter
createCounter EnvRecUmgebungsregister von createCounter

In manchen Sprachen erfolgt das Festhalten durch das Kopieren der Variable mitsamt ihres Wertes (by value). Es ist dann nicht möglich, den Wert der Variable in der Elternumgebung zu ändern. In JavaScript hingegen wird wie gesagt immer ein Verweis auf den Eltern-Geltungsbereich angelegt.

Hinweis: ECMAScript-Implementierungen können diesen Schritt optimieren, indem sie nicht die ganze Umgebung festhalten. Sie brauchen nur tatsächlich benutzte freie Variablen festhalten. Das Verhalten bei Datenänderungen im Eltern-Geltungsbereich bleibt dadurch unangetastet.

Sie finden eine genaue Beschreibung von Closures und des Funarg-Problems im entsprechenden Kapitel.

Alle Bezeichner werden also in einem statischen Geltungsbereich aufgelöst. Es gibt jedoch genau einen Wert in ECMAScript, der dynamisch aufgelöst wird. Es ist der Wert this.

This

Der Wert this ist ein spezielles Objekt, das dynamisch und implizit an den Code eines Kontexts übergeben wird. Wir können ihn als zusätzlichen impliziten Parameter verstehen, auf den wir zwar zugreifen können, der sich aber nicht nachträglich ändern lässt.

Der Sinn und Zweck des Werts this ist, dass derselbe Code für mehrere Objekte ausgeführt werden kann.

Definition 15: This: Ein implizites Kontextobjekt, auf das der Code eines Ausführungskontexts Zugriff hat, um denselben Code für mehrere Objekte auszuführen.

Der wichtigste Anwendungsfall ist die klassen-basierte, objekt-orientierte Programmierung. Eine Instanzmethode, die am Prototyp definiert ist, existiert nur einmal, aber alle Instanzen der Klasse teilen sie sich.

class Point {
  constructor(x, y) {
    this._x = x;
    this._y = y;
  }

  getX() {
    return this._x;
  }

  getY() {
    return this._y;
  }
}

let p1 = new Point(1, 2);
let p2 = new Point(3, 4);

// `getX` und `getY` sind an beiden Instanzen verfügbar.
// Die Instanz wird jeweils als `this` übergeben.

console.log(
  p1.getX(), // 1
  p2.getX(), // 3
);

Wenn die Methode getX aufgerufen wird, wird eine neue Umgebung erzeugt, um die lokalen Variablen und Parameter zu speichern. Zudem bekommt der Eintrag [[ThisValue]] im Funktions-Umgebungsregister einen Wert zugewiesen. Dieser Wert wird dynamisch bestimmt abhängig davon, wie die Funktion aufgerufen wird. Wird die Funktion als Methode von p1 aufgerufen, so ist der this-Wert eben p1. Im zweiten Fall ist der Wert p2.

Ein weiterer Anwendungsfall von this sind generische Interface-Funktionen, wie sie in Mixins oder Traits Verwendung finden.

Im folgenden Beispiel enthält das Movable-Interface eine generische (verallgemeinerte) Funktion move. Sie erwartet von den Nutzern des Mixins, dass sie die Eigenschaften _x und _y implementieren:

// Generisches Movable-Interface (Mixin).
let Movable = {

  /**
   * Diese Funktion ist generisch und arbeitet mit jedem Objekt zusammen,
   * das die Eigenschaften `_x` und `_y` bereitstellt, ungeachtet
   * der Klasse des Objekts.
   */
  move(x, y) {
    this._x = x;
    this._y = y;
  },
};

let p1 = new Point(1, 2);

// Mache `p1` beweglich (Wende den Mixin an)
Object.assign(p1, Movable);

// Die Methode `move` kann nun aufgerufen werden.
p1.move(100, 200);

console.log(p1.getX()); // 100

Alternativ kann ein Mixin auf der Ebene des Prototyps angewandt werden, anstatt bei jeder Instanz einzeln wie im obigen Beispiel.

Das folgende Beispiel demonstriert die dynamische Natur des Wertes this. Es zu verstehen ist eine kleine Aufgabe für Sie:

function foo() {
  return this;
}

let bar = {
  foo,

  baz() {
    return this;
  },
};

// `foo`
console.log(
  foo(),       // Das globale Objekt oder undefined

  bar.foo(),   // bar
  (bar.foo)(), // bar

  (bar.foo = bar.foo)(), // Das globale Objekt
);

// `bar.baz`
console.log(bar.baz()); // bar

let savedBaz = bar.baz;
console.log(savedBaz()); // Das globale Objekt

Allein durch das Anschauen des Quellcodes der Funktion foo können wir nicht vorhersagen, welchen Wert this bei einem bestimmten Funktionsaufruf haben wird. Deshalb spricht man davon, dass this dynamisch aufgelöst wird.

Hinweis: Sie finden eine genaue Erklärung, wie der Wert this bestimmt wird, im entsprechenden Kapitel. Es erklärt auch, wieso sich der Code im Beispiel so verhält, wie er sich verhält.

Die Pfeil-Funktionen (arrow functions) verhalten sich ungewöhnlich hinsichtlich des Wertes this: Darin ist this lexikalisch (statisch) und nicht dynamisch. Das heißt, ihr Funktions-Umgebungsregister enthält keinen Wert für this. Stattdessen wird der Wert der Elternumgebung verwendet.

var x = 10;

let foo = {
  x: 20,

  // Dynamisches `this`.
  bar() {
    return this.x;
  },

  // Lexikalisches `this`.
  baz: () => this.x,

  qux() {
    // Lexikalisches this im Zusammenhang mit diesem Aufruf.
    let arrow = () => this.x;

    return arrow();
  },
};

console.log(
  foo.bar(), // 20, aus `foo`
  foo.baz(), // 10, aus dem globalen Objekt
  foo.qux(), // 20, aus `foo` und der Pfeil-Funktion
);

Wie gesagt ist der Wert von this im globalen Kontext das globale Objekt (das Verbindungsobjekt des globalen Umgebungsregisters). Früher gab es nur ein globales Objekt. In der aktuellen Version der ECMAScript-Spezifikation kann es mehrere globale Objekte geben, die Teil von Code-Gebieten sind. Schauen wir uns nun diese Struktur an.

Gebiet

Bevor ECMAScript-Code ausgewertet wird, muss er einem Gebiet zugewiesen werden. Die Aufgabe eines Gebiets ist, eine globale Umgebung für einen Kontext bereitzustellen.

Definition 16: Gebiet: Ein Code-Gebiet (realm) ist ein Objekt, das eine eigenständige globale Umgebung umhüllt.

Wenn ein Ausführungskontext erzeugt wird, wird er einem bestimmten Code-Gebiet zugeordnet. Das Gebiet stellt die globale Umgebung für diesen Kontext bereit. Diese Zuordnung ist unveränderlich.

Hinweis: Das direkte Äquivalent eines Gebiets im Browser ist das iframe-Element, welches eine eigene globale Umgebung bereitstellt. Bei Node.js sind die Sandkasten (sandboxes) aus dem vm-Modul mit Gebieten vergleichbar.

Die aktuelle Version der ECMAScript-Spezifikation bietet keine Möglichkeit, um Gebiete programmatisch zu erstellen. Sie können jedoch implizit von den Implementierungen erstellt werden. Es gibt einen Vorschlag, der vorsieht, diese Programmierschnittstelle (API) für gewöhnlichen Code zugänglich zu machen.

Jeder Kontext auf dem Stapel hat eine logische Verbindung zu seinem Gebiet:

Abbildung 10. Ein Kontext und seine Verbindung zum Gebiet.

Abbildung 10. Ein Kontext und seine Verbindung zum Gebiet.

Legende
EC stackStapel der Ausführungskontexte
Realm 1, Realm 2Gebiet 1, Gebiet 2

Schauen wir uns ein Beispiel mit unterschiedlichen Gebieten an, erzeugt durch das vm-Modul:

const vm = require('vm');

// Erstes Gebiet und sein globales Objekt:
const realm1 = vm.createContext({x: 10, console});

// Zweites Gebiet und sein globales Objekt:
const realm2 = vm.createContext({x: 20, console});

// Code, der ausgeführt wird:
const code = `console.log(x);`;

vm.runInContext(code, realm1); // 10
vm.runInContext(code, realm2); // 20

Langsam bekommen wir eine Übersicht über die ECMAScript-Laufzeitumgebung. Wir müssen aber noch den Einstiegspunkt zum Code finden sowie den Initialisierungsprozess. Dieser wird geregelt durch den Mechanismus der Aufträge und der Auftrags-Warteschlange.

Auftrag

Manche Operationen können aufgeschoben werden und erst ausgeführt werden, sobald ein Platz im Stapel der Ausführungskontexte frei wird.

Definition 17: Auftrag: Ein Auftrag (job) ist eine abstrakte Operation, die eine ECMAScript-Berechnung startet, sobald keine andere Berechnung mehr läuft.

Aufträge werden in die Auftrags-Warteschlange eingereiht. In der aktuellen Spezifikation gibt es zwei solcher Warteschlangen: ScriptJobs und PromiseJobs.

Der erste Auftrag in der ScriptJobs-Warteschlange ist der Haupt-Einstiegspunkt zu unserem Programm. Der Auftrag lädt das erste Script und wertet es aus: ein Gebiet wird erzeugt, der globale Kontext wird erzeugt und mit dem Gebiet verknüpft, er wird auf den Stapel gelegt und der globale Code wird ausgeführt.

Beachten Sie, dass die ScriptJobs-Warteschlange sowohl für Scripte als auch für Module zuständig ist.

Dieser anfängliche Kontext kann später weitere Kontexte hervorbringen oder weitere Aufträge in die Warteschlange einreihen. Ein Beispiel für einen Auftrag, der erzeugt und eingereiht wird, ist ein Promise.

Wenn es keinen aktiven Ausführungskontext gibt und der Stapel von Ausführungskontexten leer ist, so nimmt die ECMAScript-Implementierung den ersten wartenden Auftrag aus der Warteschlange und arbeitet ihn ab. Dazu wird ein Ausführungskontext erzeugt und seine Ausführung gestartet.

Hinweis: Auftrags-Warteschlangen werden üblicherweise mithilfe des Konzepts der »Ereignisschleife« (event loop) abgearbeitet. Der ECMAScript-Standard definiert die Ereignisschleife nicht und überlässt sie den Implementierungen. Sie können jedoch ein Lehrbeispiel hier finden.

Ein Beispiel:

// Füge einen neuen Promise zur PromiseJobs-Warteschlange hinzu.
new Promise(resolve => setTimeout(() => resolve(10), 0))
  .then(value => console.log(value));

// Diese Ausgabe findet vorher statt, da sie Teil des aktiven Kontexts
// ist. Der anstehende Auftrag vorher nicht abgearbeitet werden.
console.log(20);

// Ausgabe: 20, 10

Hinweis: Mehr über Promises finden Sie in dieser Dokumentation.

Asynchrone Funktionen, die mit dem Schlüsselwort async definiert werden, können mit await auf Promises warten. Sie können darüber ebenfalls Promise-Aufträge einreihen:

async function later() {
  return await Promise.resolve(10);
}

(async () => {
  let data = await later();
  console.log(data); // 10
})();

// Dies passiert ebenfalls vorher, da die asynchrone Ausführung
// zunächst in der PromiseJobs-Warteschlange wartet.
console.log(20);

// Output: 20, 10

Hinweis: Lesen Sie mehr über asynchrone Funktionen hier.

Wir sind dem Ziel näher gekommen, einen vollständigen Überblick über das derzeitige ECMAScript-Universum zu bekommen. Wenden wir uns schließlich den Haupteignern aller genannten Komponenten zu, den Agenten.

Agent

Nebenläufigkeit (concurrency) und parallele Programmierung sind in ECMAScript mithilfe des Agenten-Musters (agent pattern) umgesetzt. Das Agenten-Muster ähnelt dem Aktorenmodell. Aktoren sind leichtgewichtige Prozesse, die miteinander kommunizieren, indem sie Nachrichten austauschen.

Definition 18: Agent: Ein Agent ist eine Abstraktion, die den Stapel von Ausführungskontexten, eine Anzahl von Auftrags-Warteschlangen sowie Code-Gebiete beinhaltet.

Abhängig von der Implementierung kann ein Agent auf demselben oder einem eigenen Thread ausgeführt werden. In der Browserumgebung ist der Worker ein Beispiel für das Agenten-Konzept.

Die Agenten haben einen voneinander getrennten, also isolierten Zustand und können miteinander kommunizieren, indem sie Nachrichten austauschen. Bestimmte Daten können zwischen Agenten geteilt werden, zum Beispiel SharedArrayBuffer. Agenten können zudem zusammengefasst werden in Agentengruppen.

Im folgenden Beispiel ruft der Code in index.html den Worker in agent-smith.js auf und übergibt ihm einen geteilten Speicherbereich:

// Der Code in `index.html`:

// Geteilte Daten zwischen diesem Agent und einem Worker.
let sharedHeap = new SharedArrayBuffer(16);

// Unsere Sicht auf die Daten.
let heapArray = new Int32Array(sharedHeap);

// Erzeuge einen neuen Agenten (Worker).
let agentSmith = new Worker('agent-smith.js');

agentSmith.onmessage = (message) => {
  // Der Agent sendet den Index der Daten, die er modifiziert hat.
  let modifiedIndex = message.data;

  // Überprüfe, ob die Daten geändert wurden:
  console.log(heapArray[modifiedIndex]); // 100
};

// Sende die geteilten Daten an den Agenten.
agentSmith.postMessage(sharedHeap);

Hier der Worker-Code:

// agent-smith.js

// Empfange den geteilten Array-Buffer in diesem Worker.
onmessage = (message) => {
  // Die Sicht des Workers auf die geteilten Daten.
  let heapArray = new Int32Array(message.data);

  let indexToModify = 1;
  heapArray[indexToModify] = 100;

  // Sende den Index in einer Nachricht zurück.
  postMessage(indexToModify);
};

Sie finden den kompletten Code für das obige Beispiel in diesem Gist.

(Beachten Sie: Wenn Sie das Beispiel lokal aufrufen, sollten Sie es im Firefox laufen lassen. Aus Sicherheitsgründen erlaubt Chrome das Laden von Web-Workern aus einer lokalen Datei nicht.)

Die folgende Illustration zeigt die gesamte ECMAScript-Laufzeitumgebung:

Abbildung 11. Die ECMAScript-Laufzeitumgebung.
Abbildung 11. Die ECMAScript-Laufzeitumgebung.

Und das ist schon alles! Das ist, was unter der Motorhaube von ECMAScript passiert!

Wir kommen nun zum Ende. Dies sind alle Informationen zum JavaScript-Kern, die ein Überblicksartikel abdecken kann. Wie gesagt kann JavaScript-Code in Modulen gruppiert werden, der Zugriff auf Eigenschaften kann durch Proxy-Objekte abgefangen werden usw. usf. Es gibt viele praxisrelevante Details, über die Sie in anderen Dokumentationen der Sprache JavaScript lesen können.

Dieser Artikel hingegen hat versucht, die logische Struktur eines ECMAScript-Programms darzustellen. Falls Sie Fragen, Anregungen oder Feedback haben: wie immer freue ich mich darauf, sie im Kommentarbereich zu diskutieren.

Ich möchte mich bei den Mitgliedern der TC-39-Arbeitsgruppe und den AutorInnen der Spezifikation bedanken, die mir mit Erklärungen bei diesem Artikel geholfen haben. Die Diskussion finden Sie in diesem Twitter-Thread.

Viel Erfolg beim Lernen von ECMAScript!

Autor: Dmitry Soshnikov
Veröffentlicht am:


Deutsche Übersetzung: Mathias Schäfer (molily). Dank geht an Ingo Chao, Peter Seliger, Axel Wienberg, Jo Liss, Julian Tauert, Thomas Stratmann und Anja Hülsmans für ihre Mitarbeit.
Veröffentlicht am

Das Übersetzungsprojekt auf GitHub.

Feedback und Korrekturen bitte an molily@mailbox.org oder als Issues auf GitHub.