Blocks and Lambdas

Toit provides two mechanisms for creating and passing around pieces of executable code: blocks and lambdas. While they share some similarities, they have distinct characteristics, use cases, and performance implications.

Introduction to blocks and lambdas

Blocks

Blocks are a highly efficient mechanism to provide callbacks to functions. They are introduced with a colon : and are commonly used in the core libraries, for example with int.repeat and List.do. Blocks are very efficient and should be used liberally.

Example of a block:

main:
  // Print the numbers from 1 to 10, one per line.
  10.repeat:
    print it + 1

  // Print the elements in the list, one per line.
  ["what", "a", "list"].do:
    print it

Here we used the automatic block argument, it, see below.

Syntactically repeat and do look like they are built-in to the language like for and if are, but they are actually normal methods that use the block feature.

Lambdas

Lambdas are more flexible function-like objects introduced with a double colon ::. They can be stored in variables, passed as arguments, and returned from functions. While not as efficient as blocks, lambdas offer greater versatility.

Example of a lambda:

create-printer:
  return (:: print it)

main:
  printer := create-printer
  [1, 2, 3].do: printer.call it

Key differences

  1. Syntax: Blocks use :, lambdas use ::.
  2. Return behavior: Blocks can have non-local returns, lambdas cannot use return at all.
  3. Lifetime: Blocks are tied to the current context and cannot outlive it. Lambdas can be stored and used later.
  4. Flexibility: Lambdas can be stored in variables, fields, and collections, and returned from functions. Blocks cannot.
  5. Efficiency: Blocks are significantly more efficient than lambdas.

Blocks in detail

Efficiency of blocks

Blocks are designed to be highly efficient in Toit. They are stack-allocated, which means they have minimal overhead in terms of memory usage and execution time. This efficiency makes blocks the preferred choice for most callback scenarios.

Basic use of blocks

Blocks are used extensively in the Toit standard library, so you need to know the basics of calling functions that accept blocks. A function that takes a block as an argument surrounds the parameter with brackets [ ]. For example, the function signature of int.repeat is

repeat [block]:
  // ...

The [block] parameter states that the repeat function takes exactly one argument which must be a block (because of the [ and ]).

On the caller side, block arguments look like a scope for if or for. For example, if you want to call repeat, you could write

main:
  4.repeat:
    print "again"

Just like for if and for, you can choose to put single line blocks on the same line as the call. For example

main:
  4.repeat: print "again"

Block arguments

Blocks often receive arguments when they are called as callbacks. For instance, List.do iterates over a list and calls the block with every element. When the block receives one argument you can refer to the argument with the keyword it. For example

main:
  ["Siri", "John", "Sue"].do:
    print it

prints

Siri
John
Sue

You can name the parameter of the block if you want to use something else than it. To do so, you start the block with the name enclosed in pipes |. For example

main:
  ["Siri", "John", "Sue"].do: | name |
    print name

prints

Siri
John
Sue

Some blocks receive more than one argument. In that case you have to use the | notation. You just write all the names in between the two pipes. For example, Map.do iterates all the key-value pairs of a Map, and calls the block with the key as the first argument and the value as the second:

main:
  map ::= {
    1234: "Siri",
    2345: "John",
    3456: "Sue"
    }
  map.do: | id name |
    print "$name has ID $id"

which prints

Siri has ID 1234
John has ID 2345
Sue has ID 3456

If a block accepts multiple arguments, but you only need to refer to the first, then you can omit the naming and just use it. If there are parameters that you don't need, then you can use a wildcard _ instead of giving them a name.

Returning from blocks and accessing variables

Blocks look like they are built-in which makes it important that they also act like they are built-in. This means that returning from a block with return, or accessing local variables must just work.

Non-local return

The non-local return is the most common return from blocks. It is best explained based on an example:

/**
Takes a list $list and returns the first negative number.
Returns null if $list contains no negative number.
*/
first-negative list:
  list.do:
    if it < 0: return it
  return null

main:
  print
    first-negative [1, 2, 3, -3, -2, -1]

which would print

-3

The return it in first-negative is a non-local return which means that it doesn't just return from the block or do, it returns from first-negative. This means that we can short circuit the search when we find the first negative number.

Local return

In some situations, it is useful to do a local return. That is, to just return from the block. To do this, you use the continue keyword followed by a dot . and the label of the function you want to continue in. For example

