Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Low-Level Design (Object-Oriented Design) Module 4 – Design principles: SOLID and beyond

Coupling, cohesion and information hiding

How to judge a design by what a change costs: coupling, cohesion, Parnas's information-hiding criterion, and two changes measured in two designs.

  • Intermediate
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7 and Node.js 24.21.0
  • By MySmartCoPilot

What you will learn

  • Recognise the kinds of coupling and cohesion in real Python code
  • Apply Parnas's criterion by hiding each design decision that is likely to change
  • Separate concerns so that a change request touches one module
  • Measure how far a change spreads by diffing two versions of a design
On this page

A design is not judged when it is first written. It is judged by the changes it meets later: a new fare rule, a new input format, a second kind of report. Two measures from the 1970s, coupling and cohesion, and one criterion, information hiding, tell you before those changes arrive which of them will be cheap. Everything else in this module, the SOLID principles included, builds on these three ideas.

Coupling: what one module knows about another

In 1974 Wayne Stevens, Glenford Myers and Larry Constantine described coupling, in the paper “Structured design”, as the strength of the association that a connection sets up from one module to another. The more strongly two modules are coupled, the harder each one is to understand, change or fix on its own, and the further a change or a bug can travel. They named three things that decide how strong a connection is:

  1. How complicated the connection is. A call with two plain arguments is simpler than one with a dozen, or one whose meaning you can only guess.
  2. Whether it goes to the module’s interface or to something inside it. Using what a module offers is weaker coupling than reaching for its internal parts.
  3. What travels along it. Passing data is the weakest kind. Passing control, a flag or switch that tells the other module what to do, is stronger. Changing another module’s code is the strongest of all.

Here is what each kind looks like in Python, from the loosest to the tightest:

  • Data: you pass only the values the callee needs, such as fare_for(zones=3, peak=True).
  • Control: you pass a flag that picks what the callee does, such as load(path, parse=True). The 1974 paper’s fix still works: two functions, load_text(path) and parse(text), and no flag.
  • Common environment: several modules read and write the same module-level dictionary or global variable. Each one is then coupled to every other one, even to those it never calls.
  • Internals: one module uses a part that another one never offered, such as stock._items. In Python a leading underscore marks a name as non-public: an implementation detail that may change without notice. The language does not stop you from using it; only the convention does.
  • Changing code: one module replaces a function of another at run time (monkeypatching). The changed module now behaves in a way its own source no longer shows.

Reaching past the interface

StockV1 and StockV2 below keep the same public methods, codes() and units(), but StockV2 stores every delivery separately. Two reorder reports use them: one asks the public methods, the other reads the dictionary behind the underscore.

A report coupled to another class's internals Python · internals/stock_report.py
"""Two reorder reports: one reads Stock's non-public dict, one asks Stock's public methods.

StockV2 changes how units are stored (one entry per delivery) and keeps the same public methods.
"""


class StockV1:
    """Units on hand per product code, one number each."""

    def __init__(self):
        self._items = {}

    def receive(self, code, units):
        self._items[code] = self._items.get(code, 0) + units

    def units(self, code):
        return self._items.get(code, 0)

    def codes(self):
        return sorted(self._items)


class StockV2:
    """Keeps every delivery separately, so the oldest units can be sold first."""

    def __init__(self):
        self._items = {}

    def receive(self, code, units):
        self._items.setdefault(code, []).append(units)

    def units(self, code):
        return sum(self._items.get(code, []))

    def codes(self):
        return sorted(self._items)


def reorder_by_peeking(stock, minimum):
    """Coupled to how Stock stores its units: it reads the dict behind the underscore."""
    return [code for code, units in sorted(stock._items.items()) if units < minimum]


def reorder_by_asking(stock, minimum):
    """Coupled only to what Stock promises: codes() and units()."""
    return [code for code in stock.codes() if stock.units(code) < minimum]


for stock_class in (StockV1, StockV2):
    stock = stock_class()
    stock.receive("rice-5kg", 4)
    stock.receive("tea-250g", 30)
    stock.receive("rice-5kg", 3)
    print(f"{stock_class.__name__}: rice-5kg has {stock.units('rice-5kg')} units")
    print("  asking: ", reorder_by_asking(stock, 10))
    try:
        print("  peeking:", reorder_by_peeking(stock, 10))
    except TypeError as error:
        print("  peeking: TypeError:", error)

Output

