Skip to content

Tutorial 9.1 (Functions)

Aerll edited this page Dec 28, 2023 · 39 revisions

Functions

Functions are nothing more than reusable blocks of code. We can essentially wrap any logic in a function and run it from elsewhere. It's a useful tool, which makes the code less repetitive and much easier to read.

Unlike everything we wrote up until now, functions are not executed by the program and instead we need to call them. In a way it's similar to variables, where we first introduce a name, and then this name refers to some value. The difference is that functions aren't values, therefore we need to use a slightly more sophisticated syntax.

Signature

In r++ all functions have a signature, which is used to identify them. It's basically the full name of a function, which includes the name itself and a list of parameter types. Here are some examples:

Function(int i)
Function(coord c)
Function(int x, int y)
Function(int x, int y, int z)
AnotherFunction()
YetAnotherOne(range r, coord c)

As you may have noticed, there are 4 signatures with the same name, yet they would be perfectly valid to use. If we write down the full name for each of these, we get the following:

name: Function -> types: int
name: Function -> types: coord
name: Function -> types: int int
name: Function -> types: int int int

We can see that the types are different, therefore all signatures are different.

But it doesn't end there, signature is also unique to the kind of function we're using. We can use the same signature for a function and a nested function. Here's a minimal example (which you don't have to understand) showcasing it:

#include "base.r"

AutoMapper("");

function->null test() nested(test)
    warning("test()");
    invoke(nested);
end
nested function->null test.test()
    warning("test.test()");
end

test().test();

Even though both functions have the same signature test(), the program has no problem with distinguishing them and displays 2 warnings test() and test.test().

Creating a function

To create a function we write:

function->type signature
    ...
    return value;
end

Let's break it down:

  • every function starts with a function keyword and a return type, followed by a signature, and ends with the end keyword
  • type - type of the value returned by the function, null indicates that nothing is returned
  • signature - consists of functions's name and a list of parameters
  • return value; - stops the execution of the function and returns value, in a non-returning function return is not required, but can still be used without value
  • ... - this is where we write our usual r++ code, it will be executed when the function is called

Return

return is simply a way to pass a value back to where from the function was called. In other words, a function call becomes that value. Here's a simple example of a function returning a sum of 2 values:

function->int Sum(int a, int b)
    return a + b;
end

Now when we call this function with values 5 and 10, it will give us the result of 5 + 10.

But wait there's more, if we combine it with if, we can return different results. We could for example create a simple calculator:

function->int Calculate(int a, int b, int op)
    if (op == 1)
        return a + b;
    end
    if (op == 2)
        return a - b;
    end
    if (op == 3)
        return a * b;
    end
    if (op == 4)
        return a / b;
    end

    error("Invalid operation");
end

When we call this function, we can specify the op and the function will return the result of that operation.

Note: Execution of all functions (except for function->null) must always end with a return. If there's a possibility for the function to not return anything, and it's not a function->null, you will get an error. In the function above, it is valid to omit the return at the end, since error() will immediately halt the program.

Another important thing to note is that we can actually return from a non-returning function:

function->null DoSomething(int tile)
    if (tile == 0)
        return;
    end
    Replace(tile, 0);
end

Calling a function

Calling a function is pretty simple and we already did it several times. To call the above function Sum, we write the following:

Sum(5, 10);

Since Sum returns a value, we can use it in the exact same contexts as variables. For example we could assign the result of Sum to a variable:

int sum = Sum(5, 10); // sum = 15

Overloads

Overloading is essentially having 2 or more functions with the same name, but with different parameters. For example we could have a function, which creates a run, that clears all tiles:

function->null Sweep()
    NewRun();
    OverrideLayer();
    Insert(0);
end

However we could also provide an additional overload, which clears a specific tile:

function->null Sweep(int tile)
    NewRun();
    OverrideLayer();
    Replace(tile, 0);
end

Now we can call both overloads as follows:

Sweep(); // calls 'function->null Sweep()'
Sweep(1.N); // calls 'function->null Sweep(int tile)'

Depending on the values passed, different function gets executed.

Important thing to note here, is that the values don't necessarily need to be of the same type. As long as the program is able to convert them, we're fine. For example we could pass a coord to our function:

Sweep([1, 0].N);

This can however lead to some unexpected results in cases where the program has no way of knowing which function we mean to call. Suppose we have 2 functions:

function->null SweepAt(int tile, coord at)
    NewRun();
    OverrideLayer();
    Insert(0).If(IndexAt(at).Is(tile));
end

function->null SweepAt(coord at, int tile)
    NewRun();
    OverrideLayer();
    Insert(0).If(IndexAt(at).Is(tile));
end

This may seem nice, the user can pass his values in any order and the code will just work:

SweepAt(1, [0, 0]); // calls 'function->null SweepAt(int tile, coord at)'
SweepAt([0, 0], 1); // calls 'function->null SweepAt(coord at, int tile)'

Great. Now let's imagine, that we have some object and we want to remove its anchor from the map using our SweepAt function:

object o = Indices(1);
SweepAt(o.anchor, [0, 0]); // calls `function->null SweepAt(coord at, int tile)`

And here we have an oopsie. We clearly meant to call function->null SweepAt(int tile, coord at), yet we ended up calling the other overload.

Why is that? This is due to the types of values we passed. You may think that the anchor is an int, however surprise, it's a coord. The program is looking for the best match for each of the values and prioritizes those signatures. Since our first value is a coord, the better match here is obviously a coord, so now function->null SweepAt(coord at, int tile) is considered a better candidate. The second value can be converted to an int, therefore this signature wins the contest.

Example

