In structured programming,
arbitrary, non-local control flow is generally forbidden.
In fact, negative aspects of statements such as goto
have been criticized to the degree where
they found themselves a place in the programming folklore.
This reputation is largely deserved –
it is difficult to make sense of or maintain a program
that has excessive and undisciplined uses of goto
,
and it is far too easy to become undisciplined.
There are exceptions to this, of course (pun intended). Most prominent is the widespread adoption of exception-handling in many programing languages. Exception-handling allows non-local jumps in control flow, since an exception raised in one method may cause the control flow to continue in an arbitrary other method. Despite their perceived usefulness, exceptions can be problematic both from program readability and programming language implementation viewpoint. For this reason, some programming languages are now stepping away from exceptions – newer languages such as Rust and Go do not have classic exception handling.
Coroutines themselves are a language primitive that supports
non-local control flow.
It makes sense to ask:
why would we use a primitive that enables non-local control flow?
There are two parts to that answer –
first of all, experience has shown that some programs need non-local control flow.
For example, we encode callback chains and implement iterators
without unrestricted control flow.
This results is a phenomenon called inversion of control,
where behavior is not directly apparent from the code that we write,
but is governed by a different part of the program.
Non-local control flow can increase program readability.
Second part of the answer is that
coroutines do not enable unrestricted control flow.
Since control flow jumps in a coroutine invocation (i.e. a coroutine instance)
are restricted to yieldval
and resume
pair,
it is much easier to understand the control flow compared to something like goto
.
In this section, we will see an alternative form of control flow jump called the control transfer, where a coroutine instance suspends itself and transfers control flow to a different coroutine instance.
Control transfer
Transferring control to a different coroutine instance
works with a special construct called yieldto
.
This construct takes a target coroutine instance (not coroutine),
and continues executing
Consider the following example.
Let’s say that we have a coroutine check
that can be periodically resumed
to check if there are any errors in the program.
The check
coroutine yields a Boolean
value,
and assigns an error description to a global error
variable.
var error: String = ""
val check: ~~~>[Boolean, Unit] = coroutine { () =>
yieldval(true)
error = "Total failure."
yieldval(false)
}
val checker: Boolean <~> Unit = call(check())
We then define another coroutine called random
,
which yields random Double
values,
but occasionally transfers control to checker
.
val random: ~~~>[Double, Unit] = coroutine { () =>
yieldval(Random.nextDouble())
yieldto(checker)
yieldval(Random.nextDouble())
}
Above, note that transferring control to an already completed coroutine instance
is not legal, and results in an exception being raised
in the coroutine that called yieldto
.
Graphically, the control flow jump is as follows.
Note that after check
yields or completes,
control is transferred back to the main program.
We can now test that this works as expected –
we start the random
coroutine and name the instance r0
,
and call resume
and hasValue
:
val r0 = call(random())
assert(r0.resume)
assert(r0.hasValue)
As expected, resume
returns true
and
and hasValue
returns true
, as well, since r0
yielded a value.
However, on the next resume, coroutine reaches a yieldto
:
assert(r0.resume)
assert(!r0.hasValue)
This time, resume
returns true
, since r0
is still live,
but hasValue
returns false
, because,
the coroutine instance r0
did not yield a value.
After this, resume
and hasValue
calls behave as expected.
assert(r0.resume)
assert(r0.hasValue)
assert(!r0.resume)
assert(!r0.hasValue)
We now see that it is insufficient
to just call resume
and value
to extract values from a coroutine.
If resume
results in transfering control
to another coroutine instance with a yieldto
,
then the original coroutine instance will not yield a value.
So, the right way to extract all the value from a coroutine is as follows:
val r1 = call(random())
val values = mutable.Buffer[Double]()
while (r1.resume) if (r1.hasValue) values += r1.value
assert(values.length == 2)
The following rule is important to remember.
Each resume may or may not actually yield a value.
Therefore, a value call **must be**
preceded by a hasValue call.
|
The complete example is shown below.
Summary
We learned that:
- A coroutine instance can transfer control to another coroutine instance
with a
yieldto
statement. - When control is transferred to another coroutine instance,
the original instance may not yield a value,
so callers of
resume
must additionally callhasValue
to check if a value was yielded.
In the next part, we will see how to capture a snapshot of a coroutine instance, and learn how to use this operation.