Modular Design & Object-Oriented Programming

COMP 536 | Python Fundamentals

Dr. Anna Rosen

2026-04-22

Learning Objectives

By the end of this lecture, you will be able to:

  • Explain why modular design prevents costly software failures
  • Apply the function contract pattern (preconditions, postconditions, validation)
  • Identify when to use classes vs functions for scientific code
  • Build classes with properties that enforce physical constraints
  • Debug the top 3 OOP errors that waste hours of your time

Part 0: Why This Matters

The $370 Million Bug

Ariane 5, June 4, 1996

  • Rocket self-destructs 37 seconds after launch
  • Destroys 4 satellites worth $370M
  • Root cause: one function, wrong assumption
# What they had (conceptually):
def convert_velocity(velocity_64bit):
    return int(velocity_64bit)
    # Assumes fits in 16 bits!

# What they needed:
def convert_velocity_safe(velocity):
    MAX_INT16 = 32767
    if velocity > MAX_INT16:
        raise ValueError(
            f"Overflow: {velocity}"
        )
    return int(velocity)

A function converted 64-bit velocity to 16-bit integer.

Ariane 4: velocity never exceeded 32,767

Ariane 5: faster rocket \(\to\) overflow \(\to\) crash

The Lesson

Design first. Validate always.

This lecture: how to write code you can trust

Why Not Jupyter Notebooks?

Five dangers that make notebooks unsuitable for serious work:

  1. Hidden state - Variables persist after cells deleted
  2. Non-linear execution - Run cells out of order = wrong results
  3. Version control fails - JSON diffs are unreadable
  4. Cannot test - No way to run automated tests
  5. Cannot reuse - Can’t import notebook functions elsewhere

The Hidden State Trap

# Imagine running these cells out of order...

# Cell 1: Define calibration
calibration_factor = 1.02

# Cell 2: Apply calibration
raw_measurement = 100.0
calibrated = raw_measurement * calibration_factor

# Cell 3: Oops, change calibration (but forget to re-run Cell 2!)
calibration_factor = 0.98

# Cell 4: Report results
print(f"Calibrated value: {calibrated}")  # Still uses OLD calibration!
print(f"Current factor: {calibration_factor}")  # Shows NEW value
Calibrated value: 102.0
Current factor: 0.98

The code lies to you. The displayed factor doesn’t match the computed result.

The Alternative: Scripts + IPython

Notebooks Scripts + IPython
Hidden state Fresh state each run
Non-linear execution Top-to-bottom always
Can’t version control Clean git diffs
Can’t test pytest works
Can’t reuse import mymodule

This lecture: How to structure that script-based code

Part 1: Functions as Contracts

Functions Are Promises

Every well-designed function is a contract:

Contract Element Question It Answers
Preconditions What must be true before calling?
Postconditions What will be true after calling?
Invariants What stays unchanged?
Side effects What else happens?

A function that doesn’t document its contract is a function waiting to betray you.

What Is a Contract?

In software, a (function) contract is an explicit agreement between a caller and a function about correct use and guaranteed behavior.

  • Preconditions: what the caller must provide (valid types, ranges, units, shapes)
  • Postconditions: what the function guarantees on success (return value meaning, units, shape, accuracy)
  • Failure mode: what happens when preconditions aren’t met (errors raised, messages)
  • Invariants / side effects: what must not change, and what else the function touches (files, globals, randomness)

Contracts matter because they make bugs loud and early, make tests obvious, and make code safe to reuse without re-reading the implementation.

Example: Kinetic Energy with Contract

def kinetic_energy_cgs(mass_g, velocity_cms):
    """
    Calculate kinetic energy in ergs.

    Parameters:
        mass_g: mass in grams (must be positive)
        velocity_cms: velocity in cm/s

    Returns:
        energy in ergs (g*cm^2/s^2)
    """
    # PRECONDITION: mass must be positive
    if mass_g <= 0:
        raise ValueError(f"mass must be positive, got {mass_g}")

    # COMPUTATION: KE = (1/2)mv^2
    energy_ergs = 0.5 * mass_g * velocity_cms**2
    return energy_ergs

# Test it
print(f"Electron at 1% c: {kinetic_energy_cgs(9.109e-28, 2.998e8):.2e} erg")
Electron at 1% c: 4.09e-11 erg

Mutable vs Immutable (Python)

In Python, an object is mutable if it can be changed in place after it’s created. An object is immutable if it can’t; you can only create a new value and rebind the name.

  • Mutable: list, dict, set (methods like .append() / .update() modify the same object)
  • Immutable: int, float, bool, str, tuple (operations produce a new value)

This matters because default function arguments are created once, so a mutable default can silently accumulate changes across calls.

The Mutable Default Trap

One of Python’s most infamous gotchas:

# WRONG - This creates a SHARED list!
def add_measurement_buggy(value, data=[]):
    data.append(value)
    return data

# Watch the disaster
day1 = add_measurement_buggy(23.5)
print(f"Day 1: {day1}")

