MetaES docs
Caveat: what you're reading is an alpha version of docs from 2019. Bigger rewrite is planned for 2023.
About MetaES as a metacircular interpreter
MetaES is a ECMAScript metacircular interpreter. You can learn more about such interpreter for example in SICP book that is available for free online https://mitpress.mit.edu/sites/default/files/sicp/full-text/book/book.html
Metacircular interpreter interprets the language that it is written in, but that interpretation process is easier, because there is a lot of features implemented in base interpreter. In case of MetaES those features are available in every ECMAScript 5.1+ interpreter, for example:
- operators like
+
,-
,*
,/
, - literals parsing:
boolean
,String
,Number
, Objects -{...}
, Arrays -[...]
etc, - functions support (with closures), internal function calling -
[[Call]]
, function methods -bind
,apply
,call
etc, - prototypes – MetaES doesn’t rewrite them,
- objects creation with
new
,Object.create
, - standard global objects, like
Object
,Array
,String
,Date
etc.
Therefore, the big part of metacircullar interpretation is just reusing capabilities of original interpreter. However, MetaES adds some informations available to user, that in normal execution are hidden and possibly available only through the debugger API specific for each engine or non-standard functions.
Installation
Follow README.md on GitHub.
Using MetaES
It's highly recommended to read Deeper understanding of MetaES first. That will make reading this manual way easier. You can skip it if you just want to use MetaES in a basic way.
Before beginning
We need to understand one thing outside of MetaES business - parsing and ASTs - Abstract Syntax Trees.
Let's say we have a function parse
that accepts string and returns AST object:
parse('2+2');
// returns
{
"type": "Program",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Literal",
"value": 2,
"raw": "2"
},
"right": {
"type": "Literal",
"value": 2,
"raw": "2"
}
}
}
],
"sourceType": "script"
}
AST represents code in a structure that can be evaluated, compiled, transformed etc. - you name it.
Currently used parser is Esprima. For MetaES it doesn't matter as long produced AST is of the same shape. Ideally, parse
function would be provided by the browser or host environment where MetaES is run.
Going a bit ahead, here's one interesting fact: you can get along without parser at all, as long you want to talk to MetaES using ASTs.
Basic evaluation
In simplest case you can write:
metaesEval("2+2", console.log);
which will eval to 4
- a success value. If you want to get back result, you need to provide success callback as 2nd parameter.
Errors are suported in second callback:
metaesEval("x", null, console.error);
This code will throw an error and error will be logged to the console.
You can omit success and error callback entirely - metaesEval('doAnythingCorrect()')
- but you won't get back the result.
You can run this way any code:
let anyLongCode = `
let input = [1,2,3];
let squared = input.map(x=>x*x);`;
metaesEval(anyLongCode, console.log);
that parses. Otherwise:
metaesEval(`1x`, null, console.error);
will throw parse error.
Remember that:
metaesEval("throw 1");
will not throw. You have to provide error callback. It can throw only if error happens internally in MetaES implementation.
You can also run AST directly:
metaesEval(
{
type: "BinaryExpression",
operator: "+",
left: {
type: "Literal",
value: 2,
raw: "2"
},
right: {
type: "Literal",
value: 2,
raw: "2"
}
},
console.log
); // 4
That way MetaES won't need to parse source. Downside is there's no AST validator - a JavaScript parser was validating incorrect programs. MetaES doesn't check AST correctness too.
metaFunction
The last bit to explain on this stage is evaluation of functions:
metaesEval(function(x) {
return x * x;
}, console.log);
Result is a function - a metacircular function. When called, it runs MetaES previously parsed its stringified sources:
let fn;
metaesEval(
function(x) {
return x * x;
},
result => (fn = result)
);
fn(2); // 4;
But, it's not a full featured function - as is function in native interpterer - metacircular function doesn't support closures. You'll have to construct closure by yourself. We'll come back to that later. All in all, that's convenient way to run MetaES code wrapped inside function.
You can think of metaFunction
as a wrapper for metaesEval
.
Environment
MetaES allows direct interaction with host environment. Host environment is interpreter environment that is used to run MetaES.
For example:
let user = { name: "User1" };
metaesEval(`user.name="User2"`, console.log, console.error, { user });
console.log(user);
At the end user.name
will be equal to User2
, because this is how it was changed in MetaES.
However, if you try to access another variable:
let user = { name: "User1" };
let user2 = { name: "User2" };
metaesEval(`user2.name="User2"`, console.log, console.error, { user, user3 });
It will throw a ReferenceError
.
Environments can have any number of properties.
Those were examples of shortcut object environment, which internally were converted to regular environments.
Regular environment consists of two fields: values
(shortcut values go here) and prev
, which contains reference to outer environment. prev
is optional. It works similarly to prototypical inheritance in ECMAScript.
Let's see:
let environment = { values: { a: 1 } };
metaesEval("a*2", console.log, console.error, environment);
You may want to create environments chain and MetaES does it anyway the same way as JavaScript does:
let environment0 = { values: { b: 2 } };
let environment = { values: { a: 1 }, prev: environment0 };
metaesEval("a*b", console.log, console.error, environment);
MetaES will recursively look for variables until prev
field exists. Environment without prev
is a global environment.
If variable is not found, it will throw.
Variables shadowing works as expected.
Try to play with values of environment0
and environment
and shadow b
variable from environment0
.
Going back to the previous example and improving it a bit:
let fn;
let b = 3;
metaesEval(
function(x) {
return x * x * b;
},
result => (fn = result),
console.error
);
fn(2); // ReferenceError: "b" is not defined
b
is there defined on line 2. But is not visible for metacircular function.
Remember that function was stringified using its .toString()
method. b
in closure can be fixed in a following manual way:
let fn;
let b = 3;
metaesEval(
function(x) {
return x * x * b;
},
result => (fn = result),
console.error,
{ b }
);
fn(2); // 12
Now b
belongs to fn
closure, because it was there at the time of function creation.
Trying to cheat environment and adding b
on-the-fly doesn't work:
let fn;
let b = 3;
metaesEval(
function(x) {
return x * x * b;
},
result => (fn = result),
console.error
);
metaesEval(
// [1]
`fn(2)`,
console.log,
console.error,
{ fn, b }
);
This is because MetaES and JavaScript support static variable binding, not dynamic. It means variables in environment do not flow down to the function. If variables weren't seen in environments chain during creation of function, they're not available in function body during execution.
Another interesting thing is you can pass around metacircular MetaES functions as if there were normal functions. Because they are on the surface. That happened in [1]
.
typeof fn === "function"; // true
There is hacky way to add missing b
to closure:
let fn;
let b = 3;
metaesEval(
function(x) {
return x * x * b;
},
result => (fn = result)
);
fn.__meta__.closure.values.b = b;
fn(2);
Conceptually it's similar to non-standard __proto__
in JavaScript engines.
On top of metaesEval
function there are created utility functions, like evalToPromise
, evalFunctionBody
, evaluateFunction
etc. Go ahead to the MetaES GitHub repository and explore.
Interceptor
Onto the last but not least argument of metaesEval
- config
.
config
is a configuration object, not a single value.
Provided config
will be passed around during execution of given script inside MetaES, until the execution ends.
It may contain many things, but most interesting is interceptor
.
Think of an interceptor
as a function that is called every time MetaES enters or exits AST node.
It's a basic building block for many MetaES use cases.
Example:
let start = new Date().getTime();
let padding = 0;
let source = "a+2";
metaesEval(
source,
console.log, // 4
console.error,
{ values: { a: 2 } },
{
interceptor({ phase, timestamp, e, value, config }) {
if (phase === "exit") {
padding--;
}
console.log(
`[${timestamp - start}ms] script${config.script.scriptId}:${
e.loc ? e.loc.start.line + "," + e.loc.start.column : "*"
}`,
"\t",
" ".repeat(padding),
e.type,
e.range ? `"${source.substring(e.range[0], e.range[1])}"` : "",
`(${value})`
);
if (phase === "enter") {
padding++;
}
}
}
);
will output:
1ms enter Program
1ms enter ExpressionStatement
1ms enter BinaryExpression
2ms enter Identifier
2ms enter GetValue
2ms exit GetValue
3ms exit Identifier
3ms enter Literal
4ms exit Literal
5ms exit BinaryExpression
5ms exit ExpressionStatement
6ms exit Program
That kind of log is useful for all sorts of instrumentation tools. enter
/exit
phases create tree structure, let's try to visualize it better and add some more metadata:
let start = new Date().getTime();
let padding = 0;
let source = `var a = 2;
var b = 3;
b+a;`;
metaesEval(
source,
console.log, // 4
console.error,
{ values: { a: 2 } },
{
interceptor({ phase, timestamp, e, value, config }) {
if (phase === "exit") {
padding--;
}
console.log(
`[${timestamp - start}ms] script${config.script.scriptId}:${
e.loc ? e.loc.start.line + "," + e.loc.start.column : "*"
}`,
"\t",
" ".repeat(padding),
`${phase === "enter" ? "↓" : "↑"}${e.type}:`,
e.range
? `"${source
.substring(e.range[0], e.range[1])
.replace(/\n/g, "\\n")}"`
: "",
`=> ${value}`
);
if (phase === "enter") {
padding++;
}
}
}
);
Output is more verbose:
[8ms] script0:1,0 ↓Program: "var a = 2;\nvar b = 3;\nb+a;" => undefined
[12ms] script0:1,0 ↓VariableDeclaration: "var a = 2;" => undefined
[12ms] script0:1,4 ↓VariableDeclarator: "a = 2" => undefined
[13ms] script0:1,8 ↓Literal: "2" => undefined
[13ms] script0:1,8 ↑Literal: "2" => 2
[13ms] script0:* ↓SetValue: => undefined
[13ms] script0:* ↑SetValue: => 2
[13ms] script0:1,4 ↑VariableDeclarator: "a = 2" => 2
[13ms] script0:1,0 ↑VariableDeclaration: "var a = 2;" => 2
[14ms] script0:2,0 ↓VariableDeclaration: "var b = 3;" => undefined
[14ms] script0:2,4 ↓VariableDeclarator: "b = 3" => undefined
[14ms] script0:2,8 ↓Literal: "3" => undefined
[14ms] script0:2,8 ↑Literal: "3" => 3
[14ms] script0:* ↓SetValue: => undefined
[14ms] script0:* ↑SetValue: => 3
[14ms] script0:2,4 ↑VariableDeclarator: "b = 3" => 3
[14ms] script0:2,0 ↑VariableDeclaration: "var b = 3;" => 3
[14ms] script0:3,0 ↓ExpressionStatement: "b+a;" => undefined
[14ms] script0:3,0 ↓BinaryExpression: "b+a" => undefined
[15ms] script0:3,0 ↓Identifier: "b" => undefined
[15ms] script0:* ↓GetValue: => undefined
[15ms] script0:* ↑GetValue: => 3
[15ms] script0:3,0 ↑Identifier: "b" => 3
[15ms] script0:3,2 ↓Identifier: "a" => undefined
[15ms] script0:* ↓GetValue: => undefined
[15ms] script0:* ↑GetValue: => 2
[15ms] script0:3,2 ↑Identifier: "a" => 2
[15ms] script0:3,0 ↑BinaryExpression: "b+a" => 5
[16ms] script0:3,0 ↑ExpressionStatement: "b+a;" => 5
[16ms] script0:1,0 ↑Program: "var a = 2;\nvar b = 3;\nb+a;" => 5
This way of using interceptor is a base for flame graph building and creating context that can be observed.
It's time to go to other config
field - intepreters
.
Interpreters
If you are after Deeper understanding of MetaES, it will be a repetition.
Normally you run metaesEval
like that:
metaesEval("2+a", console.log, console.error); // Error
Already we know, to fix missing a
you can provide an environment:
metaesEval("2+a", console.log, console.error, { a: 2 }); // 4
But what if we want to delay a
dereferencing util a
is hit in the runtime? We'd want use custom Identifier
interpreter, meaning we want to tell MetaES how to resolve variables. Maybe that interpreter would reach over HTTP, WebSocket etc. and that would take uknown amount of time? It can be done with rewriting of interpreters
field:
const interpreters = {
prev: ECMAScriptInterpreters,
values: {
Identifier(e, c, cerr) {
c(44);
}
}
};
metaesEval(
"var b=3; 2+a+b",
console.log,
console.error,
{ a: 2 },
{ interpreters }
); // 90
Few things to note:
interpreters
is anenvironment
,interpreters
environment has to reference at some point an outer environment which isECMAScriptInterpreters
. This environment is provided by MetaES itself and contains interpreters to interpret ECMAScript nodes. Otherwise MetaES will just throwNotImplementedException
for rest of the nodes,- we actually broke
Indentifier
- it always returns 44, even if variable is defined.
Let's fix it:
const promised44 = () => Promise.resolve(44);
const interpreters = {
prev: ECMAScriptInterpreters,
values: {
Identifier(e, c, cerr, env, config) {
ECMAScriptInterpreters.values.Identifier(
e,
c,
_ =>
promised44()
.then(c)
.catch(cerr),
env,
config
);
}
}
};
metaesEval("var b=3; 2+a+b", console.log, console.error, {}, { interpreters }); // 49
Identifier
first tries to reach variable in a standard way. When fails, it always falls back with promised44
- a Promise
that resolves for any value, in example it's always 44.
Synchronous vs asynchronous code
When overriding default behaviour of MetaeES - or using MetaES mixed with native code - you have to remember that not all code can be stopped or changed that easily. Those situations include native functions, like Array.prototype.forEach
, Array.prototype.{map, filter, reduce}
etc.
For example:
const interpreters = {
prev: ECMAScriptInterpreters,
values: {
Literal(e, c, cerr, env, config) {
setTimeout(c, e.value);
}
}
};
metaesEval(
"[1,2,3].map(x=>x*x)",
console.log,
console.error,
{},
{ interpreters }
); // [ NaN, NaN, NaN ]
Why not [ 1, 4, 9 ]
? Array.prototype.map
doesn't know anything about MetaES. Even x=>x*x
being a metacircular function is not a problem:
metaesEval(
"x=>x*x",
mapper =>
// back in native code, but mapper is a metafunction
console.log([1, 2, 3].map(mapper)), // [1,4,9]
console.error
);
Problem is Array.prototype.map
wants result synchronously - immediately without leaving current callstack. This is the same issue as if there was provided asynchronous function.map
won't unpack Promise
or anything else.
Connect all the dots
Let's sum up what we've learned so far and create example service - "MetaQL", a GraphQL-inspired library.
We want to send a string query to the server through HTTP request and get JSON response.
First, a node.js part with express.js
and body-parser
:
import { metaesEval } from "metaes/lib/metaes";
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
const port = 3000;
app.use(bodyParser.json());
app.post("/", (req, res) => {
const { source, env } = req.body;
console.log(req.body);
try {
metaesEval(
source,
result => res.json(result),
error =>
res.status(500).json(
Object.assign({}, error, {
value: error.value.toString()
})
),
env
);
} catch (e) {
res.status(500).json({ value: e.message });
}
});
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Running $ curl --header "Content-Type: application/json" -X POST -d '{"source":"2+2"}' localhost:3000
will give back 4
, but
$ curl --header "Content-Type: application/json" -X POST -d '{"source":"a"}' localhost:3000
will output:
{
"type": "ReferenceError",
"value": "ReferenceError: \"a\" is not defined.",
"location": {
"type": "Identifier",
"name": "a",
"range": [0, 1],
"loc": {
"start": { "line": 1, "column": 0 },
"end": { "line": 1, "column": 1 },
"source": "true"
}
}
}
Adding env
field fixes the problem:
$ curl --header "Content-Type: application/json" -X POST -d '{"source":"a", "env":{"a":4}}' localhost:3000
gives 4
.
Now onto implementing something useful: current user and his friends:
import { metaesEval } from "metaes/lib/metaes";
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
const port = 3000;
app.use(bodyParser.json());
const users = Array.from({ length: 10 }, (_, i) => ({ name: "User" + i }));
function getCurrentUser() {
return users[Math.floor(Math.random() * users.length)];
}
function getFriendsOf(user) {
return users.filter(candidate => candidate !== user);
}
const global = { values: { getCurrentUser, getFriendsOf } };
app.post("/", (req, res) => {
const { source, env } = req.body;
try {
metaesEval(
source,
result => res.json(result),
error =>
res.status(500).json(
Object.assign({}, error, {
value: error.value.toString()
})
),
{ values: env || {}, prev: global }
);
} catch (e) {
res.status(500).json({ value: e.message });
}
});
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
Usage:
curl --header "Content-Type: application/json" -X POST -d '{"source":"getFriendsOf(getCurrentUser())"}' localhost:3000
outputs: [{"name":"User0"},{"name":"User1"},{"name":"User2"},{"name":"User3"},{"name":"User4"},{"name":"User5"},{"name":"User6"},{"name":"User8"},{"name":"User9"}]
.
Done. You can obviously compose results in any way you want, using complex queries like:
curl --header "Content-Type: application/json" -X POST -d '{"source":"let user = getCurrentUser(); let friends = getFriendsOf(user); ({ user, friends }); "}' localhost:3000
to get:
{
"user": { "name": "User8" },
"friends": [
{ "name": "User0" },
{ "name": "User1" },
{ "name": "User2" },
{ "name": "User3" },
{ "name": "User4" },
{ "name": "User5" },
{ "name": "User6" },
{ "name": "User7" },
{ "name": "User9" }
]
}
Note that script in MetaES evaluates to last expression; we had to wrap it in ()
to make it parsable by parser.
Further ideas to support as an exercise:
- allow asynchronous access - maybe
getCurrentUser()
will talk to database? - restrict
interpreters
- maybe allow using onlyCallExpression
,Identifier
and couple of other nodes to improve sequrity and make performance more predictable? - limit time of execution, limit number of interceptor calls?
Contexts and scripts
Contexts and scripts are mere a ways to organize and optimize metaesEval
calls. In project like Vanillin, there are possibly hundreds of scripts at the same time, maybe running cooperatively at the same time.
Context
Context is a place where scripts can be executed. It's basically abstraction layer over metaesEval
that remembers some default values - like default environment.
If you've ever developed browser extension, you remember there are page scripts and content scripts. Page scripts are normal script tags, but page scripts run in a different context next to page scripts and can't easily see window
object.
Similarly, context concept semantically defines a place where code will be executed (maybe in a different process, maybe on server, maybe in ServiceWorker, WebWorker?) and what global variables will be available for each script.
Example:
const context = new MetaesContext(
// default success callback
console.log,
// default error callback
console.error,
// global scope
{ Math }
);
context.evaluate("Math.random()");
// or even
const context2 = consoleLoggingMetaesContext({ Math });
// context2.evaluate(...);
As can you see, once defined callbacks and environment doesn't have to be repeated over and over again.
What is desirable, they can be overriden per call:
context.evaluate("2+x", null, null, { x: 2 });
null
s mean keeping default values. Order of arguments is the same as in metaesEval
.
Here are actuall TypeScript definitions used for contexts:
export type Evaluate = (
input: Script | Source,
c?: Continuation | null,
cerr?: ErrorContinuation | null,
environment?: Environment | object,
config?: Partial<EvaluationConfig>
) => void;
export interface Context {
evaluate: Evaluate;
}
This means, the only method required by Context
is evaluate
. Evaluation may happen anywhere including server, other process as long objects transferring from memory to memory is provided. MetaES has some features supporting that, but it's still in tests. Read more in part about remote contexts.
Script
Script is a container for code that can be executed in a context. Script can be produced from string (as in native JavaScript) which is parsed by JavaScript parser, from native JavaScript function and from JSON object. Script can be produced from any source, but eventually all kinds of inputs should be transformed to AST. Advantage of scripts is they don't have to be reparsed every time they're evaluated. They also have an unique ID (per host interpreter), which is useful for debugging and orchestration.
Example:
const script = createScript(`console.log('hello world')`);
metaesEval(script, console.log, console.error, { console });
For enabled caching:
const cache = createCache();
const script = createScript(`console.log('hello world')`, cache);
// won't be parsed again, AST object will be taken from cache
const script2 = createScript(`console.log('hello world')`, cache);
metaesEval
vs eval
metaesEval
is like eval
, but improved im some ways and limited in other. Let's compare.
First let's create metaesEval
wrapper to ease experiments.
function eval2(source, env) {
let result, error;
metaesEval(source, r => (result = r), e => (error = e), env);
if (error) {
throw error;
}
return result;
}
Now we can do:
eval("2+2") === eval2("2+2"); // true;
Errors work too:
let error1, error2;
try {
eval("throw 1");
} catch (e) {
error1 = e;
}
try {
eval2("throw 1");
} catch (e) {
error2 = e.value;
}
error1 === error2;
We had to unpack error thrown by eval2
- in MetaES they're called exceptions and exceptions wrap original JavaScript error.
Consider other example:
var a = 2;
eval("a+2"); // 4
standard eval
captures surrounding scope, parses string end executes script. But:
var a = 2;
eval2("a+2"); // ReferenceError: a is not defined
doesn't work. There is no automatic closures or scope capturing in MetaES. We can mitigate it if we know how to use call/cc
in MetaES. Read more about call/cc
, and see example:
eval3(function() {
var a = 2;
eval2("a+2");
});
We'd have to create eval3
, where eval2
is predefined in its environment and is able to capture surrunding environment.
Final examples:
eval2("eval(1+2)"); // ReferenceError: eval is not defined.
eval2("eval(1+2)", { eval }); // 3
eval('eval2("eval(1+2)", {eval})'); // 3
Observable context
Observable context follows loosely style of Proxy object. ObservableContext
ihnerits from MetaesContext
.
Example:
const value = { toObserve: {} };
const context = new ObservableContext(value);
context.addHandler({
target: value.toObserve,
traps: {
set(object, prop, value) {
console.log("set", object, prop, value);
},
didSet(object, prop, value) {
console.log("didSet", object, prop, value);
}
}
});
const source = `self.toObserve.foo="bar"`;
context.evaluate(source);
// outputs:
// 'set' {} 'foo' 'bar'
// 'didSet' { foo: 'bar' } 'foo' 'bar'
All currently avialable traps are defined in Traps interface:
type Traps = {
set?: (target: object, key: string, args: any) => void;
didSet?: (target: object, key: string, args: any) => void;
apply?: (
target: object,
method: Function,
args: any[],
expressionValue: any
) => void;
didApply?: (
target: object,
method: Function,
args: any[],
expressionValue: any
) => void;
};
ObservableContext
constructor requires 2 arguments:
target
- an object with will be come global environment and also will be available underself
variable,mainTraps
- optionalTraps
object that will be attached totarget
object.
addHandler
expects EvaluationHandler
which is:
type ObserverHandler = {
target: any;
traps: Traps;
};
Flame graph
FlameGraph
represents history of evaluation for given script. Consider:
const value = {
a: 1,
b: 2,
c() {
return 3;
}
};
const context = new ObservableContext(value);
context.addListener(function(evaluation, flameGraph) {
if (
flameGraph.executionStack.length === 1 &&
evaluation.e.type === "Program"
) {
console.log(flameGraph.executionStack);
}
});
context.evaluate(`a+b+c()`);
console.log
will output deeply nested object of whole AST interpretation. ObservableContext
and FlameGraph
are entirely based on interceptor. Go to the chapter about interceptor to understand it.
Remote contexts (Work in Progress)
Remote contexts implement Context
interface over JSON messages communication. To make it work its required to serialize/deserialize objects, gradually sending them from context to context. JSON can be send using any protocol, currently supported are HTTP and WebSockets.
For example:
const serverContext = new MetaesContext(console.log, console.error, {
user: { firstName: "user1" }
});
const server = await runWSServer("3000", serverContext); // runs both WS and HTTP server
// a) read with HTTP
// Style 1
fetch("localhost:3000", { method: "post", body: "user.firstName" }).then(d =>
d.text()
); // user1
// Style 2
const serverContext = createHTTPConnector("localhost:3000");
serverContext.evaluate("user.firstName", console.log); // user1
// b) read with WebSockets
const serverContextOverWS = createWSConnector(WebSocket)("ws://localhost:3000");
serverContextOverWS.evaluate("user.firstName", console.log); // user1
Deeper understanding of MetaES
CPS style
Let's start with CPS - continuation passing style. Let's say we've got a function to add two numbers:
function add(a, b) {
return a + b;
}
To use it just write:
function add(a, b) {
return a + b;
}
console.log(add(1, 2)); // 3
But imagine we can't use return
keyword, because it doesn't exist in a language. How could we give caller the result back? Using callbacks:
function add(a, b, onSuccess) {
onSuccess(a + b);
}
add(1, 2, result => {
// [2] here we can continue our program
console.log(result); // 3
});
// [1] don't know how to continue from this point using result
Here comes the problem: how to continue after result is given to the caller at position [1]
? We're waiting for the result inside result => console.log(result)
callback ([2]
), not outside add
call. Here we enter callback based control flow, very well known from node.js which very easily turns into callback hell. That can be fixed using generators or async/await.
But in case of MetaES, callbacks are what we want. They are low level, fast, controllable, allow to suspend execution, allow to recreate any other control flow. We can also use more than one callback: one for success value, second for error value:
function add(a, b, onSuccess, onError) {
if (typeof a === "number" && typeof b === "number") {
onSuccess(a + b);
} else {
onError(new Error("one of the operands is not a number"));
}
}
// usage
add(1, 2, result => console.log("result is:", result), console.error);
Price to pay is worst readability of MetaES source code. Most of MetaES sources in fact could be written in async/await style and then compiled to older ECMAScript version if needed, but this approach would cause compiled code size explosion and would disallow to use "calling with current continuation" for other control flows.
Let's rename few variables in add
function:
function add(a, b, c, cerr) {
if (typeof a === "number" && typeof b === "number") {
c(a + b);
} else {
cerr(new Error("one of the operands is not a number"));
}
}
// usage
add(1, 2, result => console.log("result is:", result), console.error);
c
is a continuation
. cerr
is error continuation
. Now we now what continuation passing style stands for: it is programming with callbacks for control flow, not return/throw.
Let's go on step further with abstraction - abstract over evaluation.
Instead of writing add(1, 2, result => console.log("result is:", result), console.error);
we want to type:
evaluate(
add,
[1, 2],
result => console.log("result is:", result),
console.error
);
evaluate
becomes:
function evaluate(fn, args, c, cerr) {
const fnWithArgs = fn.bind(null, args);
fnWithArgs(c, cerr);
}
// use it
evaluate(
add,
[1, 2],
result => console.log("result is:", result),
console.error
);
Next step, eliminate add
reference and use lookup table (a JavaScript object):
function add(a, b, c, cerr) {
if (typeof a === "number" && typeof b === "number") {
c(a + b);
} else {
cerr(new Error("one of the operands is not a number"));
}
}
function evaluate(fnName, args, c, cerr) {
const functions = { add };
const fn = functions[fnName];
if (fn) {
fn.apply(null, args.concat([c, cerr]));
} else {
cerr(new ReferenceError(`${fnName} is not defined`));
}
}
// use it
evaluate(
"add",
[1, 2],
result => console.log("result is:", result),
console.error
);
// it will throw
evaluate(
"add2",
[1, 2],
result => console.log("result is:", result),
console.error
);
Next piece is function subcalls. Simply use evaluate
again with different args:
function add(a, b, c, cerr) {
if (typeof a === "number" && typeof b === "number") {
c(a + b);
} else {
cerr(new Error("one of the operands is not a number"));
}
}
function multiply(a, b, c, cerr) {
if (typeof a === "number" && typeof b === "number") {
c(a * b);
} else {
cerr(new Error("one of the operands is not a number"));
}
}
function evaluate(fnName, args, c, cerr) {
const functions = { add, multiply };
const fn = functions[fnName];
if (fn) {
fn.apply(null, args.concat([c, cerr]));
} else {
cerr(new ReferenceError(`${fnName} is not defined`));
}
}
evaluate(
"add",
[1, 2],
result =>
// got result from adding, now multiply it
evaluate(
"multiply",
[result, 3],
result => console.log("result is:", result),
console.error
),
console.error
);
We can add and multiply in a verbose way. Let's add few more abstractions to be able to collapse boilerplate code:
Let's change numbers to Literals
:
const literal1 = { type: "Literal", value: 1 };
const literal2 = { type: "Literal", value: 2 };
const literal3 = { type: "Literal", value: 3 };
and create AST-like structures for add
and multiply
function calls. They're not functions anymore, but AST binary expressions:
const add = {
type: "BinaryExpression",
operator: "+",
left: literal1,
right: literal2
};
const multiply = {
type: "BinaryExpression",
operator: "*",
left: add,
right: literal3
};
Now refactor evaluate
to accept AST-like nodes, not strings mixed with function names:
const functions = {
Literal(node, c) {
c(node.value);
},
BinaryExpression(node, c) {
evaluate(node.left, left =>
evaluate(node.right, right => {
switch (node.operator) {
case "+":
c(left + right);
break;
case "*":
c(left * right);
break;
}
})
);
}
};
function evaluate(node, c, cerr) {
let fn = functions[node.type];
fn
? fn(node, c, cerr)
: cerr(new Error(`${node.type} function is not implemented yet.`));
}
evaluate(
{
type: "BinaryExpression",
operator: "+",
left: {
type: "Literal",
value: 1,
raw: "1"
},
right: {
type: "BinaryExpression",
operator: "*",
left: {
type: "Literal",
value: 2,
raw: "2"
},
right: {
type: "Literal",
value: 3,
raw: "3"
}
}
},
console.log
);
Done. And is very close to actual MetaES implementation. As an exercise you can implement evaluate
in your favourite language.
At this point you can use parser:
// Let's pretend we went few steps forward
const metaesEval = evaluate;
metaesEval(parse("2+2"), console.log, console.error);
As an exercise add implementation of missing node types - Program
and Expression
.
For the record, many of ECMAScript AST nodes and internal operations had to be implemented in similar way.
Here's incomplete list:
"SetValue", "GetValue", "Identifier", "Literal", "Apply", "GetProperty", "SetProperty", "CallExpression", "MemberExpression", "ArrowFunctionExpression", "FunctionExpression", "AssignmentExpression", "ObjectExpression", "Property", "BinaryExpression", "ArrayExpression", "NewExpression", "SequenceExpression", "LogicalExpression", "UpdateExpression", "UnaryExpression", "ThisExpression", "ConditionalExpression", "TemplateLiteral", "BlockStatement", "Program", "VariableDeclarator", "VariableDeclaration", "AssignmentPattern", "IfStatement", "ExpressionStatement", "TryStatement", "ThrowStatement", "CatchClause", "ReturnStatement", "FunctionDeclaration", "ForInStatement", "ForStatement", "ForOfStatement", "WhileStatement", "EmptyStatement", "ClassDeclaration", "ClassBody", "MethodDefinition", "DebuggerStatement"
Call with current continuation
Let's look at MetaES example use:
metaesEval(
`2 + callcc(function(){ console.log("args", arguments); }, 'an argument')`,
result => console.log("result", result),
console.error,
{
callcc: callWithCurrentContinuation,
console
}
);
will output:
args: [ 'an argument',
[Function],
[Function],
{ values:
{ callcc: [Function: callWithCurrentContinuation],
console: [Console] } },
{ script:
{ source: '2+callcc(function(){ console.log("args:", arguments); }, \'an argument\')',
ast: [Script],
scriptId: '0' },
interpreters: { values: [Object] },
interceptor: [Function: noop] } ]
As you can see, callcc
calls given function with 5 arguments which are the same for every node intepreters. That means 2nd argument is a success continuation. Also notice, "result"
wasn't logged. Script never finished, because callcc
didn't resume evaluation. Let's resume with arguments[1](2)
function call:
metaesEval(
`2 + callcc(function(){ console.log("args", arguments); arguments[1](2) }, 'an argument')`,
result => console.log("result", result),
console.error,
{
callcc: callWithCurrentContinuation,
console
}
); // 4
Now we have 4
as a success result.
It means that callcc
- "call with current continuation" - calls function provided as first argument passing it 5 params, where two of them are c
and cerr
.
Let's refactor code to make it more readable and create something more useful - asynchronous fetch functions, similar to async/await
with Promises:
// [1]
const resources = {
"/me": { firstName: "User1" },
"/me/friends": [
{ firstName: "User2" },
{ firstName: "User3" },
{ firstName: "User4" }
]
};
// [2]
function fetcher(path, c, cerr) {
const response = resources[path];
response
? c(response)
: cerr(new Error(`Resource '${path}' does not exist.`));
}
// [3]
let fetch;
metaesEval(`path=>callcc(fetcher, path)`, fn => (fetch = fn), console.error, {
fetcher,
callcc: callWithCurrentContinuation
});
metaesEval(
// [4]
`fetch('/me').firstName + ' has ' + fetch('/me/friends').length + ' friends'`,
console.log,
console.error,
{
console,
fetch
}
); // User1 has 3 friends
Explanation:
[1]
- simulate network responses,[2]
-fetcher
is a function that was called by callcc.fetcher
is in charge or resuming evaluation usingc
orcerr
with a value,[3]
- create metafunction with boundcallcc
in closure and assign it to variablefetch
.fetch
will be used as a wrapper forcallcc
. Directcallcc
calls look unnatural and it's a leaking abstraction for us.fetch
getspath
as an argument and forwards it tocallcc
.callcc
then stops evaluation and asksfetcher
function to handlec
andcerr
. Whenfetcher
calls any of those two continuations, the value ofcallcc(...)
call becomes what was provided byfetcher
,[4]
demonstrates previous statement.fetch('/me')
becomes{ firstName: "User1" }
.
Advantages and disadvantages of callcc
callcc allows reimplementation of all sorts of control flows: coroutines, asynchronity, iteration, generators and others.
Downside is higher maintenance price tag, especially for beginners. Even after creating many examples it's easy to get confused. Because call/cc
twists execution flow in any direction. Consider this:
const results = [];
let cc;
function receiver(_, _cc) {
cc = _cc;
cc([1, 2, 3]);
}
metaesEval(
`for (let x of callcc(receiver)) {
results.push(x);
}`,
null,
console.error,
{ callcc: callWithCurrentContinuation, receiver, results }
);
console.log("results", results); // results [ 1, 2, 3 ]
cc([4, 5, 6]);
console.log("results", results); // results [ 1, 2, 3, 4, 5, 6 ]
cc
was called completely outside of MetaES, after script already finished. Then script finished again.
As exercises you can implement following concepts callable form MetaES space:
getCurrentCallstack()
getThisFunctionClosure()
- your own
Proxy
object - your own
await
function - your own generator function with
yield
support