Grade by Grade
C. Designing Modular Programs · Modular and Object-Oriented Design

Designing for Reuse: Composition, Interfaces and Libraries

C1.4 A2.2 A2.1About 70 minutes
Learning goals
  • I can explain the main principles of reusable design: DRY, generality, low coupling and stable interfaces.
  • I can use composition and delegation instead of inheritance when behaviour needs to be mixed and matched.
  • I can write general functions that take other functions as parameters.
  • I can make a module easier to reuse and test by passing in its dependencies.
  • I can judge whether a piece of code is worth generalizing.

Words to know

reusability
How easily code can be used again, unchanged, in another part of the program or in another program.
DRY
"Don't repeat yourself": each piece of knowledge (a rule, a formula) should live in exactly one place.
composition
Building an object from other objects that it holds as attributes (has-a).
delegation
An object handing a task to one of its component objects, e.g. a Rental asking its pricing object for the price.
interface
A set of methods that any class can provide; code that relies only on the interface works with all of them.
higher-order function
A function that takes another function as a parameter or returns one, like count_if or sorted with key=.
dependency injection
Passing in the objects or functions a module depends on (such as the input function) instead of hard-coding them, so they can be replaced in tests.
strategy
A small object or function that represents one interchangeable way of doing a job, such as one pricing rule.

Write once, use many times

In the last few lessons you wrote functions, modules, classes and hierarchies. Expectation C1.4 asks you to apply the principle of reusability across all of them: in modules, subprograms, classes, methods and inheritance. Reusable code saves time, but more importantly it means a rule is written, tested and fixed in one place.

This lesson collects the design techniques that make code reusable, and adds two powerful new ones: composition with delegation, and passing functions as parameters.