StockV1: rice-5kg has 7 units
  asking:  ['rice-5kg']
  peeking: ['rice-5kg']
StockV2: rice-5kg has 7 units
  asking:  ['rice-5kg']
  peeking: TypeError: '<' not supported between instances of 'list' and 'int'

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stock_report.py

Both classes answer units("rice-5kg") with 7, so any test of Stock itself passes before and after the change. The report that peeked breaks anyway, because it depended on a decision Stock never promised to keep: one number per product. Here the mistake is loud, a TypeError. It can be quiet instead. Suppose a later version keeps the shelf count in _items and subtracts units reserved for open orders in units(): the peeking report keeps running, reads the shelf count and reorders too late.

Common mistake

Making an attribute public, or adding a getter for every field, does not remove this coupling; it only makes it official. A caller that receives the dictionary still depends on how Stock stores units. Offer the question the caller needs answered, such as units(code) or below(minimum), and keep the storage behind it.

Cohesion: how well a module’s parts belong together

The same paper measured the other side, how strongly the parts inside one module belong together. It called this binding; today the word is cohesion. Its scale runs from weakest to strongest:

  1. Coincidental: the parts only share a file, like a utils.py holding slugify(), send_sms() and tax_rate().
  2. Logical: the parts do the same category of thing and a flag picks one, like an export(data, kind) that writes CSV, PDF or an SMS. Its callers are control-coupled to it.
  3. Temporal: the parts run at the same time and are otherwise unrelated, like an on_startup() that opens the log file, warms a price cache and checks a licence key.
  4. Communicational: the parts work on the same data, like functions that validate, total and archive one order.
  5. Sequential: the output of one part is the input of the next, like parse, then validate, then price.
  6. Functional: every part serves one well-defined job, like fare_for(trip).

The authors stress that the steps are not evenly spaced: functional cohesion is far ahead of every other kind, while coincidental and logical cohesion lag far behind the rest. High cohesion and low coupling are two views of one goal. Every relationship that you keep inside a module is one that does not have to cross between modules.

Information hiding: Parnas’s criterion

Coupling and cohesion tell you whether a split is good. They do not tell you where to cut. In 1972 David Parnas answered that question with a small program that builds a KWIC (keyword in context) index: it reads lines of text, makes every circular shift of each line and prints them all in alphabetical order. He compared two ways of dividing it into modules. He treated a module as a responsibility assignment, a piece of work that a separate team can own, rather than as a subroutine.

  • The first made each major step of the processing a module: input, circular shift, alphabetizing and output, the boxes of a flowchart. The modules exchanged tables whose format all of them had to know.
  • The second used information hiding as its criterion. Each module knew one design decision and kept it from all the others, behind an interface that gave away as little as it could.

Parnas then listed decisions that were likely to change, such as the input format, keeping every line in memory, packing four characters into each machine word, and keeping an index of the shifts instead of the shifts themselves. A change to the second or the third of these, where the lines are kept or how they are packed, would touch every module of the first design and only one module of the second. His conclusion is the criterion this track uses:

We propose instead that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others.

The modules no longer match the steps of the processing, and that is the point.

Measuring a change in two designs

Here is the same idea on a program of today’s size. It prints a statement for each bus pass from a trip log. The first design has one module per processing step, and every step knows the layout of a row.

Design 1: one module per processing step

fares/steps/v1/main.py

from pathlib import Path

from price import price
from read_log import read_log
from statement import statement

log = Path(__file__).with_name("log.txt").read_text()
print(statement(price(read_log(log))))

Output

Card C17
  08:10 Market to Airport, 14 km: 25
  18:40 Airport to Museum, 11 km: 25
  Total: 50
Card C42
  09:05 Market to Museum, 4 km: 10
  17:15 Museum to Market, 4 km: 10
  Total: 20

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py

fares/steps/v1/read_log.py

def read_log(text):
    """Step 1: each line "card;from;to;start" becomes a row [card, from, to, start]."""
    return [line.split(";") for line in text.strip().splitlines()]

fares/steps/v1/price.py

KM = {("Market", "Museum"): 4, ("Market", "Airport"): 14, ("Museum", "Airport"): 11}


def price(rows):
    """Step 2: append the distance and the fare, so each row is [card, from, to, start, km, fare]."""
    for row in rows:
        km = KM.get((row[1], row[2])) or KM[(row[2], row[1])]
        fare = 10 if km <= 5 else 18 if km <= 10 else 25
        row.extend([km, fare])
    return rows

