Continuations

Introduction

Dodo is a language based on continuations. A continuation is a sequence of instructions to execute, which ends with invoking another continuation.

It is very similar to a function. The difference is that a function returns to the caller while a continuation hands the control over to another continuation.

In dodo, a function is a particular type of continuation which can hand the control over to either the return continuation (the instructions after the function call) or the throw continuation (the catch block instructions).

You don’t need to write these continuations explicitly. They are baked into the syntax of the language.

However manipulating continuations directly can be useful in more advanced use cases.

Rationale: continuations are both more expressive and as efficient as functions. Basing the language on continuations helps unify the concepts behind the dodo language so it can be reduced to a much smaller set of directives.

Example

While typical dodo functions will have a return and a throw continuation, they don’t have to.

Consider the following function:

def shortMult(uint16 a, b) -> exact(uint16) | overflow(uint16) =
   if (a * b > 65535)
      overflow((a * b) % 65536)
   else
      exact(a * b)

An uint16 number is quite small, so if you were to multiply 1000 by 1000 the result would overflow (not fit in the 16 bits).

The function above defines two continuations: exact and overflow.

That means the caller needs to provide two sets of instructions, one for the exact case and one for the overflow case.

Example:

def result = shortMult(1000, 1000) -> x; "ok ["(x)"]" | y; "too big ["(y)"]"

The first continuation starts with "->" and all other continuations start with "|".

The semicolons can be replaced by line breaks:

def result = shortMult(1000, 1000) -> x
   "ok ["(x)"]" | y
   "too big ["(y)"]"
Rationale: the "return(type)" syntax in a normal function definition is actually used to identify the return continuation. The example shows how to give a different name to the continuation and how to add more continuations.
In other languages error handling is a separate set of instructions, often in a catch block. Dodo extends this concept to any number of continuations, not just the usual "return" and "throw".

Default Continuation

In many situations, we want to use the same instructions for both cases. Multiplication in most programming languages just returns the truncated overflow value without a warning, and that may be what you want.

Dodo allows to set the default of a continuation to the previous one in the function declaration if the types are the same. Just replace "|" with "|=" to separate the continuations.

Example:

def shortMult(uint16, uint16) -> uint16 |= uint16

uint16 truncated = shortMult(1000, 1000) # no alternative continuation provided
use_uint16_value(truncated)
Rationale: this allows functions with alternative continuations to masquerade as normal functions with just "return" and "throw". In fact, all default boolean functions work that way so a "if" simply passes the "then" and the "else" branches as continuations under the hood, the result of the boolean function is not used.

Default Value

Another common pattern is to use a default value if the result is not coming from the first continuation. This is written:

uint16 saturated = shortMult(1000, 1000) ?? 65535

If the result comes from the overflow continuation, it is replaced with the value after "??". That is often used to provide a default value in case the function call returns an error (coming from the throw continuation).

Rationale: inserting a default value instead is a convenient way to handle unexpected results so dodo provides a short form for it. Note: the short form "(->|)" was considered during the design of the language, but luckily "??" was retained instead.

Yield and Resume

The resume keyword allows to resume the execution at a previously seen location. That can be used within a function to repeat some instructions, but it has an even more helpful use: to resume the execution of another function.

A function which has the ability to be resumed is called a generator, and the operation which allows a generator to produce a value and resume later is called yield. A yield continuation requires "@" after the type(s).

Example:

def generateSample(enum shape) -> yield(Sample @) {
   loop:
      loop foreach (sample in catalog[shape.value].samples) {
         yield sample
      }
   .
}

Each time it is resumed this generator will yield a value. If there are no more values an EndOfYield event is raised.

Example:

def sampleGen = generateSample(softsine)
loop for (def t = 0; t < 60 * 1000; .t += delay) {
   def sample = resume sampleGen
   synth.Play(sample, delay, *soundOut)
}

Dodo keeps the type of sampleGen in this example hidden from the programmer (a continuation). That is on purpose to prevent you from using the variable for anything except resume in the current function.

A continuation can also be defined by putting a label starting with "@" in front of an instruction. The label only becomes valid after the instruction is executed, resume cannot skip forward.

Rationale: yield and resume allow two functions to work together. Keeping saved continuations local to the function makes them simpler and more efficient. Resume can only use a previously seen location because the associated continuation has to be saved during the function execution.

Throw and Catch

In the same way most functions have a return continuation, most functions have a throw continuation. Since that continuation is the same for every function it is added automatically and you don’t have to declare it.

The continuation can be invoked using the throw keyword.

Example:

throw new IllegalArgument.instance(message: "Not allowed: "(arg))

Another use for the throw continuation is to hand the control over to a catch block within a function.

Example:

case if (sun >= tooMuchSun) {
   throw new Event.instance:
      def venue = beach
   .
}
...
catch (event: _(venue: $venue)) {
   ...
}

The catch blocks must be the last instructions of the function, but they behave as if they were inserted where the event is thrown. They are not executed if there is no matching event. Important note: an event can be anything you want, it is not necessarily an error. You can use throw and catch for anything.

The catch block should end with a continuation call: usually return, throw or resume.

Rationale: since continuations are the norm not the exception for dodo programs, it makes sense to use them for more than just errors. Placing the catch blocks at the end of the function helps reduce visual clutter. Because they may need access to local variables, they behave as if they were local. Filters help attach catch blocks to the correct event(s).

Try Block

Sometimes ending the function or resuming the execution at a previous location is not what you want.

A try block allows to delimit the instructions that should stop running in case of an event; any instructions outside the try block should be allowed to run before exiting the scope.

Example:

try {
   def mixture = processor.Mix(ingredients, 10)
   def dish = oven.Cook(mixture, 170, 30)
   Eat(.dish)
}
processor.Clean()

It means that if anything happens while mixing or cooking the dish, we don’t want to proceed with the next steps. But the food processor needs to be cleaned before exiting the current block.

The instructions after the try block are executed if:

  • the instructions ended normally without an event
  • there was an event handled by a catch block not ending with a continuation call (allowed with try)
  • there was an event handled by a catch block ending with a continuation call other than resume1
  • there was a resume from the try block or a catch block to a location outside
  • there was an event but no catch block handled it1

1. In these cases the control exits the function according to the event, so any "return" or "throw" instruction after the try block is replaced with the continuation attached to the event.

There is an alternative way to create a try block:

...> processor.Clean()
def mixture = processor.Mix(ingredients, 10)
def dish = oven.Cook(mixture, 170, 30)
Eat(.dish)

The dotted arrow adds the "processor.Clean()" instruction after an implicit try block which extends to the end of the current block.

Finally, the instructions outside the try block can be prefixed with __undo to indicate that they should execute only if the function is exiting with throw, not with return or another continuation.

Rationale: the "try" block clearly identifies the instructions which depend on each other so an event will break the chain of execution. Dodo has no "finally" block which would be unnecessary clutter. The rule about events setting the continuation prevents the loss of the initial event handling decision. The dotted arrow syntax helps move code related to the release of resources near to the allocation of resources, which helps reduce bugs. It also allows finer-grained resource management without excessive nesting.