Functions and methods
There are 5 kinds of functions in Firefly:
- Top-level functions
- Anonymous functions
- Local functions
- Methods
- Trait functions
Trait functions are covered in section Traits and instances , the rest are covered below.
Top-level functions
Functions at the top level are defined like this:
add(a: Int, b: Int): Int {
let sum = a + b
sum
}
This function takes two arguments of type Int
and returns their sum as an Int
. A function with a return type other than Unit
, must have an expression as the last statement, which is returned. In the example above, the value of sum
is returned.
When a function returns Unit, it can end with any statement.
class Counter(mutable value: Int)
reset(counter: Counter): Unit {
counter.value = 0
}
The example above defines a type Counter
with a mutable field. The function reset
sets this value to zero.
A Unit
return type can be omitted in a function definition, like this:
reset(counter: Counter) {
counter.value = 0
}
The parameter types must be declares expitly and the parentheses are required, even if there are no parameters. The function name and parameter names must start with a lowercase letter.
Type parameters
In a function signature, you can introduce a list of type parameters enclosed in square brackets, immediately following the function name. These type parameters can be used in the rest of the signature.
The following example defines a function swap
that takes any Pair and returns a Pair with first and second values swapped:
swap[A, B](pair: Pair[A, B]): Pair[B, A] {
Pair(pair.second, pair.first)
}
Two type parameters A
and B
are first introduced in the square brackets. These type parameters are swapped in the input and return type, expressing the value swap at type level. The type parameters are unbounded in the sense that swap
may be called with A
and B
replaced by any types.
Type parameters can be bounded or constrained like this:
same[E: Equal](pair: Pair[E, E]): Bool {
pair.first == pair.second
}
The type parameter E
must implement the Equal trait, which is required to perform equality comparisons. The section on traits and instances will discuss constraints in more detail.
Firefly cannot operate on the concrete types of type parameters at runtime. The behavior of a function is limited to what is specified in the function signature.
Calling functions
Functions are called like this:
add(1, 2) // 3
same(Pair('A', 'a')) // False
Just like variants, the arguments can be named, and given out of order, like this:
add(b = 2, a = 1)
add(b = 2, 1) // Same as above
Default Values
Function parameters in Firefly can have default values, which are used when no argument is provided for that parameter during a function call. Default values are specified in the function signature by assigning a value to the parameter.
Here’s an example of a function with a default value:
add(a: Int, b: Int = 1): Int {
a + b
}
When called without the second arguments, the function will use the default value:
add(1) // returns 2
If an argument is provided, it overrides the default:
add(1, 2) // returns 3
Recursion
Function definitions can be recursive, meaning that a function can call itself. Here’s an example of a recursive function that calculates the factorial of a number:
factorial(n: Int): Int {
if(n == 0) {
1
} else {
n * factorial(n - 1)
}
}
When calling factorial(0)
, the base case is triggered, returning 1
. Calling the function with a positive integer will recursively calculate the factorial. However, calling it with negative values will result in infinite recursion, as no base case exists for such input.
Tail Recursion
In some recursive functions, the recursive call is the last operation performed before returning the result. This is tail recursion . The Firefly compiler can optimize tail recursive calls, avoiding the buildup of function calls on the stack.
You can use the tailcall
keyword to explicitly mark a recursive call as tail-recursive, ensuring the compiler applies the optimization.
Here is a tail-recursive implementation of the factorial function:
factorial(n: Int, acc: Int = 1): Int {
if(n == 0) {
acc
} else {
tailcall factorial(n - 1, n * acc)
}
}
This version introduces an additional parameter, acc
, which acts as an accumulator to hold the running result of the factorial calculation. It is initialized to 1 by default, ensuring that when the function is first called with a single argument, the computation starts correctly. By moving the multiplication into the recursive call via the accumulator, the call becomes a tail-call, allowing the Firefly compiler to optimize it.
Anonymous functions
In firefly anonymous functions are written in curlybrases and constucted like this:
{a, b =>
let sum = a + b
sum
}
This anonymous function takes two arguments and returns their sum. Like named functions, the body is a sequence of statements where the last expression is returned.
Anonymous functions are often used right away, like below:
[1, 2, 3].map({x => x + 1}) // Returns [2, 3, 4]
An anonymous function that increments the given value by one is passed as argument to the method map
working on lists.
These functions are anonymous in the sense that they do not bring a name into scope themselves. They are just expressions that construct a function value. Like all other values, they can be assigned to variables, passed as arguments, or returned from other functions. But unlike other values, they can also be called.
This is in contrast to named functions, which are not first-class in Firefly. The name of a top-level function can only be called but is not an expression in Firefly. To pass a top-level function as an argument, for instance, it must be converted to an anonymous function first.
The type of function values are writen like this:
Int => Int // One parameter
(Int, Int) => Int // Multiple parameters
() => Int // No parameters
The type of an anonymous function cannot be written explicitly in the definition but is inferred from its usage. It will always have a monomorphic type where the argument and return types are concrete types.
Here are some examples of anonymous functions assigned to variables explicitly given a type.
Anonymous function without parameters are written without the arrow ( =>
), like this:
let life: () => Int = {42}
This is an anonymous function taking no arguments and returning Unit
:
let unit: () => Unit = {}
This is an anonymous function that increments its input by one:
let next: Int => Int = {i => i + 1}
This anonymous function takes multiple arguments:
let plus: (Int, Int) => Int = {a, b => a + b}
Anonymous function are called like named function.
life() // returns 42
unit() // returns unit
next(1) // returns 2
plus(1, 2) // returns 3
Parameter names are not part of the function type, and likewise, anonymous functions cannot be called with named arguments. The same goes for default argument values, which are not supported for anonymous functions.
The parameter list and the function arrow can be omitted when the parameters are only used once in the function body. In such cases, the parameters in the body are replaced with underscores ( _
), like this:
let next: Int => Int = {_ + 1}
let plus: (Int, Int) => Int = {_ + _}
let identity: Int => Int = {_}
These underscores, or anonymous parameters, always belong to the nearest anonymous function. Consider the following function:
let f: Int => Int = {{_ + 1}(_)}
In this code, there is an outer and an inner anonymous function, both taking one argument. The first underscore belongs to the inner function, which is called immediately by the outer function with the outer function's anonymous parameter as the argument.
Trailing Anonymous Function Arguments
Firefly has a special syntax for calling functions with function arguments. When a call has a sequence of literal anonymous functions as the last arguments, these arguments may be given using this special syntax. Consider the if
function from the standard library, with this signature:
if[T](condition: Bool, body: () => T): Option[T]
The if
function takes two parameters, where the last is a function. Calling if
with the standard syntax could look like this:
if(x == 0, {"Zero"})
Using the special syntax for trailing anonymous function arguments, it looks like this:
if(x == 0) {"Zero"}
With this syntax, the anonymous function is written after the call parentheses. Multiple trailing function arguments may be given in sequence. Consider the while
function from the standard library, with this signature:
while(condition: () => Bool, body: () => Unit): Unit
The while
function takes two parameters, both functions. Using the special syntax, a call to while
may look like this:
while {array.size() < 5} {
array.push("X")
}
The code above will push a string to an array while the size of the array is less than 5.
This syntax for trailing anonymous function arguments allows the use of if
and while
to resemble constructs in languages such as C and JavaScript, where these constructs are built-in keywords rather than functions.
Trailing Colon Function Argument
Firefly has a variation of the trailing anonymous function argument syntax. The very last anonymous function argument may be written after a colon, without curly braces. The purpose of this syntax is to avoid aditional indentation.
The example below calls if
with a trailing colon function argument:
safeFactorial(n: Int, acc: Int = 1): Option[Int] {
if(n >= 0):
factorial(n)
}
In this example, the if
function is called with the condition n >= 0
and an anonymous function that computes factorial(n)
. If n
is negative, the if
function returns None
and so does safeFactorial
. Using the colon syntax, the safeFactorial
functions continues unindented otherwise.
Local functions
Local functions are declared exactly like top-level functions but with the function
keyword in front of the signature, like this:
function square(n: Int): Int {
n * n
}
The above local function definition is a statement, similar to local variables declared with let
. The function name square
will be in scope for the rest of the code block.
Furthermore, local functions declared in sequence are in scope within each other's bodies, allowing them to be mutually recursive.
Methods
Firefly has methods, which are called like this:
Some(1).isEmpty() // False
Some(1).map({_ + 1}) // Some(2)
The examples above, calls the two methods isEmpty
and map
defined on Option
. The code below, shows how these methods are defined in ff:core
package:
extend self[T]: Option[T] {
isEmpty(): Bool {...}
map[R](body: T => R): Option[R] {...}
}
The extend
keyword is used to declare methods on values of a given type. The code above extend values of type Option[T]
with two methods. The identifier self
holds a reference to the value receiving the methods. The square bracket right after the self variable are optional and used to introduce type variables used to express the type extended with methods.
The two methods above are defined for all values of type Option[T]
, but methods can be også be defined for a more narrow targer type, like flatten
below:
extend self[T]: Option[Option[T]] {
flatten(): Option[T] {...}
}
The extend block above declares self
as Option[Option[T]]
, and likewise will only define flatten
for options types of options.
The scope of a method can also be narrowed down with trait constraints.
extend self[T: Equal]: Option[T] {
contains(value: T): Bool {...}
}
In code above, the extend block defines methods for the target type Option[T]
, but only when T
implements the Equal
trait.
Extend blocks must reside in the same module at definition of the type that are extended with methods.
Methods are equivalent to top level functions in terms of expressibility but differnt in terms of scoping. Each type definition has its own method scope.
Special method call syntax
if(x == 1) {"One"} else {"Several"}
Some(1).map {_ + 1} // Some(2)
Trait functions
Trait functions are covered in the section about traits and instances