DEV Community

Cover image for Print and Run: Making a shitty language from scratch. Part 1.5
Andi Rosca
Andi Rosca

Posted on • Updated on • Originally published at andi.dev

Print and Run: Making a shitty language from scratch. Part 1.5

Intro

Welcome back to making a shitty language from scratch.

This is a smaller installment, hence the 1.5.

We left off with our implementation allowing for addition and subtraction to be done through our custom syntax.

This post will focus on cleaning up our code and introducing a couple of utilities that will come in handy for implementing variables in the upcoming Part 2.

PS: You can read the original here

Introducing the standard library (also known as STD)

You can think of the standard library as a set of functionality that a language comes with out of the box. There's some pedantic differences between functionality that's built directly into the language, and a standard library, but let's not worry about that.

For our purposes, the STD and built-in functionality are one and the same. And right now, we only have 2 functions in our standard library: "+" and "-".

As a first step in our cleanup, let's refactor things a bit so that the standard library is more evident in the code. If you don't remember, the final code for Part 1 looked like this:

const sum = (numbers) =>
    numbers.reduce((sum, n) => sum + n, 0);

const EVALUATE = (input) => {
    if(!Array.isArray(input)) {
        return input;
    }

    const [fn, ...rawArguments] = input;
    const arguments = rawArguments.map(arg => EVALUATE(arg));


    if(fn === "+") {
        return sum(arguments);
    }
    if(fn === "-") {
        const [first, ...rest] = arguments;
        return first - sum(rest);
    }

    throw Error("Function not supported:", fn);
}
Enter fullscreen mode Exit fullscreen mode

We will extract the "+", and "-" functions into a std object. This object will hold all future std functionality as well:

const sum = (numbers) =>
    numbers.reduce((sum, n) => sum + n, 0);

// A more readable standard library
const std = {
    "+": (args) => sum(args),
    "-": ([first, ...rest]) => first - sum(rest)
}

const EVALUATE = (input) => {
    if(!Array.isArray(input)) {
        return input;
    }

    const [fn, ...rawArguments] = input;
    const arguments = rawArguments.map(arg => EVALUATE(arg));

    if(std[fn]) {
        return std[fn](arguments);
    }

    throw Error("Function not supported:", fn);
}
Enter fullscreen mode Exit fullscreen mode

You'll notice that in the body of the EVALUATE function, we've replaced the if statements for each individual operation with a generic if(std[fn]) statement. This will check if the function exists in the std, and if it does it will call it with the arguments array and return the result.

Now the body of the evaluator should stay slim, even if we add another 50 functions in the standard library.

Printing

I should've started this series with implementing a print function and running a "hello world" program.

It's not too late to add one now. It will be useful for debugging future programs once the language gets more complex.

First we will add a new function to the std:

const std = {
    "+": (args) => sum(args),
    "-": ([first, ...rest]) => first - sum(rest),
    "print": (arguments) => console.log(...arguments)
}
Enter fullscreen mode Exit fullscreen mode

Luckily, console.log already handles printing multiple arguments so our print function is a very thin wrapper around it.

Since we already have the generic if statement in the evaluator, we don't need to add any extra code.

Running

EVALUATE(["print", "hello", "world"]);
Enter fullscreen mode Exit fullscreen mode

will print "hello world" to the console.

We can also combine printing with the math operations we implemented last time:

EVALUATE(["print", ["+", 4, ["-", 3, 10]]]) // should log -3
Enter fullscreen mode Exit fullscreen mode
<sub>Note<sub>

We're going to make our language have a functional programming flavor.

One of the most important features of a functional language is that **everything** is an expression. That means that anything you write in that language will result in a value.

A good explanation for statements vs expressions is the difference between if statements and the ternary operator.

You can't do `const myVar = if(x) 10 else 20;`. The if/else statement doesn't result in a value, it just runs the code inside the branch.

But you can do `const myVar = x ? 10 : 20;` because the ternary operator is an expression, and expressions always result in values.
Enter fullscreen mode Exit fullscreen mode

