πŸ“œ ⬆️ ⬇️

Liscript - we realize TCO



In my last article, Writing a Lisp Interpreter in Java, I briefly and abstractly told about what I wrote a couple of Lisp-like interpreters that I called Liscript - in Haskell and in Java. There is nothing particularly unique and outstanding in this, but for me it was a pleasant, interesting and informative pastime. Among other features, I mentioned the implementation of TCO (tail call optimization) - the optimization of the tail function calls by the interpreter. This question aroused the interest of individual community members, and a proposal was made to disclose it in more detail in a separate article, which I tried to do. I ask those interested under the cat.

At first, I still allow myself to briefly outline some of the basics, because I will further refer to them to complete the picture.

Eval without Apple - like Miklouho without Maclay


The simplest interpreter of expressions in languages ​​of a Lisp family can be implemented as a pair of functions (pseudocode):
')
eval (expr, env) // expr - , env -  if typeof(expr) = Atom //   -  return lookup(expr, env) //       elseif typeof(expr) = List //   -  evalexpr = mapeval(expr, env) //     //   eval //     return apply(evalexpr, env) //    else return expr //    apply (expr, env) // expr - , env -  op = head(expr) //  -    args = tail(expr) //  -   case op of .................. //    .................. //      .................. //    .................. //      ... //     Function f: //   -  newenv = subenv(args, f.clojure) //   -   return eval(f.body, newenv) //    //     

In the code above, much is simplified, for example, we don’t need and even misinterpret the entire list if the type of its operation is a conditional expression, but these are details. It was possible and not to allocate the apply to a separate function by implementing the case on operations directly in the body of eval, it does not matter. It is interesting that in these functions there are no cycles and destructive assignment and change of variable values, therefore in this form the interpreter can be used in any programming language that supports function calls, recursion and has (or has the ability to create) several data types, such as a simply-connected list, tree of dictionaries for the environment, and types of works (structures / classes with fields). Also, if we leave aside user interaction (I / O) in the computation process, then these functions are β€œpure” in terms of functional programming. But even if it is possible to make internal I / O in the middle of a computation, then this variant of the interpreter is directly implemented even in a pure functional language, for example Haskell, or in any imperative language with sufficient minimum capabilities. But in the latter case there may be a limitation - the calculation function actively uses recursion, and with a small stack depth it will overflow. The situation is all the more aggravated if the implementation language does not support tailing call optimization - TCO.

Problem


I faced the problem of stack overflow when I implemented the interpreter in the Java language. Each calculation was launched in my separate thread, and by default the stack size for one thread is very small. I saw the following solutions:


Most likely there are good and reliable solutions to this problem, but ultimately I settled on a compromise - implemented in the above simple version of the TCO interpreter, and together with the possibility of increasing the size of the flow stack, this allows in many cases to avoid overflow. This interpreter was conceived as a toy, pet project, so this compromise solution suits me so far. But I do not exclude the possibility that in the future I will come up with a better solution.

Decision


The key to the solution is, when it is possible, not to call the eval function again and again recursively, plunging deeper into the stack, but to organize an explicit loop inside it in which to perform the necessary calculations. To do this, you can proceed as follows - divide the calculation of the result of applying the function to the arguments into 2 options - let's call them conditionally β€œstrict” and β€œlazy”. With a strict calculation, the function will still return the final result of the calculations, and with a lazy one, some intermediate object that will contain the function itself (along with its body and its own environment, which is part of its attributes), and a list of the calculated values ​​of the arguments with whom she should be called. Let's call this object FuncCall, below is the code of the function classes and FuncCall (the latter does not contain JavaDocs because it is an internal private class, but its essence is trivial and understandable without explanation):

  /**   Liscript -  */ public static class Func { /**      */ public ConsList pars; /**   */ public Object body; /** ,     */ public Env clojure; /**  * @param p      * @param b   * @param c ,     */ Func(ConsList p, Object b, Env c) { pars = p; body = b; clojure = c; } /** @return    */ @Override public String toString() { return showVal(this); } } private static class FuncCall { public Func f; public HashMap<String, Object> args; FuncCall(Func _f, HashMap<String, Object> _a) { f = _f; args = _a; } @Override public String toString() { return "FUNCALL: " + args.toString(); } } 

To complete the picture under the spoiler, the Env class code implements a hierarchical environment with the methods:

