Lyric v4.5.0

Coroutines

A function whose body contains yield is a coroutine. Its return type is Coroutine<T>, where T is what it yields. Calling it does not run the body; it produces a coroutine value.

import std.io.console { println };

fn counter(): Coroutine<int> {
    var n = 0;
    while (true) {
        yield n;
        n += 1;
    }
}

fn main(): int {
    let c = counter();

    var sum = 0;
    var i = 0;
    while (i < 5) {
        sum = sum + resume c;
        i += 1;
    }

    println(f"sum: {sum}");
    return sum;
}

resume runs the coroutine until its next yield and produces that value. The state between two yields — locals and the position in the loop — survives. Without that, the loop above would read 0 five times.

A coroutine may also be finite. When its body runs to the end, it is exhausted:

import std.io.console { println };

fn three(): Coroutine<int> {
    yield 10;
    yield 20;
    yield 30;
}

fn main(): int {
    let t = three();
    let a = resume t;
    let b = resume t;

    println(f"{a}, {b}");
    return a + b;
}

resume on an exhausted coroutine is a panic. A caller either knows how many values there are, or uses an infinite coroutine and stops itself — or pulls with next():

import std.io.console { println };

fn three(): Coroutine<int> {
    yield 10;
    yield 20;
    yield 30;
}

fn main(): int {
    let co = three();
    var sum = 0;
    var live = true;
    while (live) {
        let v = co.next();
        if (v == null) {
            live = false;
        } else {
            sum += v;
        }
    }
    println(f"sum: {sum}");
    return sum;
}

co.next() is the safe form of the same pull: it advances the coroutine exactly like resume and answers ?T — the value, or null once the body has run to its end. After the end it stays null on every further call; resume on the same coroutine still panics, because leniency belongs to the call, not to the state. The name and shape are Iterator<T>.next()'s on purpose.

Two yield types change the answer's form, for the same reason: a Coroutine<void> has no value to wrap, so its next() returns bool — did it advance? — and while (p.next()) { } drives it to the end. A Coroutine<?T> refuses next() outright (LYR-SEM0080): a null there would mean both "yielded null" and "done", so such a coroutine is driven with resume and a protocol of its own.

A coroutine may also end itself early with a bare return; — the next pull is then the panic or the null, exactly as if the body had run through.

A coroutine is an ordinary value: it can be a parameter, a local, a field of a class or struct, or a type argument — a driver that steps a stored List<Coroutine<float>> every frame holds them like anything else. Copying the value copies a reference to the same suspended state; two holders drive one coroutine.

Yield from any depth

Since 4.0 a coroutine is stackful: its frames live on a chain of their own, and a yield executed at any call depth beneath a running resume suspends the whole chain. A helper can wait so its callers do not have to:

import std.io.console { println };
import std.string as strings;

fn firstTwo(): void {
    yield 1;
    yield 2;
}

fn numbers(): Coroutine<int> {
    firstTwo();
    yield 3;
}

fn main(): int {
    let co = numbers();
    println(strings.fromInt(resume co));
    println(strings.fromInt(resume co));
    println(strings.fromInt(resume co));
    return 0;
}

firstTwo is an ordinary function — no marker in its signature, and its callers need none either, which is the point: waiting composes without colouring anything. What it does when it runs is decided by who is running it: beneath a resume of numbers, its yields arrive at numbers' puller.

Because the chain is a runtime fact, the failures are panics, not compile errors:

Inside a coroutine body nothing changes: a yield there still checks its value against T at compile time, the better error when the chain is statically known. And a defer runs when its frame exits — by return or by an exception unwinding toward the pull; a chain dropped while suspended runs nothing, so cleanup that must happen belongs to whoever drives the coroutine to its end.

When the body throws

A coroutine body may throw, and the exception comes out of the resume or next() that was running it — not out of the call that made the coroutine, which runs no body at all. It lands in the function driving the pull, where a try catches it like any other:

import std.core { Exception };
import std.io.console { println };

fn steps(): Coroutine<int> throws Exception {
    yield 1;
    throw Exception { text = "the second step failed" };
}

fn main(): int {
    try {
        let co = steps();
        println(f"{resume co}");
        println(f"{resume co}");
    } catch (e: Exception) {
        println(e.message());
    }
    return 0;
}

The throwability is part of the type. steps() above does not produce a Coroutine<int> but a Coroutine<int> throws Exception, and the two are different types. The call demands nothing — it builds a suspended frame and runs no body — while every pull of that value is a throw site: handled by a try around it, or declared by the function doing the pulling.

That is what lets it survive being stored. A driver holding coroutines — a cutscene runner, a task list — writes the throwability into the field, and the compiler keeps asking at every pull:

import std.core { Exception };
import std.io.console { println };

fn steps(): Coroutine<int> throws Exception {
    yield 1;
    throw Exception { text = "the second step failed" };
}

class Runner {
    current: ?Coroutine<int> throws Exception = null,

    fn load() {
        this.current = steps();
    }

    fn step(): bool throws Exception {
        let co = this.current;
        if (co == null) { return false; }
        println(f"{resume co}");   // demands handling, so this method declares 'throws'
        return true;
    }
}

fn main(): int {
    let r = Runner { };
    r.load();
    try {
        while (r.step()) { }
    } catch (e: Exception) {
        println(e.message());
    }
    return 0;
}

Write throws alone (Coroutine<int> throws) when the body may throw anything. A coroutine that cannot throw fits a slot declared throws — the slot promises a handler and a value that never throws keeps that promise — but not the other way round: a throwing coroutine in a plain Coroutine<int> field would be a pull nobody was asked to handle.

next() is no way around it: it is lenient about the END of a body, not about a body that throws, and an exception passes straight through it.

Before 3.0 the demand was checked at the CALL, which is the one event that cannot throw. It looked right as long as the coroutine stayed a local beside its try, and vanished the moment it reached a field or an optional — where an exception ended the program with exit code 101.

Send values (resume c, v) do not exist.