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 examplefhas 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)returns4 * factorial(3).factorial(3)returns3 * factorial(2).factorial(2)returns2 * factorial(1).factorial(1)hits the safeguard and returns1.
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