Principles of reusable design

  • DRY (don't repeat yourself). If you copy and paste code, the copies will drift apart. Extract a function, method or superclass.
  • Generality. Make the varying part a parameter. count_if(items, test) is reusable; count_below_freezing(temps) only does one job.
  • High cohesion, low coupling. A module that does one thing and talks only through parameters and return values can be lifted into another program.
  • Stable, documented interfaces. Reusers depend on your function names, parameters and return values. Change the inside freely; change the interface rarely and carefully.
  • No hidden dependencies. A function that secretly reads a global variable, a particular file or the keyboard can only be reused where that global, file or keyboard exists.
  • Do not over-generalize. Code nobody reuses does not need ten options. A common rule of thumb is to generalize when you need the same thing the third time.

Composition over inheritance

Inheritance is one way to reuse code, but it is rigid: a subclass is fixed when you write it. Suppose the rental shop has three item kinds and three pricing policies (standard, student discount, weekly rate). With inheritance alone you might write StudentSkis, WeeklySkis, StudentHelmet and so on: one subclass for every combination.

Composition is more flexible. Give each Rental a pricing object as an attribute. The Rental delegates the price calculation to it: "you work out the price; here are my rate and days". Any object with a price(daily_cents, days) method will do. That shared method is an interface, and each pricing class is a strategy. Many designers summarize this as "favour composition over inheritance": use inheritance for true is-a relationships, and composition to mix and match behaviour.

Inheritance only: one subclass per combinationStandardSkisStudentSkisWeeklySkisStandardBoardStudentBoardWeeklyBoardStandardHelmetStudentHelmetWeeklyHelmet3 kinds × 3 policies = 9 classes(a 4th policy adds 3 more)Composition: combine at run timeSkisBoardHelmetStandardPricingStudentPricingWeeklyPricinghas a3 + 3 = 6 classes(a 4th policy adds 1 more)
Why composition scales. Making every combination a subclass multiplies the number of classes. Giving each item a pricing object adds them instead, and any item can use any policy, even a policy written later.
Worked example

Example 1: Pricing strategies

Write three pricing classes (standard; student 20% off; weekly, where each full week costs 5 days) and a Rental class that holds one of them. Print three orders, then switch the first order to student pricing while the program runs.

  1. Each strategy class has one method with the same signature: price(self, daily_cents, days). That is the shared interface.
  2. StudentPricing uses integer arithmetic, × 80 // 100, so the result stays in whole cents: 10 500 × 80 // 100 = 8400.
  3. WeeklyPricing uses divmod(days, 7), which returns the quotient and remainder together. For 9 days: 1 week and 2 extra days, so 5 + 2 = 7 charged days × 4000 = 28 000.
  4. Rental stores the strategy in self.pricing and its cost() method delegates: return self.pricing.price(self.daily_cents, self.days). Rental contains no pricing rules at all.
  5. The format {...:<16} left-aligns text in a 16-character column, so the prices line up.
  6. Because pricing is just an attribute, it can be replaced at run time: the first order becomes 8400 cents. A subclass could never change like that.

Code and output below. Adding a fourth policy (say, a family pass) is one new class; no existing class changes.

class StandardPricing:
def price(self, daily_cents, days):
return daily_cents * days
class StudentPricing:
"""20% off with a valid student card."""
def price(self, daily_cents, days):
return daily_cents * days * 80 // 100
class WeeklyPricing:
"""Each full week costs 5 days; extra days are at the normal rate."""
def price(self, daily_cents, days):
weeks, extra = divmod(days, 7)
return daily_cents * (weeks * 5 + extra)
class Rental:
def __init__(self, item_name, daily_cents, days, pricing):
self.item_name = item_name
self.daily_cents = daily_cents
self.days = days
self.pricing = pricing # composition: a Rental HAS a pricing policy
def cost(self):
return self.pricing.price(self.daily_cents, self.days) # delegation
orders = [Rental("skis", 3500, 3, StandardPricing()),
Rental("skis", 3500, 3, StudentPricing()),
Rental("snowboard", 4000, 9, WeeklyPricing())]
for r in orders:
print(f"{r.item_name:<10}{type(r.pricing).__name__:<16}${r.cost() / 100:.2f}")
orders[0].pricing = StudentPricing() # change behaviour at run time
print(orders[0].cost())

pricing.py: composition and delegation with three interchangeable strategies.

skis StandardPricing $105.00
skis StudentPricing $84.00
snowboard WeeklyPricing $280.00
8400

Output of the program above (what Python prints).

Rental+daily_cents: int+days: int+pricing+cost(): int« interface »PricingPolicy+price(daily, days)1StandardPricingStudentPricingWeeklyPricingcost() delegates: return self.pricing.price(self.daily_cents, self.days)
A Rental has a pricing policy (composition) and delegates the price calculation to it. Any object with a price(daily, days) method fits; the dashed lines with a hollow triangle mean "implements this interface". In Python the interface does not have to exist as a real class: thanks to duck typing, the shared method is enough.

Functions as parameters

In Python, a function is a value: you can store it in a variable or pass it to another function without calling it (no parentheses). A function that takes a function is a higher-order function. You have used one already: sorted(results, key=by_time) in the decomposition lesson.

Higher-order functions are the lightest form of the strategy idea. The general algorithm ("count the items that pass a test") is written once; the specific part (which test) is passed in. Even a built-in method can be passed: str.istitle tests whether a word starts with a capital letter followed by lower-case letters.

def count_if(items, test):
"""Return how many items make test(item) True. Works for ANY list and test."""
n = 0
for x in items:
if test(x):
n += 1
return n
def below_freezing(t):
return t < 0
def long_word(w):
return len(w) >= 7
temps = [-12, 3, -1, 0, 5, -20]
words = "the northern lights danced over Yellowknife".split()
print(count_if(temps, below_freezing))
print(count_if(words, long_word))
print(count_if(words, str.istitle))

One general function reused with three different tests. In the second line, "northern" and "Yellowknife" have 7 or more letters; in the third, only "Yellowknife" is in title case.

3
2
1

Output of the program above (what Python prints).

Injecting dependencies for reuse and testing

A function that calls input() directly can only get its data from a person at a keyboard. That makes it hard to reuse (say, in a program that reads from a file) and hard to test automatically. The fix is dependency injection: pass in the function it should use to get a reply, with input as the default. Real programs call it normally; tests pass a fake.

Worked example

Example 2: A reusable, testable input helper

Write read_int(prompt, low, high, ask=input) for a reusable inputs.py module. It must keep asking until it gets a whole number in range. Test it automatically with the replies "twelve", "40" and " 12 ".

  1. The loop calls ask(prompt) instead of input(prompt). By default ask is input, so normal callers write read_int("Grade? ", 9, 12) and nothing changes for them.
  2. .strip() removes spaces, and .lstrip("-").isdigit() accepts an optional minus sign followed by digits, so "-5" is a number but "twelve" is not.
  3. If the number is out of range, the same message is printed and the loop repeats.
  4. The test builds an iterator with iter([...]); each call to next(replies) hands out the next prepared reply, just as if someone typed it.
  5. "twelve" fails the digit test; "40" is out of range; " 12 " becomes 12, which is returned.

Code and output below. The same helper can now be reused by any program in the course, and its tests run without anyone typing.

def read_int(prompt, low, high, ask=input):
"""Keep asking until the reply is a whole number from low to high.
ask is the function used to get a reply; it defaults to input(),
but a test can pass a fake one instead of typing.
"""
while True:
reply = ask(prompt).strip()
if reply.lstrip("-").isdigit():
value = int(reply)
if low <= value <= high:
return value
print(f"Please enter a whole number from {low} to {high}.")
# A test that needs no keyboard: a fake ask() that hands out prepared replies.
replies = iter(["twelve", "40", " 12 "])
def fake_ask(prompt):
return next(replies)
print(read_int("Grade? ", 9, 12, ask=fake_ask))

inputs.py with a keyboard-free test.

Please enter a whole number from 9 to 12.
Please enter a whole number from 9 to 12.
12

Output of the program above (what Python prints).

Before you call code reusable, checkWhy it matters
It has a clear name and a docstringOthers must understand it without reading the body
Its inputs are parameters, not globalsIt works wherever it is imported
It returns results instead of printing themCallers decide what to do with the answer
It raises clear exceptions for bad inputCallers can handle errors their own way
It has tests (asserts or a self-test)Reusers can trust it and change it safely
It lives in a module with one themeIt can be imported without dragging in unrelated code
A reusability checklist for functions, classes and modules.
Just for fun

Programmers joke that there are only two hard problems in computer science: cache invalidation, naming things, and off-by-one errors.

(Count them again.)

Hands-on activity

Harvest a reusable module

You need: two or three programs you wrote earlier this year or in Grade 11 (or the examples from this unit), and a computer with Python.

  1. Read the programs side by side and highlight any code that appears in more than one, or nearly so: input checking, averages, formatting money or times, menus.
  2. For each highlighted piece, write one general function in a new module, toolbox.py, with a docstring. Make the varying parts parameters. Use dependency injection for anything that reads input.
  3. Add a self-test under if __name__ == "__main__": with at least two asserts per function (include an edge case such as an empty list).
  4. Change each original program to import from toolbox instead of using its own copy. Run each program to check its output is unchanged.
  5. Write two sentences: which function was hardest to generalize, and why?

What to look for: every original program still produces the same output; toolbox functions do not print (except menu or output helpers); the self-test passes.

Your turn

1

What does DRY stand for, and what problem does it prevent?

2

Explain the difference between composition and inheritance using the rental shop.

3

Using Example 1, what does WeeklyPricing().price(3500, 16) return?

4

Write a new strategy FamilyPassPricing that charges a flat 15 000 cents for any rental of up to 3 days and the standard rate after that.

5

Using count_if, write one line that counts how many temperatures in temps are above 2 °C, with a helper function warm(t).

6

Why is read_int(..., ask=input) easier to test than a version that calls input() directly?

7

What is wrong with count_if(temps, below_freezing())?

More practice: warm-up

1

Is "a Rental has a pricing policy" composition or inheritance?

2

What do you call a function that takes another function as a parameter?

3

What does divmod(17, 5) return?

4

What does a strategy object represent?

5

Give one sign that a function is hard to reuse.

More practice: on your own

1

Write a general function transform(items, change) that returns a new list with change(x) applied to each item. Use it to convert [0, 100, -40] from Celsius to Fahrenheit.

2

A function print_report() reads the global list SCORES and prints a table. Give two changes that make it reusable.

3

Rewrite StudentPricing so the discount percentage is a constructor parameter. How do you make a 15% discount?

4

Why might you choose not to generalize a function you have used only once?

5

Predict: print(sorted(["Lac", "bay", "Arm"], key=str.lower))

Challenge

1

Write a higher-order function make_threshold_test(limit) that returns a test function which checks x > limit. Use it with count_if to count temperatures above −5 in [-12, 3, -1, 0, 5, -20].

2

A game has characters that can walk, swim or fly, in any combination (a duck can do all three; a fish can only swim). Compare an inheritance-only design with a composition design.

3

Write a test for the WeeklyPricing strategy that checks the boundary values 6, 7 and 8 days at 1000 cents a day. What should each return?

Check your understanding

Quiz

1.

Which design best lets any rental item use any of several pricing rules, chosen at run time?

2.

What is StudentPricing().price(2000, 4) in Example 1?

3.

What does count_if([2, 9, 4, 11], is_big) return if is_big(x) returns x > 5?

4.

Why does read_int take a parameter ask with the default value input?

5.

Which is not a principle of reusable design?

6.

"Favour composition over inheritance" means:

For parents and teachers

Big idea: C1.4 asks students to apply the principle of reusability in modules, subprograms, classes, methods and inheritance. This lesson pulls the unit's tools together and adds composition, delegation and functions as parameters, which are central ideas in professional software design.

How to help: The "harvest" activity uses your teen's own earlier programs. Ask them to show you one piece of code that used to be copied in several places and now lives in one function.

Common difficulties:

  • Writing f() instead of f when passing a function as an argument.
  • Using inheritance for every kind of reuse, leading to many near-identical subclasses.
  • Over-generalizing: adding options nobody needs.
  • Functions that cannot be reused because they print, read input or use globals.