Hallo! Ich präsentiere Ihnen den zweiten Teil meiner Übersetzung des Handbuchs zur Implementierung Ihrer JavaScript-Programmiersprache - PL Tutorial .
Vom Übersetzer
Wir werden unsere eigene Programmiersprache erstellen - λ-Sprache (im Original - λanguage). Während des Erstellungsprozesses werden wir viele interessante Techniken verwenden, wie z. B. rekursiven Abstieg, Kontrollübertragungsstil und grundlegende Optimierungstechniken. Es werden zwei Versionen des Interpreters erstellt - der reguläre und der CPS-Interpreter, der Transcompiler in JavaScript.
Der Autor des Originals ist Mihai Bazon , der Autor der berühmten UglifyJS-Bibliothek (ein Tool zum Minimieren und Formatieren von JS-Code).
PS Es gibt einen Fehler im Interpreter und Compiler: in Ausdrücken wie a() && b()
oder a() || b()
a() || b()
beide Teile werden immer ausgeführt. Dies ist natürlich falsch, da a()
für den Operator &&
falsch oder für ||
nicht falsch ist dann spielt der Wert von b()
keine Rolle. Dies ist nicht schwer zu beheben.
Einfacher Dolmetscher
Im vorherigen Teil haben wir 3 Funktionen geschrieben: InputStream
, TokenStream
und parse
. Um den AST aus dem Code zu erhalten, verwenden wir den folgenden Code:
var ast = parse(TokenStream(InputStream(code)));
Das Schreiben eines Interpreters ist einfacher als ein Parser: Wir durchlaufen den Baum nur rekursiv und führen die Ausdrücke in ihrer normalen Reihenfolge aus.
Kontext ( Environment
)
Für eine ordnungsgemäße Codeausführung benötigen wir einen Kontext - ein Objekt, das alle Variablen an einem bestimmten Ort enthält. Es wird als Argument an die evaluate
.
Jedes Mal, wenn wir den lambda
Knoten betreten, müssen wir den Kontextfunktionsargumenten neue Variablen hinzufügen. Wenn das Argument die Variable aus dem externen Block überlappt, müssen wir den alten Wert der Variablen nach dem Beenden der Funktion wiederherstellen.
Der einfachste Weg, dies zu tun, ist die Verwendung der JavaScript-Vererbung als Prototyp. Wenn wir eine neue Funktion eingeben, erstellen wir einen neuen Kontext, legen den externen Kontext als Prototyp fest und rufen die Funktion im neuen Kontext auf. Dank dessen haben wir nichts - im externen Kontext bleiben alle seine Variablen erhalten.
Hier ist die Implementierung des Environment
Objekts:
function Environment(parent) { this.vars = Object.create(parent ? parent.vars : null); this.parent = parent; } Environment.prototype = { extend: function() { return new Environment(this); }, lookup: function(name) { var scope = this; while (scope) { if (Object.prototype.hasOwnProperty.call(scope.vars, name)) return scope; scope = scope.parent; } }, get: function(name) { if (name in this.vars) return this.vars[name]; throw new Error("Undefined variable " + name); }, set: function(name, value) { var scope = this.lookup(name);
Das Environment
verfügt über ein parent
Feld, das auf den externen Kontext verweist. Für den globalen Kontext ist es null
. Es hat ein vars
Feld, in dem sich alle Variablen befinden, die zu diesem Kontext gehören. Für einen globalen Kontext entspricht es sofort einem leeren Objekt ( Object.create(null)
) und einer Kopie der Variablen des übergeordneten Kontexts ( Object.create(parent.vars)
) für ein nicht globales.
Es gibt verschiedene Methoden:
extend()
- Kopieren Sie den aktuellen Kontext.lookup(name)
- Finden Sie den Kontext, in dem die Variable namens name
definiert ist.get(name)
- Ermittelt den Wert einer Variablen namens name
. Löst eine Ausnahme aus, wenn die Variable nicht definiert wurde.set(name, value)
- Setzt den Wert einer Variablen. Diese Methode sucht nach dem Kontext, in dem die Variable definiert ist. Wenn es nicht definiert ist und wir uns nicht in einem globalen Kontext befinden, wird eine Ausnahme ausgelöst.def(name, value)
- Erstellt (oder überlappt oder überschreibt) eine Variable im aktuellen Kontext.
Funktion evaluate
Nachdem wir das Environment
Objekt haben, können wir mit der Lösung des Hauptproblems fortfahren. Diese Funktion ist ein großer switch
Block, der abhängig vom Typ des übertragenen Knotens einige Aktionen ausführt:
function evaluate(exp, env) { switch (exp.type) {
Für Literale geben wir einfach ihren Wert zurück:
case "num": case "str": case "bool": return exp.value;
Variablen werden aus dem Kontext entnommen (der Name der Variablen ist im Wertefeld enthalten):
case "var": return env.get(exp.value);
Um zuzuweisen, müssen wir sicherstellen, dass wir auf der linken Seite den Namen der Variablen (Knoten var
) haben. Wenn nicht, lösen wir einfach eine Ausnahme aus (wir unterstützen keine Zuordnung zu etwas anderem als Variablen). Als nächstes setzen wir den Wert der Variablen mit env.set
. Beachten Sie, dass die rechte Seite des Ausdrucks mithilfe des rekursiven Aufrufs berechnet werden muss, um Folgendes zu evaluate
:
case "assign": if (exp.left.type != "var") throw new Error("Cannot assign to " + JSON.stringify(exp.left)); return env.set(exp.left.value, evaluate(exp.right, env));
Für einen Knoten vom Typ binary
wir den Operator für beide Operanden anwenden. Wir werden die Funktion apply_op
später schreiben. Außerdem rufen wir evaluate
für beide Teile des Ausdrucks auf:
case "binary": return apply_op(exp.operator, evaluate(exp.left, env), evaluate(exp.right, env));
Ein Knoten vom Typ lambda
gibt einen normalen JavaScript-Abschluss zurück, sodass er auch aus JavaScript wie eine reguläre Funktion aufgerufen werden kann. Ich habe die Funktion make_lambda
hinzugefügt, die ich später zeigen werde:
case "lambda": return make_lambda(env, exp);
Die Ausführung des if
Knotens if
recht einfach: Zuerst finden wir den Wert der Bedingung. Wenn es nicht falsch ist, geben Sie den Wert des then
Zweigs zurück. Andernfalls, wenn es einen else
Zweig gibt, dann dessen Wert oder false
:
case "if": var cond = evaluate(exp.cond, env); if (cond !== false) return evaluate(exp.then, env); return exp.else ? evaluate(exp.else, env) : false;
Der prog
Knoten ist eine Folge von Ausdrücken. Wir führen einfach alle Ausdrücke der Reihe nach aus und nehmen den Wert des letzteren (der Wert der leeren Sequenz ist false
):
case "prog": var val = false; exp.prog.forEach(function(exp){ val = evaluate(exp, env) }); return val;
Für einen Knoten vom Typ call
wir eine Funktion aufrufen. Vorher werden wir den Wert der Funktion selbst finden, die Werte aller Argumente finden und die Funktion mit apply
aufrufen:
case "call": var func = evaluate(exp.func, env); return func.apply(null, exp.args.map(function(arg){ return evaluate(arg, env); }));
Wir werden nie hierher kommen, aber falls wir dem Parser einen neuen Knotentyp hinzufügen und vergessen, ihn dem Interpreter hinzuzufügen:
default: throw new Error("I don't know how to evaluate " + exp.type); } }
Dies war der Hauptteil des Dolmetschers. Oben haben wir zwei Funktionen verwendet, die wir noch nicht implementiert haben. Beginnen wir also:
apply_op
:
function apply_op(op, a, b) { function num(x) { if (typeof x != "number") throw new Error("Expected number but got " + x); return x; } function div(x) { if (num(x) == 0) throw new Error("Divide by zero"); return x; } switch (op) { case "+" : return num(a) + num(b); case "-" : return num(a) - num(b); case "*" : return num(a) * num(b); case "/" : return num(a) / div(b); case "%" : return num(a) % div(b); case "&&" : return a !== false && b; case "||" : return a !== false ? a : b; case "<" : return num(a) < num(b); case ">" : return num(a) > num(b); case "<=" : return num(a) <= num(b); case ">=" : return num(a) >= num(b); case "==" : return a === b; case "!=" : return a !== b; } throw new Error("Can't apply operator " + op); }
Es empfängt den Operatortyp und die Argumente. Einfacher und intuitiver switch
. Im Gegensatz zu JavaScript, das jeden Wert annehmen kann, wie Variablen, auch solche, die keinen Sinn ergeben. Wir fordern, dass die Operanden von arithmetischen Operatoren Zahlen sind und keine Division durch Null zulassen. Für Streicher werden wir uns später etwas einfallen lassen.
make_lambda
:
function make_lambda(env, exp) { function lambda() { var names = exp.vars; var scope = env.extend(); for (var i = 0; i < names.length; ++i) scope.def(names[i], i < arguments.length ? arguments[i] : false); return evaluate(exp.body, scope); } return lambda; }
Wie Sie oben sehen können, wird eine reguläre JavaScript-Funktion zurückgegeben, die den übergebenen Kontext und die AST-Funktionen verwendet. Alle Arbeiten werden nur ausgeführt, wenn die Funktion selbst aufgerufen wird: Ein Kontext wird erstellt, Argumente werden gesetzt (wenn sie nicht ausreichen, werden sie false
). Dann wird der Funktionskörper einfach in einem neuen Kontext ausgeführt.
Native Funktionen
Wie Sie sehen, hatten wir keine Möglichkeit, mit JavaScript aus unserer Sprache zu interagieren. Früher habe ich die Funktionen print
und println
verwendet, aber ich habe sie nirgendwo definiert. Wir müssen sie in JavaScript schreiben und sie einfach dem globalen Kontext hinzufügen.
Hier ist ein Beispiel für einen solchen Code:
Ganzer Code
Sie können den gesamten Code herunterladen , den wir die ganze Zeit geschrieben haben. Es kann mit NodeJS gestartet werden. Übergeben Sie einfach den Code an den Standard-Stream:
echo 'sum = lambda(x, y) x + y; println(sum(2, 3));' | node lambda-eval1.js
Codebeispiele
Obwohl unsere Programmiersprache einfach ist, kann sie (theoretisch) jedes Problem lösen, das von einem Computer überhaupt gelöst werden kann. Dies liegt daran, dass einige Typen, die klüger sind als ich jemals sein werden - Alonzo Church und Alan Turing - einmal bewiesen haben, dass λ-Kalkül (Lambda-Kalkül) einer Turing-Maschine entspricht, und unsere λ-Sprache implementiert λ-Kalkül.
Dies bedeutet, dass wir, auch wenn unsere Sprache keine Möglichkeiten hat, sie dennoch mit dem realisieren können, was wir bereits haben. Oder wenn dies schwierig ist, können wir einen Dolmetscher für eine andere Sprache in dieser Sprache schreiben.
Zyklen
Schleifen sind kein Problem, wenn wir eine Rekursion haben. Ich habe bereits ein Beispiel für eine Schleife gezeigt, die zusätzlich zur Rekursion implementiert wurde. Versuchen wir es noch einmal.
print_range = λ(a, b) if a <= b { print(a); if a + 1 <= b { print(", "); print_range(a + 1, b); } else println(""); }; print_range(1, 10);
Aber hier haben wir ein Problem: Wenn wir die Anzahl der Iterationen beispielsweise auf 1000 erhöhen, wird nach 600 der Fehler "Maximale Aufrufstapelgröße überschritten" angezeigt. Dies geschieht, weil der Interpreter rekursiv ist und die Rekursion die maximale Tiefe erreicht.
Dies ist ein ernstes Problem, aber es gibt eine Lösung. Ich möchte neue Konstrukte für die Iteration ( for
oder while
) hinzufügen, aber versuchen wir, auf sie zu verzichten. Die Rekursion sieht wunderschön aus, also lassen wir es. Wir werden später sehen, wie wir diese Einschränkung umgehen können.
Datenstrukturen (deren Fehlen)
In unserer λ-Sprache gibt es drei Arten von Daten: Zahlen, Zeichenfolgen und Boolesche Typen. Möglicherweise können Sie keine komplexen Typen wie Arrays oder Objekte erstellen. Aber das ist keine Tat, wir haben noch einen Typ: Funktion. Es stellt sich heraus, dass wir, wenn wir dem λ-Kalkül folgen, alle Datenstrukturen, einschließlich Objekte, auch mit Vererbung erstellen können.
Ich werde es am Beispiel von Listen zeigen. Stellen wir uns vor, wir haben eine cons
Funktion, die ein Objekt mit zwei Werten erstellt. Nennen wir dieses Objekt "Zelle" oder "Paar". Wir nennen einen der Werte "Auto" und den anderen "CDR". Nur weil sie in Lisp so genannt werden. Wenn wir nun ein Objekt "Zelle" haben, können wir seine Werte mit den Funktionen car
und cdr
:
x = cons(10, 20); print(car(x));
Jetzt können wir einfach eine Liste definieren:
Eine Liste ist ein Paar, das das erste Element in "Auto" und die verbleibenden Elemente in "CDR" enthält. Aber `cdr` kann nur einen Wert enthalten! Dieser Wert ist eine Liste. Eine Liste ist ein Paar, das das erste Element in "Auto" und die verbleibenden Elemente in "CDR" enthält. Aber `cdr` kann nur einen Wert enthalten! Dieser Wert ist eine Liste. [...]
Dies ist ein rekursiver Datentyp. Ein Problem bleibt jedoch: Wann müssen Sie aufhören? Logischerweise sollten wir aufhören, wenn cdr
eine leere Liste ist. Aber was ist eine leere Liste? Fügen Sie dazu ein neues Objekt mit dem Namen "NIL" hinzu. Es kann als Paar verwendet werden (wir können car
und cdr
, aber das Ergebnis wird NIL
selbst sein). Erstellen wir nun eine Liste der Elemente 1, 2, 3, 4, 5:
x = cons(1, cons(2, cons(3, cons(4, cons(5, NIL))))); print(car(x));
Es sieht schrecklich aus, wenn es dafür keine spezielle Syntax gibt. Aber ich wollte nur zeigen, dass dies mit den vorhandenen λ-Sprachfunktionen möglich ist. Hier ist die Implementierung:
cons = λ(a, b) λ(f) f(a, b); car = λ(cell) cell(λ(a, b) a); cdr = λ(cell) cell(λ(a, b) b); NIL = λ(f) f(NIL, NIL);
Als ich zum ersten Mal sah, dass cons
/ car
/ cdr
auf diese Weise hergestellt wurden, war ich überrascht, dass sie kein einziges if
brauchten (aber das ist seltsam, da es nicht im ursprünglichen λ-Kalkül enthalten ist). Natürlich macht dies keine Programmiersprache so, weil es extrem ineffizient ist, aber es macht λ-Kalküle nicht weniger schön. In einer klaren Sprache führt dieser Code Folgendes aus:
- Die
cons
Funktion nimmt zwei Werte ( a
und b
) an und gibt die Funktion zurück, die sie enthält. Diese Funktion ist das eigentliche Objekt des Paares. Sie nimmt ein Argument und nennt es für beide Werte des Paares. - Die
car
Funktion ruft das übergebene Argument auf und übergibt eine Funktion, die das erste Argument zurückgibt. - Die
cdr
Funktion macht dasselbe wie die car
Funktion, mit dem einzigen Unterschied, dass die übergebene Funktion das zweite Argument zurückgibt. - Die
NIL
Funktion funktioniert genauso wie die cons
, gibt jedoch ein Paar mit zwei Werten zurück, die NIL entsprechen.
cons = λ(a, b) λ(f) f(a, b); car = λ(cell) cell(λ(a, b) a); cdr = λ(cell) cell(λ(a, b) b); NIL = λ(f) f(NIL, NIL); x = cons(1, cons(2, cons(3, cons(4, cons(5, NIL))))); println(car(x));
Es gibt viele Algorithmen in Listen, die rekursiv implementiert werden können und logisch aussehen. Hier ist beispielsweise eine Funktion, die die übergebene Funktion für jedes Listenelement aufruft:
foreach = λ(list, f) if list != NIL { f(car(list)); foreach(cdr(list), f); }; foreach(x, println);
Und hier ist eine andere, die eine Liste für eine Reihe von Zahlen erstellt:
range = λ(a, b) if a <= b then cons(a, range(a + 1, b)) else NIL;
Die oben implementierten Listen sind unveränderlich (wir können das car
oder die cdr
nicht ändern car
nachdem die Liste erstellt wurde). Die meisten Lisp haben Funktionen zum Ändern eines Paares. In Schema werden sie set-car!
/ set-cdr!
. In Common Lisp, rplaca
/ rplacd
. Diesmal verwenden wir die Namen aus Schema:
cons = λ(x, y) λ(a, i, v) if a == "get" then if i == 0 then x else y else if i == 0 then x = v else y = v; car = λ(cell) cell("get", 0); cdr = λ(cell) cell("get", 1); set-car! = λ(cell, val) cell("set", 0, val); set-cdr! = λ(cell, val) cell("set", 1, val);
Dies zeigt, dass wir veränderbare Datenstrukturen implementieren können. Ich werde nicht näher darauf eingehen, wie es funktioniert, das geht aus dem Code hervor.
Wir können noch weiter gehen und die Objekte implementieren, aber ohne Änderungen in der Syntax wird es schwierig sein, dies zu tun. Eine andere Möglichkeit besteht darin, eine neue Syntax im Tokenizer / Parser zu implementieren und deren Verarbeitung im Interpreter hinzuzufügen. Alle wichtigen Programmiersprachen tun dies, und es ist notwendig, eine normale Leistung zu erzielen. Wir werden im nächsten Teil des Artikels eine neue Syntax hinzufügen.
[Vom Übersetzer: Wenn Sie sich für Lambda-Kalkül interessieren, gibt es einen coolen Artikel über Habré, der diesem Thema gewidmet ist: Lambda-Kalkül in JavaScript .]
Neue Syntaxkonstrukte
Unsere λ-Sprache hat einige syntaktische Konstruktionen. Beispielsweise gibt es keine direkte Möglichkeit, neue Variablen hinzuzufügen. Wie ich bereits sagte, müssen wir IIFE verwenden, also sieht es ungefähr so aus:
(λ(x, y){ (λ(z){
Wir werden das Schlüsselwort let
hinzufügen. Dadurch können wir so etwas schreiben:
let (x = 2, y = 3, z = x + y) print(x + y + z);
Für jede Variable im let
Block sollten vorherige Variablen auch aus demselben Block verfügbar sein. Daher entspricht der obige Code dem folgenden:
(λ(x){ (λ(y){ (λ(z){ print(x + y + z); })(x + y); })(3); })(2);
Diese Änderungen können direkt im Parser vorgenommen werden und erfordern dann keine Änderungen im Interpreter. Anstatt einen neuen let
Knoten hinzuzufügen, können wir ihn in call
und lambda
Knoten umwandeln. Dies bedeutet, dass wir keine semantischen Änderungen in unserer Sprache vorgenommen haben - dies wird als „syntaktischer Zucker“ bezeichnet, und die Operation zum Konvertieren in zuvor existierende AST-Knoten wird als „zuckerfrei“ bezeichnet (Original: „Desugaring“).
Wir müssen den Parser jedoch trotzdem ändern. Fügen wir einen neuen "let" -Knoten hinzu, da dieser effizienter interpretiert werden kann (Sie müssen keine Abschlüsse erstellen und diese sofort aufrufen, sondern müssen nur den Kontext kopieren und ändern).
Außerdem werden wir die Unterstützung für "let named" hinzufügen, die im Schema enthalten war. Es erleichtert das Erstellen von Schleifen:
print(let loop (n = 10) if n > 0 then n + loop(n - 1) else 0);
Dies ist eine "rekursive" Schleife, die die Summe von 10 + 9 + ... + 0 zählt. Zuvor mussten wir dies folgendermaßen tun:
print((λ(loop){ loop = λ(n) if n > 0 then n + loop(n - 1) else 0; loop(10); })());
Um dies zu vereinfachen, fügen wir die Syntax von "Funktionen mit einem Namen" hinzu. Es wird so aussehen:
print((λ loop (n) if n > 0 then n + loop(n - 1) else 0) (10));
Hierfür müssen Änderungen vorgenommen werden:
- Unterstützung für optionalen Namen nach
lambda
Schlüsselwort. Wenn es vorhanden ist, müssen wir dem aktuellen Kontext eine Variable hinzufügen, die auf die Funktion selbst verweist. Dies entspricht genau den Funktionen mit einem Namen in JavaScript. - Unterstützung für das neue Schlüsselwort
let
. Als nächstes folgen ein optionaler Name und eine Liste (möglicherweise leer) von Variablendefinitionen in dieser Form: foo = EXPRESSION
, durch Kommas getrennt. Der Körper des let
Ausdrucks ist ein einzelner Ausdruck (der natürlich eine Folge von Ausdrücken sein kann).
Parser ändert sich
Fügen Sie zunächst bei einer kleinen Änderung im Tokenizer das Schlüsselwort let
zur Liste der Schlüsselwörter hinzu:
var keywords = " let if then else lambda λ true false ";
Ändern Sie die Funktion parse_lambda
dass sie einen optionalen Namen liest:
function parse_lambda() { return { type: "lambda", name: input.peek().type == "var" ? input.next().value : null,
Fügen Sie nun eine Funktion hinzu, die den let
Ausdruck analysiert:
function parse_let() { skip_kw("let"); if (input.peek().type == "var") { var name = input.next().value; var defs = delimited("(", ")", ",", parse_vardef); return { type: "call", func: { type: "lambda", name: name, vars: defs.map(function(def){ return def.name }), body: parse_expression(), }, args: defs.map(function(def){ return def.def || FALSE }) }; } return { type: "let", vars: delimited("(", ")", ",", parse_vardef), body: parse_expression(), }; }
Dies behandelt zwei Fälle. Wenn nach let
ein Token vom Typ var
, wird dieses mit einem Namen let
. Außerdem lesen wir die Liste der Definitionen mit der Funktion " parse_vardef
, da sie in Klammern stehen und durch Kommas getrennt sind, und verwenden die Funktion " parse_vardef
, die unten gezeigt wird. Als nächstes geben wir einen Knoten vom Typ call
, der sofort eine Funktion namens (IIFE) aufruft. Die Argumente für die Funktion sind die durch let
definierten Variablen, und der Aufrufknoten übergibt die Werte als Argumente. Und natürlich wird der Hauptteil der Funktion mit parse_expression()
gelesen.
Wenn es sich um eine einfache let
, geben wir einen Knoten vom Typ let
mit den Feldern vars
und body
. Das Feld vars
enthält ein Array von Variablen im folgenden Format: { name: VARIABLE, def: AST }
, die von der folgenden Funktion analysiert werden:
function parse_vardef() { var name = parse_varname(), def; if (is_op("=")) { input.next(); def = parse_expression(); } return { name: name, def: def }; }
Außerdem müssen Sie in der Funktion parse_atom
eine Prüfung auf einen neuen Ausdruckstyp parse_atom
:
Interpreter-Änderungen
Da wir beschlossen haben, die Struktur des AST zu ändern, anstatt ihn in die alten Knotentypen zu „knacken“, müssen wir dem Interpreter die Verarbeitung der neuen Logik hinzufügen.
Um die Unterstützung für den optionalen Funktionsnamen hinzuzufügen, ändern wir die Funktion make_lambda
:
function make_lambda(env, exp) { if (exp.name) {
Wenn die Funktion einen Namen hat, erstellen wir beim Erstellen des Abschlusses eine Kopie des Kontexts und fügen die Funktion dem Kontext hinzu. Der Rest bleibt gleich.
Um einen Knoten vom Typ let
, fügen wir dem Interpreter den folgenden Fall hinzu:
case "let": exp.vars.forEach(function(v){ var scope = env.extend(); scope.def(v.name, v.def ? evaluate(v.def, env) : false); env = scope; }); return evaluate(exp.body, env);
Beachten Sie, dass für jede Variable ein neuer Kontext erstellt wird, in dem eine neue Variable hinzugefügt wird. Danach führen wir den Body einfach im letzten Kontext aus.
Beispiele
println(let loop (n = 100) if n > 0 then n + loop(n - 1) else 0); let (x = 2, y = x + 1, z = x + y) println(x + y + z);
— .
. , , , . JavaScript λ.
:
globalEnv.def("fibJS", function fibJS(n){ if (n < 2) return n; return fibJS(n - 1) + fibJS(n - 2); }); globalEnv.def("time", function(fn){ var t1 = Date.now(); var ret = fn(); var t2 = Date.now(); println("Time: " + (t2 - t1) + "ms"); return ret; });
time
, , , .
fib = λ(n) if n < 2 then n else fib(n - 1) + fib(n - 2); print("fib(10): "); time( λ() println(fib(10)) ); print("fibJS(10): "); time( λ() println(fibJS(10)) ); println("---");
, Google Chrome, n (27), λ , , JS 4 . , , .
λ JavaScript. , for
/ while
; JS. ? JS , .
, , JavaScript, , JavaScript.
, , . , .
Fazit
, . , - ; , , ? — JavaScript. , JavaScript — ? , , JavaScript, , , . JavaScript ( , ).
, , Lisp — : //. , , .. Lisp . Lisp let
, , Lisp.
: JavaScript. Teil 3: CPS-Dolmetscher