Skip to main content

Control flow

Control flow constructs are used to direct the execution path of a program based on a decision.

When expressions

The when expression is for making decisions based on conditions that evaluate to Bool.

import std/io

let x = 10
when {
x > 0 => io::println("positive")
}

A catch-all can be added using the _ condition.

when {
x > 0 => io::println("positive")
_ => io::println("zero or negative")
}
info

It might be tempting to use True as the catch-all condition, and while this would work, it is idiomatic to use _. All control flow structures (and patterns) use _ as the catch-all, thus, _ consistently has "always" semantics in every context it is used.

If there are multiple arms in a when, only the first one whose condition is satisfied is evaluated. When expressions do not have to be exhaustive (i.e., guarantee that at least one condition will be satisfied) when used as a statement.

// This is valid and does not print anything
// since there is no condition that covers 0.
let x = 0
when {
x > 0 => io::println("positive")
x < 0 => io::println("negative")
}

A when block used in expression position returns its last evaluated expression, and thus, must be exhaustive by specifying an _ arm.

let x = 10
io::println(when {
x > 0 => "positive"
_ => "zero or negative"
})

Multiple statements can be used in a when arm body by wrapping with {...}.

when {
x > 0 => io::println("positive")
x < 0 => {
io::println("only works for positive numbers")
io::println("please try again")
}
}

Match expressions

A match expression is used to match against one or more patterns.

let x = Some(123)
// Get the value or a default.
let value = match x {
Some(value) => value
None => 0
}
assert_eq(value, 123)

A match case may have a guard using when to add a condition.

match "hello" {
message when length(message) > 10 => "long message"
_ => "short message"
}

A default _ pattern must be added. The compiler cannot check for exhaustion when guards are involved. To the programmer, it is obvious that either the first or second arm matches, but this is difficult for the compiler to determine.

Like when expressions, multiple statements can appear in arm bodies by enclosing the body with {...}.

Select expressions

tip

Concurrency provides context for the select expression.

The select expression is a concurrency primitive used to read from one channel that has data ready. Concurrency is covered later, however, the example below is annotated to explain the role of channels in concurrent processes.

import std/io
import std/task

// These are communication channels. Values sent over
// the sender side appears on the receiver side.
let (sender1, receiver1) = channel()
let (sender2, receiver2) = channel()

// Concurrent tasks can be spawned within an async block.
async() {
// These tasks simply send a string over the communication channels.
task::start() { send(sender1, "Hello") }
task::start() { send(sender2, "Greetings") }
// The select expression picks on the receivers and evaluates its body.
select {
message <- receiver1 => io::println("Receiver1 got a message: " + message)
message <- receiver2 => io::println("Receiver2 got a message: " + message)
}
}

The above will print either Receiver1 got a message: Hello or Receiver2 got a message: Greetings. It will print one but not both, and the one that is printed is non-deterministic. The one that is selected depends on two things:

  1. whether the tasks that produce values have been evaluated sufficiently far to send the values over the channel, and
  2. the random state that the select expression starts with.

A channel will only be selected if it has a value ready. The select statement does extra work to ensure that the selected channel is random. The usefulness of this can be illustrated by counter-example. Suppose that there is no randomness in the selection process, and the select expression looks at the channels from top to bottom. It would choose the first channel that is ready, and repeat from the top if none were found on that pass. For example, if receiver2 has values waiting to be read, but values are constantly being sent from sender1 to receiver1, then it is very likely that the second arm of the above select could be starved, as receiver1 is always tested first and is likely to have at least one value waiting. For this reason, select tests the channels in a random order that is different every time to ensure fairness.

The select expression has the same features as other control structures.

  1. A default arm can be specified with _.
  2. Arms can have multiple expressions by wrapping the body with {...}.
  3. It returns the value of the evaluated match arm when used in expression position. In this case, the _ arm must be given.

When no channels have data available, the select expression blocks until at least one has a value available to read. A select can be made non-blocking by providing an arm with the _ condition that is evaluated if none of the channels are ready.

// This select is non-blocking because of the _ arm.
// If no messages are available on the receivers then
// the _ arm is immediately evaluated.
let status = select {
message <- receiver1 => {
let prefix = "Receiver1 got a message: "
prefix + message
}
message <- receiver2 => "Receiver2 got a message: " + message
_ => "No messages were ready"
}
io::println(status)