Control flow
CPS style
Let's talk about 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, 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 the language. How could we pass result back to caller? By 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 passed 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. Fortunately, this can be fixed using generators or async/await.
However - in case of metaES - callbacks are what we want. They are low level, fast, controllable, allow to suspend execution and allow to recreate any other control flow. We can also go ahead use more than one callback: one for success value, second for error value - an exception:
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 slightly worst readability of metaES source code, but with tools like `forwarding` it's still pretty decent to read and stays maintainable. Most of metaES sources in fact could be tried to 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" and 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 statements.
Let's go one more step on abstraction ladder - abstract out the function call.
Instead of writing add(1, 2, result => console.log("result is:", result), console.error);
we'd want to type:
evaluate(add, [1, 2], (result) => console.log("result is:", result), console.error);
Therefore, 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);
As next step, eliminate add
direct reference and use string based lookup. Lookup table will be implemented using pure 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"));
}
}
// lookup table
const functions = { add };
function evaluate(fnName, args, c, cerr) {
const fn = functions[fnName];
if (fn) {
fn.apply(null, args.concat([c, cerr]));
} else {
cerr(new ReferenceError(`${fnName} is not defined`));
}
}
// example of positive succesful result
evaluate("add", [1, 2], (result) => console.log("result is:", result), console.error);
// example of error result
evaluate("add2", [1, 2], (result) => console.log("result is:", result), console.error);
Next piece is function subcalls. To achieve that, use evaluate
again, this time with different args:
// add different function
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"));
}
}
const functions = {
add,
// add to lookup table
multiply
};
evaluate(
"add",
[1, 2],
(result) =>
// got result from adding, now multiply it
evaluate(
"multiply",
[result, 3],
(result) =>
// that's our result
console.log("result is:", result),
console.error
),
console.error
);
We already can see two problems:
- two callbacks are required to support simple arthmetic operations,
- we need to handle error continuations twice (console.error is used twice), one time for each call. Errors can't be propagated yet.
Depsite the issues, we can add and multiply - in a verbose way though. Let's add few more abstractions to be able to eventually collapse previous abstractions. This sounds paradoxical but that's a very good sign. Composable design at some point allows some pieces of code to disappear.
Let's change numbers to Literals
object:
const literal1 = { type: "Literal", value: 1 };
const literal2 = { type: "Literal", value: 2 };
const literal3 = { type: "Literal", value: 3 };
Then move to creating AST-like structures for add
and multiply
function calls. They're not functions anymore, but execution tree
binary expressions:
const add = {
type: "BinaryExpression",
operator: "+",
left: literal1,
right: literal2
};
const multiply = {
type: "BinaryExpression",
operator: "*",
left: add,
right: literal3
};
Next, refactor evaluate
to accept AST-like nodes - not strings mixed with function names as it did so far:
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 supported.`));
}
// use
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
);
What we achieved is very simple evaluator of AST-like nodes. Implementation looks very overblown, but this is the inflection point where collapsing becomes possible.
To recap:
- we started with simple
1+2
expression written using infix operator, - expression was evaluated using return statement inside a function,
- we had eliminated
return
statement and switched to continuations, - we had added support for error handling using second - failure - continuation,
- we've removed need for accessing
add
function directly, - we've moved literals like
1
to move a structure of typeLiteral
- we embraced
evaluate
function which became ubiquitous used even insideadd
andmul
functions. - whole program to evaluate was expressed using evaluation tree which can be equivalent to AST (abstract syntax tree) known in parsing theory.
That's why at this point you can use a parser:
// Let's pretend we went few steps ahead
const metaesEval = evaluate;
metaesEval(
// result of `parse` function should be an AST tree
parse("2+2"),
console.log,
console.error
);
To be precise, metaES uses meriyah parser underneath which will actually return:
{
"type": "Program",
"sourceType": "module",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "BinaryExpression",
"left": {
"type": "Literal",
"value": 2
},
"right": {
"type": "Literal",
"value": 2
},
"operator": "+"
}
}
]
}
but at this point it should be easy to implement missing AST nodes as an excercise for the reader.