Iterators and generators in Python — exercises with multiples, a sequence that resets and perfect numbers (and a fine that came out letter by letter)
While preparing the library fines for a class assignment I wrote a function that, for each overdue loan, worked out the fine in cents. I ran list(fines(loans)) expecting two fines and got this: ['P', 'a', 'b', 'l', 'o', ':', ' ', '8', '0', ' ', 'c', 't', 's']. One fine, split into letters. I had written return where I wanted yield. That mistake, and the other two that keep coming up with generators, are in the “What’s wrong here?” section of this article.
You have already seen the theory and the practice. Now it is your turn, with three exercises on three levels, three tricky snippets, an exam-style quiz and the cheat sheet for the topic.
How to use this article:
- Read the statement and the expected output of each exercise, and write your solution before looking at anything else.
- If you have been stuck for more than 10 minutes, open the hint.
- Before the solutions, try to predict what the “What’s wrong here?” snippets do.
- Compare your code with the commented solution: what matters is understanding why it is done that way.
Table of Contents
Python iterators and generators exercises — Basic Level
Exercise 1 — Generator of multiples of a number
Write three generators of multiples:
infinite_multiples(base): base, 2 × base, 3 × base… without stopping.multiples_up_to(base, limit): the multiples that do not exceed the limit (the limit included).first_n_multiples(base, n): the firstn, usinginfinite_multiplesinside.
With them (and a generator expression with if or two), produce these four lists. The output should be:
Multiples of 3 up to 30: 3 6 9 12 15 18 21 24 27 30 First 8 multiples of 7: 7 14 21 28 35 42 49 56 Multiples of 5 between 20 and 50: 20 25 30 35 40 45 50 Common multiples of 3 and 5 up to 100: 15 30 45 60 75 90
For “multiples of 5 between 20 and 50” and “common multiples of 3 and 5” you don’t need any new generator: filter one you already have with (m for m in ... if ...). And remember that a generator runs out: if you want to use the same multiples twice, create two generators (or keep a list).
Python iterators and generators exercises — Intermediate Level
Exercise 2 — Arithmetic sequence iterator
Implement the class ArithmeticSequence(begin, step, terms) as an iterator written by hand (with __iter__ and __next__, no yield). For example, ArithmeticSequence(2, 3, 5) gives 2, 5, 8, 11, 14: it starts at 2, adds 3 and gives 5 terms.
Requirements:
__init__raisesValueError('The step cannot be 0')if the step is 0, and anotherValueErrorif the terms are not a positive integer.- It must be walkable several times: with
for, withlist(), withsum()… one after another, all of them complete. __len__returns the terms still left in the current walk (after twonext()calls on a 5-term sequence, 3 are left).__str__shows the whole sequence with arrows:2 → 5 → 8 → 11 → 14.
The output should be:
Sequence(2, step=3, terms=5): for: 2 5 8 11 14 list: [2, 5, 8, 11, 14] sum: 40 str: 2 → 5 → 8 → 11 → 14 len after two next(): 3 Sequence(10, step=-2, terms=6): for: 10 8 6 4 2 0 Sequence(0, step=0.5, terms=8): for: 0 0.5 1.0 1.5 2.0 2.5 3.0 3.5 Expected error: The step cannot be 0
The trick is to separate the data that never changes (begin, step, terms) from the walk state (current, remaining). In which method do you have to reset the state so that the second list() does not come out empty? And look at the 0 in the last example: the first term is begin as it is (an integer); from there on, 0 + 0.5 is already a decimal.
Python iterators and generators exercises — Final Challenge
Exercise 3 — Perfect number generator
A number is perfect if it equals the sum of its proper divisors (all of them except itself). 6 is: 1 + 2 + 3 = 6. So is 28: 1 + 2 + 4 + 7 + 14 = 28. Write:
proper_divisors(n): the sorted list of proper divisors. Make it fast: there is no need to try every number up ton.is_perfect(n).perfect_numbers(): an infinite generator of perfect numbers.perfect_up_to(limit): a generator with the perfect numbers that do not exceed the limit, which ends even if there are no more perfect numbers nearby.
The output should be:
Is 6 perfect? True Is 12 perfect? False Is 28 perfect? True Perfect numbers up to 10000: 6 28 496 8128 Divisors of 6: [1, 2, 3] → sum: 6 Divisors of 28: [1, 2, 4, 7, 14] → sum: 28 Divisors of 496: [1, 2, 4, 8, 16, 31, 62, 124, 248] → sum: 496
If i divides n, so does n // i: divisors come in pairs (2 and 14 in 28). It is enough to try i while i * i <= n. And careful with perfect_up_to: after 8128, the next perfect number is 33,550,336. If your generator waits for a perfect number bigger than the limit before stopping, your program will keep thinking for a very long time. What is it that you should compare with the limit?
What’s wrong here?
Three snippets with the same overdue-loans generator (15-day loan period). Before reading the explanation, predict what each one prints.
Snippet 1: the report that stops halfway
def overdue(loans, max_days=15):
for member, days in loans:
if days > max_days:
yield member
loans = [('Pablo', 19), ('Laura', 3), ('Marta', 22)]
notices = overdue(loans)
print('There are', len(list(notices)), 'notices')
for member in notices:
print('Notice for', member)
print('End of report')
What does it print? Think before reading on.
There are 2 notices End of report
It says there are 2 notices and prints none. len(list(notices)) walked the whole generator to count it; when the for arrives, the generator is already at the end and runs zero laps. There is no error because, for Python, walking an exhausted generator is perfectly valid: there is simply nothing there.
Counting a generator with len(list(gen)) and then walking it. Counting is walking too.
The fix: if you will use it more than once, store it in a list from the start.
def overdue(loans, max_days=15):
for member, days in loans:
if days > max_days:
yield member
loans = [('Pablo', 19), ('Laura', 3), ('Marta', 22)]
notices = list(overdue(loans)) # a list CAN be walked several times
print('There are', len(notices), 'notices')
for member in notices:
print('Notice for', member)
print('End of report')
Output:
There are 2 notices Notice for Pablo Notice for Marta End of report
Snippet 2: the generator without brackets
def overdue(loans, max_days=15):
for member, days in loans:
if days > max_days:
yield member
loans = [('Pablo', 19), ('Laura', 3), ('Marta', 22)]
for member in overdue:
print('Notice for', member)
What does it print?
Traceback (most recent call last):
File "not_called.py", line 8, in <module>
for member in overdue:
^^^^^^^
TypeError: 'function' object is not iterable
overdue is the function; what can be walked is what it returns when called, overdue(loans). Without the brackets we are asking the for to walk a function, and a function has no __iter__. The message says it exactly: 'function' object is not iterable.
When a message says 'X' object is not iterable, look at the type it names. If it says function, you are missing the brackets; if it says NoneType, some function that should return something has no return; if it says int, you meant range(n).
Fix: for member in overdue(loans):.
Notice for Pablo Notice for Marta
Snippet 3: the fine letter by letter
def fines(loans, max_days=15):
for member, days in loans:
if days > max_days:
return f'{member}: {(days - max_days) * 20} cts'
loans = [('Pablo', 19), ('Laura', 3), ('Marta', 22)]
print(list(fines(loans)))
What does it print?
['P', 'a', 'b', 'l', 'o', ':', ' ', '8', '0', ' ', 'c', 't', 's']
It is the story from the beginning. Since fines has return and no yield, it is not a generator: it is a normal function that, at the first overdue loan (Pablo), returns the string 'Pablo: 80 cts' and ends. Marta is never looked at. And list() on a string splits it into characters, because a string is iterable too. The numbers add up: (19 − 15) × 20 = 80 cents.
With yield instead of return, the function becomes a generator and hands over one fine per overdue loan: Pablo, (19 − 15) × 20 = 80, and Marta, (22 − 15) × 20 = 140.
['Pablo: 80 cts', 'Marta: 140 cts']
Mixing the two ideas. return inside a loop leaves the function on the first lap that reaches it; if you want one result per lap, it is yield (or filling a list and returning it at the end).
Commented solutions
Solution Exercise 1
def infinite_multiples(base):
"""Infinite generator: base, 2*base, 3*base..."""
multiple = base
while True:
yield multiple
multiple += base # adding is cheaper than multiplying every time
def multiples_up_to(base, limit):
"""Finite generator: stops by itself once past the limit."""
multiple = base
while multiple <= limit: # <= because the limit counts too (30 is in)
yield multiple
multiple += base
def first_n_multiples(base, n):
counter = 0
for m in infinite_multiples(base):
if counter == n:
return # we cut the infinite one with return
yield m
counter += 1
def show(title, gen):
print(title)
print(' '.join(str(m) for m in gen)) # consumes the whole generator once
show('Multiples of 3 up to 30:', multiples_up_to(3, 30))
show('\nFirst 8 multiples of 7:', first_n_multiples(7, 8))
show('\nMultiples of 5 between 20 and 50:', (m for m in multiples_up_to(5, 50) if m >= 20))
show('\nCommon multiples of 3 and 5 up to 100:', (m for m in multiples_up_to(3, 100) if m % 5 == 0))
Why it is done this way:
multiples_up_touseswhile multiple <= limit(not<) because the statement includes the limit: 30 has to come out in the multiples of 3 up to 30.- Adding
baseon each lap is cheaper and clearer than multiplyingbase * kwith a separate counter. first_n_multiplescuts an infinite generator with a counter andreturn: it is the “generator that cuts” pattern from the practice. Withcounter == nchecked before theyield, not a single extra multiple is computed.- The last two cases need no new functions: a generator expression with
iffilters what comes out ofmultiples_up_to. showreceives any generator and consumes it once with' '.join(...). That is why each call gets a new one.
multiples_up_to(3, 100) hands over | m % 5 == 0? | Comes out |
|---|---|---|
| 3, 6, 9, 12 | No | — |
| 15 | Yes | 15 |
| 18 … 27 | No | — |
| 30 | Yes | 30 |
| … | … | 45, 60, 75, 90 |
| 99 | No | — (the next one, 102, is never given) |
Solution Exercise 2
class ArithmeticSequence:
def __init__(self, begin, step, terms):
if step == 0:
raise ValueError('The step cannot be 0')
if not isinstance(terms, int) or terms <= 0:
raise ValueError('The terms must be a positive integer')
self.__begin = begin # the original data never changes...
self.__step = step
self.__terms = terms
self.__current = begin # ...and the walk state is reset in __iter__
self.__remaining = terms
def __iter__(self):
self.__current = self.__begin
self.__remaining = self.__terms
return self
def __next__(self):
if self.__remaining == 0:
raise StopIteration
value = self.__current
self.__current += self.__step
self.__remaining -= 1
return value
def __len__(self):
return self.__remaining # the ones STILL to come in the current walk
def __str__(self):
return ' → '.join(str(self.__begin + k * self.__step) for k in range(self.__terms))
def try_seq(begin, step, terms, full=False):
seq = ArithmeticSequence(begin, step, terms)
print(f'Sequence({begin}, step={step}, terms={terms}):')
print(' for: ', ' '.join(str(v) for v in seq))
if full:
print(' list: ', list(seq)) # works again because __iter__ resets
print(' sum: ', sum(seq))
print(' str: ', seq)
it = iter(seq)
next(it)
next(it)
print(' len after two next():', len(seq))
try_seq(2, 3, 5, full=True)
try_seq(10, -2, 6)
try_seq(0, 0.5, 8)
try:
ArithmeticSequence(1, 0, 5)
except ValueError as err:
print('Expected error:', err)
What makes it walkable several times is in these lines:
def __iter__(self):
self.__current = self.__begin
self.__remaining = self.__terms
return self
def __next__(self):
if self.__remaining == 0:
raise StopIteration
value = self.__current
self.__current += self.__step
self.__remaining -= 1
return value
def __len__(self):
return self.__remaining # the ones STILL to come in the current walk
__init__stores the original data (__begin,__step,__terms) and also prepares the state, in case someone callsnext()without going throughiter().__iter__puts__currentand__remainingback at the start and returnsself. Sincefor,list()andsum()calliter()before starting, each walk begins at the first term.__next__hands over the current value and then moves on; when__remainingreaches 0, it raisesStopIteration.__len__returns__remaining: afteriter()and twonext()calls on 5 terms, 5 − 2 = 3 are left.
Trace of ArithmeticSequence(2, 3, 5) in one walk:
next() | __current before | Hands over | __current after | __remaining |
|---|---|---|---|---|
| 1 | 2 | 2 | 5 | 4 |
| 2 | 5 | 5 | 8 | 3 |
| 3 | 8 | 8 | 11 | 2 |
| 4 | 11 | 11 | 14 | 1 |
| 5 | 14 | 14 | 17 | 0 |
| 6 | 17 | StopIteration | — | 0 |
The sum: 2 + 5 + 8 + 11 + 14 = 40. And __str__ does not use the iterator: it computes each term with begin + k * step, so printing the sequence does not spoil a walk that is halfway through.
This solution resets in __iter__, so two nested walks over the same object would still trample each other (the cursors from the theory). It is enough for the statement; if you wanted nesting, __iter__ would have to return a new generator, like the Shelf.
Solution Exercise 3
def proper_divisors(n):
"""Divisors of n except n itself, in order. Only goes up to the square root."""
if n < 2:
return []
small, big = [1], []
i = 2
while i * i <= n:
if n % i == 0:
small.append(i) # i divides n...
if i != n // i: # ...and so does its partner n // i (root not repeated)
big.append(n // i)
i += 1
return small + big[::-1] # partners come out largest first: reverse them
def is_perfect(n):
return n >= 2 and sum(proper_divisors(n)) == n
def perfect_numbers():
"""Infinite generator of perfect numbers."""
candidate = 2
while True:
if is_perfect(candidate):
yield candidate
candidate += 1
def perfect_up_to(limit):
"""Checks the CANDIDATES up to the limit, not the perfect ones: so it ends even if there are no more."""
for candidate in range(2, limit + 1):
if is_perfect(candidate):
yield candidate
for n in (6, 12, 28):
print(f'Is {n} perfect? {is_perfect(n)}')
print('\nPerfect numbers up to 10000:')
print(' '.join(str(p) for p in perfect_up_to(10000)))
print()
for p in perfect_up_to(500):
divs = proper_divisors(p)
print(f'Divisors of {p}: {divs} → sum: {sum(divs)}')
proper_divisors looks for divisors in pairs:
def proper_divisors(n):
"""Divisors of n except n itself, in order. Only goes up to the square root."""
if n < 2:
return []
small, big = [1], []
i = 2
while i * i <= n:
if n % i == 0:
small.append(i) # i divides n...
if i != n // i: # ...and so does its partner n // i (root not repeated)
big.append(n // i)
i += 1
return small + big[::-1] # partners come out largest first: reverse them
For 28 it only tries i = 2, 3, 4 and 5 (6 × 6 = 36 is already over): 2 brings its partner 14 and 4 brings 7. The condition i != n // i avoids adding the root twice when the number is a square (in 16, the 4 would be repeated). The big ones come out reversed (14, 7), which is why they are flipped with [::-1] before joining them with the small ones.
And here is the important decision in the exercise:
def perfect_up_to(limit):
"""Checks the CANDIDATES up to the limit, not the perfect ones: so it ends even if there are no more."""
for candidate in range(2, limit + 1):
if is_perfect(candidate):
yield candidate
perfect_up_to walks the candidates from 2 to the limit and stops when it runs out of them, whatever it has found. The version that looks natural, for p in perfect_numbers(): if p > limit: return, only checks the limit when a new perfect number appears, and the one after 8128 is 33,550,336: it would have to try thirty-three million candidates before it could stop. The old version of this article had exactly that bug.
| Candidate | Proper divisors | Sum | Perfect? |
|---|---|---|---|
| 6 | 1, 2, 3 | 6 | Yes |
| 12 | 1, 2, 3, 4, 6 | 16 | No (abundant: goes over) |
| 28 | 1, 2, 4, 7, 14 | 28 | Yes |
| 496 | 1, 2, 4, 8, 16, 31, 62, 124, 248 | 496 | Yes |
Exam-style quiz
Now test yourself as in the lab. FP2 quizzes are like this: about 10 questions in 10-15 minutes, single answer, multiple answer (wrong answers subtract), true or false, fill in the gaps, matching and “what does this code print?”. Here are 13 original questions on iterators and generators. Answer without running anything, press Check and read the explanation of each one. These questions are also the bank for the FP2 theory mock exam, which will mix them with those of the other topics.
Your Countdown class has to work as an iterator (with next() and in a for). Which methods does it need?
__iter____next____getitem____len____yield__
Solution
Answer: __iter__ · __next__
The iteration protocol is two methods: __iter__ and __next__. A for calls iter() once and then next() until StopIteration is raised.
Inside __next__, how do you signal that there are no items left?
raise StopIterationreturn Nonebreakreturn -1
Solution
Answer: raise StopIteration
The for catches StopIteration by itself and finishes without an error. In a generator function you don’t raise it by hand: the function just has to end.
What does this program print?
def evens(n):
for i in range(n):
if i % 2 == 0:
yield i
g = evens(7)
next(g)
print(list(g))Solution
Answer: [2, 4, 6]
The generator would give 0, 2, 4 and 6. The next(g) already consumed the 0, so list(g) collects only what’s left.
What does this program print?
squares = (x * x for x in range(4)) print(sum(squares), sum(squares))
Solution
Answer: 14 0
A generator expression (in parentheses) can only be walked once: the first sum adds 0 + 1 + 4 + 9 = 14 and exhausts it; the second finds nothing and gives 0. With square brackets ([x * x …]) it would be a list and you’d get 14 14.
True or false?
- Calling a generator function doesn’t run its body: it returns a generator object.
- A list comprehension and a generator expression over the same data use the same memory.
yieldpauses the function and keeps its local variables until the nextnext().iter(my_list)returns an iterator over the list.- A generator can be walked twice in a row with two
forloops.
Solution
- T — Calling a generator function doesn’t run its body: it returns a generator object.
- F — A list comprehension and a generator expression over the same data use the same memory.
- T —
yieldpauses the function and keeps its local variables until the nextnext(). - T —
iter(my_list)returns an iterator over the list. - F — A generator can be walked twice in a row with two
forloops.
The list stores all the values at once; the generator computes them one by one, when asked (lazy evaluation). That saves memory, but it gets exhausted: to walk it again you need a new one.
What does this program print? (it all comes out on one line)
def gen():
print("A", end="")
yield 1
print("B", end="")
yield 2
print("C", end="")
g = gen()
print("0", end="")
for x in g:
print(x, end="")
print()Solution
Answer: 0A1B2C
g = gen() prints nothing: the body starts at the first next() the for makes. Each next() runs up to the next yield: A and 1, B and 2, and the last next() prints C and, as the function ends, the for finishes.
What’s the shortest way to make for x in my_linked_list: work?
- Write
__iter__as a generator, withyieldin a loop that walks the nodes - Inherit from
list - Define only
__len__ - Write a
walk()method that returnsNone
Solution
Answer: Write __iter__ as a generator, with yield in a loop that walks the nodes
If __iter__ contains yield, calling it returns a generator, which is already a complete iterator. Besides, every for creates a new generator, so the list can be walked as many times as you like.
What does this program print?
class Countdown:
def __init__(self, start):
self.n = start
def __iter__(self):
return self
def __next__(self):
if self.n <= 0:
raise StopIteration
self.n -= 1
return self.n + 1
c = Countdown(3)
print(list(c), list(c))Solution
Answer: [3, 2, 1] []
__iter__ returns the object itself, so the state (self.n) is shared: the first list leaves it at 0 and the second gets StopIteration straight away. That’s the difference with the generator __iter__ of the previous question.
Match each element with what it does:
iter(x) | |
next(it) | |
yield | |
yield from other | |
StopIteration |
Definitions:
- Hands over a value and pauses the function
- Gives the next item or raises StopIteration
- Restarts the iterator from the beginning
- Hands over the values of another iterable one by one
- Asks x for an iterator
- Signals that there are no items left
Solution
iter(x)→ Asks x for an iteratornext(it)→ Gives the next item or raises StopIterationyield→ Hands over a value and pauses the functionyield from other→ Hands over the values of another iterable one by oneStopIteration→ Signals that there are no items left
There’s no standard way to «rewind» an iterator: you ask for a new one with iter() (if the object is iterable) or call the generator function again.
What does this program print?
def everything(*lists):
for l in lists:
yield from l
print(list(everything([1, 2], [], [3])))Solution
Answer: [1, 2, 3]
yield from l is the same as for x in l: yield x. The empty list adds nothing and the result is a single sequence with everything.
You have to process a 5 GB file line by line. Why is a generator a good idea?
- Because it produces each line when it’s asked for, without loading the whole file into memory
- Because generators run in parallel
- Because a generator can be walked backwards
- Because then
len()tells you how many lines there are
Solution
Answer: Because it produces each line when it’s asked for, without loading the whole file into memory
It’s the big advantage of lazy evaluation: constant memory even when the data is huge (or infinite).
What does this program print?
it = iter(["a", "b"]) next(it) next(it) print(next(it, "end"))
Solution
Answer: end
After two next() calls the iterator is exhausted. With a second argument, next() doesn’t raise StopIteration: it returns that default value.
Which are iterables but not iterators (you can loop over them, but next() on them gives TypeError)?
[1, 2, 3]"hello"range(5)(x for x in range(3))iter([1, 2])
Solution
Answer: [1, 2, 3] · "hello" · range(5)
An iterable can give an iterator (__iter__); an iterator also gives items with __next__. Lists, strings and range are iterables: each for asks them for a fresh iterator, which is why they can be looped over many times.
Watch it step by step in Python Tutor
Let’s watch snippet 1’s bug live. In Python Tutor, choose Python 3, paste this and step through it with Next:
def overdue(loans):
for member, days in loans:
if days > 15:
yield member
loans = [('Pablo', 19), ('Laura', 3), ('Marta', 22)]
notices = overdue(loans)
first_run = list(notices)
second_run = list(notices)
print(first_run, second_run)
Output:
['Pablo', 'Marta'] []
What to watch:
noticesis a generator object: the function has not started yet.- During
first_run = list(notices)theoverdueframe appears and disappears: it hands over Pablo, skips Laura, hands over Marta and ends. - At
second_run = list(notices)the arrow does not even enter the function: the generator has already finished andlist()getsStopIterationstraight away. - At the end,
first_runis['Pablo', 'Marta']andsecond_runis[]. Same object, two results.
Cheat sheet: iterators and generators in Python
The essentials in 30 seconds: an iterable can be walked (it has __iter__); an iterator walks (it has __next__ and returns itself in __iter__); the for does iter() + next() until StopIteration. A function with yield returns a generator: lazy, pausable and single-use.
| I want… | Code | Result |
|---|---|---|
| An iterator over a list | it = iter([10, 20]) | next(it) gives 10, then 20 |
| The next one without failing | next(it, 'end') | 'end' when it runs out |
| A generator function | def g(): yield 1; yield 2 | list(g()) gives [1, 2] |
| A one-line generator | (x * x for x in range(4)) | 0, 1, 4, 9 (once only) |
| A sum without a list | sum(x * x for x in range(4)) | 14 |
| The first n of an infinite one | list(islice(gen, 3)) | 3 values |
| To cut a generator | if end: return inside the generator | StopIteration |
| To chain | yield from other | Everything in other, one by one |
| To count a generator | sum(1 for _ in gen) | The number of items (and it uses it up) |
My class to work with for | def __iter__(self): for x in self._data: yield x | Walkable as often as you like |
Templates from the exercises:
| Exercise | Template | Result |
|---|---|---|
| Multiples up to a limit | m = base; while m <= limit: yield m; m += base | 3 6 9 … 30 |
| The first n | for m in infinite(): if c == n: return; yield m; c += 1 | 7 14 … 56 |
| An iterator that resets | __iter__: state back to zero and return self | list() twice, both complete |
| Divisors in pairs | while i * i <= n: if n % i == 0: … | 28 → [1, 2, 4, 7, 14] |
| Generator up to a limit | for c in range(2, limit + 1): if ok(c): yield c | Always ends |
| Symptom | Likely cause |
|---|---|
The second time it gives [] or the for runs no laps | The generator was already exhausted (walked earlier, even with len(list(...))) |
TypeError: 'list' object is not an iterator | next() on a list: iter() is missing |
TypeError: 'function' object is not iterable | The generator function was not called: g instead of g() |
list() returns single letters | return of a string where you wanted yield |
| The program never ends and gives no error | list() or a for without break over an infinite generator |
Two nested for loops give fewer pairs than expected | __iter__ returns self and they share a cursor |
StopIteration in a traceback | next() outside a for when nothing was left |
Before handing in, ask yourself:
- Will this generator be walked more than once? If so, do I create it again or keep a list?
- Does every infinite generator have something to cut it (
return,break,islice)? - Does my
__iter__reset the state or, better, return a new generator? - Have I written
yieldwhere I want one value per lap andreturnonly where I want to finish?
Did this article help you? Here is how you can support SergioLearns →




What is an operating system — theory with your laptop on a study afternoon, a write() call and a CPU that never rests (and the three programs that weren’t running at the same time)