📜 ⬆️ ⬇️

Implement java promises

Good day to all. Today I want to talk about how I wrote the implementation of the mechanism of promises for my JS engine. As you know, not so long ago came the new standard ECMA Script 6, and the concept of promises looks quite interesting, and also where a lot of web developers already apply. Therefore, for any modern JS engine, this is definitely a must-have thing.
Warning: the article is quite a lot of code. The code does not claim beauty and high quality, since the whole project was written by one person and is still in beta. The purpose of this story is to show how everything works under the hood. In addition, after a small adaptation, this code can be used to create pure Java projects, without looking at JavaScript.

The first thing it cost to start writing code is to learn how things should work in the end. The architecture of the resulting module was largely determined by the process.

What is a Promise?


Promise is a special object that, when created, is in the pending state (let it be a constant equal to 0).

Next, the object starts to execute the function that was passed to its constructor during creation. If the function was not passed - following the ES6 standard, we must throw an exception argument is not a function . However, in our Java implementation, you can not throw anything, and create an object "as is" (just add additional logic later, I will tell about it later).
')
So, the constructor accepts the function. In our engine, this is an object of class Function that implements the call method. This method allows you to call a function, taking as input the execution context, a vector with arguments, and a boolean parameter that defines the call mode (call as a constructor or normal mode).

Further, this function is recorded in the field of our object and then can be called.

public static int PENDING = 0; public static int FULFILLED = 1; public static int REJECTED = 2; ... private int state = 0; private Function func; 

At the same time, here we will create constants for our two remaining states, and an int field that stores the current state of the object.

So, according to the standard, in the course of its execution, our function can call one of two functions (which are passed to it as the first two arguments, so we should set their names in the signature of the function). Usually use something like resolve and reject for simplicity.

These are normal functions from the point of view of JavaScript, and therefore Function objects from the point of view of our engine. Add fields for them:

  public Function onFulfilled = null; public Function onRejected = null; 

These functions can be called at any time by our main working function, which means they should be in its scope. In addition, after working, they must change the state of our object to fulfilled and rejected , respectively. Our functions do not know anything about promises (and should not know). Therefore, we need to create a kind of wrapper that will know about them and will be able to initiate a state transition.

We also need the setState () method for our object (with additional checks: for example, we have no right to change the state if it is already fulfilled or rejected ).

Let's do the constructor of our object:

  public Promise(Function f) { func = f; onFulfilled = new PromiseHandleWrapper(this, null, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, null, Promise.REJECTED); if (f != null) { Vector<JSValue> args = new Vector<JSValue>(); args.add(onFulfilled); args.add(onRejected); func.call(null, args, false); } } 

Here, it seems, everything is clear. If the function is transferred, we must call it immediately. If not, then for the time being we are not doing anything (and our object maintains the pending state).

Now about the installation of these handlers themselves (after all, in the main function we only declare their names as formal parameters). There are three options for this standard: Promise.then (resolve, reject), Promise.then (resolve) (equivalent to Promise.then (resolve, null)), and Promise.catch (reject) (equivalent to Promise.then (null, reject )).

As for the then function: it is obvious that it is best to implement in detail the method with two arguments, and the remaining two to do as “shortcuts” to it. So do:

  public Promise then(Function f1, Function f2) { if (state == Promise.FULFILLED || state == Promise.REJECTED) { onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); onFulfilled.call(null, new Vector<JSValue>(), false); return this; } ... onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); if (func != null) { String name1 = func.getParamsCount() > 0 ? func.getParamName(0) : "resolve"; String name2 = func.getParamsCount() > 1 ? func.getParamName(1) : "reject"; func.injectVar(name1, onFulfilled); func.injectVar(name2, onRejected); } if (f1 != null) has_handler = true; if (f2 != null) has_error_handler = true; return this; } 

At the end, we return the link to ourselves: this is necessary for the subsequent implementation of the chasing of promises.

What kind of block do we have at the beginning of the method, you ask? But the fact is that our handler could have been executed even before we called then for the first time (this happens, and this is completely normal). In this case, we must call the necessary handler from the passed to the method immediately.

In the place of the dot then there will be another code, about it a bit later.

Next is the installation of our handlers in the required fields.

And then the most interesting. Suppose our work function runs for a long time (a request over the network, or just setTimeout for a learning example). In this case, it will essentially be executed, but will create a series of objects (timer, network XmlHttpRequest interface, etc.) that will execute some code later. And these objects have access to the scope of our function!

