2. Values, types and functions

  • chapter 2 of 20
  • 3 min read

let binds immutably, var mutably. The primitive types are Int, Float, Bool and String. A top-level function has to write out every parameter type and its return type — the signature is the contract.

fn square(x: Int) -> Int = x * x

fn abs(x: Int) -> Int =
  if x < 0 { 0 - x } else { x }

pub fn main() -> Unit !io = {
  var total = 0
  total = total + square(3)
  total = total + abs(-4)
  println(to_string(total))
}
13

Dot calls: x.f(a) is f(x, a)

When a function is about its first argument, write that argument first. x.f(a) is exactly f(x, a): the receiver fills the first parameter and the parentheses hold the rest. A chain of dot calls reads in the direction the data flows, where nested calls read inside out:

fn double(x: Int) -> Int = x * 2
fn inc(x: Int) -> Int = x + 1

pub fn main() -> Unit !io = {
  let n = 5
  n.double().inc().to_string().println()
  let total = [1, 2, 3, 4]
    .filter(x => x > 1)
    .map(double)
    .fold(0, (acc, x) => acc + x)
  println(total.to_string())
}
11
18

Two or more steps on the same value are always a chain, never nested calls: write e.insert(5).insert(2), not insert(insert(e, 5), 2). A long chain goes one step per line, with the . at the start of each continuation line. Calls with no natural subject stay ordinary calls: constructors (Node(l, v, r)), range(0, n), println("hi"), and symmetric functions like max(a, b).

A dot call only finds a function that is in scope under its bare name: your own functions, prelude builtins, and names imported selectively. After a plain use std/str, s.split(",") is an error, and the message says to write str.split(s, ",") or to add use std/str.{split}. The spec has the full resolution order (§4.3).

The pipe |>

The pipe is the same call in a different spelling: x |> f(a) is f(x, a). Reach for it when the dot does not fit: the data starts as a literal or a bigger expression, the last step is a sink or a lambda, the function is module-qualified and you would rather not import its bare name (s |> str.split(",")), or the right-hand side is a function value rather than a call. It binds looser than everything else, so a + b |> f is f(a + b). A chain uses either dots or pipes, not both.

fn double(x: Int) -> Int = x * 2
fn inc(x: Int) -> Int = x + 1

pub fn main() -> Unit !io =
  5 |> double |> inc |> to_string |> println
11

Named arguments and default values

A parameter may carry a default, written name: Type = expr, and a call may then leave that argument out. Any argument may also be passed by name: the positional ones fill the slots from the left, a named one takes the slot it names, and names may come in any order.

fn greet(name: String, greeting: String = "Hello", punct: String = "!") -> String =
  "${greeting}, ${name}${punct}"

pub fn main() -> Unit !io = {
  println(greet("Dawn"))
  println(greet("Dawn", punct: "?"))
  println(greet(punct: ".", name: "reader", greeting: "Welcome"))
}
Hello, Dawn!
Hello, Dawn?
Welcome, reader.

A default is evaluated afresh on every call that leaves it out, and it has to be pure: if it could do io, whether a call did io would depend on whether the caller passed that argument. Arguments run in the order they are written, whichever slots they land in. The names belong to the signature, so a function value has none: after let g = greet, g(name: "x") is an error and g wants all three arguments. Constructors take names the same way, their field names standing in for parameter names (Rect(w: 3.0, h: 4.0)).

See also: spec.en.md §2.1, §3.1, §4.3, §4.4