◀ Course contents Part 4 · Module 4-01

Classes & Objects

When data and the things you do to it belong together

You already have containers for data and functions for behaviour. A class is for the cases where the two keep travelling together — where every function takes the same dictionary as its first argument, and every caller has to remember which keys are supposed to be in it. That is the signal. Not every piece of data needs one.

Ready?

1

A Class Is a Template; self Is the Instance

class Order:
    def __init__(self, name, quantity, price):
        self.name = name
        self.quantity = quantity
        self.price = price

    def total(self):
        return self.quantity * self.price

order = Order("tea", 3, 1250)
order.name      # "tea"
order.total()   # 3750

__init__ runs when an instance is created. It is not a constructor in the sense other languages mean — the object already exists by then; __init__ is where you set its attributes.

self is the instance the method was called on. It is an ordinary first parameter, and Python passes it for you: order.total() is Order.total(order). Forget to declare it and you get TypeError: total() takes 0 positional arguments but 1 was given, which is confusing until you know that the one argument is the object itself.

Attributes assigned through self belong to that instance alone. Two orders have two name attributes with no connection between them.

The signal that you want a class

Every function takes the same dictionary as its first argument, and every caller has to remember which keys ought to be in it. A class gives that shape a name, puts the functions with it, and makes __init__ the one place the shape is decided.

Quick check

What is self?

2

The Two Methods Worth Writing Every Time

Print an instance of a class with no __repr__ and you get <Order object at 0x7f3c…>. That is useless in a log, useless in a debugger, and useless in a list of them.

def __repr__(self):
    return f"Order(name={self.name!r}, quantity={self.quantity})"

The convention is that __repr__ reads like the code that would rebuild the object. !r inside the f-string calls repr() on the value, which is what puts the quotes around the string — without it you get name=tea and cannot tell a string from a name.

The second one is equality. By default two objects are equal only if they are the same object, so two orders built from identical data are not equal:

def __eq__(self, other):
    if not isinstance(other, Order):
        return NotImplemented
    return (self.name, self.quantity) == (other.name, other.quantity)

The isinstance guard matters. Without it, comparing an Order to a string raises an AttributeError rather than simply answering False. Returning NotImplemented tells Python to try the other object's comparison, and to fall back to "not equal" if that also declines.

Quick check

Two Order objects built with identical arguments. Are they equal?

3

The Attribute Every Instance Shares

An attribute assigned in the class body, rather than in __init__, belongs to the class — one copy, shared by every instance ever created.

class Item:
    tags = []                      # ONE list, for every Item
    def __init__(self, name):
        self.name = name           # one per instance

a = Item("tea")
b = Item("cup")
a.tags.append("hot")
b.tags       # ['hot']  — b's tags, changed by a

This is the mutable default argument from module 3-02 wearing a different hat, and it has the same fix: anything mutable belongs in __init__, where it is built fresh per instance.

Class attributes are genuinely useful for things that really are shared and really do not change — a constant, a default, a counter of how many instances exist. The rule is the same as everywhere else: shared and mutable is the dangerous combination.

One list, shared

class Item:
    tags = []

Every instance appends to the same list, forever.

One list each

def __init__(self):
    self.tags = []

Built fresh every time an instance is created.

One more distinction worth having. A method that returns a value and changes nothing can be called twice safely; one that changes the instance cannot. Name them so the difference is visible — total() against add_item() — and do not have the second one return the object, for the same reason list.sort() returns None.

Quick check

Where should self.history = [] go?

4

A Property, and Knowing When to Stop

@property turns a method into something that reads like an attribute. It is for a value derived from the others, so it cannot drift out of step with them:

@property
def total(self):
    return self.quantity * self.price

order.total     # no brackets — and always current

Storing self.total in __init__ would work until somebody changed the quantity, at which point the total is a snapshot of a price that no longer applies — module 1-02's stale-total bug, living inside an object.

And the important half of this module: most data does not need a class.

A class with one method

and no state worth keeping is a function with extra steps.

A class that only holds data

is a dictionary, or a dataclass — which is the next module.

Data and behaviour that travel together

and a shape worth naming and validating in one place.

Several instances with independent state

each doing the same things to different data.

The honest test

Write the class. Then look at whether any method uses more than one attribute, and whether any two methods use the same one. If the answer is no both times, you have a namespace rather than an object, and a few plain functions would say the same thing with less ceremony.

Classes are not the goal. Naming a shape once, validating it once, and keeping the functions that need it nearby — that is the goal.

Quick check

Why make total a property rather than setting self.total in __init__?

0 of 9 completed

Real Python runs right here in your browser — nothing to install, nothing sent to a server. The interpreter downloads once the first time you press Run, then stays cached.

01

To do

__init__ runs when an instance is created, and self is the instance. Attributes assigned through it belong to that instance alone.

class Order:
    def __init__(self, name, quantity):
        self.name = name
        self.quantity = quantity

Your task: write Order taking a name, a quantity and a price, build two of them, and print four lines:

tea
3
whisk
990
your_code.py
Python
Hint

def __init__(self, name, quantity, price): then three lines assigning each one to self.