Therefore, it may not be too late to add the necessary variables to its scope (and if it is too late, the code will be executed at the beginning of the method). To do this, we create a new method in the Function class:

  public void injectVar(String name, JSValue value) { body.scope.put(name, value); } public void removeVar(String name) { body.scope.remove(name); } 

We don’t actually need the second method: it was created purely for the sake of completeness.

Now it's time to implement shortcuts:

  public Promise then(Function f) { return then(f, null); } public Promise _catch(Function f) { return then(null, f); } 

catch is a reserved word in java, so we had to add an underscore.

Now we will describe the setState method. In the first approximation, it will look like this:

  public void setState(int value) { if (this.state > 0) return; this.state = value; } 

Great, now we can change the state of our handlers - more precisely, of the wrappers over them. We are going to wrap:

 public class PromiseHandleWrapper extends Function { public PromiseHandleWrapper(Promise p, Function func, int type) { this.promise = p; this.func = func; this.to_state = type; } @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { return call(context, args); } @Override public JSValue call(JSObject context, Vector<JSValue> args) { JSValue result; if (func != null) { Block b = getCaller(); if (b == null) { b = func.getParentBlock(); while (b.parent_block != null) { b = b.parent_block; } } func.setCaller(b); result = func.call(context, args, false); } else { result = Undefined.getInstance(); } promise.setResult(result); promise.setState(to_state); return promise.getResult(); } @Override public JSError getError() { return func.getError(); } private Promise promise; private Function func; private int to_state = 0; } 

We have two types of wrappers, but one class. And the type is responsible for the integer field to_state. Seems not bad :)

The wrapper has links to both its function and its own promise. It is very important.

With the constructor, everything is clear, let's look at the call method, which overrides the method of the Function class. For our JS interpreter - wrappers are the same functions, that is, objects with the same interface that can be called, get their values, and so on.

First, we need to forward the Caller object to the function, which we received when we called the wrapper - this is necessary at least for the correct ascent of exceptions.

Next, we call our function and save the result of its execution in the field. At the same time, we set it to the object of the promise, for which we will create another setResult method there:

  public JSValue getResult() { return result; } public void setResult(JSValue value) { result = value; } 

We will not talk about the last line yet: it is necessary for chasing. In the most trivial case, the same value that we just received and transferred will be returned there.

The important point: the working function may call resolve or reject before we call the then or catch method (or we may not call them at all). So that at the same time we do not have an exception, right when creating a promise, we create two "default" wrappers that do not have handler functions. When called, they will only change the state of our promise (and then when called then it will be taken into account).

Cheyming promises


In short, cheining is the ability to write things like p.then (f1, f2) .then (f3, f4) .catch (f5).
That is why our then and _catch methods return a Promise object.

The first thing that the standard tells us is that the then method, in the presence of an existing handler, must create a new promise and add it to the chain. Since our promises should be equal to each other - let us not have any main promise storing a linear list, and each promise will store only a link to the following (initially it is null):

  public Promise then(Function f1, Function f2) { if (state == Promise.FULFILLED || state == Promise.REJECTED) { onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); onFulfilled.call(null, new Vector<JSValue>(), false); return this; } if (has_handler || has_error_handler) { if (next != null) { return next.then(f1, f2); } Promise p = new Promise(null); p.then(f1, f2); next = p; return p; } onFulfilled = new PromiseHandleWrapper(this, f1, Promise.FULFILLED); onRejected = new PromiseHandleWrapper(this, f2, Promise.REJECTED); if (func != null) { String name1 = func.getParamsCount() > 0 ? func.getParamName(0) : "resolve"; String name2 = func.getParamsCount() > 1 ? func.getParamName(1) : "reject"; func.injectVar(name1, onFulfilled); func.injectVar(name2, onRejected); } if (f1 != null) has_handler = true; if (f1 != null) has_error_handler = true; return this; } ... private Promise next = null; 

This is our missing block: if we already have the next promise, we hand over the call to him and exit (and he, if necessary, will hand it over to the next one, and so on). And if it is not there, we create and assign handlers to it, which we have received into the method, after which we return it. It's simple.

