Coroutines
A coroutine is a function that pauses at each yield, so multi-frame behavior (AI, cutscenes, staged animations) is straight-line code.
- The
coroutineprefix declares one. A call runs none of the body and returns a handle. Each call is an independent instance. - A step runs the body to its next
yield. A body with N yields takes N + 1 steps: the last runs from the final yield to the end, and only then isdone()true. - A handle is a reference. A copy (into a field, an array or map element, a by-value parameter, another variable, a deep copy of an instance that holds one) drives the one live coroutine.
| Form | A step that reaches a yield | The step that finishes the body | After that |
|---|---|---|---|
for (auto v : h) | v is the yielded value | the loop ends; the return value is never iterated | no iterations |
h.advance(v) | true, the value stored into v | false, v unchanged | false, v unchanged |
h.next() | the yielded value | the return value (null when there is none) | raises Cannot resume a completed coroutine |
h.next(n) | n calls to next(), returning the last value; n is an int of at least 1. When the body ends early, the steps before the end run, then it raises the same text. | ||
h.done() | false | true. It only asks: it never starts or advances the body. | |
h.result() | raises Coroutine has not finished | the return value, a copy each time, whichever form drove the body; null when the body returned nothing or failed | |
Range-for drives a coroutine
A range-based for is the way to consume a coroutine. It takes a handle or a call that makes one, and a handle already advanced continues from where it stands. When the steps cannot sit in one loop (one step per frame), h.advance(v) takes one, shaped like a C++ bool advance(T& v); a yielded null is a step like any other. A loop on done() and next() hands out the final value too:
coroutine int waves() { yield 1; yield 2; yield 3; return 9; }
for (auto w : waves()) { print("for got {}", w); }
auto a = waves();
int v = 0;
while (a.advance(v)) { print("advance got {}", v); }
print("again: {}, v: {}, result: {}", a.advance(v), v, a.result());
auto b = waves();
print("skip ahead: {}", b.next(2));
while (!b.done()) { print("next gave {}", b.next()); }for got 1
for got 2
for got 3
advance got 1
advance got 2
advance got 3
again: false, v: 3, result: 9
skip ahead: 2
next gave 3
next gave 9v binds as a T& parameter does (a variable, a field or an element), at the call, before the body runs. A const or a temporary fails the load; a temporary the checker cannot see raises Cannot pass non-lvalue to reference parameter without resuming. The yielded value converts to v's declared type as an assignment would, and a pair of known types no assignment accepts fails the load.
A value cannot be sent into a running coroutine. Pass inputs as parameters, or share state with the caller (a field of its instance, a program-scope variable). advance() without its variable, a non-integer count and a count below 1 each raise (Run-time errors). Any other member is a load error: h.resume() reports no member 'resume' on 'coroutine_handle' (did you mean 'next'?).
Spellings
coroutine goes in front of every function spelling from the Functions chapter, for methods (static ones included) and functions in a namespace body too:
coroutine int a() { yield 1; } // return-type-first
coroutine b() -> int { yield 2; } // trailing arrow
coroutine function c() -> auto { yield 3; } // function keyword + arrow
coroutine function d() { yield 4; } // function keyword, inferred
coroutine e() { yield 5; } // bare: no type, no keyword
print("{} {} {} {} {}", a().next(), b().next(), c().next(), d().next(), e().next());
class Spawner {
int base = 10;
coroutine w() -> int { yield base * 3; }
static coroutine int ticks() { yield 1; yield 2; }
}
namespace ns { coroutine int g() { yield 7; } }
auto t = Spawner::ticks();
print("{} {} {} {}", Spawner().w().next(), t.next(), t.next(), ns::g().next());1 2 3 4 5
30 1 2 7The declared type describes what the body yields and returns, never the call, which is always a handle. A handle has no type name of its own: hold it in auto or var. type_of names it "coroutine_handle".
Each yield e and the final return convert to the declared type as a return does. A float the checker sees yielded from an int coroutine fails the load; any other value the type cannot take raises at the yield, catchable around the step, and the failed step finishes the coroutine. A bare yield; hands out nothing and is not converted:
coroutine float g() { yield 2; yield "three"; return 4; }
auto h = g();
print(h.next());
try { h.next(); } catch (e) { print("caught: ", e); }
print(h.done());2.0
caught: Cannot yield string from 'g' (declared -> float)
trueA coroutine cannot be a lambda, return a reference, or take a parameter by reference (T&, const T&, auto&, var&). All are parse errors: coroutine int count_up(int& n) reports coroutine 'count_up' cannot take 'n' by reference: it runs on after the call returns, past the argument's lifetime; take it by value (int n), or pass a shared_ptr to share an object.
Method coroutines
A method coroutine sees this, and fields read and write normally across suspensions. Derived classes inherit method coroutines. The handle pins the receiver alive, here past the frame that made it:
class Gen {
int id = 0;
Gen(int i) { id = i; }
coroutine int gen() { yield id; id *= 10; yield id; }
}
function make(int i) {
auto g = Gen(i);
return g.gen();
}
auto ga = make(1);
auto gb = make(2);
print("{} {} {} {}", ga.next(), gb.next(), ga.next(), gb.next());1 2 10 20So a field holding a handle of its own instance's method is a strong cycle, broken by reassigning or nulling the field. A static coroutine's handle carries no receiver.
A behaviour in a field
A game object keeps its behaviour as a handle in a field and advances it once a frame. The field is auto from the call that makes the handle (a field initializer can make it), or var when the behaviour starts later. There is no yield from: one coroutine runs another (a free function, a method of the same or another object) with a range-for that yields what it yields:
coroutine string wait(int frames) {
for (int i = 0; i < frames; ++i) { yield "waiting"; }
}
class Boss {
int hp = 30;
string phase = "";
coroutine string charge() { phase = "charge"; yield "charging"; yield "slam"; }
coroutine string fight() {
while (hp > 20) { phase = "volley"; yield "fires a volley"; hp -= 5; }
for (auto step : wait(2)) { yield step; }
for (auto step : charge()) { yield step; }
}
auto brain = fight();
void think() {
if (brain.done()) { return; }
var said = brain.next();
if (!brain.done()) { print(`${phase}: ${said}`); }
}
}
Boss boss;
for (int frame = 0; frame < 10; ++frame) { boss.think(); }volley: fires a volley
volley: fires a volley
volley: waiting
volley: waiting
charge: charging
charge: slamA copy of the object shares the handle, and the shared coroutine keeps changing the original's fields (Value Semantics). A copy that needs a behaviour of its own assigns its field a new call.
Rules and edges
- Parameters. Each argument takes its value at the call, and the first step moves it into its parameter: a by-value parameter is one object that lives as long as the body's frame. A
shared_ptr<T>parameter is the spelling for an object the coroutine and its caller both change. - Yielded values are copies.
yield ehands out a value asreturn ewould: an existing object (a global, a local, an element) is copied, never shared. Side effects in a yield expression run exactly once. - A failed step marks only that handle done, and its throw reaches the code that took the step.
- Handles keep working across separate
execute()calls and when passed by reference. - Hot reload. A free coroutine can be redefined while one of its handles is suspended (the old handle keeps the old body), and method coroutines survive field-changing reloads of their class.
- The execution budget applies during a step, and budget errors skip catches inside coroutines too.
- The checker does not check yield and return values statically, and treats what
next()returns as unknown.result()has the declared type, leniently, since a body that falls off its end givesnull.
sleep: behavior that waits
sleep(seconds) waits that long on the engine's time source (Time). It is a builtin like print: every engine has it, and a program-scope function named sleep replaces it.
- The argument is a float or an int; anything else raises.
- Zero or less returns at once and never suspends.
sleep(0)is the debugger's sync point, so a loop the host drives calls it once per frame (Debugging). - Under a run (
engine::start, Suspendable runs) the whole program suspends at thesleep, every frame and local in place, and the host has control until the time has passed. The standalone runner runs every file this way. - Under a plain
execute()nothing suspends:sleepblocks the thread.
coroutine int countdown(int from) {
for (int n = from; n > 0; --n) { sleep(0.01); yield n; }
}
float start = time::now();
for (int n : countdown(3)) { print("{}...", n); }
print("liftoff after at least 30 ms: {}", time::now() - start >= 0.03);3...
2...
1...
liftoff after at least 30 ms: true- A sleep in a coroutine body is not a yield. The whole run waits, the caller of
next()included, and the coroutine counts as running meanwhile: anothernext()on the same handle raisesCoroutine is already running. - A sleep under native code blocks, as under
execute(), and the run warns once:sleep blocks here: a run suspends only where no host callback, destructor, signal receiver, include, import or hot_reload_migrate is running. A builtin's callback (asortcomparator, afilterpredicate) counts as a host callback. - The checker warns of a
sleepwritten in a destructor ('sleep' in a destructor blocks: a run never suspends inside a destructor) or inhot_reload_migrate, and refuses one in aparallel_forbody or a lambda handed toparallel_transformorparallel_reduce. - Hot reload reaches a waiting run. A waiting frame finishes the body it started, and every call made after a reload runs the new definitions.
- The budget covers each stretch. Every resume is a host entry with a fresh execution budget, and time asleep never counts against it.
time::execute_elapsed()counts from the start of the run, time asleep included.