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.
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)"]"
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)
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).
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.
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.
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.