Introducing Hurl, a terrible (but cute) idea for a language

Monday, June 19, 2023

Sometimes we have ideas that are bad but demand to enter reality. A few months ago, while chatting with a friend, we toyed around with the idea of a language where the only control flow you get is error handling. This idea embedded itself in my brain and wouldn’t let me go, so I kept just talking about it until two people in the same week accidentally encouraged me to do it.

Unfortunately, I decided to make this language a reality.
I’m sorry.
You are probably better off if you close the tab now.
If you keep reading, it’s at your own risk.

Here’s the premise of the language.
You know how in Python, people sometimes use exceptions for control flow?
Yeah, yeah, I know exceptions aren’t control flow and blah blah except they are.
They share a lot with goto statements, where you can just kind of get yeeted to somewhere else in the program.
But they’re less flexible, since you can only go back up the stack.

Since you can use them for control flow, the natural question is how little other control flow can you provide?
How much of the heavy lifting can exceptions provide?

Turns out, holy cow, they can cover just about everything.

Here are the core language features:

  • Binding local variables
  • Defining anonymous functions
  • Exception handling

Let’s go through those one by one and look at how they’ll work, and then we can look at how they add up to something more full-featured.

Binding local variables

This looks like and works like you’d expect.
You use the let keyword to bind a value to a name (no uninitialized variables, sorry!). Kind of like this:

let x = 10;
let name = "Nicole";

This brings up our first spicy decision: statements end in semicolons.
I’m personally a fan of semicolons, and I think they make the grammar easier to parse as a human (at least, for this human named Nicole).

Otherwise, this looks a lot like JavaScript or Rust syntax.
I just took it off the shelf.

The language is dynamically typed, so you don’t have to specify what type anything is.
This helps make the grammar small.
We’ll see how it affects the interpreter implementation!

Defining anonymous functions

The next thing we can do is define anonymous functions.
You do this with the func keyword, like in Go or Swift.
Each function may have as many arguments as you would like.

Here’s a silly example defining a function to add together two numbers.

func(x, y) {
  hurl x + y;
};

Oh yeah, forgot to mention something: we can’t return values from functions.
If you want to send something out, you have to throw it as an exception, and one of the two keywords for that is hurl.

Also, anonymous functions aren’t a whole lot of use if you can’t ever refer to them to call them.
To get around this, we just combine anonymous functions with binding local variables, and we give them a name.
Then we call them with the syntax you would expect, the usual f(1,2) type deal.

let add = func(x, y) {
  hurl x +  y;
};

Another important detail is that since Hurl is dynamically typed, you could pass in two ints, or you could pass in two strings, or an int and a string.
Some of these will work, some might cause problems if + isn’t defined for those types!
Here’s what some of the combinations would do:

// hurls 3
add(1, 2);

// hurls "1fish"
add(1, "fish");

// hurls "me2"
add("me", 2);

// hurls "blue fish"
add("blue", " fish");

Oh, also, functions cannot be recursive (without passing in a function to itself), because we won’t have the function bound to a name in the local context when defining itself.
Fun, right?

Great.
We’ve got functions.
Now we need the spice.

Exception handling

First of all, I’m really sorry.
I didn’t have to do this, but I did, and here we are.

Exception handling has two components: throwing the exception, and catching it.

There are two ways to throw an exception:

  • You can hurl it, which works like you’d expect: it unwinds the stack as you go until it either reaches a catch block that matches the value, or exhausts the stack.
  • You can toss it, which works a little differently: it traverses the stack until you reach a matching catch block, but then you can use the return keyword to go back to where the value was tossed from.

I know, it’s cursed using return in this unusual way.
Again, sorry, I didn’t make you keep reading.
But, the reward is that since you got here, you get to see how we can use these to create control flow.

Here are a couple of examples, which we will work through with explanations of the stack state in both.