/**
Takes a list of integers $list and maps it to a list of
  odd numbers by incrementing even numbers by one.
*/
oddify list:
  return list.map:
    if it % 2 == 0:
      continue.map it + 1
    continue.map it

main:
  list := [1, 2, 3, 4]
  print
    oddify list

which prints

[1, 3, 3, 5]

Here the block given to map uses continue to do a local return of an odd value when it sees an even value. The snippet continue.map can be read as "continue in the map function".

Default return

The default return of a block is a local return. The block returns the value of the last statement in the block. For example

/**
Takes a list of words, and returns a new list with only the
  words that are 5 characters or fewer.
*/
short-words words:
  return words.filter:
    it.size < 6

main:
  words := ["word", "small", "minuscule", "tiny"]
  print
    short-words words

Here the block accepted by filter returns the value of the last statement which is it.size < 6, a boolean that indicates whether the size of the element is less than 6.

It prints

[word, small, tiny]

In the oddify example in section Local return, the last local return was unnecessary. If you replace continue.map it with it, then the block uses the default local return to return it from the block. That is

/**
Takes a list of integers $list and maps it to a list of odd
  numbers by incrementing even numbers by one.
*/
oddify list:
  return list.map:
    if it % 2 == 0:
      continue.map it + 1
    it

main:
  list := [1, 2, 3, 4]
  print
    oddify list

prints

 [1, 3, 3, 5]

Blocks referencing variables

Blocks can reference everything that can be referenced from the scope the block was defined in. This includes access to local variable, field variables, and globals.

my-stringify list -> string:
  is-first := true
  str := "["
  list.do:
    str += "$(is-first ? "" : ", ")$it"
    is-first = false
  str += "]"
  return str

main:
  print
    my-stringify [1, 2, 3]

Here the block (given to do) references both is-first and str. Notice that the block both accesses and modifies the variables.

The function returns a string representation of the list corresponding to the exact syntactic construct that creates a list, and thus prints

 [1, 2, 3]

Examples of block usage

Many for-loops follow a common pattern, where the loop uses a loop-variable to count from 0 to a given limit. For example say you want a function that prints the numbers from 1 to n, you could write

/// Prints the numbers from 1 to $n on separate lines.
print-numbers n:
  for i := 0; i < n; i++:
    print i + 1

main:
    print-numbers 5

As you can see, the for-loop is mostly boilerplate code. All you want to express is that the print statement should be executed n times. This is where blocks come in handy. We can rewrite the above snippet using int.repeat:

/// Prints the numbers from 1 to $n on separate lines.
print-numbers n:
  n.repeat:
    print it + 1

main:
    print-numbers 5

The new code isn't just shorter, but also expresses the intent more clearly.

We can do the same exercise for list iterations. Say you have written a function that prints the elements of a list:

/// Prints the elements of the list on separate lines.
print-list list:
  for i := 0; i < list.size; i++:
    print list[i]

main:
    print-list [1, 2, 3, 4, 5]

Again, you can use int.repeat to rewrite the for-loop:

/// Prints the elements of the list on separate lines.
print-list list:
  list.size.repeat:
    print list[it]

main:
  print-list [1, 2, 3, 4, 5]

Now the example is shorter, but it is still not clear what the intend is. You can do even better than the above. The collections in the Toit standard library have do functions that iterate the collections and call a block for each element. You can rewrite print-list to

/// Prints the elements of the list on separate lines.
print-list list:
  list.do:
    print it

main:
  print-list [1, 2, 3, 4, 5]

Now the intend is clear: you are do'ing something to the list which corresponds to visiting each element of the list.

Calling functions with named block parameters

Block parameters can also be named. This is indicated in the function signature by enclosing the named parameter in brackets [ ] (the same as for other block parameters). For instance, the signature of Map.remove is

remove key [--if-absent] -> none:

The named parameter is given in the same way as normal block parameter. For example we could make a throwing remove for Map

class Map:
  // ...

  throw-remove key:
    remove key --if-absent=:
      throw "No such entry"

We could also implement a remove that ignores the absence of a key:

class Map:
  // ...

  remove key:
    remove key --if-absent=: null

A function can take multiple blocks as arguments. Take the Map.get signature as an example:

get key [--if-present] [--if-absent]:

Map.get takes two block arguments. For the sake of readability, it is

announcer map key:
  map.get key
    --if-present=: | value |
      print "Proudly presenting $value!"
    --if-absent=:
      print "Unable to find $key"

main:
  map ::= {
    1: "Siri",
    2: "John",
    3: "Sue"
  }
  announcer map 1
  announcer map 4

This prints

Proudly presenting Siri!
Unable to find 4

Here we have formatted the arguments differently, so each of the block arguments are easily identified.

Blocks as values

Blocks are values in Toit which is why functions accept them as arguments. A block value is defined as a colon : followed by the body of the block which is either a statement on the same line of the block, such as : null, or a series of statements on separate lines following the : (all statements indented), such as

main:
  print "multi"
  print "line"

In all the examples so far where we have passed blocks as arguments to function, we were really defining the block value in place. Because blocks are values, they can be stored in local variables, just like other values. For example

main:
  block-variable := : print it + 1
  10.repeat block-variable

Syntactically, it looks a bit cryptic when blocks are stored in local variables. However, the main purpose of blocks is to pass snippets of code to functions.

Restrictions

Blocks are stack allocated, which is what makes them so efficient, but it also limits where they can be used:

  • Blocks cannot be stored in instance fields, static class fields, and globals.
  • Blocks cannot be stored in any collection.
  • Blocks cannot be returned from functions and methods.
  • Lambdas cannot capture blocks.
  • Blocks cannot take blocks as arguments.

Lambdas in detail

Basic use of lambdas

Lambdas are more versatile than blocks and can be used in a wider variety of situations, though at a cost of reduced efficiency compared to blocks:

start-task:
  task::
    3.repeat:
      print "still running"
      sleep --ms=300

main:
  start-task
  print "task started"

In this example, we define a lambda that runs as a separate task, continuously printing a message. While less efficient than a block, a lambda is necessary here due to its ability to be stored and executed later.

Storing and reusing lambdas

Unlike blocks, lambdas can be stored in variables, fields, and collections:

class WatchedBox:
  value_/any := null
  callbacks_/List ::= []

  watch callback/Lambda:
    callbacks_.add callback

  value -> any: return value_
  value= new-value:
    value_ = new-value
    callbacks_.do: it.call new-value

add-printer box/WatchedBox:
  box.watch:: print "was changed to $it"

main:
  box := WatchedBox
  add-printer box
  box.value = 499  // Prints "was changed to 499".
  box.value = 42   // Prints "was changed to 42".

This example demonstrates how lambdas can be stored in a list (callbacks_) and called later.

Lambda arguments

Like blocks, lambdas can take arguments. If a lambda takes a single argument, you can use it as the default parameter name:

main:
  multiplier := :: it * 2
  print (multiplier.call 21)  // Prints 42.

For multiple arguments, you need to declare them explicitly:

main:
  adder := :: | a b | a + b
  print (adder.call 20 22)  // Prints 42.

Defining functions that take callbacks

You can define your own functions that accept block arguments. You have already seen what the function signature should look like: block arguments must be enclosed by brackets [ ].

In order to execute a block, you simply call call on it. For example, this is how int.repeat is implemented:

class int:
  // ...
  repeat [block]:
    for i := 0; i < this; i++:
      block.call i

Note: non-named block parameters have to be the last in the function signature.

Similarly, you can define a function that takes a lambda as an argument:

call-lambda callback/Lambda:
  callback.call 42

main:
  call-lambda:: print it

A lambda is an object of type Lambda. You can store it in a variable, pass it as an argument, and call it later. The call method is used to execute the lambda.

Lambdas versus blocks: choosing the right tool

Only the receiver of a callback can determine whether to use a block or a lambda. As a user, one has to follow the type declared in the method signature.

When writing functions that accept callbacks, consider the following guidelines:

  • Use blocks for short-lived, context-dependent callbacks that don't need to be stored or returned. Blocks are significantly more efficient and should be the default choice when possible.

  • Use lambdas when you need to store the function.

  • Consider providing both block and lambda versions if you need to support both use cases. The lambda version can always redirect to the block version:

    my-function [block]:
      block.call 42
    
    my-function callback:
      my-function: callback.call it
    
    main:
      // Both of these will print 42.
      my-function: print it
      my-function:: print it