Computational thinking & correctness
The final Python-Fundamentals part is about writing code you can reason about and trust. You will learn to state and prove a loop invariant, encode pre/postconditions as assertions, and think in properties that must hold for all inputs rather than a handful of examples. Along the way you will implement the iterator protocol two ways (a class and a generator), read a table of the real cost of the operations you reach for daily (why list.pop(0) is O(n) but deque.popleft() is O(1); why x in set beats x in list), confront the __eq__/__hash__ contract and the concrete bug when they disagree, and finally the floating-point pitfalls that make 0.1 + 0.2 != 0.3. Correctness first, then speed.
Learning objectives
- State a loop invariant and use it (with the exit condition) to argue an algorithm is correct.
- Encode pre/postconditions as assertions, and know that
-Ostrips them. - Implement the iterator protocol both as a class and as a generator, and explain iterable vs iterator.
- Read the real cost (Big-O) of list/deque/dict/set operations and pick the right structure.
- Uphold the
__eq__/__hash__contract and recognize the bug when it's broken. - Avoid floating-point pitfalls (equality, money, rounding) and think in properties, not examples.
1 · Loop invariants
A loop invariant is a statement that is true just before the loop starts and remains true after every iteration. Combined with the loop's exit condition, an invariant lets you prove the loop produces the right answer — the same reasoning graders and interviewers ask for. The trick is to write the invariant as a checkable assertion so a wrong one fails loudly.
invariant.py# A loop INVARIANT is a condition true before the loop and preserved by every
# iteration. Together with the exit condition it proves the result.
def find_max(xs):
assert len(xs) > 0, "precondition: xs is non-empty"
best = xs[0]
for i in range(1, len(xs)):
# INVARIANT (holds at the top of each iteration):
# best == max(xs[:i])
assert best == max(xs[:i]) # checkable statement of the invariant
if xs[i] > best:
best = xs[i]
# At exit i == len(xs), so the invariant gives: best == max(xs[:len(xs)])
assert best == max(xs), "postcondition"
return best
print(find_max([3, 1, 4, 1, 5, 9, 2, 6])) # 9
9
The invariant here is best == max(xs[:i]): true before the loop (best = xs[0], i = 1), and preserved by the body (each step extends the considered prefix by one and keeps the running max). At exit i == len(xs), so the invariant becomes exactly the postcondition best == max(xs) — a proof, not a hope.
2 · Pre/postconditions & assertions
A precondition is what a function requires of its inputs; a postcondition is what it guarantees about its output. assert both documents and checks them, turning silent wrong behavior into a loud failure at the exact spot. One caveat that trips people in production: assertions are removed when Python runs with -O, so never use them for input validation that must always run — use explicit checks/exceptions there, and use assertions for internal invariants.
contracts.py# Preconditions guard the inputs; postconditions guarantee the output. Assertions
# document AND check both. (Note: assertions are stripped under `python -O`.)
import math
def newton_sqrt(x, iters=50):
assert x >= 0, "precondition: x must be non-negative"
if x == 0:
return 0.0
guess = x
for _ in range(iters):
guess = (guess + x / guess) / 2 # Newton-Raphson step
assert math.isclose(guess * guess, x, rel_tol=1e-9), "postcondition failed"
return guess
print(round(newton_sqrt(2), 6)) # 1.414214
try:
newton_sqrt(-1)
except AssertionError as e:
print("caught:", e)
1.414214
caught: precondition: x must be non-negative
python -O strips assert, guarding untrusted input with assert user_id > 0 silently vanishes in an optimized deployment. Use if not ...: raise ValueError(...) for validation; reserve assert for invariants you believe are always true.3 · The iterator & iterable protocol
Iteration is a protocol, not magic. An iterable defines __iter__, which returns an iterator; an iterator defines __next__ (return the next item, raise StopIteration when done) and an __iter__ that returns itself. A for loop is sugar for “call iter(), then next() until StopIteration.” A generator is the easy way to get one: yield makes the compiler write the whole protocol for you, including the lazy, resumable state.
iterator.py# The protocol: an ITERABLE has __iter__ (returns an iterator); an ITERATOR has
# __next__ (returns items, raises StopIteration to stop) and __iter__ (returns self).
class Countdown: # a hand-written iterator
def __init__(self, start): self.n = start
def __iter__(self): return self # an iterator is its own iterable
def __next__(self):
if self.n <= 0:
raise StopIteration # signals "no more items"
self.n -= 1
return self.n + 1
print("class iterator:", list(Countdown(3))) # [3, 2, 1]
def countdown(start): # a GENERATOR: the compiler writes the
while start > 0: # __iter__/__next__/StopIteration for you
yield start
start -= 1
print("generator :", list(countdown(3))) # [3, 2, 1]
# for-loops speak this protocol: iter(obj) then repeated next() until StopIteration
it = iter([10, 20])
print(next(it), next(it)) # 10 20
try:
next(it)
except StopIteration:
print("exhausted")
class iterator: [3, 2, 1]
generator : [3, 2, 1]
10 20
exhausted
4 · The real cost of your Python
Two lines that look equally innocent can differ by orders of magnitude at scale. Choosing the right built-in data structure is usually a bigger win than any micro-optimization. The table gives the amortized Big-O of common operations; the practical headline is: list.pop(0)/insert(0, x) are O(n) (everything shifts), collections.deque gives O(1) at both ends, and membership testing is O(n) in a list but O(1) in a set/dict.
cost.pyfrom collections import deque
import time
def bench(fn, n=1000):
t = time.perf_counter()
for _ in range(n):
fn()
return (time.perf_counter() - t) * 1000 # ms per 1000 calls
L = list(range(10_000))
S = set(range(10_000))
print("x in list :", round(bench(lambda: 9999 in L), 2), "ms/1000") # slow, O(n)
print("x in set :", round(bench(lambda: 9999 in S), 2), "ms/1000") # fast, O(1)
# list vs deque at the front:
lst = list(range(100_000))
dq = deque(range(100_000))
print("list.pop(0) is O(n): shifts every element")
print("deque.popleft() is O(1):", dq.popleft()) # 0
x in list : 61.11 ms/1000
x in set : 0.05 ms/1000
list.pop(0) is O(n): shifts every element
deque.popleft() is O(1): 0
| Operation | list | deque | dict / set |
|---|---|---|---|
| Index / key access | O(1) | O(1) ends, O(n) middle | O(1) by key |
| Append at end | O(1) amortized | O(1) | O(1) insert |
| Insert / pop at front | O(n) | O(1) | — |
Membership (x in s) | O(n) | O(n) | O(1) average |
| Search by value | O(n) | O(n) | O(1) by key |
5 · The __eq__ / __hash__ contract
Hash-based containers (set, dict) rely on a contract: if two objects are equal, they must have the same hash. Break it and you get real bugs. Define __eq__ without __hash__ and Python makes your type unhashable (can't be a set member or dict key). Provide an inconsistent hash and equal objects land in different buckets, so a set keeps duplicates. The fix is always the same: hash exactly the fields you compare.
eq_hash.py# The contract: if a == b then hash(a) == hash(b). Break it and dicts/sets break.
# BUG 1: define __eq__ but forget __hash__ -> Python makes the type UNHASHABLE.
class BadPoint:
def __init__(self, x, y): self.x, self.y = x, y
def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
try:
{BadPoint(1, 2)} # can't put it in a set
except TypeError as e:
print("unhashable:", type(e).__name__)
# BUG 2: equal objects with DIFFERENT hashes -> duplicates leak into a set.
class SneakyPoint:
_n = 0
def __init__(self, x, y): self.x, self.y = x, y
def __eq__(self, o): return (self.x, self.y) == (o.x, o.y)
def __hash__(self):
SneakyPoint._n += 1
return SneakyPoint._n # inconsistent hash: violates the contract
s = {SneakyPoint(1, 2), SneakyPoint(1, 2)}
print("leaked duplicates:", len(s)) # 2 -> equal points, but both stored!
# CORRECT: hash the SAME fields you compare.
class Point:
def __init__(self, x, y): self.x, self.y = x, y
def __eq__(self, o): return isinstance(o, Point) and (self.x, self.y) == (o.x, o.y)
def __hash__(self): return hash((self.x, self.y))
print("correct dedup :", len({Point(1, 2), Point(1, 2)})) # 1
unhashable: TypeError
leaked duplicates: 2
correct dedup : 1
6 · Floating-point pitfalls
Floats are IEEE-754 binary fractions, so decimals like 0.1 and 0.2 have no exact representation — their sum is a hair off 0.3, and == says so. Never compare floats with ==; use math.isclose. For money and other exact-decimal needs use decimal.Decimal. And note Python's round uses banker's rounding (round half to even), so round(2.5) == 2.
floats.py# Floats are binary IEEE-754: most decimals (0.1, 0.2) can't be represented exactly.
import math
from decimal import Decimal
print("0.1 + 0.2 :", 0.1 + 0.2) # 0.30000000000000004
print("== 0.3 :", 0.1 + 0.2 == 0.3) # False
print("math.isclose :", math.isclose(0.1 + 0.2, 0.3)) # True
print("Decimal money :", Decimal("0.1") + Decimal("0.2")) # 0.3 (exact)
# Rounding is not always "round half up" — Python uses banker's rounding.
print("round(2.5) :", round(2.5)) # 2 (round half to even)
print("round(3.5) :", round(3.5)) # 4
0.1 + 0.2 : 0.30000000000000004
== 0.3 : False
math.isclose : True
Decimal money : 0.3
round(2.5) : 2
round(3.5) : 4
0.1 + 0.2 = 0.30000000000000004. The lesson is universal: use tolerance-based comparison for floats and a decimal type for exact base-10 arithmetic.7 · Property-based thinking
Example-based tests check a few hand-picked inputs; property-based thinking asks what must hold for every input and checks it against many generated cases. A correct sort, for instance, must output an ordered sequence that is a permutation of the input with the same length — three properties that catch far more bugs than a couple of literal examples. (Libraries like hypothesis automate the generation; here we do it by hand to show the idea.)
properties.py# Property-based thinking: assert what must hold for ALL inputs, not examples.
import random
def my_sort(xs):
return sorted(xs)
def is_permutation(a, b):
return sorted(a) == sorted(b) # same multiset of elements
# Properties of a correct sort, checked on many random inputs:
for _ in range(1000):
xs = [random.randint(-50, 50) for _ in range(random.randint(0, 20))]
out = my_sort(xs)
assert all(out[i] <= out[i + 1] for i in range(len(out) - 1)), "ordered"
assert is_permutation(xs, out), "same elements"
assert len(out) == len(xs), "same length"
print("all sort properties held over 1000 random inputs")
all sort properties held over 1000 random inputs
✓ Checkpoint — you can move on when you can…
- State a loop invariant for a running-sum or running-max loop and show it yields the postcondition.
- Explain why
assertmust not be used for input validation in production. - Write both a class-based iterator and an equivalent generator, and explain iterable vs iterator.
- Pick between list, deque, and set for a front-insert workload and a membership workload, with Big-O reasons.
- Fix a class that has
__eq__but is unhashable, and explain why0.1 + 0.2 != 0.3.
A service validates access with assert user.is_admin, 'forbidden' and it works in tests, but in production some non-admins get through. What happened?
Show answer
-O flag (or PYTHONOPTIMIZE), which strips all assert statements. The authorization check simply isn't there in the optimized bytecode, so everyone passes. Assertions are for internal invariants you believe are always true, not for security or input validation. Fix: if not user.is_admin: raise PermissionError('forbidden'). Never guard untrusted input or access control with assert.You store custom Money objects in a set to dedupe, but equal amounts aren't being deduplicated — the set keeps both. You did define __eq__. Diagnose and fix.
Show answer
Money objects land in different buckets, so the set never compares them and keeps both. Fix: define __hash__ over the same fields used in __eq__ — e.g. def __hash__(self): return hash(self.cents). (If instead you defined __eq__ and no __hash__, the type becomes unhashable and the set insert raises TypeError — the same contract, different symptom.) Also consider making value objects immutable so their hash can't drift.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Making an invariant explicit as an assertion turns a fragile loop into a self-checking one.
Your task: Write a running-sum loop and assert its invariant on each iteration.
Requirements:
- Sum a list with an explicit accumulator loop
- Assert
total == sum(xs[:i+1])after processing indexi - Return the total and print it
💡 Hint: The invariant relates the accumulator to the prefix processed so far.
Show solution
def running_sum(xs):
total = 0
for i, x in enumerate(xs):
total += x
assert total == sum(xs[:i + 1]) # invariant
return total
print(running_sum([2, 4, 6])) # 1212
Context: Understanding the protocol lets you build lazy data sources; generators make it painless.
Your task: Implement a range-like sequence as a class-based iterator and as a generator, and show they produce the same list.
Requirements:
- A class with
__iter__and__next__raisingStopIteration - A generator using
yield - Show both give the same result
💡 Hint: An iterator's __iter__ returns self.
Show solution
class Upto:
def __init__(self, n): self.i, self.n = 0, n
def __iter__(self): return self
def __next__(self):
if self.i >= self.n: raise StopIteration
self.i += 1
return self.i - 1
def upto(n):
i = 0
while i < n:
yield i
i += 1
print(list(Upto(4))) # [0, 1, 2, 3]
print(list(upto(4))) # [0, 1, 2, 3][0, 1, 2, 3]
[0, 1, 2, 3]
Context: A queue built on list.pop(0) degrades to O(n) per dequeue and tanks throughput at scale; the fix is a one-line structure change.
Your task: Show that a deque-based queue is asymptotically better than a list-based one at the front, and explain the Big-O.
Requirements:
- Dequeue from the front of a large list vs a deque
- State the Big-O of each front operation
- Recommend the correct structure
💡 Hint: Removing from the front of a list shifts every remaining element.
Show solution
from collections import deque
lst = list(range(1_000_000))
dq = deque(range(1_000_000))
# list.pop(0): O(n) -- shifts ~1M elements each call
# deque.popleft(): O(1)
print(lst.pop(0)) # 0 (but O(n): avoid in a loop)
print(dq.popleft()) # 0 (O(1): correct for a queue)0
0A FIFO queue must use deque (O(1) at both ends). Using a list and pop(0) makes each dequeue O(n), so draining n items is O(n²).
Context: Value objects used as cache keys or set members must satisfy the eq/hash contract or the cache silently misbehaves.
Your task: Write an immutable-ish Coord that compares by value and can be a dict key, and prove equal coords collapse to one entry.
Requirements:
__eq__comparing the fields__hash__over the SAME fields- Use two equal coords as dict keys and show the dict has one entry
💡 Hint: Hash a tuple of the compared fields.
Show solution
class Coord:
def __init__(self, x, y): self.x, self.y = x, y
def __eq__(self, o): return isinstance(o, Coord) and (self.x, self.y) == (o.x, o.y)
def __hash__(self): return hash((self.x, self.y))
seen = {}
seen[Coord(1, 2)] = 'a'
seen[Coord(1, 2)] = 'b' # same key -> overwrites
print(len(seen), list(seen.values())) # 1 ['b']1 ['b']@dataclass(frozen=True) auto-generates a consistent __eq__ and __hash__ and makes the object immutable — the safest way to get a correct value object without hand-writing the contract.Context: You are about to replace a hand-rolled dedup-and-sort with a faster implementation and must not change behavior. Example tests won't catch subtle regressions.
Your task: Write property checks that any correct 'sorted unique' implementation must satisfy, and run them on random inputs against your implementation.
Requirements:
- Properties: output is sorted, has no duplicates, and is a subset of the input covering all its distinct values
- Generate many random inputs and assert all properties
- Report success
💡 Hint: Compare against sorted(set(xs)) as the specification.
Show solution
import random
def sorted_unique(xs):
return sorted(set(xs)) # the implementation under test
for _ in range(2000):
xs = [random.randint(0, 9) for _ in range(random.randint(0, 15))]
out = sorted_unique(xs)
assert out == sorted(out), 'sorted'
assert len(out) == len(set(out)), 'unique'
assert set(out) == set(xs), 'covers distinct values'
print('all properties held')all properties heldhypothesis library generates inputs, shrinks failing cases to a minimal example, and integrates with pytest — the industrial version of what this loop does by hand.Context: A billing service intermittently double-charges customers and, separately, a dedup set of transactions occasionally lets duplicates through. Postmortem time: the code uses float for money, guards a critical check with assert, and stores custom Txn objects (with a mutable amount) in a set.
Your task: Identify the three latent correctness bugs from this lesson and give the fix for each.
Requirements:
- Explain the money/float bug and the fix
- Explain why the
assertguard can vanish and the fix - Explain the eq/hash + mutability bug in the dedup set and the fix
💡 Hint: Three separate principles: float equality, -O stripping asserts, and the eq/hash contract with mutable keys.
Show solution
Bug 1 — float money. Summing/compare of float amounts accumulates representation error (0.1 + 0.2 != 0.3), so totals and equality checks drift, causing off-by-a-cent and double-charge edge cases. Fix: use decimal.Decimal (or integer cents) for money, never binary floats.
Bug 2 — assert as a guard. The critical check written as assert not already_charged is silently removed when the service runs under python -O, so the guard disappears in production. Fix: if already_charged: raise BillingError(...); reserve assert for internal invariants.
Bug 3 — eq/hash + mutability. Txn objects define equality but either hash inconsistently or are mutated after insertion, so their hash changes and the set can no longer find the existing entry — duplicates leak in. Fix: make Txn immutable (@dataclass(frozen=True)) and hash the same fields you compare; never mutate an object while it lives in a set/dict key.