Let's make the print function an expression and not just a statement.

It's very simple, all it has to do to be an expression is to result in a value. And how do functions result in values? By returning something.

const std = {
    ...
    "print": (arguments) => {
        console.log(...arguments);
        return arguments;
    }
}
Enter fullscreen mode Exit fullscreen mode

It might seem inconsequential to do this, but if we stick with it for all of the language's functions, you'll notice the pay-off at the end for the kind of programs you can write when everything is an expression.

Ok, printing is done, let's switch gears to other improvements we can make.

Running multiple statements

Have you noticed that our evaluator only accepts one top-level function at a time? We can only evaluate either a print, +, or - function. These functions accept arguments that are other function calls, but we can't string multiple function calls at the top-level.

What if you want to run multiple unrelated instructions, like in a real language?

There are a few ways to do this, like allowing the evaluator to accept an array of statements as well as a single function call. Which would look like:

EVALUATE([
    ["print", "hi"],
    ["print", "this is another statement"],
]) // the 2 print statements are unrelated to each other
Enter fullscreen mode Exit fullscreen mode

But that can introduce weird edge-cases to handle all throughout our interpreter.

Remember that we call evaluate on all of the arguments recursively. So we'll then have to implement some code to distinguish arrays made of instructions, or function calls.

Maybe it's possible to distinguish:

["print", ["+", 1, 2], ["-", 6, 8]]
Enter fullscreen mode Exit fullscreen mode

From:

[["print", ["+", 1, 2]], ["print", ["-", 6, 8]]]
Enter fullscreen mode Exit fullscreen mode

But do we want to have to deal with that?

The easier choice is to do what we've been doing until now: add another function to our std.

We'll call this function run, since it'll be used to run multiple instructions. Adjusting the example above, the program would look like this instead:

EVALUATE(["run",
    ["print", "hi"],
    ["print", "this is another statement"],
])
Enter fullscreen mode Exit fullscreen mode

You can see that the function run is called with two arguments, the two print function calls.

But what should run return?

This is where we will take inspiration from functional languages again.

In an imperative language like JS, we have an explicit return statement. And a return statement is the last operation that happens before a function call results in a value.

In functional languages, every instruction is also an expression that results in a value. The usual convention is that rather than using an explicit return syntax, they implicitly return the last expression's value.

Think of this imaginary language's function:

function test() {
    "a string";
    someFunctionCall();
    10;
}
Enter fullscreen mode Exit fullscreen mode

The last evaluated expression is the hardcoded number 10, and that's what the test function will return implicitly.

Now let's implement returning the last argument for the run function:

const std = {
    ...
    run: (args) => args[args.length - 1]
}
Enter fullscreen mode Exit fullscreen mode

We could change our print function to also return only the last argument, for uniformity. We'll extract the logic for getting the last argument in a separate function as well:

const std = {
    "+": (args) => sum(args),
    "-": ([first, ...rest]) => first - sum(rest),
    last: (args) => args[args.length - 1],
    print: (args) => {
        console.log(...args);
        return std.last(args);
    },
    run: (args) => std.last(args)
}
Enter fullscreen mode Exit fullscreen mode

🎉 Notice how there's still no need to change the EVALUATE function's body, even with all these std changes!

Final code

Now we're in a good place to implement variables in Part 2. Stay tuned for it.

Here is the full code up until now

const sum = (numbers) =>
    numbers.reduce((sum, n) => sum + n, 0);

const std = {
    "+": (args) => sum(args),
    "-": ([first, ...rest]) => first - sum(rest),
    last: (args) => args[args.length - 1],
    print: (args) => {
        console.log(...args);
        return std.last(args);
    },
    run: (args) => std.last(args)
}

const EVALUATE = (input) => {
    if(!Array.isArray(input)) {
        return input;
    }

    const [fn, ...rawArguments] = input;
    const arguments = rawArguments.map(arg => EVALUATE(arg));

    if(std[fn]) {
        return std[fn](arguments);
    }

    throw Error("Function not supported:", fn);
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)