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

Sometimes ideas come to mind that sound terrible, but require implementation. A few months ago, the author of this post, a developer named ntietz*, was discussing with a friend the idea of ​​a language in which the only command flow is error handling. This thought took root in the author’s mind and gave him no rest. He kept pestering everyone with the idea until, over the course of one week, a couple of people accidentally encouraged him to go ahead with the idea.

Unfortunately, ntietz decided to bring this language to life – and now apologizes to readers in advance. If you decide to switch, please know that you do so at your own risk.

*Please note that the author’s position may not always coincide with the opinion of MyOffice.


Prerequisites for creating Hurl

This is the main idea of ​​this language.

Did you know that Python sometimes uses exceptions to control control flow? Yes, yes, I am aware that this is not their main purpose, but in fact this is what happens. Exceptions have a lot in common with goto statements, which allow you to jump to another place in your program. But they are less flexible, since they only allow movement up the stack.

Since they can be used to control control flow, a natural question arises: how much can we limit the use of other mechanisms for this? How much of the heavy lifting can exceptions take on?

It turns out they can cover almost anything.

Core of language

Here are the main functions of the language:

  • Binding Local Variables

  • Defining Anonymous Functions

  • Exception Handling

Let’s look at each one individually, understand how they work, and then see how they come together to form a more complete system.

Binding Local Variables

Looks and works as you’d expect. You use the let keyword to bind a value to a name (no uninitialized variables, sorry!). Like that:

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

This leads to our first interesting solution: instructions end with a semicolon. I prefer semicolons because for me they make grammar easier to understand.

Otherwise, it is very similar to JavaScript or Rust syntax. Essentially, I took a ready-made solution.

The language is dynamically typed, so you don’t need to specify the type of each element. This helps reduce the amount of grammar. Let’s see how this affects the implementation of the interpreter!

Defining Anonymous Functions

The next step is to define anonymous functions. This is done using the keyword funcas in Go or Swift. Each function can have as many arguments as desired.

Here is a very ridiculous example of defining a function for adding two numbers.

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

Oh yes, I forgot to say: we cannot return values ​​from functions. If you want to return a value, you must do so within the exception throw, and one of the two keywords for this is hurl.

Besides, anonymous functions are of little use if you can never reference and call them. To get around this problem, we simply combine anonymous functions with local variable binding and give them a name. Then we call them using the usual syntax like f(1,2).

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

Another important detail is that due to Hurl’s dynamic typing, you can pass two integers, or two strings, or one integer and a string. Some combinations will work, but others may cause problems if those types are not defined +! Here’s an example of what some combinations do:

// hurls 3
add(1, 2);

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

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

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

By the way, a function cannot be recursive (call itself), since when defining it we will not bind it to a name in the local context. It’s funny, isn’t it?

Wonderful. We have functions. Now we need the icing on the cake.

Exception Handling

First of all, I’m really sorry. I shouldn’t have done it, but I did, and here we are.

Exception handling consists of two stages: throwing an exception and catching it.

There are two ways to throw an exception:

  • Use hurlwhich works as expected: unwinds the stack as it goes until it reaches a block catchcorresponding to the value, or until the stack is exhausted.

  • Use tosswhich works a little differently: it traverses the stack until it finds a matching block catchbut then using the keyword return you can go back to the place where the exception was thrown.

I know how difficult it is to use return in such an unconventional way. Once again I apologize, I did not ask you to read further. But your reward will be to see how it can be used to create command flow.

Let’s look at a couple of examples with an analysis of the stack state for each case.

In the first example we will create a dummy function that throws (hurls) value, and intercept it in the calling parent function. I have added line numbers for ease of displaying the trace further.

 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 several functions and then execute the first one (first). Below is an example trace of program execution when calling 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

Tracing is a little difficult to understand (if you know a better way to describe it, please share so I can update the post and future documentation). However, it is enough to understand it as “standard exception handling, plus the ability to throw anything

Another design also appeared, catch as, which allows you to intercept all values ​​and store them in a new local variable. You could also use something like catch (true) or catch ("hello")to intercept only certain values.

And the second option is quite funny. This toss. We can modify the above example to use toss And return. This time I’ll just show the stack from the moment we get to toss; before this, the program is executed in the same way (with minor changes in 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 a shortened trace starting right from the instruction toss. Notice that we now have an index that indicates the current position on the stack. It starts from scratch, which corresponds to the language in which I will write the interpreter.

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

That’s all! This is what we need to create a useful language that can perform all the standard functions of languages.

We do not have a clear processing method errors, since exception handling is used for real control flow. So let’s be careful not to create errors and bugs.

It’s time to put everything together and do something “useful”.

Implementing control flow through exception handling

Conditional statements and loops are the fundamentals of programming. How can I express them in my paradigm?

Conditional statements are quite simple, let’s start with them. We can just throw (hurl) value in block try and use blocks catch to compare values.

Let’s, for example, define values ​​that are greater than zero.

let val = 10;

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

The result will be “over 0”. Here the condition is evaluated and the result is thrown true, and the value is immediately intercepted. If suddenly something different from true or false, the stack will continue to unwind further, so be careful. Consider adding catch as a universal error interceptor.

With cycles, things are a little more complicated. Recursion is not available to us, so we have to be smart. Let’s start by defining the loop function. This function must itself take a loop function as an argument. It must also accept the body and local variables of the loop.

The body of the loop must satisfy the following condition:

  • It should discard (toss) local variables of the next iteration before the end of the loop body.

  • Some time after this it should hurl or true (to start the next iteration), or false (to complete the 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 you need to define a body.

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

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

And if we call it out, we can see what happens!

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

The result should look like this:

round 1
round 2
round 3

And this, in general, is all we need.

Example program

Another interesting example: the fizzbuzz program. If a programming language cannot implement the fizzbuzz task, then it is useless for testing. Therefore we must make surethat you can write a solution to this problem on it.

Here is an implementation using the previously defined function loop.

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

    try {
        hurl x == max;
    } catch (true) {
        toss locals;
        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]);

In my opinion, Not bad! By “pretty good” I mean “looks like it works, technically speaking.” I’m not saying “yes, let’s use this in production” because I don’t hate my colleagues that much.

Future plan

So what are the next steps for Hurl?

We could stop here: it’s a great joke, I wrote some code examples and we had fun. But I’m not going to stop. It’s a nice compact language that I think is suitable for revisiting some of the ideas in the book Crafting Interpreters (“Creating Interpreters”). And this is my first experience in language development! The risks are minimal, so I can calmly move on without being tied to anything specific.

The plan is to iteratively work on the interpreter. My next steps:

  1. Define grammar

  2. Develop a lexical analyzer

  3. Develop a parser (demo: checking program parsing)

  4. Develop a formatter (demo: reformatting programs)

  5. Develop an interpreter

  6. Write some programs for the soul (Advent of Code 2022?) and create a standard library

First of all, I plan to develop a formatter, since all modern languages ​​use it. Creating it will be much easier than developing an interpreter, and I can get to work faster. The development of the interpreter itself will take a lot of time and will consist of several iterations.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *