Index Ai Academy
Phase 0 · Tools · Python core

Inheritance and class attributes

Build classes on other classes, and share values across every object.

About 35 minutes · Free lesson

Start this lesson

Why it matters

From the deep learning phase on, nearly every model you write starts with class MyModel(nn.Module): and calls super().__init__(). That's inheritance: your class gets everything PyTorch's Module knows, and you add your own parts. Libraries everywhere work this way, from web frameworks to custom error types, so reading and writing it is part of using Python at all.

What you'll be able to do

  • Tell class attributes, shared by every object, from attributes each object owns
  • Build a class on another with inheritance, and extend its methods with super()
  • Override a method so each kind of object behaves its own way
  • Choose between inheriting from a class and keeping an object of it inside yours

What it covers

  1. Shared by every object: class attributes
  2. The shared-list trap
  3. A class built on another: inheritance
  4. A clearance product
  5. One call, the right method
  6. Where Python looks for a method
  7. Subclassing classes you didn't write
  8. Your own error type
  9. When not to inherit
  10. Worked example: three ways to ship

Shared by every object: class attributes

So far, every attribute you've made lived on one object: self.name = name gives each object its own name. An attribute written in the class body, outside any method, belongs to the class itself. Every object of the class shares it:

class Product:
    TAX_RATE = 0.08
    made = 0

    def __init__(self, name, price):
        self.name = name
        self.price = price
        Product.made = Product.made + 1

    def price_with_tax(self):
        return round(self.price * (1 + self.TAX_RATE), 2)

tent = Product("Ridgeline tent", 289)
mug = Product("Trail mug", 14)
print(tent.price_with_tax(), mug.price_with_tax())
print(Product.made, Product.TAX_RATE)
It runs right here in your browser, with nothing to install.

TAX_RATE is written once and every product uses it. Change Product.TAX_RATE and every product sees the new rate. made counts how many products were created, so each __init__ adds one to Product.made.

Use class attributes for values that really are the same for every object: settings, limits, counters. Upper-case names like TAX_RATE say "this is a constant; don't change it".

Quick check
What does this print?
class Tent:
    sleeps = 2

big = Tent()
big.sleeps = 4
small = Tent()
print(big.sleeps, small.sleeps, Tent.sleeps)
  1. 4 4 2
  2. 2 2 2
  3. 4 4 4
  4. 4 2 2
Show the answer

4 2 2

big.sleeps = 4 creates an attribute on big alone, which hides the class value for that object. small and the class still see 2.

The shared-list trap

Sharing is a problem when the class attribute is something that can change, like a list. Every object then adds to the same list:

class Cart:
    items = []

    def add(self, item):
        self.items.append(item)

ana = Cart()
ben = Cart()
ana.add("tent")
print(ben.items)

Ben's cart contains Ana's tent, because both carts use the one items list on the class. The fix is to give each object its own list in __init__:

class Cart:
    def __init__(self):
        self.items = []

    def add(self, item):
        self.items.append(item)

ana = Cart()
ben = Cart()
ana.add("tent")
print(ana.items, ben.items)

The rule: anything that differs between objects, or changes over time, goes on self inside __init__.

A class built on another: inheritance

The shop also rents gear. A rental is a product, with a name and a price, plus a deposit. You could copy the whole Product class and add to it, but then a fix in one copy would miss the other. Inheritance builds the new class on the old one instead:

class Product:
    TAX_RATE = 0.08

    def __init__(self, name, price):
        self.name = name
        self.price = price

    def price_with_tax(self):
        return round(self.price * (1 + self.TAX_RATE), 2)

class Rental(Product):
    def __init__(self, name, price, deposit):
        super().__init__(name, price)
        self.deposit = deposit

    def price_with_tax(self):
        return super().price_with_tax() + self.deposit

stove = Rental("Camp stove", 20, 50)
print(stove.name, stove.price, stove.deposit)
print(stove.price_with_tax())

class Rental(Product): makes Rental a subclass of Product: it gets every attribute and method Product has. Product is the parent class.

  • super().__init__(name, price) runs the parent's __init__, which sets the name and price. Then Rental adds the one thing that's new.
  • Defining price_with_tax again overrides the parent's version. Inside it, super().price_with_tax() still reaches the parent's version, so the rental adds the deposit to the taxed price instead of redoing the tax sum.
Try it now

Creating the Rental runs Rental's __init__, which calls Product's __init__ through super(). Watch two frames for the same object, one stacked on the other.

Drill

A clearance product

Write Clearance, a subclass of Product for discontinued items. Its price_with_tax() takes 30% off the price before adding tax. Reuse the parent through super(), or work it out from self.price, rounded to 2 places.

class Product:
    TAX_RATE = 0.08

    def __init__(self, name, price):
        self.name = name
        self.price = price

    def price_with_tax(self):
        return round(self.price * (1 + self.TAX_RATE), 2)

# Write Clearance here
In the lesson, your code is checked as you write it.

One call, the right method

Here's the payoff. A list can mix products, rentals and clearance items, and one loop treats them all alike. Each object runs its own class's version of the method:

class Product:
    TAX_RATE = 0.08
    def __init__(self, name, price):
        self.name = name
        self.price = price
    def price_with_tax(self):
        return round(self.price * (1 + self.TAX_RATE), 2)

class Rental(Product):
    def __init__(self, name, price, deposit):
        super().__init__(name, price)
        self.deposit = deposit
    def price_with_tax(self):
        return super().price_with_tax() + self.deposit

basket = [Product("Trail mug", 14), Rental("Camp stove", 20, 50)]
for item in basket:
    print(item.name, item.price_with_tax(), isinstance(item, Product))

The loop never asks what kind of item it holds. item.price_with_tax() finds the right method because Python looks on the object's own class first, then on its parent. isinstance(item, Product) is True for the rental too: a Rental is a Product.

Where Python looks for a method

When you write stove.price_with_tax(), Python searches in a fixed order, and uses the first match it finds:

  1. the object itself (attributes set on self);
  2. the object's own class, here Rental;
  3. that class's parent, here Product, and its parent in turn, if it has one.

That one rule explains everything in this lesson. A subclass method with the same name is found first, so it overrides the parent's. A class attribute is found on the class when the object has no attribute of that name, and big.sleeps = 4 hides it by putting a value on the object, step 1. super() means "start the search one step up, at the parent".

Two built-ins let you ask about these relationships. isinstance(obj, Product) asks "is this object a Product, or a subclass of one?". issubclass(Rental, Product) asks the same about two classes. Both are True here.

Quick check
What does this print?
class Shipping:
    def cost(self):
        return 5

class Express(Shipping):
    def cost(self):
        return super().cost() * 3

class Pickup(Shipping):
    def cost(self):
        return 0

print([s.cost() for s in [Shipping(), Express(), Pickup()]])
  1. [5, 5, 5]
  2. [5, 3, 0]
  3. [5, 15, 5]
  4. [5, 15, 0]
Show the answer

[5, 15, 0]

Each object uses its own class's cost. Express calls the parent's cost (5) through super() and triples it; Pickup replaces it with 0.

Subclassing classes you didn't write

You'll often inherit from a class that comes with Python or a library. The simplest case is your own kind of error. An empty subclass of Exception is enough to give it a name that says what went wrong, and code can catch exactly that kind:

class OutOfStock(Exception):
    pass

def reserve(item, stock):
    if stock.get(item, 0) == 0:
        raise OutOfStock(f"{item} is sold out")
    stock[item] = stock[item] - 1

stock = {"tent": 1}
try:
    reserve("tent", stock)
    reserve("tent", stock)
except OutOfStock as problem:
    print("Sorry:", problem)

class OutOfStock(Exception): pass inherits everything an exception does: it can be raised, carries a message, and shows in tracebacks. The body is empty because nothing else is needed.

Drill

Your own error type

Write class InvalidCode(Exception). Then make apply_code(code) raise it, with a message, for any code that isn't "TRAIL10", and return 0.1 for "TRAIL10".

def apply_code(code):
    pass
In the lesson, your code is checked as you write it.

When not to inherit

Inheritance means "is a": a rental is a product, an express delivery is a kind of shipping. When the honest sentence is "has a", use an attribute instead. An order has a shipping method; it isn't one. So the order keeps a shipping object inside it:

class Order:
    def __init__(self, items, shipping):
        self.items = items
        self.shipping = shipping      # an Express(), a Pickup()...

    def total(self):
        return sum(i.price for i in self.items) + self.shipping.cost()

Keeping one object inside another is called composition. It's often the better choice, because you can swap the shipping method without making a new kind of order. Reach for inheritance when the new class really is a more specific version of the old one, and you want to keep almost all of its behavior.

Worked example

Worked example: three ways to ship

An order's total depends on how it ships. Standard costs 5, express costs three times standard, and pickup is free. One loop prints every option without an if in sight.

class Shipping:
    name = "Standard"
    def cost(self, weight):
        return 5 + weight * 0.5

class Express(Shipping):
    name = "Express"
    def cost(self, weight):
        return super().cost(weight) * 3

class Pickup(Shipping):
    name = "Store pickup"
    def cost(self, weight):
        return 0

weight = 4
for option in [Shipping(), Express(), Pickup()]:
    print(f"{option.name}: {option.cost(weight)}")
In the lesson you can change this and run it.

Each class sets its own name as a class attribute, so the loop can print it. Standard works out 5 + 4 × 0.5 = 7.0. Express reuses that through super().cost(weight) and triples it to 21.0, so a change to the standard formula reaches express automatically. Pickup overrides the method and returns

  1. To add a new option, you write one small class; nothing else changes.

Practice what you just read

  • 8 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Start this lesson

Try a quiz question

Every new Basket shows the items from earlier baskets. What's the likely cause?

  1. items = [] is a class attribute, so all baskets share one list
  2. The baskets weren't created with parentheses
  3. append doesn't work on objects
  4. The class needs a parent class

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson