Skip to content
HopperHopper

Course navigation

Phase 3 · Real programsDay 17

Classes and objects, part 2

  • About 90 minutes
  • 9 sections
  • Runnable Python examples
  • Practice questions

Before you start

About 90 minutes

By the end you will be able to

  1. Build a subclass that inherits from a class, extends __init__ with super(), and overrides methods.
  2. Choose between inheritance and composition, and decide when a class is worth it and when a function and a dictionary are enough.
  3. Make ==, sorting, and len() work on your own objects with __eq__, __lt__, and __len__.
  4. Use @property for computed attributes and @dataclass for classes that mostly hold data.

What to remember from earlier

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.

bank/bank.py
"""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?

Section 1 of 9

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 SavingsAccount starts 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 from BankAccount.

Worked example: A savings account that reuses everything

bank/savings.py
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). SavingsAccount has 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, which SavingsAccount also never defined: the balance becomes 250.
  • Line 11 calls the new method. Inside it, self is the savings account, so self.balance * rate is 250 × 0.02 = 5.0, and self.deposit(5.0) uses the inherited deposit. 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:

exception_family.py
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:

bank/one_way.py
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:

bank/one_way_fixed.py
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.00

Pause and try

Question 1 · Tracing

What does this program print?

ticket_trace.py
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 False

VipTicket 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?

  1. boss.pay()
  2. boss.approve()
  3. clerk.approve()
  4. 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: boss is a Manager, which inherits pay from Employee.
  • B is wrong: approve is defined in Manager itself.
  • D is wrong: isinstance never raises for a class; it returns True here, because a manager is an employee.

Reread 17.1 Inheritance: Building on an Existing Class if you chose differently.

Section 2 of 9

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

bank/savings_rate.py
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 -10

Line 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:

bank/forgot_super.py
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.

manager_trace.py
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
250

Manager("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.

dog_breed.py
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:

dog_breed_fixed.py
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 beagle

The 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.

Section 3 of 9

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

bank/fee_account.py
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’s withdraw, 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”:

notifiers.py
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 own send refuses to run.
  • EmailNotifier and TextNotifier each override send. A text message is short, so TextNotifier keeps only the first 20 characters with a slice (Day 3).
  • Banner (lines 22 to 24) does not inherit from Notifier at 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 a send method 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:

override_typo.py
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.

price_trace.py
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.00

All 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")?

  1. It returns None, because PushNotifier has no send method.
  2. It raises NotImplementedError, from Notifier’s send.
  3. It raises AttributeError, because PushNotifier has no send method.
  4. 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 AttributeError would need the whole search to fail, but Notifier has send.
  • D is wrong: nothing in either class prints.

Reread 17.3 Overriding Methods and Polymorphism if you chose differently.

Section 4 of 9

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

bank/branch.py
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. Branch inherits from nothing (other than object): it is built from accounts, not as an account.
  • open_account makes a BankAccount, keeps it in the list, and returns it, so the caller can use it too. Line 19 names it ana; the list and ana refer to the same object (Day 16), so the deposit on line 21 shows up in the branch total.
  • total asks 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):

playlist_inherit.py
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:

playlist_compose.py
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?

  1. Car and Engine
  2. Classroom and Student
  3. Square and Shape
  4. Order and list
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 engine attribute.
  • B is wrong: a classroom has students. That is composition, a list of Student objects.
  • D is wrong: an order has a list of items. Inheriting from list would give it insert, 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.

Section 5 of 9

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

point_eq.py
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_spot calls start.__eq__(same_spot), which compares 2 with 2 and 3 with 3 and returns True. They are still two separate objects, so is is False. 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 != is True.
  • Line 18: in and the list method index both 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: remove also 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:

point_tuple.py
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:

book_eq.py
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 other is not a Book, the method returns NotImplemented, 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 is False for two different objects.
  • Line 16: the two copies of Holes have different conditions but the same code, so they are equal. Wonder has a different code.
  • Line 17: comparing a book with a string or a tuple now gives False for == and True for !=, 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:

point_set.py
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):

point_set_fixed.py
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 True

Three 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.

coin_trace.py
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: in compares Coin(25, 1999) with each coin using __eq__; the first coin has value 25, so the answer is True.
  • Line 14: count finds two coins worth 25 (2019 and 2024). index finds the first coin worth 10 at index 1.
  • Line 15: purse[0] and purse[2] are both worth 25, so == is True, but they are two different objects, so is is False. purse[0] == 25 compares a coin with an int: __eq__ returns NotImplemented, the int does not know how to compare with a coin either, and Python falls back to identity, which gives False.

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.

card_eq.py
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:

card_eq_fixed.py
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"))
True

This 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.

Section 6 of 9

17.6 Ordering with __lt__ and Size with __len__

Ask Python to sort a list of your objects, and it does not know how:

runners_no_lt.py
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

runners_sort.py
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: sorted now works with no key, putting the fastest time first. Each comparison it makes, such as “is Ana before Ben?”, calls Ana.__lt__(Ben), which works out 31 < 28.
  • Line 15: min and max compare with < too, so they find the fastest and the slowest runners.
  • Line 16: runners[0] < runners[1] asks whether 31 < 28, which is False. The class has no “greater than” method, yet > works: when the left side cannot answer a > b, Python flips it into b < a and asks the right side’s __lt__. Ben’s 28 < 31 is True.
  • 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:

runner_le.py
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:

playlist_len.py
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:

len_float.py
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 integer

450 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.

box_trace.py
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: sorted uses __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: max uses __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=len sorts by each box’s len(), 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?

  1. full, because every object counts as true.
  2. empty, because cart has length 0, so it is falsy.
  3. full, because cart is not None.
  4. 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 not None, 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.

Section 7 of 9

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:

stale_area.py
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

rectangle_property.py
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:

account_property.py
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 balance a property that just returns _balance. Outside code reads account.balance exactly as before.
  • Line 15: the methods change _balance, the internal attribute.
  • Line 22: assigning account.balance is 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:

property_call.py
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?

thermometer_trace.py
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 True

Only 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?

  1. enroll(course), which adds a course to the student’s list
  2. full_name, which returns the first and last names joined by a space
  3. save(path), which writes the student to a JSON file
  4. add_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.

Section 8 of 9

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:

point_by_hand.py
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

point_dataclass.py
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) passes x and y in field order. Line 13 shows that the generated __init__ takes keyword arguments too (Day 10).
  • Line 11: print has 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.y is 5.
  • Line 14: Python does not check annotations. x: int documents what x should hold, but Point("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:

song_dataclass.py
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 seconds a default, so Song("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 dataclasses module also has a function asdict(), which turns a data class object into a dictionary of its fields. That is exactly what json.dump needs (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.

dataclass_order.py
from dataclasses import dataclass


@dataclass
class Song:
    title: str
    seconds: int = 180
    artist: str
Traceback (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.

grade_order.py
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:

list_default.py
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_factory

The 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):

list_default_fixed.py
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?

order_trace.py
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) fills item and quantity by position; price keeps its default 2.5.
  • Line 15: the keyword argument price=3.0 skips quantity, which keeps its default 1.
  • Line 16: print uses 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) matches first exactly. second has 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.

forgot_dataclass.py
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 arguments

The @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:

forgot_dataclass_fixed.py
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.

Section 9 of 9

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:

word_count.py
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:

tip_class.py
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.0

The 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:

tip_function.py
def tip(bill, percent):
    return round(bill * percent / 100, 2)


print(tip(40, 15))
6.0

Pause 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.

Wrap-up · 1 of 8

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.

library_loans.py
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.

Wrap-up · 2 of 8

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.

Wrap-up · 3 of 8

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)
Wrap-up · 4 of 8

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: isinstance says so, and except Parent catches it. Every class inherits from object in the end.
  • A child method with a parent’s name overrides it. super() reaches the parent’s version, so a child’s __init__ calls super().__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 == (return NotImplemented for other types), __lt__ for < and sorting, and __len__ for len() and truthiness.
  • @property makes a computed, read-only attribute that is never stale. @dataclass writes __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.
Wrap-up · 5 of 8

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:

shapes.py
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.14

How 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:

todo_app/task.py
"""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}
True

The 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.

todo_app/tasks.py
"""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:

students_sort.py
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 False

How 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.

Wrap-up · 6 of 8

Review questions

Question 21 · Tracing

What does this program print? Watch both the class attribute and the method in each class.

review_vehicles.py
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 True

All 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.

super_self.py
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 given

Line 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:

super_self_fixed.py
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 True

Reread 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?

  1. __init__, __repr__, and __eq__
  2. __init__, __repr__, __eq__, and __lt__
  3. __init__ and __str__ only
  4. __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__. print shows 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.

review_cart.py
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: add checks item not in self.items, and in uses the generated __eq__. The second Item(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. The total property 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, so bool gives False. max finds 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:

  1. 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, FeeAccount gets 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.
  2. 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.

Wrap-up · 7 of 8

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.

Wrap-up · 8 of 8

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