Now we will finalize the setState method:

  public void setState(int value) { if (this.state > 0) return; this.state = value; Vector<JSValue> args = new Vector<JSValue>(); if (result != null) args.add(result); if (value == Promise.FULFILLED && next != null) { if (onFulfilled.getError() == null) { if (result != null && result instanceof Promise) { ((Promise)result).then(next.onFulfilled, next.onRejected); next = (Promise)result; } else { result = next.onFulfilled.call(null, args, false); } } else { args = new Vector<JSValue>(); args.add(onFulfilled.getError().getValue()); result = next.onRejected.call(null, args, false); } } if (value == Promise.REJECTED && !has_error_handler && next != null) { result = next.onRejected.call(null, args, false); } } 

Firstly, the standard says that we are obliged to transfer the result of the previous work to the next promise handler (this is the basic meaning of the scheduling - assign an operation, then assign the second, and make the second accept the result at the start).

Secondly - errors are processed in a special way. If the successful result is passed along the chain (changing) to the end, then the error that occurred in the handler code is transmitted only one step, until the next onrejected, or it pops up if the end of the chain is reached.

Thirdly, functions can return a new promise. In this case, we are obliged to replace our next, if it is already set, on it (by transferring the available handlers). This, again, allows you to combine a chain of instant execution and asynchronous handlers, which return Promise themselves.

The above code addresses all of these scripts.

First tests


  JSParser jp = new JSParser("function cbk(str) { \"Promise fulfilled: \" + str } function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 1500) }"); System.out.println(); System.out.println("function cbk(str) { \"Promise fulfilled: \" + str }"); System.out.println("function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 1500) }"); System.out.println(); Expression exp = Expression.create(jp.getHead()); exp.eval(); jsparser.Function f = (jsparser.Function)Expression.getVar("f", exp); f.setSilent(true); jsparser.Promise p = new jsparser.Promise(f); p.then((jsparser.Function)Expression.getVar("cbk", exp)); 

So far, we are managing everything from Java code. Nevertheless, everything is already working: in a second and a half we will see the inscription “Promise fulfilled: OK” on the console. By the way, our resolve and reject functions, being called from the working function of promise, without changing, can take an arbitrary number of arguments. Very convenient. In this example, we passed the string "OK".

A small note: the promises created during the process of the process of chasing do not have working functions in principle. They immediately call handlers when the state of the previous promise changes.

The example is more complicated:

  JSParser jp = new JSParser("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str } " + "function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" } " + "function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str } " + "function err(str) { \"An error has occured: \" + str } " + "function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }"); System.out.println(); System.out.println("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str }"); System.out.println("function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" }"); System.out.println("function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str }"); System.out.println("function err(str) { \"An error has occured: \" + str }"); System.out.println("function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }"); System.out.println("(new Promise(f)).then(cbk1).then(cbk2).then(cbk3, err)"); System.out.println(); Expression exp = Expression.create(jp.getHead()); ((jsparser.Function)Expression.getVar("f", exp)).setSilent(true); ((jsparser.Function)Expression.getVar("cbk2", exp)).setSilent(true); exp.eval(); jsparser.Function f = (jsparser.Function)Expression.getVar("f", exp); f.setSilent(true); jsparser.Promise p = new jsparser.Promise(f); p.then((jsparser.Function)Expression.getVar("cbk1", exp)) .then((jsparser.Function)Expression.getVar("cbk2", exp)) .then((jsparser.Function)Expression.getVar("cbk3", exp), (jsparser.Function)Expression.getVar("err", exp)); 

By calling this example, we get the following output:

{}
"Promise 1 fulfilled: OK"
"OK"
"An error has occured: ERROR"
undefined
"Promise 2 fulfilled: OK"

The first curly braces are the object of promise that our call chain then returned to us as a result of the cheining. In the cbk1 function, we returned “OK” - and this value was transferred to cbk2, which we see in the last line. Inside cbk2, we throw an error with the value “ERROR” - therefore cbk3 is not executed here, but err is executed (as it should be when an error occurs in the previous promise handler in the chain). But this code is executed instantly, but the output of cbk2 is carried out through an auxiliary function hung on the timer. It has access to the str variable, as it should, but its output is because of this below. If you execute this example in Chrome 49, we get exactly the same output with one exception: the str variable is not visible in the anonymous function passed to setTimeout. This is a feature of the behavior of the switch functions in Chrome (and perhaps so necessary by the standard, here I find it difficult to say what is the matter). If you change the switch function to the usual one, the output will become identical.