fares/steps/v1/statement.py

def statement(rows):
    """Step 3: one block per card, read from the positions that steps 1 and 2 chose."""
    out = []
    for card in sorted({row[0] for row in rows}):
        mine = [row for row in rows if row[0] == card]
        out.append(f"Card {card}")
        for row in mine:
            out.append(f"  {row[3]} {row[1]} to {row[2]}, {row[4]} km: {row[5]}")
        out.append(f"  Total: {sum(row[5] for row in mine)}")
    return "\n".join(out)

fares/steps/v1/log.txt

C17;Market;Airport;08:10
C42;Market;Museum;09:05
C42;Museum;Market;17:15
C17;Airport;Museum;18:40

The second design prints the same statements. Its modules each hide one decision: trips.py the format of the log, fares.py the fare rule and statement.py the layout. The statement asks price(trip) for an amount and a short text that explains it, so it never learns how a fare is worked out.

Design 2: one module per hidden decision

fares/decisions/v1/main.py

from pathlib import Path

from fares import price
from statement import statement
from trips import read_trips

log = Path(__file__).with_name("log.txt").read_text()
print(statement(read_trips(log), price))

Output

Card C17
  08:10 Market to Airport, 14 km: 25
  18:40 Airport to Museum, 11 km: 25
  Total: 50
Card C42
  09:05 Market to Museum, 4 km: 10
  17:15 Museum to Market, 4 km: 10
  Total: 20

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 main.py

fares/decisions/v1/trips.py

"""Hides the format of the trip log. Other modules see Trip objects only."""
from dataclasses import dataclass


@dataclass(frozen=True)
class Trip:
    card: str
    origin: str
    destination: str
    start: str


def read_trips(text):
    trips = []
    for line in text.strip().splitlines():
        card, origin, destination, start = line.split(";")
        trips.append(Trip(card, origin, destination, start))
    return trips

fares/decisions/v1/fares.py

"""Hides the fare rule. Other modules call price(trip) and get (amount, basis)."""

KM = {("Market", "Museum"): 4, ("Market", "Airport"): 14, ("Museum", "Airport"): 11}


def price(trip):
    km = KM.get((trip.origin, trip.destination)) or KM[(trip.destination, trip.origin)]
    amount = 10 if km <= 5 else 18 if km <= 10 else 25
    return amount, f"{km} km"

fares/decisions/v1/statement.py

"""Hides the layout of a statement. It knows Trip's fields and what price() returns."""


def statement(trips, price):
    out = []
    for card in sorted({trip.card for trip in trips}):
        out.append(f"Card {card}")
        total = 0
        for trip in (t for t in trips if t.card == card):
            amount, basis = price(trip)
            total += amount
            out.append(f"  {trip.start} {trip.origin} to {trip.destination}, {basis}: {amount}")
        out.append(f"  Total: {total}")
    return "\n".join(out)

fares/decisions/v1/log.txt

C17;Market;Airport;08:10
C42;Market;Museum;09:05
C42;Museum;Market;17:15
C17;Airport;Museum;18:40
Two designs of a fare statement: three step modules that share one row layout, and three modules that each hide one decision.1. By processing steps2. By hidden decisionsread_log.pylines become rowsprice.pyappends km and farestatement.pyprints rows by positionShared by all three:the row layout[card, from, to, start, km, fare]statement.pyhides the layouttrips.pyhides the log formatgives Trip objectsfares.pyhides the fare ruleprice(trip)writesextendsreadsuses Tripcalls price()

The same program, decomposed two ways

Text description of the diagram

The diagram shows two designs of one program that prints bus-fare statements from a trip log.

  1. By processing steps: read_log.py turns lines into rows, price.py appends the distance and the fare to each row, and statement.py prints the rows. All three depend on one shared decision, the row layout [card, from, to, start, km, fare]: read_log.py writes it, price.py extends it and statement.py reads its fields by position.
  2. By hidden decisions: trips.py hides the format of the log and hands out Trip objects, fares.py hides the fare rule behind price(trip), and statement.py hides the layout of a statement. statement.py uses Trip objects and calls price(), and knows nothing else about the other two modules.

Both designs print the same statements; they differ in which modules a change has to touch.

