Introducing Subroutines and Coroutines
-
Subroutines and coroutines are an abstraction of:
- An instruction pointer
- A call stack
-
An instruction pointer is a pointer to the current instruction
- This is useful when dealing with generators
- This is because generators need to know where to resume
- So, they must know where to resume on the call stack
- A call stack is a collection of code relevant to its scope
-
These pieces of code include the following:
- Local variables
- Functions
- etc.
-
The following are examples of subroutines and coroutines:
subroutines:
functions, procedures, etc.coroutine:
generator, etc.
Defining a Subroutine
- A subroutine is represented by a stack frame
- A stack frame represents a function call
- This stack frame gets pushed on a call stack
- A call stack is a data structure that tracks running subroutines
- A function is an example of a subroutine
Walkthrough of a Subroutine
- Consider the following subroutine:
>>> def subroutine1(foo):
... print(foo)
... return 'done'
>>> def subroutine2():
... s = 'start'
... d = subroutine1(s)
... d = d + '!!!'
... return d
>>> subroutine2() # run -- notice how we can't suspend
'start'
'done!!!'
-
Python will do the following when
subroutine2
is called:-
Execute
subroutine2
-
Create a stack frame for
subroutine2
- A stack frame represents a frame of data
- This frame of data represents a function call
- This frame should allocate space for
s
andd
too
-
Push this frame of data onto the call stack
- A call stack is a data structure tracking subroutines
- Execute
s = 'start'
-
Execute
d = subroutine1(s)
- Create a stack frame for
subroutine1
- This frame should allocate space for
foo
- Create a stack frame for
- Execute
print(foo)
-
Execute
return 'done'
- This includes pushing
done
to the calling function - This includes exiting
subroutine1
- This includes pushing
d = subroutine1(s)
saves return value tod
- Execute
d = d + '!!!'
-
Execute
return d
- This includes exiting
subroutine2
- This includes returning
done!!!
- This includes exiting
-
-
Defining a Coroutine
- A coroutine is similar to a subroutine
-
However, a coroutine can:
- Suspend a function without destroying its state
- Resume a function since state is not destroyed
- Coroutine function suspension feels like setting a breakpoint
Walkthrough of a Coroutine
- Consider the following coroutine:
>>> def gen(foo):
... yield foo
... return 'done'
>>> def coroutine():
... s = 'start'
... d = yield from gen(s)
... d = d + '!!!'
... return d
>>> f = coroutine()
>>> next(f)
'start'
>>> next(f) # resume -- notice how we can suspend
StopIteration: 'done!!!'
-
Python will do the following when
coroutine
is called:-
Execute
f = coroutine()
-
Create a stack frame for
coroutine
- A stack frame represents a frame of data
- This frame of data represents a function call
- This frame should allocate space for
s
andd
too
-
Push this frame of data onto the call stack
- A call stack is a data structure tracking subroutines
-
-
Execute
next(f)
- Execute
s = start
-
Execute
d = yield from gen(s)
- Create a stack frame for
gen
- Push this frame of data onto the call stack
- Create a stack frame for
-
Execute
yield foo
- This includes pushing
foo
to the calling function - This includes pushing pointer for resuming later
- This includes suspending
gen
- This includes pushing
-
yield from gen(s)
yieldsfoo
as output- This includes suspending
coroutine
- This includes returning
'start'
- This includes suspending
- Execute
-
Execute
next(f)
-
Resume
coroutine
- This includes resuming
coroutine
where we left off - This resumes
gen
atd = yield from gen(s)
- This includes resuming
-
Execute
return 'done'
- This includes pushing
done
to the calling function - This doesn't include pushing a pointer
- This is because
gen
has finished executing
- This includes pushing
d = yield from gen(s)
saves return value tod
- Execute
d = d + '!!!'
-
Execute
return d
- This includes exiting
coroutine
- This includes returning
StopIteration: done!!!
- This includes exiting
-
-
Summarizing Subroutines and Coroutines
-
The main difference between the two routines:
- Subroutines can suspend functions once using
return
- Coroutines can suspend functions frequently using
yield
- Subroutines can suspend functions once using
-
Once a function is suspended:
- Subroutines can't resume again
- Coroutines can resume again (using
next()
logic)
-
The output of each routine:
- Subroutines return data values
- Coroutines yield a data value, call stack, and pointer
-
Specifically, coroutines are good for:
- Looping over large data objects
- Asynchronous I/O
Describing yield from
-
A subroutine can do the following:
- Go down the call stack with
return
- Go up the call stack with
()
- Go down the call stack with
-
A coroutine can do the following:
- Go down the call stack with
yield
- Go up the call stack with
yield from
- Go down the call stack with
-
yield from
includes two steps:-
yield
:yield:
yield
a value yielded by a sub-generator- Thus, suspending execution until resumed by
next()
-
from
:from:
Receiving a return valuefrom
a sub-generator- After initial suspension, it will resume the sub-generator again if
next()
is called on the generator - Then, it will receive a return value from a sub-generator
-
Comparing yield
and yield from
-
yield from
andyield
are similar by:- Suspending
foo
untilbar
finishes - Running the
bar
generator function
- Suspending
-
yield from
differs fromyield
in some ways:- Reading data from a generator without looping
- Receives a return value from a sub-generator
-
In other words,
yield from
does the following:- Improves readability by implicitly looping
- Allows us to return and manipulate data between generators
Benefit 1: Reading data without Looping
>>> def bar():
... yield 1
... yield 2
... yield 3
>>> def foo():
... # for i in bar(): # Replaced these
... # yield i # lines...
... yield from bar() # ...with this line
>>> for i in foo(): print(i)
1
2
3
Benefit 2: Manipulating Data from Sub-Generator
>>> def inner(j):
... yield j
... return j
>>> def outer():
... yield 'before'
... i = yield from inner(1)
... yield i+1
... yield 'after'
>>> for i in outer(): print(i)
before
1
2
after
Describing async
and await
- Python 3.5 introduced
async
andawait
- Essentially,
await
replacedyield from
- This was to enforce a cleare role of coroutines
- Again, they mainly changed for clarity purposes:
>>> # Python 3.4 and older
>>> def foo(): # subroutine?
... return None
>>> def bar():
... yield from foobar() # generator? coroutine?
>>> # Python 3.5
>>> async def foo(): # coroutine!
... await foobar() # coroutine!
... return None
Differentiating Coroutines from Generators
-
A generator can be used in two different contexts:
- As an iterator
- As a coroutine
- Therefore, a coroutine is a generator
-
Generators and coroutines have many similarities:
- They both can
yield
- They both can pause functions
- They both can
-
However, they differ in one key area:
- A coroutine can contain
yield
andawait
- A generator only contains
yield
- A coroutine can contain
- In other words, a coroutine receives a value returned by a generator