day2 = add_measurement_buggy(24.1)  # Fresh list? Nope!
print(f"Day 2: {day2}")  # Contains BOTH days!

print(f"Same object? {day1 is day2}")  # True!
Day 1: [23.5]
Day 2: [23.5, 24.1]
Same object? True

The Fix: None Sentinel Pattern

# CORRECT - New list each time
def add_measurement_fixed(value, data=None):
    if data is None:
        data = []  # Fresh list!
    data.append(value)
    return data

# Now it works
day1 = add_measurement_fixed(23.5)
day2 = add_measurement_fixed(24.1)

print(f"Day 1: {day1}")
print(f"Day 2: {day2}")
print(f"Same object? {day1 is day2}")  # False!
Day 1: [23.5]
Day 2: [24.1]
Same object? False

Rule: Always use None as default for mutable arguments (lists, dicts, sets).

Name Lookup and Scope

When Python evaluates a variable name, it searches a sequence of scopes — places where names can live. That search rule is called LEGB:

  • Local: inside the current function
  • Enclosing: inside any outer function (nested functions)
  • Global: at the module (file) level
  • Built-in: names like len, print, range

Concept Check: LEGB Scope

What does this code print?

counter = 0

def increment():
    counter += 1
    return counter

result = increment()
print(result)

Answer: Raises UnboundLocalError!

Python sees the assignment counter += 1, assumes counter is local, but can’t find a local value to increment.

LEGB: How Python Finds Variables

┌─────────────────────────────────────────┐
│  Built-in (print, len, range, ...)      │
│  ┌─────────────────────────────────┐    │
│  │  Global (module level)          │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │  Enclosing (outer func) │    │    │
│  │  │  ┌─────────────────┐    │    │    │
│  │  │  │  Local (inner)  │ <-──Search starts here
│  │  │  └─────────────────┘    │    │    │
│  │  └─────────────────────────┘    │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

Key insight: Assignment to a name makes it local to that scope.

Module Organization: The Main Guard

# physics_cgs.py

SPEED_OF_LIGHT = 2.998e10  # cm/s

def kinetic_energy(mass_g, velocity_cms):
    """Calculate KE in ergs."""
    return 0.5 * mass_g * velocity_cms**2

# This block ONLY runs when executed directly
if __name__ == "__main__":
    # Test the module
    print("Testing physics_cgs...")
    ke = kinetic_energy(1.0, 100.0)
    print(f"KE test: {ke} erg")

Why? Module can be both imported and run directly.

Part 2: Why Objects?

The Parallel Lists Problem

Tracking 3 particles with functions alone:

# Separate lists for each property
masses = [1.0, 0.5, 2.0]
positions_x = [0, 10, -5]
positions_y = [0, 5, 3]
velocities_x = [10, -5, 0]
velocities_y = [5, 0, -10]

# Easy to mix up indices!
def update_positions(px, py, vx, vy, dt):
    for i in range(len(px)):
        px[i] += vx[i] * dt
        py[i] += vy[i] * dt
        # Wait, did I use the right index?
        # Is mass[0] paired with position_x[0]?

Problem: Nothing connects related data together.

The Same Problem with Objects

class Particle:
    def __init__(self, mass, x, y, vx, vy):
        self.mass = mass
        self.x, self.y = x, y
        self.vx, self.vy = vx, vy

    def update_position(self, dt):
        self.x += self.vx * dt
        self.y += self.vy * dt

# Each particle is self-contained
particles = [
    Particle(1.0, 0, 0, 10, 5),
    Particle(0.5, 10, 5, -5, 0),
    Particle(2.0, -5, 3, 0, -10)
]

# Clear, intuitive operations
for p in particles:
    p.update_position(dt=0.1)

Objects bundle data with the code that operates on it.

State: What Your Code Remembers

State is the information a program stores that can persist over time and change what happens next.

In an object, state usually lives in instance attributes like self.x, self.y, and self.vx.

  • A stateful design updates stored values (e.g., a Particle moves each timestep by changing self.x and self.y).
  • A stateless function doesn’t remember anything: its output depends only on its inputs (same inputs \(\to\) same output).

This is why classes shine in simulations: the “thing” you’re modeling needs to carry its state forward from one step to the next.

When to Use Classes vs Functions

Use Classes When

  • State persists between calls
  • Data and behavior are inseparable
  • Need to validate constraints
  • Modeling real entities

Examples: Particle, Star, Telescope, Dataset

Use Functions When

  • Operation is stateless
  • Simple input \(\to\) output
  • Logic is generic
  • One-time calculation

Examples: celsius_to_kelvin(), orbital_period()

The Decision Flowchart

Does your code need to remember state between calls?
    │
    ├── NO -> Use a function
    │
    └── YES -> Does the state need validation/protection?
                │
                ├── NO -> Maybe a dict is enough
                │
                └── YES -> Use a class with properties

Rule of Thumb

If you’re passing the same 5 variables to every function, they want to be an object.

Part 3: Building Classes