Env class
 /** *    -  :   - ,    *  .       . */ public class Env { /**     -   */ public HashMap<String, Object> map; /**     */ public Env parent; /**     . * @param m   - * @param p   */ Env (HashMap<String, Object> m, Env p) { map = m; parent = p; } /**   .        * . */ Env () { this(new HashMap<String, Object>(), null); } /**          ,  *     . * @param var - * @param value - */ public void setVar(String var, Object value) { Env env = this; while (env != null) { if (env.map.containsKey(var)) {env.map.put(var, value); break;} env = env.parent; } } /**          ,  *     .    -      * . * @param var - * @return - */ public Object getVar(String var) { Env env = this; while (env != null) { if (env.map.containsKey(var)) return env.map.get(var); env = env.parent; } return var; } /**        * @param var - * @param value - */ public void defVar(String var, Object value) { this.map.put(var, value); } /**  ,             *  . * @param var - * @return / */ public boolean isBounded(String var) { Env env = this; while (env != null) { if (env.map.containsKey(var)) return true; env = env.parent; } return false; } } 


The logic of the work was the following: in the code text, the programmer explicitly marks (in my case with the help of an additional special form) tail function calls. When calculating such calls, the lazy FuncCall is returned, and right there in the cycle it is calculated, while the return type is all the same FuncCall, and as soon as we get a different type in the calculation, we return it as a result. In my implementation, new objects are created in the loop each time, but the garbage collector built into Java takes care of them. But we implemented an iterative imperative loop inside the recursive function of computing, and our stack stopped growing with tail calls. Everything worked, the only inconvenience was that the text of the code required to explicitly specify tail calls. Without this, calculations were performed as usual, with a dip in the stack. I wanted the interpreter to determine the tail calls itself, and calculate them in a loop. This was implemented through the optional parameter of the eval function - the strict boolean flag of strict / lazy evaluation of functions. The logic of operation is the following: for any value of the flag, when calculating a function, first a FuncCall object is created with this function and calculated values ​​of the arguments. But with strict calculation, this object is immediately calculated in a loop, but with a lazy calculation, while the type of the result is FuncCall. With a lazy calculation, the created FuncCall is immediately returned as a result. Nowhere else does the value of this flag determine the logic of operation, but it was necessary to choose in which nested calls it should be calculated strictly and in which with the flag value transmitted in the input parameter (to preserve the severity / laziness of the external calculation). But it turned out to be not difficult - when choosing always a strict calculation, in addition to calculating the last element of the list, the result of the conditional expression (the conditions themselves are strictly calculated) and the runtime macros - these 3 cases are calculated with the incoming flag value, in all examples the interpreter correctly determined tail calls, and optimized them, including cross-recursions. In the code, it looks much more concise than a verbal description:

  else if (op instanceof Func) { Func f = (Func)op; //       FuncCall FuncCall fcall = new FuncCall(f, getMapArgsVals(d, io, env, f.pars, ls, true)); if (strict) { v = fcall; while (v instanceof FuncCall) { FuncCall fc = (FuncCall) v; v = eval(d, false, io, new Env(fc.args, fc.f.clojure), fc.f.body); } return v; } else return fcall; 

An example of work in the interpreter (the functions foldl and foldr are defined in the standard library, here their definitions are duplicated for clarity):

 def a (list-from-to 1 100) => OK def b (list-from-to 1 100000) => OK defn foldl (fal) (cond (null? l) a (foldl f (f (car l) a) (cdr l)) ) => OK defn foldr (fal) (cond (null? l) a (f (car l) (foldr fa (cdr l))) ) => OK foldl + 0 a => 5050 foldr + 0 a => 5050 foldl + 0.0 b => 5.00005E9 foldr + 0.0 b => java.lang.StackOverflowError 

Among the special forms of the language is the tray service command, which prints a call stack with an indication of the calculation step and the recursive call nesting level, then you can see "in a section" which calls are received with different options:

Factorial (implementation with battery) - without TCO
 defn f (na) (cond (< n 2) a (f (- n 1) (* na))) => OK tray (f 5 1) => 1 ← (f 5 1) 2 ← f 2 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 2 ← (cond (< n 2) a (f (- n 1) (* na))) 3 ← (< n 2) 4 ← n 4 β†’ 5 3 β†’ false 3 ← (f (- n 1) (* na)) 4 ← f 4 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 4 ← (- n 1) 5 ← n 5 β†’ 5 4 β†’ 4 4 ← (* na) 5 ← n 5 β†’ 5 5 ← a 5 β†’ 1 4 β†’ 5 4 ← (cond (< n 2) a (f (- n 1) (* na))) 5 ← (< n 2) 6 ← n 6 β†’ 4 5 β†’ false 5 ← (f (- n 1) (* na)) 6 ← f 6 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 6 ← (- n 1) 7 ← n 7 β†’ 4 6 β†’ 3 6 ← (* na) 7 ← n 7 β†’ 4 7 ← a 7 β†’ 5 6 β†’ 20 6 ← (cond (< n 2) a (f (- n 1) (* na))) 7 ← (< n 2) 8 ← n 8 β†’ 3 7 β†’ false 7 ← (f (- n 1) (* na)) 8 ← f 8 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 8 ← (- n 1) 9 ← n 9 β†’ 3 8 β†’ 2 8 ← (* na) 9 ← n 9 β†’ 3 9 ← a 9 β†’ 20 8 β†’ 60 8 ← (cond (< n 2) a (f (- n 1) (* na))) 9 ← (< n 2) 10 ← n 10 β†’ 2 9 β†’ false 9 ← (f (- n 1) (* na)) 10 ← f 10 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 10 ← (- n 1) 11 ← n 11 β†’ 2 10 β†’ 1 10 ← (* na) 11 ← n 11 β†’ 2 11 ← a 11 β†’ 60 10 β†’ 120 10 ← (cond (< n 2) a (f (- n 1) (* na))) 11 ← (< n 2) 12 ← n 12 β†’ 1 11 β†’ true 11 ← a 11 β†’ 120 10 β†’ 120 9 β†’ 120 8 β†’ 120 7 β†’ 120 6 β†’ 120 5 β†’ 120 4 β†’ 120 3 β†’ 120 2 β†’ 120 1 β†’ 120 120 


Parity check (cross recursion) - no TCO
 defn is-even (n) (cond (= n 0) true (is-odd (- n 1)) ) => OK defn is-odd (n) (cond (= n 0) false (is-even (- n 1)) ) => OK tray (is-even 5) => 1 ← (is-even 5) 2 ← is-even 2 β†’ (lambda (n) (cond (= n 0) true (is-odd (- n 1)))) 2 ← (cond (= n 0) true (is-odd (- n 1))) 3 ← (= n 0) 4 ← n 4 β†’ 5 3 β†’ false 3 ← (is-odd (- n 1)) 4 ← is-odd 4 β†’ (lambda (n) (cond (= n 0) false (is-even (- n 1)))) 4 ← (- n 1) 5 ← n 5 β†’ 5 4 β†’ 4 4 ← (cond (= n 0) false (is-even (- n 1))) 5 ← (= n 0) 6 ← n 6 β†’ 4 5 β†’ false 5 ← (is-even (- n 1)) 6 ← is-even 6 β†’ (lambda (n) (cond (= n 0) true (is-odd (- n 1)))) 6 ← (- n 1) 7 ← n 7 β†’ 4 6 β†’ 3 6 ← (cond (= n 0) true (is-odd (- n 1))) 7 ← (= n 0) 8 ← n 8 β†’ 3 7 β†’ false 7 ← (is-odd (- n 1)) 8 ← is-odd 8 β†’ (lambda (n) (cond (= n 0) false (is-even (- n 1)))) 8 ← (- n 1) 9 ← n 9 β†’ 3 8 β†’ 2 8 ← (cond (= n 0) false (is-even (- n 1))) 9 ← (= n 0) 10 ← n 10 β†’ 2 9 β†’ false 9 ← (is-even (- n 1)) 10 ← is-even 10 β†’ (lambda (n) (cond (= n 0) true (is-odd (- n 1)))) 10 ← (- n 1) 11 ← n 11 β†’ 2 10 β†’ 1 10 ← (cond (= n 0) true (is-odd (- n 1))) 11 ← (= n 0) 12 ← n 12 β†’ 1 11 β†’ false 11 ← (is-odd (- n 1)) 12 ← is-odd 12 β†’ (lambda (n) (cond (= n 0) false (is-even (- n 1)))) 12 ← (- n 1) 13 ← n 13 β†’ 1 12 β†’ 0 12 ← (cond (= n 0) false (is-even (- n 1))) 13 ← (= n 0) 14 ← n 14 β†’ 0 13 β†’ true 12 β†’ false 11 β†’ false 10 β†’ false 9 β†’ false 8 β†’ false 7 β†’ false 6 β†’ false 5 β†’ false 4 β†’ false 3 β†’ false 2 β†’ false 1 β†’ false false 


Factorial (implementation with battery) - with TCO
 defn f (na) (cond (< n 2) a (f (- n 1) (* na))) => OK tray (f 5 1) => 1 ← (f 5 1) 2 ← f 2 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 2 ← (cond (< n 2) a (f (- n 1) (* na))) 3 ← (< n 2) 4 ← n 4 β†’ 5 3 β†’ false 3 ← (f (- n 1) (* na)) 4 ← f 4 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 4 ← (- n 1) 5 ← n 5 β†’ 5 4 β†’ 4 4 ← (* na) 5 ← n 5 β†’ 5 5 ← a 5 β†’ 1 4 β†’ 5 3 β†’ FUNCALL: {a=5, n=4} 2 β†’ FUNCALL: {a=5, n=4} 2 ← (cond (< n 2) a (f (- n 1) (* na))) 3 ← (< n 2) 4 ← n 4 β†’ 4 3 β†’ false 3 ← (f (- n 1) (* na)) 4 ← f 4 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 4 ← (- n 1) 5 ← n 5 β†’ 4 4 β†’ 3 4 ← (* na) 5 ← n 5 β†’ 4 5 ← a 5 β†’ 5 4 β†’ 20 3 β†’ FUNCALL: {a=20, n=3} 2 β†’ FUNCALL: {a=20, n=3} 2 ← (cond (< n 2) a (f (- n 1) (* na))) 3 ← (< n 2) 4 ← n 4 β†’ 3 3 β†’ false 3 ← (f (- n 1) (* na)) 4 ← f 4 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 4 ← (- n 1) 5 ← n 5 β†’ 3 4 β†’ 2 4 ← (* na) 5 ← n 5 β†’ 3 5 ← a 5 β†’ 20 4 β†’ 60 3 β†’ FUNCALL: {a=60, n=2} 2 β†’ FUNCALL: {a=60, n=2} 2 ← (cond (< n 2) a (f (- n 1) (* na))) 3 ← (< n 2) 4 ← n 4 β†’ 2 3 β†’ false 3 ← (f (- n 1) (* na)) 4 ← f 4 β†’ (lambda (na) (cond (< n 2) a (f (- n 1) (* na)))) 4 ← (- n 1) 5 ← n 5 β†’ 2 4 β†’ 1 4 ← (* na) 5 ← n 5 β†’ 2 5 ← a 5 β†’ 60 4 β†’ 120 3 β†’ FUNCALL: {a=120, n=1} 2 β†’ FUNCALL: {a=120, n=1} 2 ← (cond (< n 2) a (f (- n 1) (* na))) 3 ← (< n 2) 4 ← n 4 β†’ 1 3 β†’ true 3 ← a 3 β†’ 120 2 β†’ 120 1 β†’ 120 120 


Parity check (cross recursion) - with TCO
 defn is-even (n) (cond (= n 0) true (is-odd (- n 1)) ) => OK defn is-odd (n) (cond (= n 0) false (is-even (- n 1)) ) => OK tray (is-even 5) => 1 ← (is-even 5) 2 ← is-even 2 β†’ (lambda (n) (cond (= n 0) true (is-odd (- n 1)))) 2 ← (cond (= n 0) true (is-odd (- n 1))) 3 ← (= n 0) 4 ← n 4 β†’ 5 3 β†’ false 3 ← (is-odd (- n 1)) 4 ← is-odd 4 β†’ (lambda (n) (cond (= n 0) false (is-even (- n 1)))) 4 ← (- n 1) 5 ← n 5 β†’ 5 4 β†’ 4 3 β†’ FUNCALL: {n=4} 2 β†’ FUNCALL: {n=4} 2 ← (cond (= n 0) false (is-even (- n 1))) 3 ← (= n 0) 4 ← n 4 β†’ 4 3 β†’ false 3 ← (is-even (- n 1)) 4 ← is-even 4 β†’ (lambda (n) (cond (= n 0) true (is-odd (- n 1)))) 4 ← (- n 1) 5 ← n 5 β†’ 4 4 β†’ 3 3 β†’ FUNCALL: {n=3} 2 β†’ FUNCALL: {n=3} 2 ← (cond (= n 0) true (is-odd (- n 1))) 3 ← (= n 0) 4 ← n 4 β†’ 3 3 β†’ false 3 ← (is-odd (- n 1)) 4 ← is-odd 4 β†’ (lambda (n) (cond (= n 0) false (is-even (- n 1)))) 4 ← (- n 1) 5 ← n 5 β†’ 3 4 β†’ 2 3 β†’ FUNCALL: {n=2} 2 β†’ FUNCALL: {n=2} 2 ← (cond (= n 0) false (is-even (- n 1))) 3 ← (= n 0) 4 ← n 4 β†’ 2 3 β†’ false 3 ← (is-even (- n 1)) 4 ← is-even 4 β†’ (lambda (n) (cond (= n 0) true (is-odd (- n 1)))) 4 ← (- n 1) 5 ← n 5 β†’ 2 4 β†’ 1 3 β†’ FUNCALL: {n=1} 2 β†’ FUNCALL: {n=1} 2 ← (cond (= n 0) true (is-odd (- n 1))) 3 ← (= n 0) 4 ← n 4 β†’ 1 3 β†’ false 3 ← (is-odd (- n 1)) 4 ← is-odd 4 β†’ (lambda (n) (cond (= n 0) false (is-even (- n 1)))) 4 ← (- n 1) 5 ← n 5 β†’ 1 4 β†’ 0 3 β†’ FUNCALL: {n=0} 2 β†’ FUNCALL: {n=0} 2 ← (cond (= n 0) false (is-even (- n 1))) 3 ← (= n 0) 4 ← n 4 β†’ 0 3 β†’ true 2 β†’ false 1 β†’ false false 


The source code of the interpreter is still available in my repository on Github .

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


All Articles