Functions - cyxigo/wi GitHub Wiki

Introduction

In programming, a function is simply a reusable block of code. In Wi, functions are first-class citizens, meaning they can be stored in variables, passed as arguments to other functions, and returned from other functions.

Function definition

In Wi, functions are expressions, so to define a function you need to declare a variable and define it with a function expression:

f := || => {
};

Let's analyze what is what here.

  • ||: this is the function's parameter list. A parameter is an input to a function. In this example f has no parameters. In this one, however:
f := |x| => {
};

It has a parameter called x.

  • { and }: this is the function's body. It is defined by a block and has its own scope. Parameters are locals within that scope. It's optional but we'll get to it later.

Calling functions

Executing a function is called calling it. To call a function, we use the call operator () which we discussed earlier. In case you forgot:

Call operator () is used to call functions. It accepts arguments separated by ,

Arguments are values that you pass into the function parameters.

For example, our good old function f. To call it, we need to pass a value to the parameter x:

f := |x| => {
};

f(10);

Here, 10 is the argument, x is the parameter. We pass 10 into x. If you pass the wrong number of arguments into a function, you will cause a runtime error:

f := |x| => {
};

f();
runtime error: expected 1 arguments but got 0
   --> file.wi:4 in main function

A technical term for a function's accepted parameter count is arity.

Returning values

Every function returns some value. Think of returning a value as the answer. You called a function, it answered with a value. Even if you don't specify one, Wi implicitly returns null from every function (the function has to answer our call). So if we were to print out the f example:

f := |x| => {
};

std::puts(f(10));

We would see:

null

But we have a handy return statement, which accepts an expression to make our answer different:

f := |x| => {
    return x;
};

std::puts(f(10));

Now when we call our f, it answers with x, its parameter, and since we passed 10 into x we see:

10

return statement can also be used to stop some code from executing, for example:

f := |x| => {
    if (x == 4) {
        return;
    }

    std::puts(x);
};

f(4);
f(10);

In this example, if the passed argument is 4, we won't print it. This program will output:

10

return without an expression will return null.

You can also make one-line functions that implicitly return a value! Let's say we have a function called add and we want it to do exactly what it should do – add two numbers:

add := |a, b| => a + b;
std::puts(add(2, 2)); %% 4

Or another example:

say_hi := |name| => std::puts("Hi ${name}!");
say_hi("Bob"); %% Hi Bob!

As I said earlier function's body is optional exactly because of this, pretty useful when you know function can be boiled down to exactly one simple line.

Recursion

A function can call itself. This is called recursion.

Let's define a function to calculate the factorial of a number n using recursion:

factorial := |n| => {
    if (n <= 1) {
        return 1;
    }

    return n * factorial(n - 1);
};

std::puts(factorial(5)); %% 120

Let's analyze what's happening here.

When we call factorial(5), it checks if n is less than or equal to 1. This is a safeguard here to prevent infinite recursion – if we didn't have it, the function would call itself forever, eventually causing a stack overflow error. Since 5 is not <= 1, it returns 5 * factorial(4).

  • factorial(4) returns 4 * factorial(3).
  • factorial(3) returns 3 * factorial(2).
  • factorial(2) returns 2 * factorial(1).
  • factorial(1) hits the safeguard and returns 1.

Then the results "unwind":

factorial(5) = 5 * 4 * 3 * 2 * 1 = 120

Recursion has limits. If you call a function too many times without returning, you'll cause a stack overflow:

infinite := || => {
    infinite();
};

infinite();

The error message isn't pretty, but you'll definitely notice it. Stack overflow doesn't happen with tail calls, about them...

Tail calls

If a function's return statement returns a call expression directly – nothing wrapping it – Wi optimizes it into a tail call, reusing the current call frame instead of growing the stack:

count_down := |n| => {
    if (n <= 0) {
        return 0;
    }

    return count_down(n - 1); %% tail call, won't overflow no matter how large n is!
};

count_down(200000000); %% LARGE n!!! ...and no overflow!

This only applies when the call is directly in return position. return count_down(n - 1) + 1; or return x && count_down(n - 1); don't qualify, since the returned value isn't just the call's result.
So recursion has no limits? Welp, sometimes as you can see. Programming is confusing a bit like that.

Variadic Functions

But what if you want to create a function that takes any number of arguments. You use a variadic function, here's how you define it:

f := |...args| => {
};

... is the syntax for variadic parameters, args here is the name for the variadic parameters array, when we call a variadic function:

f := |...args| => {
};

f(1, 2, 3, 4);

1, 2, 3, and 4 all become args items, so if you were to print it you'd get:

[1, 2, 3, 4]

Variadic parameters can only appear as the last parameters, this syntax is invalid:

f := |...args, b| => {
};

You get:

compile error: expected |
   |
 1 | f := |...args, b| => {
   |              ^
   --> file.wi:1:14

But you can add parameters before variadic parameters:

f := |a, ...args| => {
};

Now, f requires at least one argument (a), so in this case:

f := |a, ...args| => {
};

f(1, 2, 3, 4);

a becomes 1, while args become [2, 3, 4]. But if we try:

f := |a, ...args| => {
};

f();

We get:

runtime error: expected at least 1 arguments but got 0
   --> file.wi:4 in main function

Variadic parameters are optional, you can just not pass any arguments and args will become [].

Closures

A function can reference variables from an enclosing function, even after that enclosing function has returned. This is called a closure – the function "closes over" those variables.

[!NOTE] In Wi's implementation, technically every function is a closure. Key word: "technically"

make_counter := || => {
    count := 0;
    return || => {
        count = count + 1;
        return count;
    };
};

counter := make_counter();
std::puts(counter()); %% 1
std::puts(counter()); %% 2
std::puts(counter()); %% 3

count lives inside make_counter, but the inner function keeps a live reference to it, so it can read and modify it long after make_counter itself returned. Each call to make_counter creates a brand new, independent count.

This may be confusing a bit, but there are reasons why you should know about closures and their semantics, like...

Closures inside loops

A common mistake: capturing a for loop's control variable directly.

fns := [];

for (i := 0; i < 3; i = i + 1) {
    fns->add(|| => i);
}

fns->each(|f| => std::puts(f())); %% 3 3 3, not 0 1 2!

Boom! See? Told ya you should know about closures.

Why does that happen? i is declared once in the for loop's initializer, which runs a single time before the loop starts. The loop mutates i on each iteration, but it's the same variable the whole time. To get a separate value per iteration, declare a new local inside the body and capture that instead:

fns := [];

for (i := 0; i < 3; i = i + 1) {
    j := i;
    fns->add(|| => j);
}

fns->each(|f| => std::puts(f())); %% 0 1 2, correct!

So my friend, be careful with closures.

Next: Objects