Farewell, Eval (LLO Archive)

Created 2025-12-12, last modified 2025-12-12. Visibility: public

Part of my archive of Layover Linux Official posts on Tumblr.


2025-11-06

Usually, in these updates, I post samples of Prone code. Today is gonna be more about C, and because there's syntax highlighting for C, I'll be posting code samples as pictures with alt text, to get around formatting limitations of Tumblr. This is truly not an optimal platform for devblogging, but we make do.

Editor's note: I have now switched to syntax highlighted code blocks on my own website. Huzzah! No more making do.

Almost all the work done in the last few days (as foreshadowed by my last post, about changing the function signature for builtin C functions) sums up in this sample:

// Before:
typedef struct {
  VVec *scope;
  DV value;
} Eval;
DV op_plus(VVec *arguments);

// After:
typedef struct {
  RC rc;
  VVec *scope;
  DV value;
} IState;
IState *prn_std_plus(IState *state, VVec *arguments);

So what's the context? What's going on here?

First of all, let's look at this signature for the op_plus function. When you use the + operator or plus() function within Prone, some interpreter machinery happens, but ultimately op_plus() gets called and returns a dynamic value. It does the work. Every built-in function in Prone takes a Value Vector of arguments, and returns a Dynamic Value. And a lot of operators are just function calls, like suffixes on number literals, or even this.that syntax. Prone functions written in native C doing a huge amount of heavy lifting.

Now let's think about assignment, because it will motivate the need for the next stop in our code sample tour. When you run x = 5u8, when you assign to x, what happens? Well, you have a scope - a bag full of key-value pairs - and you set x to u8(5) in that bag. Then later on, when code refers to x, the interpreter can reach into the bag o' scope and find x, and use the value in whatever calculation you're doing. This means that the evaluation machinery in the interpreter needs to be able to read and write scope. You can't run x = 5u8 without a storage area for x, and you can't use x later without storage to reach into.

This is where we get the Eval struct. It became really obvious pretty early that, when you need to evaluate an assignment statement, you need two inputs (the original scope and the ASSIGN construct), that gets munched a bit, and it spits out a new pair: (new scope, result). In C, taking multiple inputs to a function is easy, but you can only return ONE thing. So you need some explicit type to bundle up the output values into a single C object.

The thing is, as a totally bare struct, Eval structs make manual memory management a lot harder, because there's established patterns for how memory management works in Prone, and Evals are a rare exception to that pattern. So everywhere you use them, there's an awkward interface boundary between two ways of thinking. And you hit this barrier constantly because they aren't designed for chaining computations into each other - you're constantly unpacking things out of an Eval, passing them into another prn_evaluate_whatever(...) function as separate parameters, and what do you get as a return value? Another Eval you have to unpack.

So this is why I created the Interpreter State (IState) object. It's reference-counted and (for now) heap-allocated, exactly like all the other memory-manageable types in Prone. It doesn't exist "in-language", there's no DV tag for it, but this means you can pass IStates into a piece of computation and get a new IState out. You get all the normal benefits of the Prone memory management model, like automatic copy-on-write. Which is why the code for handling assignment statements looks like this now:

IState *istate_ec_assign(IState *state, Construct *c) {
  DV lvalue = dv_atom_from_id(c->data.assign.lvalue);
  DV rvalue = dv_lend(c->data.assign.rvalue);
  construct_release(c);

  state = istate_eval(state, rvalue);
  state->scope =
    vvec_table_set(state->scope, dv_move(lvalue), dv_lend(state->value));
  return state;
}

Note the state = istate_eval(state, rvalue) line in there. This handles turning whatever was on the right-hand of the Prone assignment statement into as simple a value as possible before storing it. If we say x = "foo" + "bar", we want to store "foobar", not a block of code saying to add two strings together. The computation might depend on local scope, too. So it has to happen now. But what I'm pointing out is how easy it is now, to take a detour in the middle of evaluating Thing A and say "hold up, lemme evaluate Thing B real quick" (which might read or write scope) and it's a one-liner with sane copy-on-write semantics. Freaking tasty, is what that is.

So, for a little while, we lived in this place where the Eval struct had been entirely eradicated, but built-in functions still had the old "take a VVec, return a DV" signature. This works, but it's limiting. It means a native C function has no access to whatever scope it's being called in, not even to read. And to be fair, none of the existing functions had needed it yet. But that's a bit of a survivorship bias - there's things I had to do not as a native C function in the standard library, because of the limitations of native C functions in the standard library.

Those limitations are about to be a problem. I want to implement locals(), which gets you a list of local variables in your current scope, as a table. The typecheck(type, value) function will need to work. Being able to implement assignment as a function would be nice and philosophically consistent (and make some in-language code generation easier by making the %% operator more useful). And behind the scenes, it's really nice for calling native functions to have the same model of flow as the deeper interpreter machinery does.

So, I've ported all the native functions over to this new interface, unified them all under std.whatever() in-language (which changed a few reprs), and I'm well set up for implementing zero-dispatch functions before the weekend.