Hosting a small language (Ovo2) from scratch in Elixir, pt 7
Weird features, towards a global stateful machine
full code hosted on github
- Part 1 : gathering requirements from a previous experiment
- Part 2 : tokenization
- Part 3 : parsing
- Part 4 : AST emission and print-parse-print loop
- Part 5 : evaluation
- Part 6 : basic recursion : environment as processes
- Part 7 : weird features, towards a global stateful machine
- Part 8 : a graphical environment with liveview
After my last post about recursion and (mostly abused) processes, I had a kind of feeling that I’d send this project off-track, and towards something funnier than its intended destination. And indeed it happened. After fixing a few bugs allowing to correctly run canonical examples like a fibonacci number calculator, I paused and thought that I made a completely standard language.
This post is about adding curious features to such a toy language.
shakes : stateful functions
I wondered what would happen if you shakeed
a lambda, meaning, what happens if you hit a function like a pinata after it has run ? The only meaningful thing I thought of would be to shed its previous result. So, I modified the tokenizer and parser to add a new syntax for that.
The shake
feature works with lambdas that have been declared with a !
before their argument list.
A regular lambda is \a -> add(a, 1) end
, whereas a shakable lambda is !\a -> add(a, 1) end
.
A shakable lambda pushes its results in a stack, like this :
add_one = !\a -> add(a, 1) end # a stack [] is created
add_one(1) # produces the value 2, stack is [2]
add_one(3) # produces the value 4, stack is [4, 2]
Calling shake
on a shakable lambda pops a value from its stack.
add_one = !\a -> add(a, 1) end # a stack [] is created
add_one(1) # produces the value 2, stack is [2]
add_one(3) # produces the value 4, stack is [4, 2]
shake(add_one) # produces the value 4, stack is [2]
shake(add_one) # produces the value 2, stack is []
shake(add_one) # to this day, returns :error which isn't an ovo-compatible value
You can imagine things like :
add_one = !\\a -> add(a, 1) end
add_one(1)
add_one(3)
add_one(4)
a = shake(add_one)
shake(add_one)
add(a, shake(add_one))
Now that the parser works well, adding “slices” of functionality like that becomes quite simple. I removed the ability to print the language to an elixir-equivalent representation, as it started to seriously derive from a tiny functional data manipulation language, and my end goal was shifting.
program ocean
Quite happy with my stateful functions which I don’t really find an use for, I thought of having all declared programs in a global namespace. Like a registry of Ovo programs, that would not have to be re-parsed from code at every execution. Enter Ovo.Registry
and Ovo.Runner
:
Instead of running Ovo programs by writing code and calling Ovo.run/2
with code and some input, you can also run programs as independent Runners
inside a stateful system. Registering a Runner
gives back {:ok, hash}
with an unique (collisions excepted :^)) hash, that you can keep around to call back that runner with some input. I introduced arg/1
to the standard library, as I added shake/1
earlier. arg/1
gives you the value in a positional argument.
# Start an Ovo.Registry
Ovo.Registry.start()
# Start some Ovo.Runners
{:ok, ovo_adder} = Ovo.Runner.register("""
add(arg(0), arg(1))
""")
{:ok, ovo_times2} = Ovo.Runner.register("""
multiply(arg(0), 2)
""") # ovo_times2 is 0ceaimhlh, which is this runner's ID and this program's hash
You can then call those runners with input :
Ovo.Runner.run(ovo_adder, [2, 3]) # %Ovo.Ast{value: 5}
Ovo.Runner.run(ovo_times2, [5]) # %Ovo.Ast{value: 10}
program chains
If you have all those declared programs, waiting for input to run, and a registry of them, the next logical step seemed to have a way to stitch small programs together, the output of one becoming the input of the next. I added Ovo.Registry.run_chain
to this effect, which, in retrospect, could also be identified by a hash and become a runner, with AST stitching.
Ovo.Registry.run_chain([ovo_adder, ovo_times2], [2, 3]) # %Ovo.Ast{value: 10}
So, you can now build program chains and some serious computation can happen.
remote calls
If we have a global registry, and declared programs are immutable since they’re identified by the hash of their AST, why wouldn’t we have a way to make calls cross-programs ? Again, following simple logic™, I added invoke/2
to the standard library. invoke
takes the hash of a program and invokes it from the registry, with user input, from inside ovo.
{:ok, dependent_program} = Ovo.Runner.register("""
invoke(`0ceaimhlh`, [2])
""")
Ovo.Runner.run(dependent_program, []) # %Ovo.Ast{value: 4}
global shakes
I then thought that it would only be sensible to be able to shake
runners too, to get their previous execution result, which is popped from a stack. The programmer is responsible for not shakeing a runner with an empty stack. shakeing a runner from its hash from within another ovo program is in the works.
Ovo.Runner.shake(dependent_program) # %Ovo.Ast{value: 4}
Ovo.Runner.shake(dependent_program)
17:14:13.814 [error] GenServer Ovo.Registry terminating
** (FunctionClauseError) no function clause matching in anonymous fn/1 in Ovo.Registry.pop_result/1
where is this going ?
Excellent question. I have started an implementation of this ovo computing system
in Phoenix
with Liveview
. The goal is to have a demo of a graphical environment where each user has a Registry
, and can graphically create little blocks of programs (or Runners
), run them, and link them in chains, or cross-invoke them, to compose a larger program. I think this project will stop after this point, but I had great fun going against Elixir’s rules.
I liked going over the top with processes where nested (or even, flattened) indexable data structures would have done the trick instead of recursive processes. I also liked making a kind of global stateful system with processes where they weren’t needed, and adding dangerous features that leaves the fate of an ovo system on the programmer’s hands : with shakes, you depend of every previous execution of a function or runner, or the lack of them, and must carefully think about execution order to avoid crashing everything. But I think this is a nice feature to have in the final graphical environment, that will feel more like a game than anything practical.
With all those changes, the next post about the in-the-works graphical environment will be the last. I hope that this series of posts will show how you can work your way, without libraries, from text input to running programs, without limiting yourself at parsing and running simple statements, and how Elixir makes that kind of task enjoyable.