Refactoring: change the structure, keep the behaviour
Refactoring improves code without changing what it does. The safety net is a set of tests that pin the current behaviour first; then small steps (extract a function, replace a chain of ifs with a table, remove duplication), checking after each.
- Write characterization tests that record what code does now, before changing it
- Replace if/elif chains with a lookup table and extract duplicated logic into a helper
- Prove a refactor kept the behaviour by comparing old and new over many inputs
A refactor must not change behaviour, including the odd parts users rely on. So first pin the current behaviour with characterization tests: call the old code with representative inputs (normal, boundaries, odd cases) and record its answers as expected values. Then change the structure in small steps, re-running the tests after each. Common steps: extract a function for a named idea, replace an if/elif chain with a table when the branches only differ in data, and remove duplication by moving the shared part into one helper.
Run each example to see what it prints. Running examples doesn't count toward progress.
def shipping_old(zone, weight_kg): if zone == "domestic": if weight_kg <= 1: return 4.99 else: return 4.99 + (weight_kg - 1) * 1.5 elif zone == "canada": if weight_kg <= 1: return 12.5 else: return 12.5 + (weight_kg - 1) * 3.0 elif zone == "international": if weight_kg <= 1: return 24.0 else: return 24.0 + (weight_kg - 1) * 6.0 else: raise ValueError(f"unknown zone {zone!r}") RATES = {"domestic": (4.99, 1.5), "canada": (12.5, 3.0), "international": (24.0, 6.0)} def shipping_new(zone, weight_kg): if zone not in RATES: raise ValueError(f"unknown zone {zone!r}") base, per_kg = RATES[zone] return base + max(0, weight_kg - 1) * per_kg cases = [(zone, w) for zone in RATES for w in [0.2, 1, 1.5, 3, 12.75]] print(all(shipping_old(z, w) == shipping_new(z, w) for z, w in cases), len(cases)) for function in (shipping_old, shipping_new): try: function("mars", 1) except ValueError as error: print(function.__name__, error)
The second classic is duplication: two functions that validate an email the same way will drift apart the first time someone fixes one of them. Extract the shared part into a helper with a name that says what it does, and call it from both.
def clean_email(raw): email = raw.strip().lower() if email.count("@") != 1 or email.startswith("@") or email.endswith("@"): raise ValueError(f"invalid email {raw!r}") return email def register(customers, raw_email, name): email = clean_email(raw_email) customers[email] = {"name": name} return email def change_email(customers, old, raw_new): new = clean_email(raw_new) customers[new] = customers.pop(old) return new customers = {} print(register(customers, " Nora_Garcia@Fastmail.com ", "Nora")) print(change_email(customers, "nora_garcia@fastmail.com", "nora@runtime.market")) print(customers) try: register(customers, "jamal.walker62", "Jamal") except ValueError as error: print(error)
When to refactor: before adding a feature to code that is hard to change, and when you see the same change being made in several places. Mistakes to avoid: refactoring without tests; mixing a refactor and a behaviour change in one step (when a test fails, you can't tell which one broke it); "fixing" odd behaviour silently during a refactor (change behaviour deliberately, in a separate step, with its own test); and big-bang rewrites.
- Pin the current behaviour with characterization tests before changing structure.
- Small steps: extract functions, replace data-only if/elif chains with tables, remove duplication.
- Prove equality on many inputs; keep behaviour changes in separate steps.
- Next: testing the modules together, through the real entry point.