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.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
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) # 1500The 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| Term | Meaning |
|---|---|
| Class | The description. Book. |
| Instance or object | One thing built from it. book. |
| Attribute | Data belonging to an instance. book.title. |
| Method | A function belonging to the class. book.borrow(). |
| Instantiation | Creating an instance. Book(...). |
What happens when you call a class
book = Book("Dune", "Frank Herbert", 412)- Python creates a new empty object of type
Book. - It calls
Book.__init__(new_object, "Dune", "Frank Herbert", 412). __init__attaches the attributes to that object.- 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)) # TrueEach 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 dictionaryAttributes 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 asbook.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 LibraryThe 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 againPython 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
selfin 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 toNone. - 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
- Write a
Rectangleclass with width and height, and methods for area and perimeter. - Write a
BankAccountclass that refuses a withdrawal larger than the balance. - Demonstrate the mutable class attribute trap and fix it.
- Show what happens when an instance shadows a class attribute, then remove the shadow.
- 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.