Anatomy of a Class

class Particle:  # 1. Class definition

    # 2. Class attribute (shared by ALL instances)
    G = 6.674e-8  # CGS gravitational constant

    # 3. Constructor - runs when creating object
    def __init__(self, mass, x, y, vx, vy):
        # 4. Instance attributes (unique to each)
        self.mass = mass
        self.x, self.y = x, y
        self.vx, self.vy = vx, vy

    # 5. Instance method
    def update_position(self, dt):
        self.x += self.vx * dt
        self.y += self.vy * dt

    # 6. Property (computed attribute)
    @property
    def kinetic_energy(self):
        return 0.5 * self.mass * (self.vx**2 + self.vy**2)

The self Parameter

The #1 confusion for beginners

You write:

p = Particle(1.0, 0, 0, 10, 5)
p.update_position(0.1)

Python actually does:

p = Particle(1.0, 0, 0, 10, 5)
Particle.update_position(p, 0.1)
#                        ^
#                   p becomes self!

The universal error:

def method():  # WRONG - missing self!
    return 42

TypeError: method() takes 0 positional arguments but 1 was given

Properties: Enforcing Physics

class Temperature:
    """Temperature with automatic validation."""

    def __init__(self, kelvin):
        self.kelvin = kelvin  # Uses setter!

    @property
    def kelvin(self):
        return self._kelvin  # Note underscore!

    @kelvin.setter
    def kelvin(self, value):
        if value < 0:
            raise ValueError("Below absolute zero!")
        self._kelvin = value

    @property
    def celsius(self):
        return self._kelvin - 273.15

# Try it
temp = Temperature(300)
print(f"{temp.kelvin} K = {temp.celsius:.1f} C")
300 K = 26.9 C

Concept Check: Properties

Why does the Temperature class use self._kelvin internally instead of self.kelvin?

A. It’s faster

B. Python requires underscores for private data

C. To avoid infinite recursion in the property

D. Convention only, no technical reason

Answer: C - self.kelvin calls the getter, which returns self.kelvin, which calls the getter… infinite loop!

Special Methods: Making Objects Pythonic

class Vector3D:
    def __init__(self, x, y, z):
        self.x, self.y, self.z = x, y, z

    def __str__(self):
        """For print() - human readable."""
        return f"({self.x}, {self.y}, {self.z})"

    def __add__(self, other):
        """Enable v1 + v2."""
        return Vector3D(self.x + other.x,
                       self.y + other.y,
                       self.z + other.z)

    def __abs__(self):
        """Enable abs(v) for magnitude."""
        return (self.x**2 + self.y**2 + self.z**2)**0.5

# Now vectors work naturally!
v1 = Vector3D(3, 0, 4)
v2 = Vector3D(1, 2, 2)
print(f"v1 = {v1}, magnitude = {abs(v1)}")
print(f"v1 + v2 = {v1 + v2}")
v1 = (3, 0, 4), magnitude = 5.0
v1 + v2 = (4, 2, 6)

Part 4: Common Pitfalls

Pitfall 1: Forgetting self

# WRONG
class BadExample:
    def method():  # Missing self!
        return 42

# CORRECT
class GoodExample:
    def method(self):
        return 42

Error message: TypeError: method() takes 0 positional arguments but 1 was given

Pitfall 2: Mutable Default in init

WRONG

class Observatory:
    def __init__(self, name,
                 telescopes=[]):
        self.telescopes = telescopes
        # All instances share
        # the SAME list!

CORRECT

class Observatory:
    def __init__(self, name,
                 telescopes=None):
        if telescopes is None:
            telescopes = []
        self.telescopes = telescopes

Pitfall 3: Property Recursion

WRONG - Infinite loop!

@property
def value(self):
    return self.value
    # Calls itself forever!

CORRECT - Different name

@property
def value(self):
    return self._value
    # Returns stored value

Rule: Property name and storage name must differ. Convention: _ prefix for storage.

Think-Pair-Share: Design a Star Class

Challenge: Design a Star class for stellar evolution

Think about:

  1. What attributes should a Star have?
  2. What validation is needed?
  3. What methods would be useful?
  4. What should be properties vs methods?

Discuss with a neighbor for 2 minutes!

Part 5: Synthesis

Key Takeaways

  • Functions are contracts - validate inputs, document assumptions
  • Use None for mutable defaults - avoid the shared list trap
  • Classes bundle state + behavior - use when data persists
  • Properties enforce invariants - physics constraints in code
  • Special methods make objects feel Pythonic
  • Design first, code second - prevents $370M disasters

Preview: Why This Matters for NumPy

import numpy as np

# NumPy arrays are OBJECTS with METHODS!
arr = np.array([1, 2, 3, 4, 5])

arr.mean()      # Method call, not mean(arr)
arr.reshape(5, 1)  # Returns new array object
arr.dtype       # Property - no parentheses

# Now you understand WHY this design exists!

Next lecture: Vectorization - making your code 1000x faster

One Thing to Remember

Design first. Validate always.