Classes and Objects

A class describes a kind of thing; an object is one of them. Python builds every object the same way, and understanding that removes most of the mystery.

The idea

Every program has data and behaviour. Without classes, you keep them apart: a dictionary holds the data and separate functions act on it. A class puts them together, so an object carries both what it knows and what it can do.

# Without a class
account = {"owner": "Meera", "balance": 1000}


def deposit(acc, amount):
    acc["balance"] += amount


deposit(account, 500)
print(account["balance"])
# With a class
class Account:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount


account = Account("Meera", 1000)
account.deposit(500)
print(account.balance)          # 1500

The second version keeps the rules in one place. Nothing outside the class needs to know that the balance is stored under a particular key, and the operations that are allowed are visible in one block.

Defining a class

class Book:
    """A single book in a library."""

    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
        self.is_borrowed = False

    def borrow(self):
        if self.is_borrowed:
            return False
        self.is_borrowed = True
        return True

    def describe(self):
        return f"{self.title} by {self.author}, {self.pages} pages"


book = Book("Dune", "Frank Herbert", 412)
print(book.describe())
print(book.borrow())        # True
print(book.borrow())        # False - already out
TermMeaning
ClassThe description. Book.
Instance or objectOne thing built from it. book.
AttributeData belonging to an instance. book.title.
MethodA function belonging to the class. book.borrow().
InstantiationCreating an instance. Book(...).

What happens when you call a class

book = Book("Dune", "Frank Herbert", 412)
  1. Python creates a new empty object of type Book.
  2. It calls Book.__init__(new_object, "Dune", "Frank Herbert", 412).
  3. __init__ attaches the attributes to that object.
  4. The object is returned and bound to book.

Note that __init__ does not create the object; it configures one that already exists. It is an initialiser, not a constructor, which is why it returns None.

class Book:
    def __init__(self, title):
        self.title = title
        # return "something"       # TypeError: __init__ should return None


print(type(Book))              # <class 'type'> - the class is itself an object
print(type(Book("Dune")))      # <class '__main__.Book'>
print(isinstance(Book("Dune"), Book))     # True

Each instance is separate

first = Book("Dune", "Frank Herbert", 412)
second = Book("Emma", "Jane Austen", 474)

first.borrow()

print(first.is_borrowed)       # True
print(second.is_borrowed)      # False - a different object
print(first is second)         # False
print(first.__dict__)          # every instance attribute, as a dictionary

Attributes can be added at any time

book = Book("Dune", "Frank Herbert", 412)

book.rating = 5                # a new attribute, on this instance only
print(book.rating)

other = Book("Emma", "Jane Austen", 474)
# print(other.rating)          # AttributeError

print(hasattr(book, "rating"))          # True
print(getattr(other, "rating", None))   # None - with a default
setattr(other, "rating", 4)
delattr(book, "rating")
This flexibility is also a hazard. A typo such as book.titel = "x" creates a new attribute instead of raising, so the real one keeps its old value. Declare every attribute in __init__, and the typo becomes visible when you read the object.

Restricting attributes with __slots__

class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y


p = Point(1, 2)
# p.z = 3          # AttributeError: 'Point' object has no attribute 'z'

__slots__ fixes the attribute names and saves memory, which matters when you create millions of small objects. It also blocks the accidental typo.

A worked class

class ShoppingCart:
    """Items a customer intends to buy."""

    def __init__(self, customer):
        self.customer = customer
        self.items = []                 # a fresh list for every cart

    def add(self, name, price, quantity=1):
        self.items.append({"name": name, "price": price, "quantity": quantity})

    def remove(self, name):
        before = len(self.items)
        self.items = [i for i in self.items if i["name"] != name]
        return len(self.items) != before

    def total(self):
        return sum(i["price"] * i["quantity"] for i in self.items)

    def count(self):
        return sum(i["quantity"] for i in self.items)

    def receipt(self):
        lines = [f"Cart for {self.customer}", "-" * 32]
        for item in self.items:
            line_total = item["price"] * item["quantity"]
            lines.append(f"{item['name']:<16}{item['quantity']:>3} {line_total:>10,.2f}")
        lines.append("-" * 32)
        lines.append(f"{'Total':<19}{self.total():>10,.2f}")
        return "\n".join(lines)


cart = ShoppingCart("Meera")
cart.add("Notebook", 45.50, 3)
cart.add("Pen", 8.75, 12)
print(cart.receipt())
print(cart.count(), "items")

Class attributes

class Book:
    library_name = "City Library"      # shared by EVERY instance
    total_books = 0                    # a shared counter

    def __init__(self, title):
        self.title = title             # unique to this instance
        Book.total_books += 1          # update the class, not the instance


a = Book("Dune")
b = Book("Emma")

print(a.library_name, b.library_name)      # both see the same value
print(Book.total_books)                    # 2

Book.library_name = "County Library"       # changing the class changes both
print(a.library_name)                      # County Library

The shadowing trap

a.library_name = "My Shelf"        # creates an INSTANCE attribute on a only

print(a.library_name)              # My Shelf
print(b.library_name)              # County Library
print(Book.library_name)           # County Library - the class is untouched

del a.library_name
print(a.library_name)              # County Library - the class value shows again

Python looks for an attribute on the instance first, then on the class. Assigning through an instance always writes to the instance.

The mutable class attribute trap

class Basket:
    items = []                     # WRONG: one list shared by every basket

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


a, b = Basket(), Basket()
a.add("pen")
print(b.items)                     # ['pen'] - not what anyone wanted


class Basket:
    def __init__(self):
        self.items = []            # correct: a new list per instance

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

This is the same trap as the mutable default argument, in a different place. Mutable state belongs in __init__; class attributes are for constants and counters.

Common mistakes

  • Forgetting self in a method definition.
  • Forgetting self. when assigning an attribute, creating a local variable instead.
  • Using a mutable class attribute for per instance state.
  • Returning a value from __init__.
  • Assigning to a misspelled attribute name and silently creating a new one.
  • Writing a class with no methods, where a dictionary or a dataclass would do.

Best practices

  • Assign every instance attribute in __init__, even if only to None.
  • Give the class a docstring saying what one instance represents.
  • Use class attributes only for constants and counters.
  • Name classes in CapWords and methods in lower_case_with_underscores.
  • Keep a class focused on one responsibility.

Practice

  1. Write a Rectangle class with width and height, and methods for area and perimeter.
  2. Write a BankAccount class that refuses a withdrawal larger than the balance.
  3. Demonstrate the mutable class attribute trap and fix it.
  4. Show what happens when an instance shadows a class attribute, then remove the shadow.
  5. Add __slots__ to a class and prove a typo now raises.

Conclusion

A class packages data with the behaviour that acts on it. __init__ configures each new instance, instance attributes belong to one object, and class attributes are shared - so keep mutable state out of them.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.