Forwarding in javascript


But that is not all. Our ultimate goal is for new features to be able to use JS code executed by our interpreter. However, this is a matter of technology.

Create a constructor:

 public class PromiseC extends Function { public PromiseC() { items.put("prototype", PromiseProto.getInstance()); PromiseProto.getInstance().set("constructor", this); } @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { return call(context, args); } @Override public JSValue call(JSObject context, Vector<JSValue> args) { if (args.size() == 0) return new Promise(null); if (!args.get(0).getType().equals("Function")) { JSError e = new JSError(null, "Type error: argument is not a function", getCaller().getStack()); getCaller().error = e; return new Promise(null); } return new Promise((Function)args.get(0)); } } 

And a prototype object with a set of necessary methods:

 public class PromiseProto extends JSObject { class thenFunction extends Function { @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { if (args.size() == 1 && args.get(0).getType().equals("Function")) { return ((Promise)context).then((Function)args.get(0)); } else if (args.size() > 1 && args.get(0).getType().equals("Function") && args.get(1).getType().equals("Function")) { return ((Promise)context).then((Function)args.get(0), (Function)args.get(1)); } else if (args.size() > 1 && args.get(0).getType().equals("null") && args.get(1).getType().equals("Function")) { return ((Promise)context)._catch((Function)args.get(1)); } return context; } } class catchFunction extends Function { @Override public JSValue call(JSObject context, Vector<JSValue> args, boolean as_constr) { if (args.size() > 0 && args.get(0).getType().equals("Function")) { return ((Promise)context)._catch((Function)args.get(0)); } return context; } } private PromiseProto() { items.put("then", new thenFunction()); items.put("catch", new catchFunction()); } public static PromiseProto getInstance() { if (instance == null) { instance = new PromiseProto(); } return instance; } @Override public void set(JSString str, JSValue value) { set(str.getValue(), value); } @Override public void set(String str, JSValue value) { if (str.equals("constructor")) { super.set(str, value); } } @Override public String toString() { String result = ""; Set keys = items.keySet(); Iterator it = keys.iterator(); while (it.hasNext()) { if (result.length() > 0) result += ", "; String str = (String)it.next(); result += str + ": " + items.get(str).toString(); } return "{" + result + "}"; } @Override public String getType() { return type; } private String type = "Object"; private static PromiseProto instance = null; } 

Do not forget to add one line to Promise constructor at the very beginning, so that everything works:

  public Promise(Function f) { items.put("__proto__", PromiseProto.getInstance()); ... } 

And change our test a bit:

  JSParser jp = new JSParser("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str } " + "function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" } " + "function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str } " + "function err(str) { \"An error has occured: \" + str } " + "function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }; " + "(new Promise(f)).then(cbk1).then(cbk2).then(cbk3, err)"); System.out.println(); System.out.println("function cbk1(str) { \"Promise 1 fulfilled: \" + str; return str }"); System.out.println("function cbk2(str) { setTimeout(str => { \"Promise 2 fulfilled: \" + str }, 1000); throw \"ERROR\" }"); System.out.println("function cbk3(str) { \"Promise 3 fulfilled: \" + str; return str }"); System.out.println("function err(str) { \"An error has occured: \" + str }"); System.out.println("function f(resolve, reject) { setTimeout(function() { resolve(\"OK\") }, 300) }"); System.out.println("(new Promise(f)).then(cbk1).then(cbk2).then(cbk3, err)"); System.out.println(); Expression exp = Expression.create(jp.getHead()); ((jsparser.Function)Expression.getVar("f", exp)).setSilent(true); ((jsparser.Function)Expression.getVar("cbk2", exp)).setSilent(true); exp.eval(); jsparser.Function f = (jsparser.Function)Expression.getVar("f", exp); f.setSilent(true); 

The output should not change.

That's all! Everything works fine, you can write additional unit tests and look for possible errors.

How to adapt this mechanism for Java? Very simple. Create a class similar to our Function, which does something in the operate method. And we already wrap it in our wrapper. In any case, there are many wonderful patterns on this subject that can be played with.

I hope this article was useful to someone. I will definitely lay out the sources of the engine as soon as I bring them to mind and add the missing functionality. Have a good day!

Source: https://habr.com/ru/post/350444/


All Articles