Design principles show when a class is wrong
Object-oriented design principles are guidelines that keep classes small, clear and easy to change. Two of the best known are the single responsibility principle and the open/closed principle. They help you see when a class does too much, and when a new feature forces you to edit code that already works.
By the end of this lesson
Note
I can spot code that gives a class two jobs or is hard to extend.
Key words
| Word | What it means |
|---|---|
| Responsibility | one job a class is there to do |
| Reason to change | a kind of request that forces an edit |
| Single responsibility | a class has one reason to change |
| Open/closed | open to extension, closed to editing |
| Extension | new behaviour added without editing old code |
The cells students fill in are left blank in this preview.
A class should have one reason to change
Ask of any class: what kinds of request would force me to edit it? If the answer is two or more, the class has two jobs. This is the single responsibility principle.
Look at Summary. It counts errors and it also decides the wording. A change to the counting rule and a change to the wording both edit the same class.
A class with two jobs
class Summary:
def __init__(self, levels):
self.levels = levels
def count_errors(self):
total = 0
for level in self.levels:
if level == "ERROR":
total = total + 1
return total
def text(self):
return "Errors: " + str(self.count_errors())Make a folder for this lesson
mkdir -p ~/oop4Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Go into it
cd ~/oop4Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Write the class to smell.py
printf 'class Summary:\n def __init__(self, levels):\n self.levels = levels\n\n def count_errors(self):\n total = 0\n for level in self.levels:\n if level == "ERROR":\n total = total + 1\n return total\n\n def text(self):\n return "Errors: " + str(self.count_errors())\n' > smell.pyShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Run the summary on three levels
python3 -c "from smell import Summary; print(Summary(['INFO', 'ERROR', 'ERROR']).text())"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Split a class by its reasons to change
The Run printed Errors: 2, so the class works. It still has two reasons to change. Give each reason its own class.
ErrorCounter only counts. SummaryText only words the answer. A French summary or an HTML one now means a new text class, and the counter is never touched.
One job for each class
class ErrorCounter:
def __init__(self, levels):
self.levels = levels
def count(self):
total = 0
for level in self.levels:
if level == "ERROR":
total = total + 1
return total
class SummaryText:
def words(self, count):
return "Errors: " + str(count)Write the two classes to better.py
printf 'class ErrorCounter:\n def __init__(self, levels):\n self.levels = levels\n\n def count(self):\n total = 0\n for level in self.levels:\n if level == "ERROR":\n total = total + 1\n return total\n\nclass SummaryText:\n def words(self, count):\n return "Errors: " + str(count)\n' > better.pyShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Count with one class, word with the other
python3 -c "from better import ErrorCounter, SummaryText; c = ErrorCounter(['INFO', 'ERROR', 'ERROR']); print(SummaryText().words(c.count()))"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Open for extension, closed for editing
The open/closed principle says you should add new behaviour by adding code, not by editing code that works. Each edit risks breaking something that was fine.
Look at score below. A new level, CRITICAL, means opening the function and adding another elif. The next level means doing it again.
A score edited for every level
def score(level):
if level == "ERROR":
return 5
elif level == "WARN":
return 2
return 0Write it to chain.py
printf 'def score(level):\n if level == "ERROR":\n return 5\n elif level == "WARN":\n return 2\n return 0\n' > chain.pyShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Score a level the function does not know
python3 -c "from chain import score; print(score('ERROR'), score('CRITICAL'))"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Move what varies into data or a part
The Run printed 5 0. CRITICAL scores 0 because nobody has edited the function yet.
The fix is to move what changes out of the code. Scorer takes a dictionary of weights. A new level is one more entry in the data, and the class is never edited.
A scorer that is open to new levels
class Scorer:
def __init__(self, weights):
self.weights = weights
def score(self, level):
if level in self.weights:
return self.weights[level]
return 0Write it to scorer.py
printf 'class Scorer:\n def __init__(self, weights):\n self.weights = weights\n\n def score(self, level):\n if level in self.weights:\n return self.weights[level]\n return 0\n' > scorer.pyShown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Add a level by changing data only
python3 -c "from scorer import Scorer; a = Scorer({'ERROR': 5, 'WARN': 2}); print(a.score('ERROR'), a.score('WARN'), a.score('INFO')); b = Scorer({'ERROR': 5, 'WARN': 2, 'CRITICAL': 9}); print(b.score('CRITICAL'))"Shown here, not run. Create a free account to press Run and type it into your own lab’s terminal.
Signs that a design needs work
| What you see | The sign | A way out |
|---|---|---|
| A class whose name has "and" in it | Two jobs | Split it into two classes |
| An if and elif chain on a kind or a type | Every new kind edits old code | Move the kind into data or a part |
| A change in one place breaks a far away place | Too much shared in one class | Give each reason its own class |
| Tests that need a lot of setup | A class doing too much | Split it and test the parts |
The cells students fill in are left blank in this preview.
A common mistake
Warning
Many people think a principle is a law, so every class must be tiny and every if must go. A principle is a guide for the code you expect to change. Split a class when you can name its two reasons to change, and leave a short stable class alone.