In this example we will be writing something similar to the grass_main automapper from tutorial 4.2. This time however, we won't be doing it for any specific tileset.

Our goal is to create a function, which will automatically generate a run for borders. What we need is 13 values: a filler, 4 walls, 4 outer corners and 4 inner corners. Let's write them down as parameters:

function->null Border(
    int fill,
    int wall:top, int wall:right, int wall:bottom, int wall:left,
    int outer:topLeft, int outer:topRight, int outer:bottomRight, int outer:bottomLeft,
    int inner:topLeft, int inner:topRight, int inner:bottomRight, int inner:bottomLeft
)

end

Now that our function is all set and ready, we can write our run. We're essentially gonna borrow a part of the code from the GrAss example. But before we do that, we should think about what could go wrong here. As we know, all of the values come from the outside, therefore we should make sure they're all valid. If any of the values are not in range [0-255], we throw an error:

if (s:debug)
    array int values = util:ArrayInt(
        fill, wall:top, wall:right, wall:bottom, wall:left,
        outer:topLeft, outer:topRight, outer:bottomRight, outer:bottomLeft,
        inner:topLeft, inner:topRight, inner:bottomRight, inner:bottomLeft
    );
    for (i = 0 to values.last)
        if (values[i] < 0 or values[i] > 255)
            error("Values must be in range [0-255].");
        end
    end
end

This will prevent the user from passing incorrect values, while also informing him about the mistake.

Note: s:debug is a flag used in base.r, which enables error checking. It is important to include it, since it allows the user to turn it off and speed up the execution.

Note: This is purely informational, in reality we don't have to validate anything, as long as we're using functions provided in base.r to create rules. They validate every value anyway.

Now that the values are validated, we can write all of the rules:

// fill everything
Insert(fill);

// insert walls
Insert(wall:top).If(IndexAt([0, 0]).IsWall(top));
Insert(wall:right).If(IndexAt([0, 0]).IsWall(right));
Insert(wall:bottom).If(IndexAt([0, 0]).IsWall(bottom));
Insert(wall:left).If(IndexAt([0, 0]).IsWall(left));

// insert outer corners
Insert(outer:topLeft).If(IndexAt([0, 0]).IsOuterCorner(topLeft));
Insert(outer:topRight).If(IndexAt([0, 0]).IsOuterCorner(topRight));
Insert(outer:bottomRight).If(IndexAt([0, 0]).IsOuterCorner(bottomRight));
Insert(outer:bottomLeft).If(IndexAt([0, 0]).IsOuterCorner(bottomLeft));

// insert inner corners
Insert(inner:topLeft).If(IndexAt([0, 0]).IsInnerCorner(topLeft));
Insert(inner:topRight).If(IndexAt([0, 0]).IsInnerCorner(topRight));
Insert(inner:bottomRight).If(IndexAt([0, 0]).IsInnerCorner(bottomRight));
Insert(inner:bottomLeft).If(IndexAt([0, 0]).IsInnerCorner(bottomLeft));

And this pretty much wraps it up. We can put our function in a separate file and it's ready to use. Now we can apply it to different tilesets.

// grass_main.r++
#include "base.r"
#include "border.r"

AutoMapper("Border");
Border(1, 16, 17, 18, 19, 32, 33, 34, 35, 48, 49, 50, 51);

// winter_main.r++
#include "base.r"
#include "border.r"

AutoMapper("Border");
Border(1, 19, 2.V, 98, 2, 16, 20, 100, 96, 4, 3, 7, 8);

Full code

function->null Border(
    int fill,
    int wall:top, int wall:right, int wall:bottom, int wall:left,
    int outer:topLeft, int outer:topRight, int outer:bottomRight, int outer:bottomLeft,
    int inner:topLeft, int inner:topRight, int inner:bottomRight, int inner:bottomLeft
)

    if (s:debug)
        array int values = util:ArrayInt(
            fill, wall:top, wall:right, wall:bottom, wall:left,
            outer:topLeft, outer:topRight, outer:bottomRight, outer:bottomLeft,
            inner:topLeft, inner:topRight, inner:bottomRight, inner:bottomLeft
        );
        for (i = 0 to values.last)
            if (values[i] < 0 or values[i] > 255)
                error("Values must be in range [0-255].");
            end
        end
    end

    NewRun();
    
    // fill everything
    Insert(fill);

    // insert walls
    Insert(wall:top).If(IndexAt([0, 0]).IsWall(top));
    Insert(wall:right).If(IndexAt([0, 0]).IsWall(right));
    Insert(wall:bottom).If(IndexAt([0, 0]).IsWall(bottom));
    Insert(wall:left).If(IndexAt([0, 0]).IsWall(left));

    // insert outer corners
    Insert(outer:topLeft).If(IndexAt([0, 0]).IsOuterCorner(topLeft));
    Insert(outer:topRight).If(IndexAt([0, 0]).IsOuterCorner(topRight));
    Insert(outer:bottomRight).If(IndexAt([0, 0]).IsOuterCorner(bottomRight));
    Insert(outer:bottomLeft).If(IndexAt([0, 0]).IsOuterCorner(bottomLeft));

    // insert inner corners
    Insert(inner:topLeft).If(IndexAt([0, 0]).IsInnerCorner(topLeft));
    Insert(inner:topRight).If(IndexAt([0, 0]).IsInnerCorner(topRight));
    Insert(inner:bottomRight).If(IndexAt([0, 0]).IsInnerCorner(bottomRight));
    Insert(inner:bottomLeft).If(IndexAt([0, 0]).IsInnerCorner(bottomLeft));

    // safety measure in case the user doesn't create a new run after calling this function
    NewRun();
end