Two change requests then arrive. First, the trip log will come as CSV with a header row. Second, fares will depend on the number of zones a trip travels in, not on kilometres. Each design was changed in the smallest way that keeps it correct, and every version was saved in its own folder. This program checks that both designs still print the same statements and uses difflib from the standard library to count what each change touched:

How far each change spread Python · fares/measure_change.py
"""Measures how much of each design two change requests touch, and checks that both designs print the same.

Each design has three versions in its folder: v1, v2 (the log arrives as CSV) and v3 (fares by zones).
"""
import contextlib
import difflib
import io
import runpy
import sys
from pathlib import Path

HERE = Path(__file__).parent
DESIGNS = {"steps": "by processing steps", "decisions": "by hidden decisions"}
CHANGES = [
    ("v1", "v2", "Change 1: the trip log arrives as CSV with a header row"),
    ("v2", "v3", "Change 2: fares by zones travelled instead of kilometres"),
]


def run(folder):
    """What folder/main.py prints. Its modules are imported fresh, because every version has the same names."""
    for module in folder.glob("*.py"):
        sys.modules.pop(module.stem, None)
    sys.path.insert(0, str(folder))
    try:
        printed = io.StringIO()
        with contextlib.redirect_stdout(printed):
            runpy.run_path(str(folder / "main.py"), run_name="__main__")
        return printed.getvalue()
    finally:
        sys.path.remove(str(folder))


def edits(old, new):
    """{file name: (lines added, lines removed, diff)} of the .py files that differ (blank lines not counted)."""
    out = {}
    for path in sorted(old.glob("*.py")):
        before = path.read_text().splitlines()
        after = (new / path.name).read_text().splitlines()
        names = (f"{old.name}/{path.name}", f"{new.name}/{path.name}")
        diff = list(difflib.unified_diff(before, after, *names, n=0, lineterm=""))
        if diff:
            changed = [line for line in diff[2:] if not line.startswith("@@") and line[1:].strip()]
            added = sum(line.startswith("+") for line in changed)
            out[path.name] = (added, len(changed) - added, diff)
    return out


sys.dont_write_bytecode = True
for version in ("v1", "v2", "v3"):
    same = run(HERE / "steps" / version) == run(HERE / "decisions" / version)
    print(f"{version}: both designs print the same statements: {'yes' if same else 'NO'}")

for old, new, title in CHANGES:
    print(f"\n{title}")
    for design, label in DESIGNS.items():
        changed = edits(HERE / design / old, HERE / design / new)
        added = sum(a for a, _, _ in changed.values())
        removed = sum(r for _, r, _ in changed.values())
        count = f"{len(changed)} file" + ("s" if len(changed) != 1 else "")
        print(f"  {label}: {count} ({', '.join(changed)}), +{added} -{removed} lines")

print("\nThe edits of change 2:")
for design, label in DESIGNS.items():
    print(f"\n[{label}]")
    for _, _, diff in edits(HERE / design / "v2", HERE / design / "v3").values():
        print("\n".join(diff))

Output

v1: both designs print the same statements: yes
v2: both designs print the same statements: yes
v3: both designs print the same statements: yes

Change 1: the trip log arrives as CSV with a header row
  by processing steps: 1 file (read_log.py), +5 -2 lines
  by hidden decisions: 1 file (trips.py), +4 -5 lines

Change 2: fares by zones travelled instead of kilometres
  by processing steps: 2 files (price.py, statement.py), +7 -6 lines
  by hidden decisions: 1 file (fares.py), +5 -4 lines

The edits of change 2:

[by processing steps]
--- v2/price.py
+++ v3/price.py
@@ -1 +1,2 @@
-KM = {("Market", "Museum"): 4, ("Market", "Airport"): 14, ("Museum", "Airport"): 11}
+ZONE = {"Market": 1, "Museum": 1, "Airport": 3}
+FARE = {1: 10, 2: 16, 3: 22}
@@ -5 +6 @@
-    """Step 2: append the distance and the fare, so each row is [card, from, to, start, km, fare]."""
+    """Step 2: append the zones travelled and the fare, so each row is [card, from, to, start, zones, fare]."""
@@ -7,3 +8,2 @@
-        km = KM.get((row[1], row[2])) or KM[(row[2], row[1])]
-        fare = 10 if km <= 5 else 18 if km <= 10 else 25
-        row.extend([km, fare])
+        zones = abs(ZONE[row[1]] - ZONE[row[2]]) + 1
+        row.extend([zones, FARE[zones]])
--- v2/statement.py
+++ v3/statement.py
@@ -8 +8,2 @@
-            out.append(f"  {row[3]} {row[1]} to {row[2]}, {row[4]} km: {row[5]}")
+            unit = "zone" if row[4] == 1 else "zones"
+            out.append(f"  {row[3]} {row[1]} to {row[2]}, {row[4]} {unit}: {row[5]}")

