Make a day17 folder for today’s files, open it in your editor, and run each script from its terminal, as on earlier days. Several of today’s programs import Day 16’s BankAccount class, so save the file bank.py below into day17 first; the scripts that import it sit beside it, exactly like the modules you split up on Day 13.
"""The BankAccount class from Day 16, ready to import."""
class BankAccount:
"""An account with an owner and a balance that cannot go negative."""
def __init__(self, owner, balance=0):
if balance < 0:
raise ValueError(f"opening balance cannot be negative, not {balance}")
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount <= 0:
raise ValueError(f"deposit must be more than 0, not {amount}")
self.balance += amount
def withdraw(self, amount):
if amount <= 0:
raise ValueError(f"withdrawal must be more than 0, not {amount}")
if amount > self.balance:
raise ValueError(f"only {self.balance:.2f} available, not {amount:.2f}")
self.balance -= amount
def __str__(self):
return f"{self.owner}'s account: {self.balance:.2f}"Yesterday you wrote classes from scratch. Today you learn the tools that make classes fit together and fit into Python. You will build a new class on top of an existing one instead of copying it, teach your objects to answer ==, <, and len(), make attributes that work themselves out when you read them, and let Python write the routine parts of a class for you. The day ends with a question that matters as much as any syntax: when is a class worth writing at all?
17.1 Inheritance: Building on an Existing Class
The bank wants a savings account. A savings account is a bank account in every way (it has an owner and a balance, and it takes deposits and withdrawals with the same rules), plus one extra ability: it can add interest. With only Day 16’s tools, you would copy the whole BankAccount class, rename it SavingsAccount, and add one method. Now there are two copies of the deposit and withdrawal rules. If you later find a bug in withdraw, you have to fix it twice, and sooner or later someone fixes only one copy.
You have actually done the better thing already. On Day 15 you wrote class TaskNumberError(Exception): and got a working exception type in two lines, without writing any of the code that lets an exception be raised, caught, and printed. The (Exception) part told Python: “make this new class a kind of Exception, and give it everything an Exception already has.” That idea has a name.
Inheritance means defining a new class as an extended version of an existing one. The new class automatically has every method and class attribute of the existing class, and you add only what is different. The existing class is called the parent class (also the base class or superclass), and the new one is the child class (also the subclass). The child inherits from the parent.
class SavingsAccount(BankAccount):
def add_interest(self, rate):
self.deposit(round(self.balance * rate, 2))
class SavingsAccountstarts a class definition as on Day 16.(BankAccount)in parentheses names the parent. Leave it out and you get an ordinary class with no deposit, withdraw, or anything else.- The body holds only what is new: one method.
__init__,deposit,withdraw, and__str__all come fromBankAccount.
Worked example: A savings account that reuses everything
from bank import BankAccount
class SavingsAccount(BankAccount):
def add_interest(self, rate):
self.deposit(round(self.balance * rate, 2))
savings = SavingsAccount("Ana", 200)
savings.deposit(50)
savings.add_interest(0.02)
print(savings)
print(savings.owner, savings.balance)
plain = BankAccount("Ben", 40)
print(isinstance(savings, SavingsAccount), isinstance(savings, BankAccount))
print(isinstance(plain, SavingsAccount), isinstance(plain, object))Ana's account: 255.00
Ana 255.0
True True
False True- Line 1 imports the parent class from
bank.py(Day 13). - Line 9 calls
SavingsAccount("Ana", 200).SavingsAccounthas no__init__of its own, so Python uses the parent’s, which stores the owner and balance and checks that the balance is not negative. - Line 10 calls
deposit, whichSavingsAccountalso never defined: the balance becomes 250. - Line 11 calls the new method. Inside it,
selfis the savings account, soself.balance * rateis 250 × 0.02 = 5.0, andself.deposit(5.0)uses the inheriteddeposit. The balance becomes 255.0. - Line 12 prints with the parent’s
__str__.
When you call savings.deposit(50), Python searches for deposit in a fixed order: first on the object itself, then in its class, SavingsAccount, then in the parent, BankAccount, where it finds it. The search stops at the first match.
| Name looked up | On savings? |
In SavingsAccount? |
In BankAccount? |
Found in |
|---|---|---|---|---|
balance |
yes | the object | ||
add_interest |
no | yes | SavingsAccount |
|
deposit |
no | no | yes | BankAccount |
__str__ |
no | no | yes | BankAccount |
Lines 15 to 17 introduce a built-in function you need with inheritance. isinstance(obj, SomeClass) returns True if obj is an instance of SomeClass or of any child of it. A child object counts as an instance of its parent, because a savings account really is a bank account. The reverse is not true: plain, an ordinary BankAccount, is not a SavingsAccount. That is why inheritance is often described as an “is-a” relationship: a SavingsAccount is a BankAccount, and a TaskNumberError is an Exception.
The last True on line 17 shows that even BankAccount has a parent. Every class you write without parentheses quietly inherits from a built-in class called object. That is where Day 16’s defaults came from: the <__main__.Player object at 0x...> text and the “same object” meaning of == are methods that every class inherits from object until it defines its own.
Because a child is also an instance of its parent, code written for the parent keeps working for the child. Here is the same idea with the exception classes you already know. AmountError inherits from the built-in ValueError, so an except ValueError clause catches it too:
class AmountError(ValueError):
pass
def check_amount(amount):
if amount <= 0:
raise AmountError(f"amount must be more than 0, not {amount}")
return amount
for text in ["25", "-5", "ten"]:
try:
print(check_amount(int(text)))
except ValueError as error:
print(f"Refused {text}: {error}")25
Refused -5: amount must be more than 0, not -5
Refused ten: invalid literal for int() with base 10: 'ten'
-5 raises an AmountError, which is a kind of ValueError, so except ValueError catches it. "ten" makes int() raise a plain ValueError, which the same clause catches. An except clause catches the type it names and every child of that type.
Key idea
class Child(Parent): makes a class that inherits every method and class attribute of Parent; write only what is
new. Python looks up a name on the object, then in its class, then in the parent. isinstance(obj, Parent) is True
for instances of Parent and of its children, because a child “is a” parent.
Watch out
Inheritance only goes down. A child gets the parent’s methods, but the parent never gets the child’s. This loop gives interest to every account in the list:
from bank import BankAccount
class SavingsAccount(BankAccount):
def add_interest(self, rate):
self.deposit(round(self.balance * rate, 2))
accounts = [SavingsAccount("Ana", 200), BankAccount("Ben", 40)]
for account in accounts:
account.add_interest(0.02)
print(account)Ana's account: 204.00
Traceback (most recent call last):
File ".../day17/one_way.py", line 11, in <module>
account.add_interest(0.02)
^^^^^^^^^^^^^^^^^^^^
AttributeError: 'BankAccount' object has no attribute 'add_interest'Ana’s savings account worked. Ben’s account is a plain BankAccount, and add_interest lives only in SavingsAccount, so the search (object, BankAccount, object) never finds it. Call a child-only method only on objects that are that child, for example by checking with isinstance first:
from bank import BankAccount
class SavingsAccount(BankAccount):
def add_interest(self, rate):
self.deposit(round(self.balance * rate, 2))
accounts = [SavingsAccount("Ana", 200), BankAccount("Ben", 40)]
for account in accounts:
if isinstance(account, SavingsAccount):
account.add_interest(0.02)
print(account)Ana's account: 204.00
Ben's account: 40.00Pause and try
Question 1 · Tracing
What does this program print?
class Ticket:
def __init__(self, seat, price):
self.seat = seat
self.price = price
def describe(self):
return f"Seat {self.seat}: {self.price}"
class VipTicket(Ticket):
def lounge(self):
return f"Seat {self.seat} may use the lounge"
regular = Ticket("B4", 12)
vip = VipTicket("A1", 30)
print(vip.describe())
print(vip.lounge())
print(isinstance(vip, Ticket), isinstance(regular, VipTicket))Check your reasoning
Seat A1: 30
Seat A1 may use the lounge
True FalseVipTicket has no __init__ or describe of its own, so VipTicket("A1", 30) uses Ticket’s __init__, and vip.describe() finds describe in Ticket. lounge is found in VipTicket, and it reads self.seat, which the parent’s __init__ stored. vip is a VipTicket, which is a kind of Ticket, so isinstance(vip, Ticket) is True. regular is a plain Ticket, not a VipTicket, so the last check is False.
Reread 17.1 Inheritance: Building on an Existing Class if your output differed.
Question 2 · Multiple choice
Manager inherits from Employee. Employee defines a pay() method, and Manager adds an approve() method. boss is a Manager and clerk is an Employee. Which line raises an AttributeError?
boss.pay()boss.approve()clerk.approve()isinstance(boss, Employee)
Check your reasoning
C. approve exists only in Manager. Looking it up on clerk searches the object, then Employee, then object, and never reaches Manager, because a parent does not inherit from its children.
- A is wrong:
bossis aManager, which inheritspayfromEmployee. - B is wrong:
approveis defined inManageritself. - D is wrong:
isinstancenever raises for a class; it returnsTruehere, because a manager is an employee.
Reread 17.1 Inheritance: Building on an Existing Class if you chose differently.
17.2 Extending __init__ with super()
The savings account from 17.1 takes the interest rate as an argument every time. A real savings account has its own rate, agreed when it is opened, so the rate belongs in an instance attribute set in __init__. That means SavingsAccount needs an __init__ of its own, with a rate parameter.
There is a catch. Defining a method in the child with the same name as one in the parent replaces it for the child: the name lookup from 17.1 finds the child’s version first and stops. So the child’s __init__ runs instead of BankAccount.__init__, and the parent’s work (checking the opening balance, storing the owner and balance) would not happen. You do not want to copy that work into the child, which is the duplication inheritance was supposed to remove. You want the child’s __init__ to say “first do everything the parent’s __init__ does, then add the rate.”
The built-in super() gives you exactly that. Inside a method, super() means “the parent class, working on this same object”. super().__init__(owner, balance) calls the parent’s __init__ on the object being set up, with those arguments.
Worked example: A savings account with its own rate
from bank import BankAccount
class SavingsAccount(BankAccount):
def __init__(self, owner, balance=0, rate=0.02):
super().__init__(owner, balance)
self.rate = rate
def add_interest(self):
self.deposit(round(self.balance * self.rate, 2))
savings = SavingsAccount("Ana", 200, rate=0.05)
savings.add_interest()
print(savings, savings.rate)
try:
SavingsAccount("Cy", -10)
except ValueError as error:
print(f"Refused: {error}")Ana's account: 210.00 0.05
Refused: opening balance cannot be negative, not -10Line 13, SavingsAccount("Ana", 200, rate=0.05), does this:
| Step | What Python does |
|---|---|
| 1 | Makes a new, empty SavingsAccount object |
| 2 | Finds __init__ in SavingsAccount (the child’s own), and calls it with self = the new object |
| 3 | Line 6: super().__init__("Ana", 200) runs BankAccount.__init__ on the same object: checks 200, stores owner and balance |
| 4 | Back in the child, line 7 stores rate = 0.05 |
| 5 | The object now has owner, balance, and rate, and line 13 names it savings |
Line 14 adds interest using the stored rate: 200 × 0.05 = 10.0, so the balance becomes 210.0.
Notice what you do not pass: self. super() already knows which object it is working on, just as savings.deposit(50) passes savings for you.
The second half of the program shows why calling the parent matters. SavingsAccount("Cy", -10) runs the child’s __init__, which calls the parent’s, and the parent’s check on line 8 of bank.py refuses the negative balance. The rule “an opening balance cannot be negative” is written once, in BankAccount, and every kind of account obeys it.
You may also see the parent named directly, BankAccount.__init__(self, owner, balance), which is Day 16’s long form of a method call (and there you do pass self). It works, but super() is the usual choice: it does not repeat the parent’s name, so the line stays correct if the parent class is ever renamed.
Key idea
A child method with the same name as a parent method replaces it for the child. When a child needs its own __init__,
call super().__init__(...) first, so the parent sets up its attributes and runs its checks, then add the child’s own
attributes.
Watch out
Forgetting to call super().__init__. Without it, the parent’s __init__ never runs, and the attributes it would have made do not exist:
from bank import BankAccount
class SavingsAccount(BankAccount):
def __init__(self, owner, balance=0, rate=0.02):
self.rate = rate
def add_interest(self):
self.deposit(round(self.balance * self.rate, 2))
savings = SavingsAccount("Ana", 200)
print(savings.rate)
savings.add_interest()0.02
Traceback (most recent call last):
File ".../day17/forgot_super.py", line 14, in <module>
savings.add_interest()
~~~~~~~~~~~~~~~~~~~~^^
File ".../day17/forgot_super.py", line 9, in add_interest
self.deposit(round(self.balance * self.rate, 2))
^^^^^^^^^^^^
AttributeError: 'SavingsAccount' object has no attribute 'balance'Making the account worked, and rate printed, so the mistake stays hidden until something reads an attribute the parent should have stored. When a child object is missing an attribute that the parent’s __init__ sets, look for a missing super().__init__(...) line. Add it as the first line of the child’s __init__, as in savings_rate.py.
Pause and try
Question 3 · Tracing
What does this program print? Look closely at what line 12 passes to the parent.
class Employee:
def __init__(self, name, hourly):
self.name = name
self.hourly = hourly
def pay(self, hours):
return self.hourly * hours
class Manager(Employee):
def __init__(self, name, hourly, team):
super().__init__(name, hourly + 5)
self.team = team
lead = Manager("Ana", 20, ["Ben", "Cy"])
print(lead.name, lead.hourly, len(lead.team))
print(lead.pay(10))Check your reasoning
Ana 25 2
250Manager("Ana", 20, ["Ben", "Cy"]) runs Manager.__init__. Line 12 calls the parent’s __init__ with name = "Ana" and hourly + 5 = 25, so the parent stores hourly = 25, not 20. Line 13 stores the team list, which has 2 names. lead.pay(10) is inherited from Employee and works out self.hourly * hours = 25 × 10 = 250.
Reread 17.2 Extending __init__ with super() if your output differed.
Question 4 · Debugging
This program should print beagle and then Rex the beagle. It prints beagle and then crashes. Find the bug and fix it.
class Pet:
def __init__(self, name):
self.name = name.title()
class Dog(Pet):
def __init__(self, name, breed):
self.breed = breed
rex = Dog("rex", "beagle")
print(rex.breed)
print(f"{rex.name} the {rex.breed}")Check your reasoning
The traceback ends with:
AttributeError: 'Dog' object has no attribute 'name'Dog defines its own __init__, which replaces Pet.__init__ for dogs. It stores breed but never runs the parent’s __init__, so name is never stored. Call the parent first:
class Pet:
def __init__(self, name):
self.name = name.title()
class Dog(Pet):
def __init__(self, name, breed):
super().__init__(name)
self.breed = breed
rex = Dog("rex", "beagle")
print(rex.breed)
print(f"{rex.name} the {rex.breed}")beagle
Rex the beagleThe parent’s __init__ also tidies the name with title(), which is why "rex" prints as Rex. That is one more reason to call the parent rather than copy its line: any rule the parent applies comes along for free.
Reread 17.2 Extending __init__ with super() if you missed it.
17.3 Overriding Methods and Polymorphism
Some accounts charge a fee: every withdrawal costs an extra 1.50. A fee account is still a bank account, so inheritance fits, but its withdrawal works differently. 17.2 showed that a child method with the same name as a parent method replaces it. Doing this on purpose, to change how a child behaves, is called overriding the method. The child’s version is the override.
An override does not have to start from nothing. Inside it, super().withdraw(...) still reaches the parent’s version, so the child can do its own part and hand the rest to the parent.
Worked example: An account that charges for withdrawals
from bank import BankAccount
class FeeAccount(BankAccount):
fee = 1.5
def withdraw(self, amount):
super().withdraw(amount + self.fee)
def __str__(self):
return f"{super().__str__()} (fee {self.fee:.2f} per withdrawal)"
accounts = [BankAccount("Ana", 50), FeeAccount("Ben", 50)]
for account in accounts:
account.withdraw(10)
print(account)Ana's account: 40.00
Ben's account: 38.50 (fee 1.50 per withdrawal)- Line 5 is a class attribute (Day 16): one fee shared by every fee account.
- Lines 7 and 8 override
withdraw. The override adds the fee and passes the total to the parent’swithdraw, which still checks the amount and the balance. So a fee account can never go negative either, and the checking code exists only once. - Lines 10 and 11 override
__str__.super().__str__()gets the parent’s text,"Ben's account: 38.50", and the override adds a note after it.
Now look at the loop on lines 15 to 17. It treats every item the same way: account.withdraw(10), then print(account). It does not check which kind of account it has. Yet each object responds in its own way:
| Account | Class | withdraw that runs |
Balance after | __str__ that runs |
|---|---|---|---|---|
| Ana’s | BankAccount |
BankAccount.withdraw(10) |
50 − 10 = 40.0 | BankAccount’s |
| Ben’s | FeeAccount |
FeeAccount.withdraw(10), which calls the parent’s with 11.5 |
50 − 11.5 = 38.5 | FeeAccount’s |
One line of code, account.withdraw(10), ran two different methods, chosen by each object’s class at the moment of the call. This is polymorphism (Greek for “many forms”): the same method call works on objects of different classes, and each object answers in its own way. You have relied on it since Day 1 without knowing: print works on ints, strings, lists, and your own objects, because each type has its own __str__, and print just asks.
Polymorphism lets you write a loop or a function once and use it with kinds of objects that did not exist when you wrote it. To plan for that, a parent class can define a method that only its children are meant to fill in. The parent’s version raises NotImplementedError, a built-in exception that means “this has to be provided by a child class”:
class Notifier:
def send(self, text):
raise NotImplementedError("each kind of notifier must define send()")
class EmailNotifier(Notifier):
def __init__(self, address):
self.address = address
def send(self, text):
return f"Email to {self.address}: {text}"
class TextNotifier(Notifier):
def __init__(self, phone):
self.phone = phone
def send(self, text):
return f"Text to {self.phone}: {text[:20]}"
class Banner:
def send(self, text):
return f"Banner: {text.upper()}"
notifiers = [EmailNotifier("ana@example.com"), TextNotifier("555-0142"), Banner()]
for notifier in notifiers:
print(notifier.send("Your library book is due tomorrow"))Email to ana@example.com: Your library book is due tomorrow
Text to 555-0142: Your library book is
Banner: YOUR LIBRARY BOOK IS DUE TOMORROW
Notifier(lines 1 to 3) is a parent that only says what every notifier can do:send(text). Its ownsendrefuses to run.EmailNotifierandTextNotifiereach overridesend. A text message is short, soTextNotifierkeeps only the first 20 characters with a slice (Day 3).Banner(lines 22 to 24) does not inherit fromNotifierat all, yet the loop works with it too. Python never checks an object’s class before calling a method; it only looks the name up. Any object that has asendmethod works in this loop. This style, caring about what an object can do rather than what class it is, is called duck typing, from the saying “if it walks like a duck and quacks like a duck, it is a duck.”
Inheritance is still useful here: the parent documents what every notifier must provide, and its NotImplementedError catches a notifier that forgot.
Key idea
Overriding means a child defines a method with the parent’s name to change how it behaves; inside the override,
super().method(...) still reaches the parent’s version. Polymorphism means one call, such as account.withdraw(10),
runs whichever version belongs to each object’s class.
Watch out
A misspelled override is a new method, not an override. Python cannot know that sned was meant to be send, so it adds a second method and leaves the parent’s send in place:
class Notifier:
def send(self, text):
raise NotImplementedError("each kind of notifier must define send()")
class PopupNotifier(Notifier):
def sned(self, text):
return f"Popup: {text}"
popup = PopupNotifier()
print(popup.send("Practice starts at 4"))Traceback (most recent call last):
File ".../day17/override_typo.py", line 12, in <module>
print(popup.send("Practice starts at 4"))
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^
File ".../day17/override_typo.py", line 3, in send
raise NotImplementedError("each kind of notifier must define send()")
NotImplementedError: each kind of notifier must define send()The traceback shows that the send that ran was on line 3, the parent’s. This is the parent’s NotImplementedError doing its job: without it, the parent’s version would run silently and you might never notice. When an override seems to be ignored, compare its name with the parent’s letter by letter. Rename sned to send.
Pause and try
Question 5 · Tracing
What does this program print? label is defined only in Price.
class Price:
def __init__(self, amount):
self.amount = amount
def final(self):
return self.amount
def label(self):
return f"{self.final():.2f}"
class Sale(Price):
def final(self):
return self.amount * 0.8
class Clearance(Sale):
def final(self):
return super().final() - 1
for price in [Price(10), Sale(10), Clearance(10)]:
print(price.label())Check your reasoning
10.00
8.00
7.00All three objects use Price.label, which calls self.final(). Which final runs depends on self’s class, not on where label is written:
| Object | final that runs |
Result |
|---|---|---|
Price(10) |
Price.final: 10 |
10.00 |
Sale(10) |
Sale.final: 10 × 0.8 |
8.00 |
Clearance(10) |
Clearance.final: super().final() is Sale.final, 8.0, then − 1 |
7.00 |
Clearance’s parent is Sale, so super() there reaches Sale, not Price. This is polymorphism inside a class: a parent method that calls self.something() gets each child’s override.
Reread 17.3 Overriding Methods and Polymorphism if your output differed.
Question 6 · Multiple choice
Notifier.send raises NotImplementedError, as in notifiers.py. A new class is written as class PushNotifier(Notifier): with a body of just pass. What happens when a program runs PushNotifier().send("Hi")?
- It returns
None, becausePushNotifierhas nosendmethod. - It raises
NotImplementedError, fromNotifier’ssend. - It raises
AttributeError, becausePushNotifierhas nosendmethod. - It prints
Hi.
Check your reasoning
B. PushNotifier inherits everything from Notifier, including send. The lookup does not find send in PushNotifier, moves on to Notifier, and runs that version, which raises.
- A is wrong: the method is found in the parent, and it raises rather than returning.
- C is wrong: an
AttributeErrorwould need the whole search to fail, butNotifierhassend. - D is wrong: nothing in either class prints.
Reread 17.3 Overriding Methods and Polymorphism if you chose differently.
17.4 Composition Versus Inheritance
Inheritance is not the only way to build a class out of other classes. Think about a bank branch. It holds many accounts, but a branch is not an account; you would never deposit money “into the branch” the way you deposit into an account. The branch has accounts. When one object keeps other objects as its attributes and uses them to do its job, that is composition, a “has-a” relationship. You have already done it: Day 16’s Cart kept a dictionary of prices, and its library kept lists of Book objects.
Worked example: A branch that has accounts
from bank import BankAccount
class Branch:
def __init__(self, city):
self.city = city
self.accounts = []
def open_account(self, owner, deposit):
account = BankAccount(owner, deposit)
self.accounts.append(account)
return account
def total(self):
return sum(account.balance for account in self.accounts)
branch = Branch("Springfield")
ana = branch.open_account("Ana", 50)
branch.open_account("Ben", 20)
ana.deposit(30)
print(f"{branch.city}: {len(branch.accounts)} accounts, {branch.total():.2f} in total")
print(ana)Springfield: 2 accounts, 100.00 in total
Ana's account: 80.00- Line 7 gives each branch its own list of accounts.
Branchinherits from nothing (other thanobject): it is built from accounts, not as an account. open_accountmakes aBankAccount, keeps it in the list, and returns it, so the caller can use it too. Line 19 names itana; the list andanarefer to the same object (Day 16), so the deposit on line 21 shows up in the branch total.totalasks each account for its balance: 80 + 20 = 100. When an object hands part of its job to an object it holds, that is called delegating.
Why not always inherit, since it saves typing? Because a child gets everything the parent has, wanted or not. Here is a playlist that must never hold the same song twice. Inheriting from the built-in list class looks convenient (Python’s built-in types are classes, so you can inherit from them):
class Playlist(list):
def add(self, song):
if song in self:
raise ValueError(f"{song} is already on the playlist")
self.append(song)
road_trip = Playlist()
road_trip.add("Holiday")
road_trip.add("Sunrise")
road_trip.append("Holiday")
road_trip.insert(0, 42)
print(road_trip)[42, 'Holiday', 'Sunrise', 'Holiday']
add refuses duplicates, but a Playlist is a list, so it also has append, insert, extend, and every other list method. Line 11 adds a duplicate with append, and line 12 even puts a number in front with insert. The rule lives in one method, and a dozen other methods walk around it. Composition gives the playlist a list instead, and offers only the operations that keep the rule:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add(self, song):
if song in self.songs:
raise ValueError(f"{song} is already on {self.name}")
self.songs.append(song)
def __str__(self):
return f"{self.name}: {', '.join(self.songs)}"
road_trip = Playlist("Road trip")
road_trip.add("Holiday")
road_trip.add("Sunrise")
try:
road_trip.add("Holiday")
except ValueError as error:
print(f"Not added: {error}")
print(road_trip)
try:
road_trip.append("Holiday")
except AttributeError as error:
print(error)Not added: Holiday is already on Road trip
Road trip: Holiday, Sunrise
'Playlist' object has no attribute 'append'
Now add is the only way in (line 6), so the rule always holds, and append simply does not exist on a playlist (line 24). The list is still there doing the storing, in self.songs; the playlist decides what the outside world may do with it.
Two questions help you choose:
| Question | If yes | Example |
|---|---|---|
| Is the new thing truly a kind of the old thing (“a savings account is a bank account”)? | Inheritance may fit | SavingsAccount(BankAccount) |
| Does every method of the old thing make sense for the new thing? | Inheritance may fit | A fee account still deposits and withdraws |
| Does the new thing just hold or use the old thing (“a branch has accounts”)? | Composition | self.accounts = [] |
Inheritance needs both of the first two answers to be yes. When you are unsure, choose composition. It is the safer default: it keeps the classes separate, so changing one does not quietly change the other, and the outer class shows only the methods you chose.
Key idea
Inheritance is “is-a”: the child is a kind of the parent and gets all its methods. Composition is “has-a”: an object keeps other objects as attributes and delegates work to them. Prefer composition unless the new class truly is a kind of the old one and every inherited method makes sense for it.
Pause and try
Question 7 · Multiple choice
Which pair is the best fit for inheritance, with the first class inheriting from the second?
CarandEngineClassroomandStudentSquareandShapeOrderandlist
Check your reasoning
C. A square is a shape, and anything every shape can do (work out its area, describe itself) makes sense for a square.
- A is wrong: a car has an engine; a car is not a kind of engine. Give the car an
engineattribute. - B is wrong: a classroom has students. That is composition, a list of
Studentobjects. - D is wrong: an order has a list of items. Inheriting from
listwould give itinsert,clear, and other methods that could break the order’s rules, as playlist_inherit.py showed.
Reread 17.4 Composition Versus Inheritance if you chose differently.
Question 8 · Short answer
A team may have at most 11 players. A classmate writes class Team(list): with an add_player method that refuses a twelfth player. Explain why this design cannot keep the rule, and describe a design that can.
Check your reasoning
Model answer: because Team inherits from list, every team also has append, insert, extend, and the other list methods, and none of them check the limit. Any code can call team.append(player) and make a twelfth player without add_player ever running. A design that keeps the rule uses composition: Team inherits from nothing, its __init__ makes self.players = [], and add_player checks len(self.players) before appending. Then add_player is the only way to add a player, so the limit always holds.
A correct answer says that inheriting from list brings unchecked ways to add players, and that holding a list as an attribute (has-a) lets the class control every change.
Reread 17.4 Composition Versus Inheritance if your reasoning differed.
17.5 Comparing with __eq__
Day 16 ended with a puzzle: two Player objects with the same name and score were not ==, because for your own classes == starts out meaning “same object”. You now know where that meaning comes from: == is a method that every class inherits from object. And 17.3 showed that a child can override any inherited method. So you can override equality.
When Python works out a == b, it calls a.__eq__(b) and uses what the method returns. __eq__ is another dunder method (Day 16): you write it, and Python calls it for you whenever == is used. It takes self and one more parameter, by convention named other, for the object on the right of ==. It returns True or False.
You decide what “equal” means. For points on a game board, two points are equal when they are at the same place: same x and same y.
Worked example: Points that are equal when they are at the same place
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __repr__(self):
return f"Point({self.x}, {self.y})"
start = Point(2, 3)
same_spot = Point(2, 3)
print(start == same_spot, start is same_spot)
print(start != Point(3, 2))
visited = [Point(0, 0), Point(2, 3), Point(5, 1)]
print(Point(2, 3) in visited, visited.index(Point(5, 1)))
visited.remove(Point(0, 0))
print(visited)True False
True
True 2
[Point(2, 3), Point(5, 1)]- Line 15:
start == same_spotcallsstart.__eq__(same_spot), which compares 2 with 2 and 3 with 3 and returnsTrue. They are still two separate objects, soisisFalse. Equality and identity have come apart, exactly as for two equal lists on Day 4. - Line 16: Python works out
!=as “not==” when a class defines only__eq__.Point(3, 2)is a different place, so!=isTrue. - Line 18:
inand the list methodindexboth search with==(Day 6), so they now find a point with the same coordinates even though it is a brand-new object.Point(5, 1)is at index 2. - Line 19:
removealso searches with==, so it removes the point at (0, 0).
Equality does not have to use every attribute. A library may own two copies of the same book, one new and one worn. For the catalog, they are the same book, because they have the same catalog code. So __eq__ compares only the code. This version also handles a question the first one ignored: what if the other value is not a Book at all? Look at what the first Point does when compared with a tuple:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
print(Point(2, 3) == Point(2, 3))
print(Point(2, 3) == (2, 3))True
Traceback (most recent call last):
File ".../day17/point_tuple.py", line 11, in <module>
print(Point(2, 3) == (2, 3))
^^^^^^^^^^^^^^^^^^^^^
File ".../day17/point_tuple.py", line 7, in __eq__
return self.x == other.x and self.y == other.y
^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'tuple' object has no attribute 'x'
Asking “is this point equal to that tuple?” should simply be False, not a crash. The careful pattern checks the other value’s type first:
class Book:
def __init__(self, title, code, condition):
self.title = title
self.code = code
self.condition = condition
def __eq__(self, other):
if not isinstance(other, Book):
return NotImplemented
return self.code == other.code
copy_1 = Book("Holes", "B-04", "new")
copy_2 = Book("Holes", "B-04", "worn")
wonder = Book("Wonder", "B-17", "new")
print(copy_1 == copy_2, copy_1 == wonder)
print(copy_1 == "Holes", copy_1 != (2, 3))True False
False True
- Lines 8 and 9: if
otheris not aBook, the method returnsNotImplemented, a special built-in value that means “I do not know how to compare with that.” It is not an error. Python then gives the other value a chance to compare itself, and if neither side knows,==falls back to identity, which isFalsefor two different objects. - Line 16: the two copies of
Holeshave different conditions but the same code, so they are equal.Wonderhas a different code. - Line 17: comparing a book with a string or a tuple now gives
Falsefor==andTruefor!=, with no crash.
NotImplemented (a value you return) is not the same thing as NotImplementedError from 17.3 (an exception you raise). Only __eq__ and its relatives return NotImplemented.
Key idea
Define __eq__(self, other) to decide what == means for your class; !=, in, index, count, and remove all
use it. Start it with if not isinstance(other, YourClass): return NotImplemented, so comparing with another type
gives False instead of a crash.
Watch out
A class with __eq__ cannot go in a set. Day 8 explained that set items and dictionary keys must be hashable: Python files them by a number worked out from the value. Equal values must get the same number, and Python cannot know how to work one out from your __eq__, so when a class defines __eq__, Python makes its objects unhashable:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
visited = [Point(0, 0), Point(2, 3)]
print(len(visited))
seen = {Point(0, 0), Point(2, 3)}2
Traceback (most recent call last):
File ".../day17/point_set.py", line 12, in <module>
seen = {Point(0, 0), Point(2, 3)}
^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')A list of points works (line 11); a set of them does not. Older versions of Python print just TypeError: unhashable type: 'Point'. When you need a set, store something hashable that stands for each object, such as a tuple of its coordinates (Day 7):
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
visited = [Point(0, 0), Point(2, 3), Point(0, 0)]
seen = {(point.x, point.y) for point in visited}
print(len(seen), (0, 0) in seen)2 TrueThree points went in but the set has 2 items, because the two (0, 0) tuples are equal and a set keeps only one.
Pause and try
Question 9 · Tracing
What does this program print? Coins are equal when they have the same value, whatever their year.
class Coin:
def __init__(self, value, year):
self.value = value
self.year = year
def __eq__(self, other):
if not isinstance(other, Coin):
return NotImplemented
return self.value == other.value
purse = [Coin(25, 2019), Coin(10, 2020), Coin(25, 2024)]
print(Coin(25, 1999) in purse)
print(purse.count(Coin(25, 2000)), purse.index(Coin(10, 1990)))
print(purse[0] == purse[2], purse[0] is purse[2], purse[0] == 25)Check your reasoning
True
2 1
True False False- Line 13:
incomparesCoin(25, 1999)with each coin using__eq__; the first coin has value 25, so the answer isTrue. - Line 14:
countfinds two coins worth 25 (2019 and 2024).indexfinds the first coin worth 10 at index 1. - Line 15:
purse[0]andpurse[2]are both worth 25, so==isTrue, but they are two different objects, soisisFalse.purse[0] == 25compares a coin with an int:__eq__returnsNotImplemented, the int does not know how to compare with a coin either, and Python falls back to identity, which givesFalse.
Reread 17.5 Comparing with __eq__ if your output differed.
Question 10 · Debugging
This program should print True, because both cards are the queen of hearts. It prints None. Find the bug and fix it.
class Card:
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
def __eq__(self, other):
self.rank == other.rank and self.suit == other.suit
print(Card("Q", "hearts") == Card("Q", "hearts"))Check your reasoning
There is no error message: the program runs and prints the wrong thing. Line 7 works out True, but nothing returns it. A function or method without return gives back None (Day 9), and == passes on whatever __eq__ returns. Add return:
class Card:
def __init__(self, rank, suit):
self.rank = rank
self.suit = suit
def __eq__(self, other):
return self.rank == other.rank and self.suit == other.suit
print(Card("Q", "hearts") == Card("Q", "hearts"))TrueThis is the same mistake as printing instead of returning in __str__ (Day 16): Python asks the method for a value, so the method must return it.
Reread 17.5 Comparing with __eq__ if you missed it.
17.6 Ordering with __lt__ and Size with __len__
Ask Python to sort a list of your objects, and it does not know how:
class Runner:
def __init__(self, name, time):
self.name = name
self.time = time
runners = [Runner("Ana", 31), Runner("Ben", 28), Runner("Cy", 35)]
print(sorted(runners))Traceback (most recent call last):
File ".../day17/runners_no_lt.py", line 8, in <module>
print(sorted(runners))
~~~~~~^^^^^^^^^
TypeError: '<' not supported between instances of 'Runner' and 'Runner'
The message gives away how sorting works: sorted compares items two at a time with <. Day 10 worked around this with key=, telling sorted to compare a value taken from each item. That is still the right tool for a one-off order. But if your objects have one natural order (runners by finishing time, versions by number), you can teach the class itself what < means. a < b calls a.__lt__(b) (“lt” is short for “less than”), and __lt__ returns True if self should come before other.
Worked example: Runners that sort by time
class Runner:
def __init__(self, name, time):
self.name = name
self.time = time
def __lt__(self, other):
return self.time < other.time
def __repr__(self):
return f"Runner({self.name!r}, {self.time})"
runners = [Runner("Ana", 31), Runner("Ben", 28), Runner("Cy", 35)]
print(sorted(runners))
print(min(runners), max(runners))
print(runners[0] < runners[1], runners[0] > runners[1])
runners.sort(reverse=True)
print(runners)[Runner('Ben', 28), Runner('Ana', 31), Runner('Cy', 35)]
Runner('Ben', 28) Runner('Cy', 35)
False True
[Runner('Cy', 35), Runner('Ana', 31), Runner('Ben', 28)]- Line 14:
sortednow works with nokey, putting the fastest time first. Each comparison it makes, such as “is Ana before Ben?”, callsAna.__lt__(Ben), which works out31 < 28. - Line 15:
minandmaxcompare with<too, so they find the fastest and the slowest runners. - Line 16:
runners[0] < runners[1]asks whether31 < 28, which isFalse. The class has no “greater than” method, yet>works: when the left side cannot answera > b, Python flips it intob < aand asks the right side’s__lt__. Ben’s28 < 31isTrue. - Line 17:
sort(reverse=True)(Day 6) uses the same__lt__and reverses the result.
The lists print with __repr__ for each item (Day 16).
__lt__ gives you < and >, but not <= or >=. Those are separate methods (__le__ and __ge__), and this class does not define them:
class Runner:
def __init__(self, name, time):
self.name = name
self.time = time
def __lt__(self, other):
return self.time < other.time
ana = Runner("Ana", 31)
ben = Runner("Ben", 28)
print(ben < ana)
print(ben <= ana)True
Traceback (most recent call last):
File ".../day17/runner_le.py", line 13, in <module>
print(ben <= ana)
^^^^^^^^^^
TypeError: '<=' not supported between instances of 'Runner' and 'Runner'
For sorting, min, and max, __lt__ is all you need. 17.8 shows a way to get all four comparisons at once.
The other special method today answers “how big is it?” len(obj) calls obj.__len__(), and __len__ must return a whole number of 0 or more. It makes sense for objects that hold a collection of things, like the playlist from 17.4:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add(self, song):
self.songs.append(song)
def __len__(self):
return len(self.songs)
road_trip = Playlist("Road trip")
print(len(road_trip), bool(road_trip))
if not road_trip:
print("Road trip is empty")
road_trip.add("Holiday")
road_trip.add("Sunrise")
print(len(road_trip), bool(road_trip))0 False
Road trip is empty
2 True
__len__ delegates to the list inside (composition again): the playlist’s length is the number of songs. Defining __len__ has a second effect. Day 4 taught that an empty string or list counts as False in an if. Python decides that for your objects too, using __len__: an object whose length is 0 is falsy. So bool(road_trip) is False while the playlist is empty, and if not road_trip: reads like English. After two songs it is truthy.
Here are the special methods so far, and what triggers each:
| You write | Python calls |
|---|---|
a == b, a != b |
a.__eq__(b) (!= flips its answer) |
a < b |
a.__lt__(b) |
a > b |
b.__lt__(a), when the class has only __lt__ |
sorted, min, max, sort |
__lt__, many times |
len(a) |
a.__len__() |
if a:, bool(a) |
a.__len__(), and checks whether it is 0 |
print(a), str(a) |
a.__str__() (Day 16) |
Key idea
Define __lt__(self, other) so <, >, sorted, min, and max work on your objects with no key. Define
__len__(self) to return a count, so len(obj) works and an empty object is falsy.
Watch out
__len__ must return a whole number. It is tempting to use len for any measurement, such as a playlist’s length in minutes. But len() means “how many”, and Python refuses anything that is not a whole number:
class Playlist:
def __init__(self, name):
self.name = name
self.seconds = []
def add(self, seconds):
self.seconds.append(seconds)
def __len__(self):
return sum(self.seconds) / 60
road_trip = Playlist("Road trip")
road_trip.add(200)
road_trip.add(250)
print(len(road_trip))Traceback (most recent call last):
File ".../day17/len_float.py", line 16, in <module>
print(len(road_trip))
~~~^^^^^^^^^^^
TypeError: 'float' object cannot be interpreted as an integer450 seconds / 60 is 7.5, a float, and the error appears at the call to len. Keep __len__ for counting the items an object holds (here, len(self.seconds)), and give other measurements their own name, such as a minutes method or the computed attribute you meet in 17.7.
Pause and try
Question 11 · Tracing
What does this program print? Boxes compare by weight, and a box’s length is the length of its label.
class Box:
def __init__(self, label, weight):
self.label = label
self.weight = weight
def __lt__(self, other):
return self.weight < other.weight
def __len__(self):
return len(self.label)
def __repr__(self):
return self.label
boxes = [Box("books", 12), Box("lamp", 4), Box("kettle", 9)]
print(sorted(boxes))
print(max(boxes), len(boxes), len(boxes[0]))
print(sorted(boxes, key=len))Check your reasoning
[lamp, kettle, books]
books 3 5
[lamp, books, kettle]- Line 16:
sorteduses__lt__, so the boxes go from lightest to heaviest: lamp (4), kettle (9), books (12).__repr__returns just the label, so the list shows no quotes. - Line 17:
maxuses__lt__too and finds books (12).len(boxes)is the length of the list, 3 boxes.len(boxes[0])calls__len__on the books box:"books"has 5 letters. - Line 18:
key=lensorts by each box’slen(), which calls__len__: lamp 4, books 5, kettle 6.
Reread 17.6 Ordering with __lt__ and Size with __len__ if your output differed.
Question 12 · Multiple choice
A Cart class defines __len__ to return the number of items in the cart. cart has no items yet. What does print("full" if cart else "empty") print?
full, because every object counts as true.empty, becausecarthas length 0, so it is falsy.full, becausecartis notNone.- It raises a
TypeError, because a cart is not a bool.
Check your reasoning
B. When a class defines __len__, Python uses it to decide truthiness: a length of 0 is falsy, like an empty list (Day 4). The conditional expression (Day 4) picks "empty".
- A is wrong: that is true only for objects without
__len__. - C is wrong: truthiness is not a check for
None; an empty list is notNone, yet it is falsy. - D is wrong: any object can be used in a condition; Python works out its truthiness.
Reread 17.6 Ordering with __lt__ and Size with __len__ if you chose differently.
17.7 Computed Attributes with @property
A rectangle’s area depends on its width and height. A natural first try stores it in __init__, next to them:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
self.area = width * height
room = Rectangle(4, 3)
print(room.area)
room.width = 5
print(room.width, room.area)12
5 12
The width changed to 5, but the area still says 12, which is now wrong (5 × 3 is 15). self.area was worked out once, when the object was made, and nothing updates it. A value copied from other attributes goes stale as soon as they change.
A method fixes that, because it works the area out each time: room.area(). But the area feels like a fact about the rectangle, like its width, not like an action. Python lets you have both: a method that is read like an attribute. Put the line @property directly above the method’s def:
@property
def area(self):
return self.width * self.height
A line that starts with @ and sits right above a def is a decorator: it changes what the def makes. Day 18 shows how decorators work and how to write your own. Today you only use two that come with Python. @property turns the method into a property: an attribute whose value is computed by running the method each time the attribute is read. You read it with no parentheses, room.area, and Python calls the method for you, the same way it calls __str__ for print.
Worked example: An area that is always up to date
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
@property
def is_square(self):
return self.width == self.height
room = Rectangle(4, 3)
print(room.area, room.is_square)
room.width = 3
print(room.area, room.is_square)
try:
room.area = 100
except AttributeError as error:
print(error)12 False
9 True
property 'area' of 'Rectangle' object has no setter| Line | What happens |
|---|---|
| 16 | Reading room.area runs the area method: 4 × 3 = 12. is_square runs too: 4 == 3 is False |
| 17 | Only width is stored, so only width changes |
| 18 | Reading the properties again runs the methods again: 3 × 3 = 9, and 3 == 3 is True |
| 20 | Assigning to room.area raises an AttributeError, which line 22 prints |
Line 20 shows that a property is read-only unless you do more work. That is right for a computed value: setting the area of a rectangle directly makes no sense, because the area must always match the width and height. A property whose name starts with is_ and answers yes or no, like is_square, is a common, readable pattern. Older versions of Python word the last message differently (for example can't set attribute).
Being read-only solves a problem from Day 16. The bank account’s rules hold only if every change goes through deposit and withdraw, but any code could still write account.balance = -5. With a property, outside code can read the balance but not assign it:
class BankAccount:
def __init__(self, owner, balance=0):
if balance < 0:
raise ValueError(f"opening balance cannot be negative, not {balance}")
self.owner = owner
self._balance = balance
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError(f"deposit must be more than 0, not {amount}")
self._balance += amount
account = BankAccount("Ana", 50)
account.deposit(25)
print(account.balance)
try:
account.balance = -5
except AttributeError as error:
print(f"Refused: {error}")
print(account.balance)75
Refused: property 'balance' of 'BankAccount' object has no setter
75
- Line 6 stores the real number in an attribute named
_balance. A name that starts with one underscore is a convention, not a rule: it tells other programmers “this is internal; use it only inside the class.” Python does not stop anyone from reading or changing_balance, but anyone who does has been warned. - Lines 8 to 10 make
balancea property that just returns_balance. Outside code readsaccount.balanceexactly as before. - Line 15: the methods change
_balance, the internal attribute. - Line 22: assigning
account.balanceis refused, so the balance can change only through the methods, where the checks are.
When should something be a property rather than a method? Use a property for a value that describes the object, takes no arguments, is quick to work out, and changes nothing (area, is_square, a full name built from first and last names). Use a method for an action (deposit), for anything that needs arguments (withdraw(amount)), or for slow work, because a reader expects reading an attribute to be instant.
Key idea
@property above a method makes it a computed attribute: reading obj.name with no parentheses runs the method every
time, so the value is never stale, and assigning to it raises an AttributeError. Pair it with an underscore
attribute, such as _balance, to let outside code read a value but not set it.
Watch out
Calling a property with parentheses. After @property, room.area is already the number, so room.area() tries to call the number:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
room = Rectangle(4, 3)
print(room.area())Traceback (most recent call last):
File ".../day17/property_call.py", line 12, in <module>
print(room.area())
~~~~~~~~~^^
TypeError: 'int' object is not callable“Not callable” means “you put () after something that is not a function”. Reading room.area gave 12, and then 12() failed. When you see this message on a line that uses a property, drop the parentheses: print(room.area).
Pause and try
Question 13 · Tracing
What does this program print?
class Thermometer:
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
return self.celsius * 9 / 5 + 32
@property
def is_freezing(self):
return self.celsius <= 0
reading = Thermometer(25)
print(reading.fahrenheit, reading.is_freezing)
reading.celsius = -10
print(reading.fahrenheit, reading.is_freezing)Check your reasoning
77.0 False
14.0 TrueOnly celsius is stored. Each read of fahrenheit or is_freezing runs its method with the current celsius:
| Line | celsius |
fahrenheit |
is_freezing |
|---|---|---|---|
| 14 | 25 | 25 × 9 / 5 + 32 = 77.0 | 25 <= 0: False |
| 16 | −10 | −10 × 9 / 5 + 32 = 14.0 | -10 <= 0: True |
The / makes a float (Day 2), so 77.0 prints with .0.
Reread 17.7 Computed Attributes with @property if your output differed.
Question 14 · Multiple choice
A Student class stores first and last names. Which of these is the best fit for a property?
enroll(course), which adds a course to the student’s listfull_name, which returns the first and last names joined by a spacesave(path), which writes the student to a JSON fileadd_grade(score), which appends a score
Check your reasoning
B. full_name describes the student, needs no arguments, is quick to work out, and changes nothing. Making it a property means it is always built from the current names and reads naturally: student.full_name.
- A is wrong: enrolling is an action that changes the object and needs an argument, so it is a method.
- C is wrong: saving needs an argument and does slow work with a file, which a reader would not expect from reading an attribute.
- D is wrong: adding a grade changes the object and needs an argument.
Reread 17.7 Computed Attributes with @property if you chose differently.
17.8 Data Classes with @dataclass
Many classes mostly hold data. A point, a song, a task, a grade: each has a few attributes and not much behavior. Yet a well-behaved data-holding class needs the same routine methods every time. Here is a point written by hand with __init__, a __repr__ in the Day 16 style, and a careful __eq__ from 17.5:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return self.x == other.x and self.y == other.y
print(Point(2, 3), Point(2, 3) == Point(2, 3))Point(x=2, y=3) True
Twelve lines for two attributes, and x and y each appear six times. Add a field and you must update three methods; forget one and the class is quietly wrong. Python can write these methods for you. The module dataclasses in the standard library provides a decorator, @dataclass, that goes above a class line:
Worked example: The same point as a data class
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
start = Point(2, 3)
print(start)
print(start == Point(2, 3), start.x + start.y)
print(Point(y=1, x=4))
print(Point("two", "three"))Point(x=2, y=3)
True 5
Point(x=4, y=1)
Point(x='two', y='three')The class body (lines 6 and 7) lists the class’s fields: the attributes every object will have, one per line, in the form name: type. The part after the colon is a type annotation: a note saying what kind of value the field is meant to hold. A class decorated with @dataclass is called a data class. From the fields, @dataclass writes three methods for you:
| Generated method | What it does | Line that uses it |
|---|---|---|
__init__ |
Takes one parameter per field, in order, and stores each one | 10, 13 |
__repr__ |
Shows the class name and every field, like Point(x=2, y=3) |
11 |
__eq__ |
Equal when both are the same class and every field is equal | 12 |
- Line 10:
Point(2, 3)passesxandyin field order. Line 13 shows that the generated__init__takes keyword arguments too (Day 10). - Line 11:
printhas no__str__to use, so it uses the generated__repr__(Day 16). - Line 12:
==compares the fields, and the fields are ordinary attributes:start.x + start.yis 5. - Line 14: Python does not check annotations.
x: intdocuments whatxshould hold, butPoint("two", "three")is accepted. Day 19 shows tools that do check them.
A data class is still a class. You can give fields default values, and add methods and properties exactly as before:
from dataclasses import asdict, dataclass
@dataclass
class Song:
title: str
artist: str
seconds: int = 180
@property
def minutes(self):
return round(self.seconds / 60, 1)
hit = Song("Sunrise", "The Larks", 210)
demo = Song("Untitled", "The Larks")
print(hit)
print(demo.seconds, hit.minutes)
print(hit == Song("Sunrise", "The Larks", 210))
print(asdict(demo))Song(title='Sunrise', artist='The Larks', seconds=210)
180 3.5
True
{'title': 'Untitled', 'artist': 'The Larks', 'seconds': 180}
- Line 8 gives
secondsa default, soSong("Untitled", "The Larks")on line 16 gets 180, like a default parameter value (Day 10). - Lines 10 to 12 are a property from 17.7: 210 / 60 = 3.5 minutes.
- Line 20: the
dataclassesmodule also has a functionasdict(), which turns a data class object into a dictionary of its fields. That is exactly whatjson.dumpneeds (Day 14).
Fields with defaults must come after fields without them, for the same reason as in a def line: the generated __init__ lists the fields in order, and a parameter without a default cannot follow one with a default.
from dataclasses import dataclass
@dataclass
class Song:
title: str
seconds: int = 180
artist: strTraceback (most recent call last):
File ".../day17/dataclass_order.py", line 4, in <module>
@dataclass
^^^^^^^^^
...
TypeError: non-default argument 'artist' follows default argument 'seconds'
The ... stands for several lines from inside the dataclasses module; the first and last lines are the ones that matter. Notice that the error comes when the class is defined, before any object is made. Move artist above seconds.
Finally, @dataclass(order=True) also writes __lt__, __le__, __gt__, and __ge__. They compare two objects like tuples of their fields (Day 10): first fields first, and the next field only to break a tie.
from dataclasses import dataclass
@dataclass(order=True)
class Grade:
score: int
name: str
grades = [Grade(88, "Ben"), Grade(95, "Ana"), Grade(88, "Ali")]
print(sorted(grades))
print(max(grades).name)[Grade(score=88, name='Ali'), Grade(score=88, name='Ben'), Grade(score=95, name='Ana')]
Ana
The two 88s tie on score, so name decides: 'Ali' comes before 'Ben'. Put the fields in the order you want them compared.
Key idea
@dataclass above a class with fields written as name: type (and optional defaults) generates __init__,
__repr__, and __eq__; @dataclass(order=True) adds < and its relatives. Annotations document types but are not
checked. Add methods and properties as usual, and use asdict() to get a dictionary of the fields.
Watch out
A list as a default field. Day 10’s mutable default trap would strike here too: one [] made once and shared by every recipe. Python catches it for you and refuses to define the class:
from dataclasses import dataclass
@dataclass
class Recipe:
name: str
ingredients: list = []
soup = Recipe("Tomato soup")Traceback (most recent call last):
File ".../day17/list_default.py", line 4, in <module>
@dataclass
^^^^^^^^^
...
ValueError: mutable default <class 'list'> for field ingredients is not allowed: use default_factoryThe message names the fix. field(default_factory=list), with field imported from dataclasses, says “for each new object, call list() to make a fresh empty list”. Pass the function list itself, without parentheses (Day 10: functions are values):
from dataclasses import dataclass, field
@dataclass
class Recipe:
name: str
ingredients: list = field(default_factory=list)
soup = Recipe("Tomato soup")
salad = Recipe("Green salad")
soup.ingredients.append("tomatoes")
print(soup)
print(salad)Recipe(name='Tomato soup', ingredients=['tomatoes'])
Recipe(name='Green salad', ingredients=[])Each recipe has its own list, so adding tomatoes to the soup leaves the salad’s list empty.
Pause and try
Question 15 · Tracing
What does this program print?
from dataclasses import dataclass
@dataclass
class Order:
item: str
quantity: int = 1
price: float = 2.5
def total(self):
return self.quantity * self.price
first = Order("bagel", 3)
second = Order("bagel", price=3.0)
print(first)
print(first.total(), second.total())
print(first == Order("bagel", 3, 2.5), first == second)Check your reasoning
Order(item='bagel', quantity=3, price=2.5)
7.5 3.0
True False- Line 14:
Order("bagel", 3)fillsitemandquantityby position;pricekeeps its default 2.5. - Line 15: the keyword argument
price=3.0skipsquantity, which keeps its default 1. - Line 16:
printuses the generated__repr__, with every field. - Line 17: 3 × 2.5 = 7.5 and 1 × 3.0 = 3.0.
- Line 18: the generated
__eq__compares all three fields.Order("bagel", 3, 2.5)matchesfirstexactly.secondhas a different quantity and price, so it is not equal.
Reread 17.8 Data Classes with @dataclass if your output differed.
Question 16 · Debugging
This program should print Pet(name='Rex', age=3). It crashes. Explain the error and fix it.
from dataclasses import dataclass
class Pet:
name: str
age: int = 1
rex = Pet("Rex", 3)
print(rex)Check your reasoning
The traceback’s last line is:
TypeError: Pet() takes no argumentsThe @dataclass line is missing above class Pet:. Importing dataclass does nothing by itself; only the decorator line writes the methods. Without it, Pet is an ordinary class with no __init__, which is why it takes no arguments (Day 16 met this message for a misspelled __init__). The annotated lines just record notes, and age = 1 becomes a class attribute. Add the decorator:
from dataclasses import dataclass
@dataclass
class Pet:
name: str
age: int = 1
rex = Pet("Rex", 3)
print(rex)Pet(name='Rex', age=3)Reread 17.8 Data Classes with @dataclass if you missed it.
17.9 When a Class Is Worth It
You now have a lot of tools: plain functions, dictionaries, classes, inheritance, data classes. Having them does not mean using all of them every time. More structure is not better in itself; the best code is the simplest code that keeps the program correct and easy to change. So, before writing a class, ask what it would buy you.
Some jobs need nothing more than a function and a dictionary. Counting words takes some text and gives back counts. No data has to be kept between calls, and there are no rules to protect:
def count_words(text):
counts = {}
for word in text.lower().split():
counts[word] = counts.get(word, 0) + 1
return counts
print(count_words("The cat saw the dog")){'the': 2, 'cat': 1, 'saw': 1, 'dog': 1}
A WordCounter class here would add a name, an __init__, and a method call, and do nothing a function cannot. On the other hand, the bank account earned its class: it keeps data between operations, it has rules that must always hold (the balance cannot go negative), several operations work on the same data, and the program makes many accounts of the same shape. Day 16 started from exactly this kind of signal: several functions that all took the same dictionary, with nothing stopping a misspelled key.
| Situation | Usually best |
|---|---|
| One calculation: values in, a result out, nothing kept | A function |
| A lookup table or a tally that you build and read | A dictionary |
| Many records with the same fields and little behavior | A data class |
| Data plus rules that must always hold, and several operations on it | A class |
| Several kinds of thing that share most of their behavior | A parent class and child classes |
The table is a guide, not a law. A program often starts with a function and a dictionary and grows into a class when a second and third function appear that all work on the same data. That is a good moment to switch, not a failure to plan.
Key idea
Write a class when data and its rules belong together, several operations share that data, or you need many objects of the same shape. A single calculation is a function, a simple lookup is a dictionary, and a record with little behavior is a data class.
Watch out
A function wearing a class costume. This class stores two values only so that one method can use them once:
class TipCalculator:
def __init__(self, bill, percent):
self.bill = bill
self.percent = percent
def calculate(self):
return round(self.bill * self.percent / 100, 2)
print(TipCalculator(40, 15).calculate())6.0The tell-tale signs: the class has only __init__ and one other method, and every use makes an object, calls the method once, and throws the object away. Nothing is kept, and there are no rules to protect. A function says the same thing in a third of the lines, and the call reads better:
def tip(bill, percent):
return round(bill * percent / 100, 2)
print(tip(40, 15))6.0Pause and try
Question 17 · Short answer
For each program, say whether you would use a function, a dictionary, a data class, or a class, and give one reason: (a) a program that converts a distance in miles to kilometers; (b) a phone book that maps each friend’s name to a phone number; (c) a game in which each character has health that must stay between 0 and 100, and can be healed, hit, and printed.
Check your reasoning
Model answer:
- (a) A function. Miles in, kilometers out, and nothing is kept between calls, so a class would add nothing.
- (b) A dictionary. It is a lookup table: names are keys and numbers are values, and there are no rules beyond that.
- (c) A class. Each character keeps data (health) between operations, there is a rule that must always hold (0 to 100), and several operations (heal, hit, print) work on that data. Methods can enforce the rule, and a property could let outside code read the health without setting it.
A correct answer matches each choice to whether data is kept, whether rules must be protected, and how many operations share the data.
Reread 17.9 When a Class Is Worth It if your reasoning differed.
Putting it together
A school library lends books and films. Films go out for less time and cost double in late fines. Each loan remembers what was borrowed and for how many days, and each member’s loan card can work out the total fine. The program below uses every idea from today.
from dataclasses import dataclass
class Item:
kind = "item"
loan_days = 21
daily_fine = 0.25
def __init__(self, title, code):
self.title = title
self.code = code
def fine(self, days_late):
return days_late * self.daily_fine
def __eq__(self, other):
if not isinstance(other, Item):
return NotImplemented
return self.code == other.code
def __lt__(self, other):
return self.title < other.title
def __str__(self):
return f"{self.title} ({self.kind})"
class Book(Item):
kind = "book"
def __init__(self, title, code, pages):
super().__init__(title, code)
self.pages = pages
class Film(Item):
kind = "film"
loan_days = 7
def fine(self, days_late):
return super().fine(days_late) * 2
@dataclass
class Loan:
item: Item
days_out: int
@property
def days_late(self):
return max(0, self.days_out - self.item.loan_days)
@property
def fine(self):
return self.item.fine(self.days_late)
class LoanCard:
def __init__(self, owner):
self.owner = owner
self.loans = []
def borrow(self, item, days_out):
if item in [loan.item for loan in self.loans]:
raise ValueError(f"{self.owner} already has {item}")
self.loans.append(Loan(item, days_out))
def __len__(self):
return len(self.loans)
@property
def total_fine(self):
return sum(loan.fine for loan in self.loans)
card = LoanCard("Ana")
card.borrow(Book("Wonder", "B-17", 310), 25)
card.borrow(Film("Up", "F-02"), 10)
card.borrow(Book("Holes", "B-04", 233), 3)
try:
card.borrow(Book("Wonder", "B-17", 310), 1)
except ValueError as error:
print(f"Refused: {error}")
for loan in sorted(card.loans, key=lambda loan: loan.item):
print(f"{loan.item}: {loan.days_late} days late, fine {loan.fine:.2f}")
print(f"{card.owner} has {len(card)} loans; total fine {card.total_fine:.2f}")Refused: Ana already has Wonder (book)
Holes (book): 0 days late, fine 0.00
Up (film): 3 days late, fine 1.50
Wonder (book): 4 days late, fine 1.00
Ana has 3 loans; total fine 2.50
| Lines | What happens | Idea |
|---|---|---|
| 4–25 | Item is the parent: class attributes for the rules, a fine method, equality by catalog code, order by title |
17.1, 17.5, 17.6 |
| 28–33 | Book adds pages, calling super().__init__ so the parent still stores the title and code |
17.2 |
| 36–41 | Film overrides the class attribute loan_days and the fine method, using super().fine and doubling it |
17.3 |
| 44–55 | Loan is a data class that has an item; its properties work out the days late and the fine every time they are read |
17.4, 17.7, 17.8 |
| 58–73 | LoanCard has a list of loans (composition), refuses an item it already has, and defines __len__ |
17.4, 17.6 |
| 81 | A new Book object with code "B-17" is == to the Wonder already on the card, so in finds it and borrow refuses |
17.5 |
| 85 | Sorting by loan.item uses Item.__lt__, so the loans come out in title order |
17.6 |
| 86 | loan.fine is polymorphic: a book’s fine and a film’s fine run different methods |
17.3 |
| 87 | len(card) calls __len__; total_fine is a property |
17.6, 17.7 |
Work through the fines. Wonder was out 25 days and a book may go out for 21, so it is 4 days late: 4 × 0.25 = 1.00. Up was out 10 days and a film may go out for 7, so 3 days late; Film.fine doubles the parent’s 3 × 0.25 = 0.75 to 1.50. Holes was out 3 days, and max(0, 3 - 21) is 0, so no fine. The total is 2.50.
Look at what each tool bought. Adding a third kind of item, say a magazine with a 14-day loan, is a three-line child class; the loan card, the loans, and the fine total all work with it unchanged, through polymorphism. And Loan, which only holds two values and works things out from them, took five lines as a data class.
Common mistakes
Many of today’s mistakes show up as error messages that point at the line where the problem appears, not where it was made. Use this table to go from a message to its usual cause.
| Message | Usual cause | Fix |
|---|---|---|
AttributeError: 'SavingsAccount' object has no attribute 'balance' |
The child’s __init__ never calls super().__init__ |
Call super().__init__(...) first |
AttributeError: 'BankAccount' object has no attribute 'add_interest' |
Called a child-only method on a parent object | Call it only on child objects |
TypeError: Pet.__init__() takes 2 positional arguments but 3 were given |
Passed self to super().__init__(self, name) |
super().__init__(name) |
NotImplementedError from the parent’s method |
The child forgot the override, or misspelled its name | Define the method with the parent’s exact name |
TypeError: '<' not supported between instances of 'Runner' and 'Runner' |
Sorted, or used <, min, or max, without __lt__ |
Define __lt__, or pass key= |
TypeError: cannot use 'Point' as a set element (unhashable type: 'Point') |
The class defines __eq__, so its objects are unhashable |
Store a hashable stand-in, such as a tuple |
== gives None |
__eq__ works out the answer but does not return it |
return the comparison |
TypeError: 'int' object is not callable |
Called a property with parentheses | Drop the () |
AttributeError: property 'area' of 'Rectangle' object has no setter |
Assigned to a property | Change the attributes it is computed from |
TypeError: 'float' object cannot be interpreted as an integer |
__len__ returned something other than a whole number |
Return a count; use a property for measurements |
TypeError: non-default argument 'artist' follows default argument 'seconds' |
A data class field without a default after one with a default | Put fields with defaults last |
ValueError: mutable default <class 'list'> for field ... |
A list default in a data class | field(default_factory=list) |
TypeError: Pet() takes no arguments |
The @dataclass line is missing |
Add @dataclass above the class |
Older versions of Python may word some of these messages a little differently.
Vocabulary
| Term | Definition | Example |
|---|---|---|
| inheritance / inherit | Defining a class as an extended version of another, getting all its methods | class SavingsAccount(BankAccount): |
| parent class / base class / superclass | The class being inherited from | BankAccount |
| child class / subclass | The class that inherits | SavingsAccount |
isinstance() |
True if an object is an instance of a class or of any child of it |
isinstance(savings, BankAccount) |
| “is-a” relationship | The link between a child and its parent: a child object is a kind of parent object | A savings account is a bank account |
object |
The built-in class every class inherits from in the end | isinstance(plain, object) |
super() |
The parent class, working on the same object | super().__init__(owner, balance) |
| overriding / override | Defining a method in a child with the parent’s name, to change its behavior | FeeAccount.withdraw |
| polymorphism | One method call working on objects of different classes, each in its own way | account.withdraw(10) in a loop |
NotImplementedError |
An exception a parent’s method raises when a child must provide that method | raise NotImplementedError("...") |
| duck typing | Using any object that has the needed method, whatever its class | Banner().send(text) |
| composition / “has-a” relationship | An object keeping other objects as attributes and using them | self.accounts = [] |
| delegating | Handing part of a job to an object you hold | len(self.songs) |
__eq__ |
The method == calls |
def __eq__(self, other): |
NotImplemented |
A value __eq__ returns to say “I cannot compare with that” |
return NotImplemented |
__lt__ |
The method < (and sorting) calls |
return self.time < other.time |
__len__ |
The method len() calls; it must return a whole number, and 0 makes the object falsy |
return len(self.songs) |
| decorator | A line starting with @ above a def or class that changes what it makes |
@property |
| property | A computed attribute: reading it runs a method, with no parentheses | room.area |
_name (one leading underscore) |
A convention meaning “internal; use only inside the class” | self._balance |
data class / @dataclass |
A class whose __init__, __repr__, and __eq__ are written from its fields |
@dataclass above class Point: |
| field | One attribute declared in a data class body | x: int |
| type annotation | A note after a colon saying what type a value should be; not checked when the program runs | title: str |
asdict() |
Turns a data class object into a dictionary of its fields | asdict(song) |
field(default_factory=list) |
A data class default that makes a fresh value for each object | tags: list = field(default_factory=list) |
Summary
- Inheritance (
class Child(Parent):) gives a child every method and class attribute of its parent, so you write only what is new. A child object is a parent object:isinstancesays so, andexcept Parentcatches it. Every class inherits fromobjectin the end. - A child method with a parent’s name overrides it.
super()reaches the parent’s version, so a child’s__init__callssuper().__init__(...)and an override can build on the parent. Polymorphism means one call runs each object’s own version. - Composition (“has-a”) keeps other objects as attributes and delegates to them. Prefer it unless the new class truly is a kind of the old one and every inherited method makes sense for it.
- Special methods plug your objects into Python:
__eq__for==(returnNotImplementedfor other types),__lt__for<and sorting, and__len__forlen()and truthiness. @propertymakes a computed, read-only attribute that is never stale.@dataclasswrites__init__,__repr__, and__eq__from the fields of a class that mostly holds data.- A class is worth writing when data, its rules, and several operations belong together; otherwise a function, a dictionary, or a data class is often enough.
Practice
Do these in your day17 folder. Type each program yourself, run it, and only then open the answer.
Question 18 · Short answer
Shapes. Write a Shape parent class whose area() method raises NotImplementedError, and whose __str__ describes the shape using self.area(). Then write Circle (made from a radius) and Rectangle (made from a width and a height) child classes that each compute area(). Put several shapes in a list, print each one, and print the total area.
Check your reasoning
One possible solution, which also adds a Square as a child of Rectangle:
import math
class Shape:
name = "shape"
def area(self):
raise NotImplementedError("each kind of shape must define area()")
def __str__(self):
return f"{self.name} with area {self.area():.2f}"
class Circle(Shape):
name = "circle"
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius**2
class Rectangle(Shape):
name = "rectangle"
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle):
name = "square"
def __init__(self, side):
super().__init__(side, side)
shapes = [Circle(1), Rectangle(4, 2.5), Square(3)]
for shape in shapes:
print(shape)
print(f"Total area: {sum(shape.area() for shape in shapes):.2f}")circle with area 3.14
rectangle with area 10.00
square with area 9.00
Total area: 22.14How it works: Shape.__str__ calls self.area(), and polymorphism picks each child’s area (17.3). Each child sets a class attribute name that overrides the parent’s, so the description says what kind of shape it is. Circle uses math.pi (Day 13) and ** (Day 2): π × 1² is about 3.14. Square has only an __init__, which hands the side to Rectangle.__init__ twice through super() (17.2); it inherits area from Rectangle. The total adds 3.14159… + 10.0 + 9.0 with a generator expression (Day 11). Forgetting area in a new child raises the parent’s NotImplementedError as soon as the shape is printed.
Reread 17.2 Extending __init__ with super() and 17.3 Overriding Methods and Polymorphism if your result differed.
Question 19 · Short answer
Task as a data class. Rewrite the to-do manager’s Task class from Day 16 as a data class, keeping its complete() method and the __str__ from Day 16’s practice. Use asdict() to save it. Then simplify tasks.py wherever the data class makes that possible.
Check your reasoning
In todo_app/task.py, the hand-written __init__ becomes two fields, and to_dict uses asdict:
"""The Task class for the to-do manager, written as a data class."""
from dataclasses import asdict, dataclass
@dataclass
class Task:
"""One thing to do: a title, and whether it is done yet."""
title: str
done: bool = False
def complete(self):
"""Mark this task as done."""
self.done = True
def to_dict(self):
"""Return this task as a dictionary, ready to save as JSON."""
return asdict(self)
def __str__(self):
mark = "x" if self.done else " "
return f"[{mark}] {self.title}"
if __name__ == "__main__":
task = Task("Test the class")
print(repr(task))
task.complete()
print(task)
print(task.to_dict())
print(task == Task("Test the class", True))Run directly, its test block prints:
Task(title='Test the class', done=False)
[x] Test the class
{'title': 'Test the class', 'done': True}
TrueThe first line comes from the generated __repr__. print(task) still uses your own __str__, because @dataclass writes __repr__ but leaves a __str__ you wrote alone. The last line uses the generated __eq__, which Day 16’s class did not have.
In tasks.py, two things change. list_tasks prints each task with its __str__, and load_tasks builds each task with Task(**item): each saved dictionary has exactly the keys title and done, which are the names of the generated __init__’s parameters, so ** (Day 10) passes them as keyword arguments.
"""Task functions for the to-do manager. main.py imports them."""
import json
from task import Task
def show_menu():
"""Print the menu of choices."""
print()
print("1) Add 2) List 3) Complete 4) Delete 5) Quit")
def add_task(tasks, title):
"""Add a new, unfinished Task to the end of tasks."""
tasks.append(Task(title))
def list_tasks(tasks):
"""Print every task with its number, using Task.__str__."""
if not tasks:
print("No tasks yet.")
return
for number, task in enumerate(tasks, 1):
print(f"{number}. {task}")
def complete_task(tasks, number):
"""Mark task number (counting from 1) as done.
Return the Task, or None if no task has that number.
"""
if not 1 <= number <= len(tasks):
return None
task = tasks[number - 1]
task.complete()
return task
def delete_task(tasks, number):
"""Remove task number (counting from 1) from tasks.
Return the removed Task, or None if no task has that number.
"""
if not 1 <= number <= len(tasks):
return None
return tasks.pop(number - 1)
def save_tasks(tasks, path):
"""Write tasks to the JSON file at path, replacing what was there."""
data = [task.to_dict() for task in tasks]
with path.open("w", encoding="utf-8") as file:
json.dump(data, file, indent=2)
def load_tasks(path):
"""Return the Tasks saved at path, or [] if there are none to load.
A missing file means this is the first run. A file that is not valid
JSON gets a warning, and the program starts with no tasks.
"""
try:
with path.open(encoding="utf-8") as file:
data = json.load(file)
except FileNotFoundError:
return []
except json.JSONDecodeError:
print(f"Warning: {path.name} is not valid JSON, so no tasks loaded.")
return []
return [Task(**item) for item in data]
if __name__ == "__main__":
test_tasks = []
add_task(test_tasks, "Test the module")
print(complete_task(test_tasks, 1).done)
print(complete_task(test_tasks, 2))
list_tasks(test_tasks)main.py does not change at all; it is the same file as on Day 16. With the same typed answers, the session is exactly Day 16’s:
To-do list manager
Loaded 2 tasks from tasks.json
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 2
1. [x] Return library books
2. [ ] Practice piano
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 3
Task number: two
Please type a task number, such as 1.
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 3
Task number: 2
Completed: Practice piano
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 4
Task number: 9
There is no task 9.
1) Add 2) List 3) Complete 4) Delete 5) Quit
Choose 1-5: 5
Goodbye!How it works: a task is a record with two fields and almost no behavior, which is exactly what a data class is for (17.9). The save file stays the same JSON because asdict produces the same dictionary that to_dict built by hand. If a later version adds a field, such as due, you add one line to the class and asdict and the __init__ pick it up.
Reread 17.8 Data Classes with @dataclass if your result differed.
Question 20 · Short answer
Sortable objects. Write a Student class with a name and a test score. Add __eq__ (same name and same score) and __lt__ (lower score first; for equal scores, alphabetical by name), so that a list of students can be sorted with plain sorted(). Test sorting, min, max, and in.
Check your reasoning
One possible solution:
class Student:
def __init__(self, name, score):
self.name = name
self.score = score
def __eq__(self, other):
if not isinstance(other, Student):
return NotImplemented
return self.name == other.name and self.score == other.score
def __lt__(self, other):
if self.score != other.score:
return self.score < other.score
return self.name < other.name
def __repr__(self):
return f"Student({self.name!r}, {self.score})"
roster = [Student("Cy", 88), Student("Ana", 95), Student("Ben", 88)]
print(sorted(roster))
print(min(roster), max(roster))
print(Student("Ana", 95) in roster, Student("Ana", 90) in roster)[Student('Ben', 88), Student('Cy', 88), Student('Ana', 95)]
Student('Ben', 88) Student('Ana', 95)
True FalseHow it works: __lt__ compares scores first; only when they are equal does it compare names, so Ben and Cy (both 88) come out alphabetically. min and max use the same __lt__. __eq__ starts with the isinstance check and returns NotImplemented for other types (17.5), so in can find Student("Ana", 95) but not Student("Ana", 90). __repr__ (Day 16) makes the printed lists readable. The same order could come from @dataclass(order=True) with the fields in the order score, name (17.8), at the cost of showing the score first in the repr.
Reread 17.5 Comparing with __eq__ and 17.6 Ordering with __lt__ and Size with __len__ if your result differed.
Review questions
Question 21 · Tracing
What does this program print? Watch both the class attribute and the method in each class.
class Vehicle:
wheels = 4
def __init__(self, name):
self.name = name
def describe(self):
return f"{self.name}: {self.wheels} wheels, {self.power()}"
def power(self):
return "gas"
class Bike(Vehicle):
wheels = 2
def power(self):
return "pedals"
class EBike(Bike):
def power(self):
return super().power() + " and a battery"
for ride in [Vehicle("Van"), Bike("Road bike"), EBike("Cargo bike")]:
print(ride.describe())
print(EBike.wheels, isinstance(EBike("Mini"), Vehicle))Check your reasoning
Van: 4 wheels, gas
Road bike: 2 wheels, pedals
Cargo bike: 2 wheels, pedals and a battery
2 TrueAll three objects use Vehicle.describe, which reads self.wheels and calls self.power(). Each lookup starts at the object’s own class:
| Object | wheels found in |
power that runs |
|---|---|---|
Vehicle |
Vehicle: 4 |
Vehicle.power: "gas" |
Bike |
Bike: 2 |
Bike.power: "pedals" |
EBike |
not in EBike; Bike: 2 |
EBike.power: super().power() is Bike.power, plus " and a battery" |
EBike.wheels is 2, found in its parent Bike. An EBike is a Bike, which is a Vehicle, so isinstance is True two levels up.
Reread 17.1 Inheritance: Building on an Existing Class and 17.3 Overriding Methods and Polymorphism if your output differed.
Question 22 · Debugging
This program should print Tom True. It crashes. Explain the error and fix it.
class Pet:
def __init__(self, name):
self.name = name
class Cat(Pet):
def __init__(self, name, indoor):
super().__init__(self, name)
self.indoor = indoor
tom = Cat("Tom", True)
print(tom.name, tom.indoor)Check your reasoning
The traceback’s last line is:
TypeError: Pet.__init__() takes 2 positional arguments but 3 were givenLine 8 passes self to super().__init__. But super() already knows the object and passes it as self automatically, just as tom.method() does. So Pet.__init__ receives the object, then self again, then name: three arguments for two parameters (self and name). As on Day 16, an error that counts one more argument than you meant points at a hidden self. Remove the extra self:
class Pet:
def __init__(self, name):
self.name = name
class Cat(Pet):
def __init__(self, name, indoor):
super().__init__(name)
self.indoor = indoor
tom = Cat("Tom", True)
print(tom.name, tom.indoor)Tom TrueReread 17.2 Extending __init__ with super() if you missed it.
Question 23 · Multiple choice
Which methods does a plain @dataclass (with no order=True) write for you?
__init__,__repr__, and__eq____init__,__repr__,__eq__, and__lt____init__and__str__only__init__,__len__, and__eq__
Check your reasoning
A. A data class gets an __init__ with one parameter per field, a __repr__ that shows every field, and an __eq__ that compares every field.
- B is wrong: the ordering methods, including
__lt__, come only with@dataclass(order=True). - C is wrong: it writes
__repr__, not__str__.printshows the repr only because there is no__str__to use, and it also writes__eq__. - D is wrong: a data class has no idea what its “length” would mean, so it writes no
__len__.
Reread 17.8 Data Classes with @dataclass if you chose differently.
Question 24 · Tracing
What does this program print? Item is a data class with order=True, so it compares by price first and then by name.
from dataclasses import dataclass
@dataclass(order=True)
class Item:
price: float
name: str
class Cart:
def __init__(self):
self.items = []
def add(self, item):
if item not in self.items:
self.items.append(item)
def __len__(self):
return len(self.items)
@property
def total(self):
return sum(item.price for item in self.items)
cart = Cart()
shopping = [
Item(2.5, "milk"),
Item(1.25, "bread"),
Item(2.5, "milk"),
Item(1.25, "apple"),
]
for item in shopping:
cart.add(item)
print(len(cart), cart.total)
print([item.name for item in sorted(cart.items)])
print(bool(Cart()), max(cart.items).name)Check your reasoning
3 5.0
['apple', 'bread', 'milk']
False milk- Lines 33 and 34:
addchecksitem not in self.items, andinuses the generated__eq__. The secondItem(2.5, "milk")is a new object, but it equals the first, so it is not added. The cart holds milk, bread, and apple. - Line 35:
len(cart)calls__len__: 3. Thetotalproperty adds 2.5 + 1.25 + 1.25 = 5.0. - Line 36: sorting compares like tuples. Bread and apple tie at 1.25, so the names decide:
'apple'before'bread'. Milk at 2.5 is last. - Line 37: a new, empty
Cart()has length 0, soboolgivesFalse.maxfinds the item that sorts last, milk.
Reread 17.6 Ordering with __lt__ and Size with __len__ and 17.8 Data Classes with @dataclass if your output differed.
Question 25 · Short answer
In fee_account.py, FeeAccount.withdraw is a single line: super().withdraw(amount + self.fee). A classmate instead copies the body of BankAccount.withdraw into FeeAccount and adds the fee to it. Give two reasons the super() version is better.
Check your reasoning
Model answer:
- The rules live in one place. The checks (the amount must be more than 0; the balance cannot go negative) stay only in
BankAccount.withdraw. If a check changes or a bug is fixed there,FeeAccountgets the change automatically. With a copy, someone has to remember to change both, and the two kinds of account can quietly start following different rules. - The override says only what is different. One line states exactly how a fee account differs: it withdraws the amount plus the fee. A reader does not have to compare two long methods to find the difference.
A correct answer mentions that the parent’s checks are reused rather than duplicated, so a later fix to the parent applies to the child, and that the override shows only the difference.
Reread 17.3 Overriding Methods and Polymorphism if your reasoning differed.
Question 26 · Short answer
A program keeps each pet at a vet as a dictionary with "name", "weight", and "vaccines" keys. It has three functions, add_vaccine(pet, name), weight_change(pet, new_weight), and describe(pet), and every one takes a pet dictionary first. A weight must never be 0 or less. Would you turn this into a function-and-dictionary design, a data class, or a class? Explain your choice, and say what each part of today’s toolkit would do in your design.
Check your reasoning
Model answer: a class, perhaps written as a data class with methods. Three functions all take the same kind of dictionary first, which is the signal from Day 16 and 17.9 that the data and the functions belong together. There is also a rule to protect (weight above 0), which a dictionary cannot guarantee, since any code can set pet["weight"] = -3 or misspell a key.
In the design: the fields name, weight, and vaccines become attributes, with vaccines made fresh for each pet (in __init__, or with field(default_factory=list) in a data class); add_vaccine and weight_change become methods, and weight_change raises ValueError for a weight of 0 or less; describe becomes __str__, so print(pet) works. A data class would also give a readable __repr__ and __eq__ for free. To stop outside code from assigning a bad weight directly, store it as _weight and expose it with a @property.
A correct answer names the signals (several functions sharing one dictionary, a rule to keep) and puts the rule inside a method so it is checked on every change.
Reread 17.9 When a Class Is Worth It and 17.7 Computed Attributes with @property if your reasoning differed.
Go further
The official Python Tutorial covers inheritance, including classes with more than one parent, in Classes: Inheritance, and the dataclasses module documentation lists every option @dataclass accepts. Tomorrow, Day 18, explains how decorators such as @property work by writing your own.
Reflection
Check each statement. If the answer is “not yet,” reread the linked subsection.
- I can write a child class that inherits from a parent and say where Python finds each method. Reread
- I can extend __init__ in a child class by calling super().__init__ first. Reread
- I can override a method, reuse the parent version with super(), and explain polymorphism. Reread
- I can choose between inheritance (is-a) and composition (has-a) for a design. Reread
- I can write __eq__ that returns NotImplemented for other types. Reread
- I can write __lt__ and __len__ so sorting and len() work on my objects. Reread
- I can use @property for a computed, read-only attribute. Reread
- I can write a data class with fields, defaults, and default_factory. Reread
- I can decide when a class is worth it and when a function and a dictionary are enough. Reread