Principles of Programming worked example · one problem · four paradigms

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.

Goal
Evaluate arithmetic in 4 paradigms
Languages
Python 3.11 · JS (ES2020)
Sessions exercised
S03–S12 · S19
Module focus
M1 fundamentals · M3 paradigms
Core idea
state & control flow
Lines of code
~25 per paradigm
What this project exercises

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.

AspectSpecification
InputA string, e.g. "3 4 + 5 *" (meaning (3 + 4) * 5).
TokensSpace-separated. Each token is an integer/float literal or one of + - * /.
OutputA single number — the value of the expression (35.0 for the example).
AlgorithmStack machine: push operands; on an operator pop b then a, push a op b.
ErrorsAn empty stack on pop, or a leftover stack ≠ 1 item, is a malformed expression.
Out of scopeParentheses, 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):

3
[3]
4
[3, 4]
+
[7]
5
[7, 5]
*
[35]
35.0
Why a stack? This is the same last-in-first-out structure the call-stack demo animates for recursion — each operator "returns" a value that the next operation consumes. Recursion (the functional version) and an explicit list-as-stack (the imperative version) are two faces of the same idea.

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.

Pythonrpn_imperative.py
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
Notes

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.

Pythonrpn_oo.py
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)
Notes

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.

Pythonrpn_functional.py
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:

JavaScriptrpnFunctional.js
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
Notes

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.

DimensionImperativeObject-orientedFunctional
StateExplicit mutable list; you watch it changeEncapsulated in self._stack, owned by the objectNone — immutable tuple threaded through the fold
Dispatchif/elif chain (grows per operator)OPS dict on the class (one line per operator)OPS dict of lambdas
Control flowVisible for loopfor loop inside a methodHidden inside reduce
Lines of code~22~30~16
Readability (beginner)highmediummedium-low
TestabilityTest the one functionTest the object; can mock piecesTest step in isolation (pure)
ExtensibilityEdit the if-chainSubclass / extend OPSAdd to OPS; compose functions
Reuse across callsCall the function againOne object, many evaluations + historyPure, trivially reusable
Main riskSide-effect bugs (pop order)Over-engineering a tiny problemAllocation cost; opaque to newcomers
Invites patternLinear dispatchStrategy table + encapsulationFold / pipeline of pure functions
Which paradigm fits when

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.

The honest takeaway: for this tiny problem the imperative version is the right default — it is the shortest path to correct, readable code. The OO and functional versions earn their keep only as requirements grow (history, configurability, composition, parallelism). Knowing when the extra structure pays for itself is the real skill.

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.

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.

Pythonrpn_declarative.py
# 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
Notice: there is no explicit stack here — the recursion's call stack is the stack. This closes the loop back to Session 6 (recursion) and the call-stack demo: the imperative loop with a list and the recursive parser are the same computation, one with an explicit data structure and one with the runtime's own frames.
Further exercises

8 · References

Standard documentation and companions for the ideas used above; cross-referenced to the sessions where each is introduced.