[by hidden decisions]
--- v2/fares.py
+++ v3/fares.py
@@ -3 +3,2 @@
-KM = {("Market", "Museum"): 4, ("Market", "Airport"): 14, ("Museum", "Airport"): 11}
+ZONE = {"Market": 1, "Museum": 1, "Airport": 3}
+FARE = {1: 10, 2: 16, 3: 22}
@@ -7,3 +8,3 @@
-    km = KM.get((trip.origin, trip.destination)) or KM[(trip.destination, trip.origin)]
-    amount = 10 if km <= 5 else 18 if km <= 10 else 25
-    return amount, f"{km} km"
+    zones = abs(ZONE[trip.origin] - ZONE[trip.destination]) + 1
+    basis = "1 zone" if zones == 1 else f"{zones} zones"
+    return FARE[zones], basis

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure_change.py

The first change cost each design one file, because both had kept the reading of the log in one place. The second cost the step design two files and the other design one. The step design had told statement.py that the fifth field of a row is a distance in kilometres, so a new fare rule had to edit the layout code as well; even the docstring of price.py had to change, because it describes the row. In the second design the fare rule was one module’s secret, and the statement printed whatever explanation price() gave it.

The difference here is one file, because the program has three modules. It grows with every module that shares a decision: a monthly summary, a refund report and an export would each have read the same row layout. That is what Parnas showed, too. Information hiding does not make every change cheaper, only changes to the decisions you hid.

Text Diff Paste two versions of a file and see every changed line, the comparison measure_change.py prints above.

Separation of concerns

Edsger Dijkstra wrote about “the separation of concerns” in an essay of 1974: give your full attention to one aspect of a problem at a time, on its own terms, while remembering that it is only one aspect. In a design, the usual concerns are reading the input, the business rules, storage and presentation. Give each its own module, and a change in one, such as CSV instead of semicolons or a new statement layout, does not need you to read the others.

Fewer connections, not more classes

Lower coupling is about the connections between modules, not about how many classes you have. Splitting a class in three while all three still read and write one shared dictionary leaves the coupling where it was, now spread over three files. What lowers it:

  • Narrower interfaces: a method that answers the caller’s question, such as units(code), instead of the data needed to work the answer out.
  • Fewer shared data structures: a value object such as Trip, passed along, instead of a row whose layout every step must know.
  • Data instead of control: two functions instead of one function and a flag.
  • One home per decision: a module of plain functions hides a decision as well as a class does. fares.py above has no class at all.

In a design interview

When you present your classes, name the decisions you expect to change (pricing rules, input format, storage, notification channels) and say which class hides each one. That is a quick way to show a reviewer you designed for change, and a test of your design: if a likely change touches three of your classes, look for the decision that they share.

Python Online Compiler Copy a design's files into the runner and try your own change requests on it.

Key takeaways

  • Coupling is how strongly a connection ties two modules together. It grows with a connection’s complexity, with reaching past the interface, and from data to control to changing code.
  • Cohesion (the 1974 paper’s binding) runs from coincidental to functional; aim for modules whose parts serve one job, and avoid grab-bag modules.
  • Parnas’s criterion: list the difficult decisions and those likely to change, and give each module one of them to hide. A Python name with a leading underscore is a decision the class has not promised to keep.
  • Measure a design by the changes it meets: the same change cost the step design two files and the hiding design one, and the gap widens as more modules share a decision.

Exercise

Exercise · Medium · Python

Make the report's output format swappable

AttendanceReport in attendance.py counts a gym's visits per member and builds the text of the weekly report in the same loop. The gym now wants the same numbers as JSON for its app, and more formats will follow. Today every new format means editing the counting code. Refactor it so that the layout is one object you can swap, and counting no longer knows how the result is shown:

  • TextFormatter().format(rows, total) returns today's text layout, shown below. rows is a list of (name, visits) pairs and total is a whole number.
  • AttendanceReport(visits, formatter=None) takes the list of visits (one member name per visit) and a formatter. Without one it uses a TextFormatter, so existing callers get exactly what they get today.
  • report.rows() returns the (name, visits) pairs, most visits first and then by name; report.total() returns the number of visits.
  • report.render() hands the rows and the total to the formatter and returns what the formatter returns.

