Chapter 12: Object-Oriented Programming
Object-Oriented Programming (OOP) is a way of structuring code around data and behaviour that belong together. Rather than writing a series of standalone functions, you create objects, which are self-contained units that hold both state (data) and methods (functions that act on that data). This model helps you build systems that are modular, reusable, and easier to reason about as they grow.
Python fully supports object-oriented principles such as encapsulation, inheritance, and polymorphism, yet it does so with remarkable flexibility. Unlike many strictly typed or class-based languages such as Java or C++, Python blends object orientation naturally with its dynamic, interpretive nature. Almost everything in Python, such as numbers, strings, lists, functions, and even modules, is an object. Each has a type, attributes, and methods that define how it behaves.
Defining your own classes allows you to create new object types suited to your program’s domain. Classes serve as blueprints, while instances represent individual realisations of those blueprints. This pattern lets you organise data and logic together, avoiding scattered global state and repeated code.
This chapter explores how to define and use classes, create and access attributes, design constructors, apply inheritance, and use special methods to integrate seamlessly with Python’s built-in behaviour. The goal is not only to understand the syntax but to think in terms of objects, including how they interact, how they represent concepts, and how they simplify complex programs.
Classes, Instances, and Attributes
Classes are the fundamental building blocks of Python’s object system. A class defines the structure and behaviour of a particular kind of object, while each instance represents one concrete example of that class. The class acts as a blueprint, describing what data (attributes) and actions (methods) its instances will have.
You define a class using the class keyword. Inside the class body, you can define attributes directly or through methods:
class Dog:
species = "Canis familiaris" # class attribute
def __init__(self, name, age):
self.name = name # instance attribute
self.age = age
Here, species belongs to the class itself (it is shared across all dogs) while name and age belong to each instance and can differ for every object you create. You make an instance by calling the class like a function:
buddy = Dog("Buddy", 4)
luna = Dog("Luna", 2)
Each instance keeps its own state, accessible via dot notation:
print(buddy.name) # Buddy
print(luna.species) # Canis familiaris
When Python looks up an attribute (for example luna.species), it first checks whether it exists on the instance, then on the class. This shared lookup mechanism makes it easy to define default data or behaviour at the class level while allowing instances to override it when necessary.
The __init__ Constructor and self
When you create an instance of a class, Python automatically calls a special method named __init__. This method acts as the object’s constructor: it runs immediately after a new instance is created, allowing you to set up its initial state. Most classes define __init__ to accept parameters and assign them to instance attributes.
class Book:
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
self is a convention, not a keyword. You could technically use another name, but doing so would confuse readers. Always use self for clarity and consistency.
The first parameter of __init__ is always self, which represents the instance being created. When you call Book("1984", "George Orwell", 1949), Python internally does the following:
re class="tight">b = Book.__new__(Book) # create empty instance
Book.__init__(b, "1984", "George Orwell", 1949)
This process ensures that every object has a distinct identity and its own data. You can then access those attributes directly:
novel = Book("Dune", "Frank Herbert", 1965)
print(novel.title) # Dune
print(novel.year) # 1965
Inside any method, self always refers to the current instance. It allows each object to manage its own state while sharing the same class definition.
__init__ unless necessary.
Methods and Encapsulation
Methods are functions that belong to a class. They describe the behaviours of its instances and usually operate on the instance’s data. Every method takes self as its first parameter, giving it access to the instance’s attributes and other methods.
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1
def reset(self):
self.value = 0
def display(self):
print("Current value:", self.value)
When you call c.increment(), Python automatically passes the instance c as the first argument to the method. This is equivalent to Counter.increment(c) but is written in a more natural and readable way.
c = Counter()
c.increment()
c.increment()
c.display() # Current value: 2
Encapsulation refers to the practice of bundling data (attributes) and behaviour (methods) together, while controlling access to an object’s internal state. In Python, this is a matter of convention rather than enforced restriction. Attribute names beginning with an underscore indicate that they are intended for internal use only.
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance # internal attribute
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
def get_balance(self):
return self._balance
This convention allows for safe modification later. For example, turning _balance into a computed property without breaking existing code. Python also supports name mangling for attributes prefixed with two underscores, which makes them harder to access accidentally from outside the class.
class Example:
def __init__(self):
self.__hidden = 42
e = Example()
# e.__hidden # AttributeError
print(e._Example__hidden) # accessible but discouraged
Inheritance and super()
Inheritance allows you to create new classes that build upon or extend existing ones. A derived (or child) class inherits attributes and methods from its base (or parent) class, enabling code reuse and hierarchical design. This approach helps organise related classes and avoids repetition of shared logic.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "(silent)"
Now a subclass can extend or override the behaviour of Animal:
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
Each subclass inherits the __init__ method from Animal, so you can instantiate them directly:
dog = Dog("Buddy")
cat = Cat("Luna")
print(dog.name, dog.speak()) # Buddy Woof!
print(cat.name, cat.speak()) # Luna Meow!
If the subclass needs to extend rather than replace the parent’s constructor, you can call it explicitly using super(). This function returns a temporary object that allows access to the parent class without naming it directly. It is the preferred modern approach for cooperative multiple inheritance.
class Bird(Animal):
def __init__(self, name, can_fly=True):
super().__init__(name)
self.can_fly = can_fly
def speak(self):
return "Chirp!"
super() to directly naming the parent class. It automatically handles the correct method resolution order, which is especially important when multiple inheritance is involved.
Here, super().__init__(name) ensures that the base class’s initialisation logic runs before adding the subclass-specific attribute can_fly. This preserves proper setup even in complex inheritance chains.
ClassName.mro() or ClassName.__mro__ if you need to understand how methods are resolved.
Method Overriding and Polymorphism
When a subclass defines a method with the same name as one in its parent, the new definition overrides the inherited version. This lets subclasses specialise or adapt behaviour without modifying the base class itself, which is a key principle of extensible design.
class Shape:
def area(self):
return 0
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
Here, Rectangle overrides Shape.area() to compute a meaningful value. The same interface name (area) can produce different results depending on the actual type of the object. This ability is called polymorphism, which literally means “many forms.” It allows different objects to respond to the same message in ways appropriate to their type. This makes code both flexible and scalable:
shapes = [Shape(), Rectangle(4, 5)]
for s in shapes:
print(s.area())
Although both objects are treated uniformly, each calls the version of area() defined in its own class. This is one of the most powerful aspects of OOP; code that can operate on abstract interfaces rather than concrete types.
super() when you need to extend, not replace, its behaviour.
class LoggedShape(Shape):
def area(self):
print("Calculating area...")
result = super().area()
print("Done.")
return result
Polymorphism also enables duck typing, a Python idiom that focuses on what an object can do rather than what it is. It comes from the saying “If it walks like a duck and quacks like a duck, it’s probably a duck.” In programming terms, it means that Python cares about what an object can do, not what type it claims to be. If an object implements the expected methods, it can be used interchangeably, regardless of its class hierarchy.
class Circle:
def area(self):
return 3.14 * (5 ** 2)
def print_area(shape):
print("Area:", shape.area())
print_area(Rectangle(3, 4))
print_area(Circle())
Data Classes (@dataclass)
Many classes exist primarily to store data rather than to define complex behaviour. Python’s @dataclass decorator (introduced in version 3.7) simplifies such classes by automatically generating common methods such as __init__, __repr__, __eq__, and others based on declared fields.
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
This single declaration defines a full-featured class that supports initialisation, readable string output, and value comparison:
p1 = Point(2.5, 4.0)
p2 = Point(2.5, 4.0)
print(p1) # Point(x=2.5, y=4.0)
print(p1 == p2) # True
@dataclass automatically provides __init__, __repr__, and __eq__. You can disable or customise any of these with parameters such as repr=False or eq=False.
Each attribute is declared with a type hint, and you can also define default values or computed defaults using field() from the dataclasses module:
from dataclasses import dataclass, field
@dataclass
class Book:
title: str
author: str
year: int = 0
tags: list = field(default_factory=list)
b = Book("1984", "George Orwell")
print(b.tags) # []
The default_factory is particularly important for mutable types like lists and dictionaries. It ensures that each instance receives its own new object rather than sharing one across all instances.
default_factory for containers, otherwise changes made in one instance will appear in others.
You can also make dataclasses immutable by passing frozen=True to the decorator. This prevents modification of attributes after creation, similar to named tuples:
@dataclass(frozen=True)
class Color:
r: int
g: int
b: int
c = Color(255, 200, 150)
# c.r = 0 # would raise FrozenInstanceError
Dataclasses streamline the creation of lightweight, structured objects. They encourage explicit, readable declarations and remove much of the boilerplate associated with small data containers.
Class Methods vs. Static Methods
Not all methods in a class operate on individual instances. Sometimes you need behaviour that relates to the class itself, or utility functions that logically belong to the class but do not depend on instance or class state. Python provides two decorators (@classmethod and @staticmethod) to support these cases.
Class Methods
A class method receives the class itself as its first argument, traditionally named cls. It can access or modify class-level data and is often used as an alternative constructor or to maintain state shared across all instances.
class Book:
total_created = 0
def __init__(self, title):
self.title = title
Book.total_created += 1
@classmethod
def from_series(cls, series_name, number):
title = f"{series_name} Vol. {number}"
return cls(title)
b1 = Book("Python Essentials")
b2 = Book.from_series("Python Handbook", 2)
print(b1.title) # Python Essentials
print(b2.title) # Python Handbook Vol. 2
print(Book.total_created) # 2
Static Methods
A static method is simpler as it does not receive an implicit first argument (self or cls). It behaves like a normal function but resides inside the class’s namespace for organisational purposes. It is useful for operations that are conceptually related to the class but independent of its state.
class MathUtils:
@staticmethod
def area_of_circle(radius):
return 3.14159 * (radius ** 2)
print(MathUtils.area_of_circle(3)) # 28.27431
Static methods do not require access to instance or class attributes, yet keeping them inside the class helps group related functionality together, keeping the design coherent.
- Use
instance methodswhen the behaviour depends on instance data. - Use
class methodswhen it acts on the class as a whole or creates new instances. - Use
static methodswhen the method neither accesses nor modifies instance or class state.
These decorators promote cleaner design by clarifying a method’s intended scope and by keeping related functionality together in one logical place.
Introduction to Special (“Dunder”) Methods
Python objects can define special methods, also known as dunder methods because their names begin and end with double underscores (for example, __init__, __str__, __len__). These methods let your classes integrate smoothly with Python’s built-in operations and syntax. They allow user-defined objects to behave like native types.
Special methods are not called directly in normal code. Instead, Python automatically invokes them when you use certain operators or functions. For instance, when you write len(obj), Python calls obj.__len__() internally. Likewise, print(obj) triggers obj.__str__().
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __str__(self):
return f"({self.x}, {self.y})"
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __len__(self):
return 2
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1) # (3, 4)
print(v1 + v2) # (4, 6)
print(len(v1)) # 2
__repr__ for debugging and developer-facing output, and __str__ for user-friendly display. If only __repr__ is defined, Python uses it as a fallback for str().
There are many categories of dunder methods, including:
- Object creation and representation:
__new__,__init__,__repr__,__str__ - Comparison and hashing:
__eq__,__lt__,__hash__ - Arithmetic and bitwise operators:
__add__,__sub__,__mul__,__and__, etc. - Container behaviour:
__len__,__getitem__,__setitem__,__iter__ - Context management:
__enter__and__exit__for use with thewithstatement
These hooks are what make Python’s object system so expressive. With them, you can define classes that behave just like built-in types: comparable, iterable, printable, and composable, without any special syntax beyond normal class definitions.