Worked example — one problem, four paradigms
A single, in-depth worked project that takes one small program — an arithmetic-expression evaluator — and implements it four ways: imperative, object-oriented, functional, and a short declarative coda. The goal is not the program itself (it is deliberately tiny) but seeing how the same logic re-shapes itself under different paradigms, and learning to read each style on its merits: readability, how it handles state, and the design patterns each one invites.
This is the paradigm contrast promised in Session 19 (Intro to OOP), made
concrete. It reuses ideas you have already seen interactively in the demos: the call stack
(recursion), scope & closures, map/filter/reduce, mutability
& side effects, and dictionaries as dispatch tables. Every snippet below is real,
runnable code — copy it into a Colab cell or a .py file and it works.
- Decomposition — the same problem split into tokenize → parse → evaluate, with clear interfaces (Session 5, outcome on modular components).
- Control flow & state — loops and an explicit stack (imperative) vs. encapsulated state (OO) vs. no mutable state at all (functional).
- Paradigm fluency — the procedural / functional / object-oriented contrast named explicitly in Session 19.
- Good practices — docstrings, type hints, small testable functions, and an explicit comparison of trade-offs.
1 · The shared problem specification
Every implementation below solves exactly this. Fixing the spec first is the whole point: it lets us compare paradigms on equal footing instead of comparing different programs.
Evaluate a postfix (Reverse Polish) arithmetic expression. The input is a string
of numbers and the operators + - * / separated by spaces, written in
postfix order — the operator comes after its two operands. Postfix is chosen because it needs
no parentheses and no precedence rules, so the parsing logic stays small enough to hold all
four paradigms side by side. The classic algorithm is the stack machine: scan left to right,
push numbers, and when you hit an operator pop the top two values, apply it, and push the result.
| Aspect | Specification |
|---|---|
| Input | A string, e.g. "3 4 + 5 *" (meaning (3 + 4) * 5). |
| Tokens | Space-separated. Each token is an integer/float literal or one of + - * /. |
| Output | A single number — the value of the expression (35.0 for the example). |
| Algorithm | Stack machine: push operands; on an operator pop b then a, push a op b. |
| Errors | An empty stack on pop, or a leftover stack ≠ 1 item, is a malformed expression. |
| Out of scope | Parentheses, variables, unary minus, operator precedence (postfix removes the need). |
Trace. Evaluating 3 4 + 5 * with a stack
(bottom → top shown under each token as it is consumed):
2 · Imperative implementation
The procedural baseline (Sessions 3–5). One function, an explicit loop, and a list used as a mutable stack. State is a variable you watch change as the loop runs — exactly the mental model the flow-control and mutability demos build.
def eval_rpn(expr: str) -> float:
"""Evaluate a postfix expression with an explicit stack (imperative)."""
stack = [] # mutable state we update in-place
for token in expr.split(): # step through tokens left to right
if token in ("+", "-", "*", "/"):
if len(stack) < 2:
raise ValueError(f"not enough operands for {token!r}")
b = stack.pop() # top is the RIGHT operand
a = stack.pop()
if token == "+": stack.append(a + b)
elif token == "-": stack.append(a - b)
elif token == "*": stack.append(a * b)
else:
if b == 0:
raise ZeroDivisionError("division by zero")
stack.append(a / b)
else:
stack.append(float(token)) # a number literal → push it
if len(stack) != 1:
raise ValueError(f"malformed expression, stack = {stack}")
return stack[0]
if __name__ == "__main__": # the S17 entry-point guard
print(eval_rpn("3 4 + 5 *")) # → 35.0
print(eval_rpn("10 2 / 3 -")) # → 2.0
- State is explicit and mutable.
stackchanges on every iteration; correctness depends on the order ofpop()calls — poppingbbeforeamatters for-and/. This is the side-effect-driven thinking from the mutability demo. - Control flow carries the logic. The
if/elif/elsechain and theforloop are the program — the same primitives from Sessions 3–4. - Pattern it invites: a long
if/elifdispatch. It is readable at four operators but grows linearly with each new one — a smell the OO and functional versions both fix.
3 · Object-oriented implementation
Session 19's paradigm. The stack stops being a loose variable and becomes encapsulated state inside an object; the operator dispatch moves into a class-level table. Data and the behavior that acts on it are bundled together — the defining move of OOP.
import operator
class RPNCalculator:
"""A stack machine that bundles its state with the methods on it."""
# class-level dispatch table: operator symbol → binary function
OPS = {
"+": operator.add,
"-": operator.sub,
"*": operator.mul,
"/": operator.truediv,
}
def __init__(self) -> None:
self._stack: list[float] = [] # encapsulated, owned by the object
def push(self, value: float) -> None:
self._stack.append(value)
def apply(self, symbol: str) -> None:
if len(self._stack) < 2:
raise ValueError(f"not enough operands for {symbol!r}")
b = self._stack.pop()
a = self._stack.pop()
self._stack.append(self.OPS[symbol](a, b)) # table lookup, no if-chain
def evaluate(self, expr: str) -> float:
self._stack.clear()
for token in expr.split():
if token in self.OPS:
self.apply(token)
else:
self.push(float(token))
if len(self._stack) != 1:
raise ValueError(f"malformed expression, stack = {self._stack}")
return self._stack[0]
if __name__ == "__main__":
calc = RPNCalculator()
print(calc.evaluate("3 4 + 5 *")) # → 35.0
print(calc.evaluate("2 3 4 * +")) # → 14.0 (reusing one object)
- State is encapsulated.
self._stackbelongs to the object; the leading underscore signals "internal." Callers interact only throughpush/apply/evaluate— the abstraction and encapsulation goals named in Session 19. - Dispatch becomes a dictionary. The
OPStable replaces theif/elifchain — adding an operator is one line, not a new branch. This is a dictionary as hash map (Session 10) used as a strategy table. - Pattern it invites: a reusable, configurable object you could subclass (e.g. a calculator that logs every operation) — at the cost of more ceremony than the problem strictly needs.
4 · Functional implementation
Session 7's tools taken to their conclusion: no mutable stack at all. The whole evaluation is a
single reduce (a fold) over the tokens, where the "stack" is an immutable tuple threaded through
and a dictionary of lambdas does the dispatch. Same algorithm, zero side effects.
from functools import reduce
OPS = { # symbol → pure binary function
"+": lambda a, b: a + b,
"-": lambda a, b: a - b,
"*": lambda a, b: a * b,
"/": lambda a, b: a / b,
}
def step(stack: tuple, token: str) -> tuple:
"""Pure fold step: (stack, token) -> new stack. No mutation."""
if token in OPS:
*rest, a, b = stack # unpack top two without popping
return (*rest, OPS[token](a, b)) # build a NEW tuple
return (*stack, float(token)) # push → new tuple
def eval_rpn(expr: str) -> float:
"""Evaluate by folding `step` over the tokens (functional)."""
result = reduce(step, expr.split(), ()) # initial stack = ()
if len(result) != 1:
raise ValueError(f"malformed expression, stack = {result}")
return result[0]
if __name__ == "__main__":
print(eval_rpn("3 4 + 5 *")) # → 35.0
The same idea is just as natural in JavaScript, where
Array.reduce is the everyday fold and arrow functions stand in for lambda:
const OPS = {
"+": (a, b) => a + b,
"-": (a, b) => a - b,
"*": (a, b) => a * b,
"/": (a, b) => a / b,
};
const evalRpn = (expr) =>
expr
.trim()
.split(/\s+/)
.reduce((stack, tok) => {
if (tok in OPS) {
const b = stack[stack.length - 1];
const a = stack[stack.length - 2];
// return a NEW array — no mutation of `stack`
return [...stack.slice(0, -2), OPS[tok](a, b)];
}
return [...stack, Number(tok)];
}, [])[0];
console.log(evalRpn("3 4 + 5 *")); // → 35
- No mutable state. Each
steptakes a stack and returns a new one; nothing is ever changed in place. This is the "return-a-new-object" discipline contrasted with mutation in Session 12 — pushed to the limit. - The loop disappears.
reduce(step, tokens, ())folds the whole expression into one value — the exactreducefrom the map/filter/reduce demo, and the pipeline mindset of Session 7. - Pattern it invites: small pure functions that compose and are trivial to unit-test in isolation (
stephas no hidden dependencies). The cost is that immutable rebuilding is less obvious to a beginner, and slower for huge inputs.
5 · Comparison — and which paradigm fits when
The same algorithm, three shapes. The differences are not about correctness (all three return
35.0) but about how each handles state, how it reads, and what it makes easy to extend.
| Dimension | Imperative | Object-oriented | Functional |
|---|---|---|---|
| State | Explicit mutable list; you watch it change | Encapsulated in self._stack, owned by the object | None — immutable tuple threaded through the fold |
| Dispatch | if/elif chain (grows per operator) | OPS dict on the class (one line per operator) | OPS dict of lambdas |
| Control flow | Visible for loop | for loop inside a method | Hidden inside reduce |
| Lines of code | ~22 | ~30 | ~16 |
| Readability (beginner) | high | medium | medium-low |
| Testability | Test the one function | Test the object; can mock pieces | Test step in isolation (pure) |
| Extensibility | Edit the if-chain | Subclass / extend OPS | Add to OPS; compose functions |
| Reuse across calls | Call the function again | One object, many evaluations + history | Pure, trivially reusable |
| Main risk | Side-effect bugs (pop order) | Over-engineering a tiny problem | Allocation cost; opaque to newcomers |
| Invites pattern | Linear dispatch | Strategy table + encapsulation | Fold / pipeline of pure functions |
Imperative procedural
Short scripts, one-off automation, hot loops where you control performance directly. The clearest choice for a beginner and for code that is read more than extended. The course's default through Modules 1–2.
Object-oriented OOP
When state and behavior travel together and the thing has a lifetime — a calculator that remembers history, a game entity, a parser with configuration. Pays off as a system grows; overkill for a 20-line script (Session 19).
Functional FP
Data pipelines, transformations, anything you want pure and parallelizable. Shines when you can express a problem as "fold/map/filter over a sequence" — the NumPy and pandas mindset of Module 4.
Declarative + extension
When you would rather state the rules than the steps — grammars, queries, constraints. See the extension below for a grammar-style take on the same evaluator.
6 · Mapping to learning outcomes
How this single worked example touches the course's stated objectives and sessions. Each line links back to where the idea is introduced.
- O2Core programming concepts — variables, expressions, control flow and functions all appear in the imperative version (Sessions 2–5).
- O4Decompose into modular components — tokenize → dispatch → evaluate is a clean interface reused across all three styles (Session 5).
- O5Good programming practices — docstrings, type hints, the
__main__guard, and an explicit complexity/trade-off discussion (Sessions 6, 17). - O3Fundamental data structures — a list/tuple used as a stack, and a dictionary used as a dispatch table (Sessions 8, 10).
- S07Functional tools —
lambdaandreducedrive the functional version (Session 7). - S12Mutability & side effects — the contrast between the mutating imperative stack and the immutable functional fold is the whole lesson of Session 12, made concrete.
- S19Contrast paradigms — procedural vs. functional vs. object-oriented, exactly as Session 19 asks, with a working class.
- TSAbstraction & communication — choosing a representation (the stack) and explaining a design decision (which paradigm, and why) are the transversal skills.
7 · Extension — a declarative / logic take
A fourth angle, beyond the syllabus core: instead of describing how to evaluate step by step, describe the grammar of valid expressions and let a recursive-descent reading follow the rules. This is the declarative spirit — state the structure, not the procedure.
# Grammar (declarative spec of WHAT a valid expression IS):
# expr := number | expr expr op
# op := "+" | "-" | "*" | "/"
#
# We consume tokens from the RIGHT and let recursion mirror the rule,
# so the call stack itself plays the role of the explicit stack above.
OPS = {"+": lambda a, b: a + b, "-": lambda a, b: a - b,
"*": lambda a, b: a * b, "/": lambda a, b: a / b}
def parse(tokens: list) -> float:
"""Recursively read one sub-expression from the end of `tokens`."""
tok = tokens.pop() # take the rightmost token
if tok in OPS:
b = parse(tokens) # right operand parses first
a = parse(tokens) # then the left
return OPS[tok](a, b)
return float(tok) # base case: a literal
def eval_rpn(expr: str) -> float:
tokens = expr.split()
value = parse(tokens)
if tokens: # nothing should be left over
raise ValueError(f"trailing tokens: {tokens}")
return value
if __name__ == "__main__":
print(eval_rpn("3 4 + 5 *")) # → 35.0
- Add an
"%"(modulo) and"**"(power) operator — count how many lines each paradigm needs (the dispatch-table versions win). - Make the functional version a generator that
yields the stack after each token, then feed it the lazy-evaluation demo's idea of pulling values on demand. - Give the OO version a
historylist and a.replay()method — the kind of stateful feature that justifies the class in the first place. - Convert infix to postfix with the shunting-yard algorithm to handle parentheses and precedence (the part deliberately left out of the spec).
8 · References
Standard documentation and companions for the ideas used above; cross-referenced to the sessions where each is introduced.
- The Python Tutorial & Language Reference (docs.python.org) — classes,
functools.reduce, theoperatormodule, and the data model used throughout. Sessions 5–19 - Python
functools&operatordocs —reduceand ready-made binary operators behind the functional and OO dispatch tables. Session 7 - "Think Python" (Downey) — functions, recursion and scope; the base-case reasoning behind the declarative parser. Sessions 5–7
- Reverse Polish notation & the stack machine — the classic O(n) one-pass evaluation algorithm shared by all four versions. Sessions 6, 8
- MDN —
Array.prototype.reduce& arrow functions — the JavaScript fold used in the functional implementation. Session 7 (paradigm contrast) - This repo's interactive demos — call stack, map/filter/reduce, mutability and dictionaries-as-hash-maps animate the mechanics referenced on this page. cross-linked per section