Skip to main content

Functions

Functions are declared with def and have zero or more arguments in parentheses (...). More formally these are called normal arguments (not to be confused with implicit arguments), but in most cases, simply calling them arguments is clear. Argument and return types of function declarations must be specified; however, in a special case, omitting the return type is equivalent to returning ().

def add_five(x: Int) -> Int {
return x + 5
}

Functions implicitly return the last evaluated expression.

def add_five(x: Int) -> Int {
x + 5
}

A function can be applied in a similar way to how it is written.

let eight = add_five(3)
assert_eq(eight, 8)

Recursive functions

Repetition can be achieved using a recursive function that calls itself, the classic example is the factorial function, which accepts an integer n and computes the product of all positive integers up to and including n.

def factorial(n: Int) -> Int {
when {
n < 0 => panic("cannot compute factorial of a negative number")
n == 0 => 1
_ => n * factorial(n - 1)
}
}

This form is not tail recursive, which means that the factorial function will require an amount of memory proportional to n. Tail recursive functions are optimized to use finite stack space. Tail recursion usually requires adding another argument to track state.

def factorial_tail(n: Int, acc: Int) -> Int {
when {
n == 0 => acc
_ => factorial_tail(n - 1, n * acc)
}
}

def factorial(n: Int) -> Int {
when {
n < 0 => panic("cannot compute factorial of a negative number")
_ => factorial(n, 1)
}
}

Anonymous functions

Anonymous functions are created as needed and do not have names.

fn(x, y) { x + y }                   // With types.
fn(x: Int, y: Int) -> Int { x + y } // Without types.

Functions can be passed to other functions and used at a later time.

def apply(x: Int, f: fn(Int) -> Int) -> Int {
f(x)
}
let y = apply(5, fn(x) { 2 * x })
assert_eq(y, 10)

Anonymous functions are closures and can use any values in scope at the point where they are defined.

let multiplier = 10
let z = apply(5, fn(x) { multiplier * x })
assert_eq(z, 50)

For simple functions of one argument, a terse form is available using $. When a $ appears with a unary operator, binary operator, or as an isolated argument in a function call, that expression is turned into an anonymous function.

// This expression ... is translated into this.
-$ fn(x) { -x } // Unary operator.
$ + 3 fn(x) { x + 3 } // Binary operator.
2 ** $ fn(x) { 2 ** x } // Binary operator.
f(True, $, 5) fn(x) { f(True, x, 5) } // Function call.

// Be careful with arguments to function calls, as only the
// tightest expression is converted into an anonymous function.
//
// This expression ... is translated into this.
f(True, $ + 3, 5) f(True, fn(y) { y + 3 }, 5)

Passing a function as the last argument to another function is a common pattern. In these cases the block syntax can be used, in which the anonymous function is moved outside the function call, the fn is removed, and the argument list appears in |...| inside of {...}. The || is not used when there are no arguments. The block syntax does not support argument or return type annotations.

let y = apply(5) { |x| 2 * x }
assert_eq(y, 10)

// This is especially useful for longer function bodies.
let z = apply(5) { |x|
when {
x % 2 == 0 => 2 * x
_ => x + 1
}
}
assert_eq(z, 6)

fn call(f: fn()) {
f()
}
// No || when there are no arguments.
f() {
import std/io
io::println("Hello!")
}

Anonymous function blocks also accept implicits, although no using list is provided. The using list will be inferred based on context.

import std/io

type Context(String)

fn show_context(using ctx: Context) {
io::println(ctx.0)
}

fn with_context(fn(Int)(using Context) -> String) -> String {
f(123)(using Context("Hello"))
}

with_context() { |x|
io::println(x) // Prints: 123
show_context() // Prints: Hello
}

In the above example, the block passed to with_context() unifies to the type of fn(Int)(using Context) -> String, and the implicit argument of type Context is provided to the anonymous block.

For expressions

A for expression is similar to the block syntax for anonymous functions, however, it does not introduce a new pair of { and }. The for expression puts all of the expressions that follow it into an anonymous function block.

// This for expression
for f(x1, ..., xN)
body

// Becomes this function call.
f(x1, ..., xN) {
body
}

One or more arguments to the anonymous function block can be bound with patterns.

// This for expression
for a1, ..., aM <- f(x1, ..., xN)
body_that_uses_a1_to_am

// Becomes this function call.
f(x1, ..., xN) { |a1, ..., aM|
body_that_uses_a1_to_am
}

This is can eliminate visual nesting of anonymous function blocks when several are used.

import std/file

// Both of these functions open the file at `path1`, open the file
// at `path2`, then read the contents of the first file and write
// it to the second file, making sure to close the files after.

def copy_file_contents(path1: String, path2: String) {
file::with(path1) { |f1|
file::with_mode(path2, file::Write) { |f2|
let content = file::read(f1)
file::write(f2, content)
}
}
}

// The body of this function "desugars" to the function body above.
def copy_file_contents_with_for(path1: String, path2: String) {
for f1 <- file::with(path1)
for f2 <- file::with_mode(path2, file::Write)
let content = file::read(f1)
file::write(f2, content)
}

For expressions follow the same rules as anonymous blocks.

  1. The types of arguments are note specified.
  2. Implicit arguments are inferred but not listed.

Pipes

Pipes are a special syntax that enables an easy way to write computation chains. Pipes make the flow of data match the order in which it is read by the programmer.

import std/iter

let squares = [1, 2, 3]
| iter::map($ ** 2)
| iter::collect()
assert_eq(squares, [1, 4, 9])

By default the | passes the value from the left hand side into the first argument of the function on the right. The & name refers to the value on the left, allowing the argument ordering to be changed.

let x = 5 | math::pow(2, &)
assert_eq(x, 32)

Using fn has a special meaning when it appears after a |. It is used to pipe into a newly created function.

let x = 5 | fn(x) { 2 ** x }
assert_eq(x, 32)

The fn can also accept an argument name and have types specified.

let x = 5 | fn(x: Int) -> Int { 2 ** x }

The rules for multiline anonymous functions apply to piped fns too.

let z = 5 | fn(x) {
when {
x < 0 => x - 3
_ => x + 3
}
}
assert_eq(z, 8)

Parameterized functions

Functions may be parameterized to accept values of more than a single type. For example, the id function accepts a value and returns it. The id function is provided by the language, but it can also be simply implemented in a generic way.

def id(x: a) -> a {
x
}

Concrete types, such as Int and String, begin with uppercase letters, while type parameters, such as a and b, begin with lowercase letters. Type parameters indicate that any type can be used in that spot. When the id function is used, its type will be unified to the specific type that the context requires.

x = id(123)   // id unifies to fn(Int) -> Int
y = id("abc") // id unifies to fn(String) -> String

Multiple type arguments can be used at once.

import std/fmt
def apply(x: a, f: fn(a) -> b) -> b {
f(x)
}
assert_eq(apply(5, fmt::debug), "5")