Output

      
    02

    To do

    A method is a function defined inside the class, taking self first. It can reach every attribute of the instance it was called on.

    def total(self):
        return self.quantity * self.price

    order.total() is really Order.total(order) — Python fills in the first argument from whatever the method was called on.

    Your task: add total() and label(), which returns 3 x tea. Print both for each order:

    3750
    3 x tea
    11880
    12 x whisk
    your_code.py
    Python
    Hint

    Both take self and nothing else. total returns self.quantity * self.price; label returns an f-string built from self.quantity and self.name.

    Output
    
          
      03

      To do

      Print an object with no __repr__ and you get <Order object at 0x7f3c…> — useless in a log, in a debugger, and in a list of them.

      def __repr__(self):
          return f"Order(name={self.name!r}, quantity={self.quantity})"

      The convention is that it reads like the code that would rebuild the object. !r calls repr() on the value, which is what puts the quotes round the string — without it you get name=tea and cannot tell text from a name.

      Your task: add __repr__, then print one order and a list of two — a list uses each item's repr, which is why this is the method that matters:

      Order(name='tea', quantity=3, price=1250)
      [Order(name='tea', quantity=3, price=1250), Order(name='whisk', quantity=12, price=990)]
      your_code.py
      Python
      Hint

      One f-string: f"Order(name={self.name!r}, quantity={self.quantity}, price={self.price})". Only the name needs !r — the numbers print the same either way.

      Output
      
            
        04

        To do

        order.total() is Order.total(order). Python always passes the instance as the first argument, so a method that does not declare a parameter for it receives one it has no room for.

        The message — total() takes 0 positional arguments but 1 was given — is exactly accurate and confusing until you know that the one argument is the object itself.

        Your task: run it, read the error, and fix both methods:

        3750
        tea
        your_code.py
        Python
        Hint

        Both methods need self as their first parameter. The bodies already use it — they were just never given it.

        Output
        
              
          05

          To do

          An attribute assigned in the class body belongs to the class — one copy, shared by every instance ever created. For a constant that is fine. For a list it is the mutable default argument from module 3-02, wearing a different hat.

          Run it and watch the second item report a tag it was never given.

          Your task: give each item its own list, so the output is:

          ['hot']
          []
          1
          0
          your_code.py
          Python
          Hint

          Move the list into __init__ as self.tags = [], and delete the class-body line. __init__ runs once per instance, so each one gets its own.

          Output
          
                
            06

            To do

            By default, two objects are equal only if they are the same object. So two orders built from identical arguments are not equal, which is rarely what anyone means.

            def __eq__(self, other):
                if not isinstance(other, Order):
                    return NotImplemented
                return (self.name, self.quantity) == (other.name, other.quantity)

            The isinstance guard matters: without it, comparing an order to a string raises an AttributeError instead of simply answering False.

            Your task: add __eq__ comparing all three attributes, then print four comparisons:

            True
            False
            False
            True

            The last one is an order compared with a string, which must answer False rather than raising.

            your_code.py
            Python
            Hint

            Guard with isinstance and return NotImplemented when it is not an Order. Then compare the three attributes as tuples — Python compares tuples element by element.

            Output
            
                  
              07

              To do

              Some methods work something out; others change the instance. The difference should be visible in the name, and a method that changes things should not also hand the object back — for the same reason list.sort() returns None.

              Your task: write a Basket with an items list of its own, an add() that appends and returns nothing, and a total() that works the value out without changing anything:

              None
              2
              4000
              4000

              The last two lines are total() called twice — it must give the same answer both times.

              your_code.py
              Python
              Hint

              __init__ sets self.items = []. add appends a tuple of (name, quantity, price) and has no return at all. total loops the items and adds quantity * price.

              Output
              
                    
                08

                To do

                @property turns a method into something read like an attribute, with no brackets. It is for a value derived from the others, so it cannot drift out of step with them.

                @property
                def total(self):
                    return self.quantity * self.price

                Storing self.total in __init__ works until somebody changes the quantity — at which point the total is a snapshot of a state that no longer exists. That is module 1-02's stale-total bug, living inside an object.

                Your task: make total a property, then print it, change the quantity, and print it again:

                3750
                12500
                your_code.py
                Python
                Hint

                Remove the self.total line from __init__ and add a method called total with @property on the line above it. The two prints then need no brackets and no other change.

                Output
                
                      
                  09

                  To do

                  This class holds no state between calls. Every method takes what it needs as arguments, uses no attribute, and could be called on any instance with the same result.

                  That is not an object. It is a namespace with ceremony — and two plain functions say the same thing with less of it.

                  The honest test: does any method use more than one attribute, and do any two methods use the same one? Here, neither.

                  Your task: rewrite it as two module-level functions with the same names, and update the two calls:

                  3750
                  3375.0
                  your_code.py
                  Python
                  Hint

                  Take the two methods out of the class, drop the self parameter from each, delete the class and the instance, and call them directly.

                  Output
                  
                        

                    The order class

                    To do

                    One class, doing everything this module covered: validating its shape once, printing usefully, comparing by contents, keeping a derived value honest, and having exactly one method that changes anything.

                    Write Order:

                    • __init__(self, name, quantity, price) — stores all three, and gives every instance its own empty notes list. It raises ValueError naming the offending value when the quantity is negative or the price is zero or less.
                    • total — a property, quantity times price.
                    • add_note(self, text) — appends to this order's notes and returns nothing.
                    • __repr__Order(name='tea', quantity=3, price=1250).
                    • __eq__ — equal when the three values match. Comparing to anything that is not an Order answers False rather than raising.

                    Then print exactly six lines:

                    Order(name='tea', quantity=3, price=1250)
                    3750
                    12500
                    True
                    ['checked', 'packed']
                    price must be positive: 0

                    In order: the repr, the total, the total again after the quantity changes to 10, an equality check against an identical order, one order's notes after two are added, and the message from a rejected order.

                    your_code.py
                    Python
                    Hint

                    In __init__, validate before assigning anything: raise ValueError(f"quantity must not be negative: {quantity}") and the same shape for the price. self.notes = [] belongs there too, so each order gets its own. total gets @property and takes only self. add_note appends and has no return. __eq__ guards with isinstance and returns NotImplemented, then compares the three values as tuples.

                    Output
                    
                          

                      Notification