In the first example, we’ll make a dummy function which hurls a value, and catch it in the grandparent caller.
I’ve inserted line numbers for ease of displaying a trace later.

 1 | let thrower = func(val) {
 2 |   hurl val + 1;
 3 | };
 4 |
 5 | let middle = func(val) {
 6 |   print("middle before thrower");
 7 |   thrower(val);
 8 |   print("middle after thrower");
 9 | };
10 |
11 | let first = func(val) {
12 |   try {
13 |     middle(val);
14 |   } catch as new_val {
15 |     print("caught: " + new_val);
16 |   };
17 | };
18 |
19 | first(2);

This program will define a few functions, then execute first.
Here’s an imprecise trace of the program execution when we call first(2):

(file):19:
  stack: (empty)
  calls first

first:12:
  stack: [ (first, 2) ]
  enters try block

first:13:
  stack: [ (first, 2), (<try>) ]
  calls middle

middle:6:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  prints "middle before thrower"

middle:7:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  calls thrower

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  resolves val as 2, adds 1, and stores this (3) as a temp

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  hurls 3, pops current stack frame

middle:7:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  status: hurling 3
  not in a try block, pops stack frame

first:13:
  stack: [ (first, 2), (<try>) ]
  status: hurling 3
  in a try block, try block matches, jump into matching block

first:15:
  stack: [ (first, 2), (<try>), (<catch>, 3) ]
  print "caught: 3"
  pop catch and try stack frames
  pop first stack frame

file:19:
  stack: []
  execution complete

That’s a bit to follow (and if you have a better way of expressing this trace, please let me know so I can update the post and the future docs), but it’s sufficient to understand it as “normal exception handling except you can throw anything.”

This also introduced one other construct, catch as, which lets you catch all values and store it in a new local variable.
The other thing you can do is something like catch (true) or catch ("hello") to only match specific values.

Now the other one is pretty fun.
This is toss.
We can change the above example to use toss and return.
This time I’ll just illustrate the stack starting from when we reach toss; execution is the same up until then (with slightly different line numbers).

 1 | let thrower = func(val) {
 2 |   toss val + 1;
 3 | };
 4 |
 5 | let middle = func(val) {
 6 |   print("middle before thrower");
 7 |   thrower(val);
 8 |   print("middle after thrower");
 9 | };
10 |
11 | let first = func(val) {
12 |   try {
13 |     middle(val);
14 |   } catch as new_val {
15 |     print("caught: " + new_val)
16 |     return;
17 |   };
18 | };
19 |
20 | first(2);

Here’s the abridged trace, starting just from the toss statement.
Note that now we have an index of where we are in the stack.
This is 0-indexed, since that reflects the language I’ll write the interpreter in.

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 3
  tosses 3 from stack index 3, decrements stack index

middle:7:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 2
  status: tossing 3 from stack index 3
  not in a try block, decrements stack index

first:13:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 1
  status: tossing 3 from stack index 3
  in a try block, try block matches, jump into matching block creating a substack

first:15:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 1
  status: tossing 3 from stack index 3
  substack: [ (<catch>, 3) ]
  print "caught: 3"

first:16:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 1
  status: tossing 3 from stack index 3
  substack: [ (<catch>, 3) ]
  returning, pop the substack, set stack index to 3

thrower:2:
  stack: [ (first, 2), (<try>), (middle, 2), (thrower, 2) ]
  stack index: 3
  finish this function, pops current stack frame

middle:8:
  stack: [ (first, 2), (<try>), (middle, 2) ]
  stack index: 2
  prints "middle after thrower"
  finish this function, pops current stack frame

first:13:
  stack: [ (first, 2), (<try>) ]
  stack index: 1
  finishes the try block, pops current stack frame
  finish this function, pops current stack frame

file:20:
  stack: []
  stack index: 0
  execution complete

And that’s it!
That’s what we need to make a useful language that can do all the ordinary things languages do.

Well, we don’t have a clear way of handling errors since exception handling is being used for actual control flow.
So let’s just be careful and not write any bugs, and not have errors.

But now it’s time to put together the pieces and do “useful” things.