For the visits ["Ravi", "Asha", "Meena", "Asha", "Ravi", "Asha"] the text is:

Visits this week
  Asha     3
  Ravi     2
  Meena    1
Total: 6

The sample tests check today's text, call rows() and total(), and pass in formatters of their own, one that writes JSON and one that records what it was given.

Starter code · attendance.py

class AttendanceReport:
    """Counts a gym's visits per member and lays out the weekly report, all in one loop."""

    def __init__(self, visits):
        self.visits = visits  # one member name per visit

    def render(self):
        counts = {}
        for name in self.visits:
            counts[name] = counts.get(name, 0) + 1
        lines = ["Visits this week"]
        total = 0
        for name, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])):
            lines.append(f"  {name:<8} {count}")
            total += count
        lines.append(f"Total: {total}")
        return "\n".join(lines)
The sample tests · test_attendance.py
import json

from attendance import AttendanceReport, TextFormatter

VISITS = ["Ravi", "Asha", "Meena", "Asha", "Ravi", "Asha"]
TODAY = "Visits this week\n  Asha     3\n  Ravi     2\n  Meena    1\nTotal: 6"


class JsonFormatter:
    def format(self, rows, total):
        return json.dumps({"rows": [{"name": n, "visits": v} for n, v in rows], "total": total})


class RecordingFormatter:
    def __init__(self):
        self.calls = []

    def format(self, rows, total):
        self.calls.append((rows, total))
        return "recorded"


def test_text_unchanged():
    """prints today's text when no formatter is given"""
    assert AttendanceReport(VISITS).render() == TODAY


def test_rows_and_total():
    """counts visits per member, most visits first, then by name"""
    report = AttendanceReport(VISITS + ["Meena"])
    assert report.rows() == [("Asha", 3), ("Meena", 2), ("Ravi", 2)]
    assert report.total() == 7


def test_json_formatter():
    """uses a formatter passed to the constructor"""
    out = AttendanceReport(VISITS, formatter=JsonFormatter()).render()
    rows = [{"name": "Asha", "visits": 3}, {"name": "Ravi", "visits": 2}, {"name": "Meena", "visits": 1}]
    assert json.loads(out) == {"rows": rows, "total": 6}


def test_formatter_gets_numbers():
    """hands the formatter numbers, not text that is already laid out"""
    recorder = RecordingFormatter()
    assert AttendanceReport(["Ravi", "Ravi"], formatter=recorder).render() == "recorded"
    assert recorder.calls == [([("Ravi", 2)], 2)]


def test_text_formatter_alone():
    """TextFormatter lays out rows without a report"""
    assert TextFormatter().format([("Zoya", 4), ("Ira", 1)], 5) == "Visits this week\n  Zoya     4\n  Ira      1\nTotal: 5"


def test_empty_week():
    """an empty week still prints the heading and a zero total"""
    assert AttendanceReport([]).render() == "Visits this week\nTotal: 0"
A hint

Split render() along its two jobs. Move the counting and sorting into rows() (the sorted(...) call can stay as it is) and the lines of text into TextFormatter.format(). What is left of render() is one line: return self.formatter.format(self.rows(), self.total()).

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

