Note: All of this capability is implemented except pure functions.
Earlier, we showed how to call functions as part of an expression. Now it is time to show how to declare and implement functions. In fact, all logic (including expressions) must be defined as part of the implementation of a function (or method).
Let's start with a simple example of a function definition and its use:
// Define the 'square' function fn square(a f32) f32: return a*a // Use square to calculate the area of a circle area = Float.pi * square(r)
This example shows the key parts of an function definition:
- The signature which declares its:
- name (square in this example).
- parameters in parentheses (a in this example).
- return type(s) which follows the parentheses (f32 in this example).
- The function's implementation block follows the signature. The block contains statement(s) that represent the logic to be performed by the function.
The declaration of a functions's parameters is a comma-separated list of variable names, enclosed in parentheses. Each parameter variable corresponds on a one-to-one basis to the value arguments passed to the function when it is called. They must match in both number and type:
// Define a method that returns the addition of its two parameters fn add(a i32, b i32) i32: return a+b add(2,3) // returns 5, since a is set to 2 and b to 3 add(2) // ERROR! add() needs exactly two values, not one or three add(2.0, 4.0) // ERROR! add() needs integers, not floating point numbers
Parameters are local variables, usable within the function. The function's signature effectively declares the parameter variables in the same format as any variable declaration. In the example above, the permission is not specified and therefore imm is assumed. That restricts the function's logic from changing the value of these passed parameters.
To allow a parameter's value to be changed, precede the name with mut. Mutation of the parameter's value has no effect on the caller since the parameter's value is a copy of the caller's value. Changes to one value do not affect the other.
fn weird(a i32, mut b i32): a = 34 // ERROR! a is immutable and may not be changed b = 4 // b may be changed since it is declared 'mut' number = 3 weird(5, number) // number is still 3, despite weird() changing b to 4
If desired, default values may be specified for any parameter value using the assignment operator '='. The default value is what we want the parameter to have if the caller provides no value for it. The default value can only be a literal value.
fn next(nbr i32, incr i32 = 1) i32: return nbr + incr next(5,2) // returns 7 next(4) // returns 5 (using incr's default value of 1) next() // ERROR! no default value for nbr
The function's implementation block is like any regular block, consisting of a ordered sequence of statements that perform the function's logic.
All local variables declared within the function's block (including the function's parameters) provide a temporary working state for the function. Every time a function is called, space is allocated on the execution stack for all its declared local variables. When the function is finished and returns to its caller, its local variable space is automatically freed from the stack.
fn summult(a f32, b f32) f32: imm sum = a + b // local variable declaration imm mult = a * b // local variable declaration return sum / mult
This means that local variables are exclusive to that call. Two identically-named local variables in different functions, or even two calls to the same function, will not collide with each other.
A return statement may be placed at the end of any block. When encountered, execution of the function ceases and the comma-separated values specified after return are returned to the caller. The number and types of all return values must match the return types declared on the function's signature.
fn ceil(x i32) i32, i32: if x > 6: return x, 6 return x, x mut a,b = ceil(8) // returns 8,6 a,b = ceil(3) // returns 3,3
A function does not have to specify a return statement at the end of its main block. If the function signature does not declare a return value, the function just returns after the last statement is performed.
If the function signature declares that values must be returned, an attempt is made to matching the values on the last statement or block of the function:
- if it is an expression whose types match, it is treated as if return
were specified before the expression:
fn ceil(x i32) i32, i32: if x > 6: return x, 6 x, x // implicit return
- An if or match returns the last statement's expression in each of its distinct blocks (this is handled recursively). All paths (including a required else block) must return valid values.
fn ceil(x i32) i32, i32: if x > 6: x, 6 // implicit return else: x, x // implicit return
fn newthing() Thing: with thing: // implicitly returns thing .name = "Doofus" .color = "brown"
Should the correct number of type-matching return values not be found as described above, a compile error will result.
A function may call itself recursively:
fn factorial(x i32, prod i32 = 1): if x<=1: return prod factorial(x-1, prod*x)
If the function returns a single value calculated by any function, this may be "tail-call" optimized. This optimization improves performance and reduces the risk of execution stack overrun with recursive calls.
Sometimes it is valuable when a function guarantees that its only work involves calculating return value(s) derived wholly from the information passed as parameters. Such functions are called pure. Pure functions may not:
- Access global, mutable variables, except to use regions to allocate memory.
- Call functions or methods that are not pure. This effectively prevents the function from doing any I/O, since that requires use of functions that won't be marked as pure.
Functions complying with these constraints should be marked as pure.
pure fn factorial(n i32) i32: if x<=1: return 1 mut result = 1 while n-- > 1: result *= n result
A key benefit for marking functions pure: it makes code easier to understand, debug and test. When examining logic that uses a pure function, we can be confident that:
- Passing it the same data will always return the same results.
- Using it will not trigger some unexpected state-changing behavior (side-effect), such as performing any sort of i/o.
- It will not mutate any state other than objects it has explicitly been given mutable references to.
Pure functions are also beneficial due to the safety guarantees they offer when:
- dynamically initializing global variables at runtime.
- using them to calculate constant values at compile-time
The compiler only enforces the purity constraints listed above, resulting in what some may consider a weak form of purity. A stronger form of purity (referential transparency) may be obtained by ensuring:
- none of the parameters are mutable references or pointers.
- the logic never uses potentially non-deterministic operations, such as comparing pointers or re-casting machine-specific value encodings to other types.
Only a strongly pure functions can be safely memo-ized.