Conditionals and loops are pretty fundamental to how we write programs.
Howe do we express them in this paradigm?

Conditionals are pretty straightforward, so we will start there.
We can just hurl a value inside a try block, and use catch blocks to match values!

For example, let’s check if a value is greater than 0.

let val = 10;

try {
  hurl val > 10;
} catch (true) {
  print("over 10");
} catch (false) {
  print("not over 10");
};

This will print “over 10”.
It evalutes the conditional, hurls the resulting true, and then immediately catches that value.
If it happens to hurl something other than true or false, that would continue unwinding the stack further, so be careful.
Consider including a catch as error catch-all.

Loops are where it gets trickier.
We don’t actually have recursion available to us, so we have to be a little clever.

We start by defining a loop function.
This function has to itself take in a loop function.
It also has to take in the loop body and the loop local values.

This loop body has to meet one requirement:

  • It must toss the next iteration’s local values before the end of the loop body
  • Sometime after that, it must hurl either true (to run another iteration) or false (to complete iteration).

It looks something like this:

let loop = func(loop_, body, locals) {
    try {
        body(locals);
    } catch as new_locals {
        try {
            // `return` goes back to where the locals were tossed from.
            // This has to be inside a new `try` block since the next things
            // the body function does is hurl true or false.
            return;
        } catch (true) {
            loop_(loop_, body, new_locals);
        } catch (false) {
            hurl new_locals;
        }
    };
};

And then to use it, we have to define our body.

let count = func(args) {
  let iter = args[1];
  let limit = args[2];
  print("round " + iter);

  toss [iter + 1, limit];
  hurl iter < limit;
}

And then if we call this, we can see what it does!

loop(loop, count, [1, 3]);

This should print:

round 1
round 2
round 3

And that’s basically all we need!

Here’s another fun sample program: fizzbuzz!
If a language can’t implement fizzbuzz, it’s useless for torturing evaluating candidates, so we have to be sure it can be written well.

Here’s an implementation utilizing our previously-defined loop function.

let fizzbuzz = func(locals) {
    let x = locals[1];
    let max = locals[2];

    try {
        hurl x == max;
    } catch (true) {
        hurl false;
    } catch (false) {};

    let printed = false;

    try {
        hurl ((x % 3) == 0);
    } catch (true) {
        print("fizz");
        printed = true;
    } catch (false) {};

    try {
        hurl ((x % 5) == 0);
    } catch (true) {
        print("buzz");
        printed = true;
    } catch (false) {};

    try {
        hurl printed;
    } catch (false) {
        print(x);
    } catch (true) {};

    toss [x+1, max];
    hurl true;
};

loop(loop, fizzbuzz, [0, 100]);

It looks pretty good to me!
By “good” I mean “it looks like it works, technically.”
I don’t mean “yeah let’s use this in production” because I don’t hate my coworkers enough for that.

So, where does Hurl go from here?

I could stop here: it’s a good gag, I’ve written the code samples and we’ve had a laugh.
I’m not going to, though.
This is a nice compact language which seems fit to revisit some of the concepts from Crafting Interpreters, and it’s my first swing at language design!
It’s very low stakes, so I get to explore without being attached to anything very much.

The plan is to work on an interpreter iteratively.
The next steps are:

  1. Define the grammar
  2. Write a lexer
  3. Write a parser (demo: check if programs parse)
  4. Write a formatter (demo: reformat programs)
  5. Write an interpreter
  6. Write some programs in it for fun (Advent of Code from 2022?) and create the standard library

I’m aiming for a formatter as one of the first components, because all modern languages need a formatter, and it will be a much smaller lift to write than the interpreter so it gets me going more quickly.
Writing the interpreter itself will take quite a while and will be a few iterations.

I’ll be writing more blog posts along the way, so get subscribed to the RSS feed if you want to follow along!


If this post was enjoyable or useful for you, please share it!
If you have comments, questions, or feedback, please email my public inbox or my personal email.
To get new posts, please use my RSS feed.


Source link