7 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 7 What kind of coupling does the parse argument create between load_orders() and the code that calls it?

    Read the code, then choose one answer.

    from pathlib import Path
    
    
    def load_orders(path, parse):
        text = Path(path).read_text()
        if parse:
            return [line.split(",") for line in text.splitlines()]
        return text
    Show the answer to question 1

    Answer: Control coupling: the caller passes a flag that decides what the function does

    parse does not carry data the function works on; it chooses which of two jobs the function does, so every caller must know how the function is organised inside. The usual fix is two functions, one that reads the text and one that parses it, and no flag.

  2. Question 2 of 7 Which criterion did Parnas propose for dividing a system into modules?

    Choose one answer.

    Show the answer to question 2

    Answer: List the difficult design decisions and those likely to change, and design each module to hide one of them

    Parnas showed that a decomposition by processing steps (the flowchart) spreads design decisions such as data formats across many modules. He proposed starting from the decisions that are hard or likely to change and giving each module one decision to hide behind an interface that reveals as little as possible.

  3. Question 3 of 7 on_startup() opens the log file, warms a price cache and checks a licence key. Which kind of cohesion does it have?

    Choose one answer.

    Show the answer to question 3

    Answer: Temporal

    The three jobs belong together only because they run at the same moment. They use different data and none feeds the next, so the function is temporally cohesive, near the weak end of the scale.

  4. Question 4 of 7 Put the kinds of cohesion (binding) from the 1974 paper in order, from the weakest to the strongest.

    Give each item its position, from 1 (first).

    Show the answer to question 4

    Answer:

    1. Coincidental
    2. Logical
    3. Temporal
    4. Communicational
    5. Sequential
    6. Functional

    Coincidental parts only share a module; logical parts do the same category of thing; temporal parts run at the same time; communicational parts use the same data; in sequential parts one feeds the next; functional parts all serve one job. The paper notes that the scale is not linear: functional is much stronger than the rest.

  5. Question 5 of 7 StockV2 keeps every delivery in a list and offers the same public methods as StockV1. What does stock_report.py print?

    What does this program print? Choose one answer.

    """Two reorder reports: one reads Stock's non-public dict, one asks Stock's public methods.
    
    StockV2 changes how units are stored (one entry per delivery) and keeps the same public methods.
    """
    
    
    class StockV1:
        """Units on hand per product code, one number each."""
    
        def __init__(self):
            self._items = {}
    
        def receive(self, code, units):
            self._items[code] = self._items.get(code, 0) + units
    
        def units(self, code):
            return self._items.get(code, 0)
    
        def codes(self):
            return sorted(self._items)
    
    
    class StockV2:
        """Keeps every delivery separately, so the oldest units can be sold first."""
    
        def __init__(self):
            self._items = {}
    
        def receive(self, code, units):
            self._items.setdefault(code, []).append(units)
    
        def units(self, code):
            return sum(self._items.get(code, []))
    
        def codes(self):
            return sorted(self._items)
    
    
    def reorder_by_peeking(stock, minimum):
        """Coupled to how Stock stores its units: it reads the dict behind the underscore."""
        return [code for code, units in sorted(stock._items.items()) if units < minimum]
    
    
    def reorder_by_asking(stock, minimum):
        """Coupled only to what Stock promises: codes() and units()."""
        return [code for code in stock.codes() if stock.units(code) < minimum]
    
    
    for stock_class in (StockV1, StockV2):
        stock = stock_class()
        stock.receive("rice-5kg", 4)
        stock.receive("tea-250g", 30)
        stock.receive("rice-5kg", 3)
        print(f"{stock_class.__name__}: rice-5kg has {stock.units('rice-5kg')} units")
        print("  asking: ", reorder_by_asking(stock, 10))
        try:
            print("  peeking:", reorder_by_peeking(stock, 10))
        except TypeError as error:
            print("  peeking: TypeError:", error)
    Show the answer to question 5

    Answer: it prints

    StockV1: rice-5kg has 7 units
      asking:  ['rice-5kg']
      peeking: ['rice-5kg']
    StockV2: rice-5kg has 7 units
      asking:  ['rice-5kg']
      peeking: TypeError: '<' not supported between instances of 'list' and 'int'

    units() adds up the deliveries, so both classes report 7 units and the report that asks works with both. The report that peeks compares the list [4, 3] with the number 10 and fails, although StockV2 itself works.

  6. Question 6 of 7 Which changes lower the coupling between a Checkout class and an Inventory class?

    Choose every answer that is right.

    Show the answer to question 6

    Answer:

    • A mode="reserve" argument is replaced by two methods, reserve() and release()
    • Checkout calls inventory.reserve(code, units) instead of editing inventory._levels
    • Checkout passes only the product code and the number of units, not the whole cart

    Calling a method instead of editing internals, passing only the values needed and replacing a flag with two methods all make the connection narrower or weaker. A shared global couples every module that touches it, and splitting a class while its parts share one dictionary only spreads the coupling over more files.

  7. Question 7 of 7 In Python, what does the leading underscore of stock._items tell code outside the class?

    Choose one answer.

    Show the answer to question 7

    Answer: It is not part of the public interface, an implementation detail that may change without notice

    Python has no private attributes. A leading underscore is a convention, described in the Python tutorial and in PEP 8, that marks a name as non-public. Code that uses it couples itself to a decision the class has not promised to keep.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.