Classes and Object-Oriented Programming
Table of Contents
Classes let you create your own data types that bundle data and behavior together. Instead of passing loose dictionaries around and writing separate functions to operate on them, you define a class that knows what data it holds and what operations make sense for that data.
Your First Class
class Dog:
def __init__(self, name, breed, age):
self.name = name
self.breed = breed
self.age = age
def bark(self):
return f"{self.name} says: Woof!"
def describe(self):
return f"{self.name} is a {self.age}-year-old {self.breed}"
# Create instances (objects)
rex = Dog("Rex", "German Shepherd", 3)
luna = Dog("Luna", "Golden Retriever", 5)
print(rex.bark()) # Rex says: Woof!
print(luna.describe()) # Luna is a 5-year-old Golden Retriever
__init__is the constructor — it runs when you create a new instanceselfrefers to the specific instance (likethisin JavaScript)- Methods are functions defined inside the class
Instance vs Class Attributes
class Counter:
# Class attribute — shared by all instances
total_counters = 0
def __init__(self, name):
# Instance attributes — unique to each instance
self.name = name
self.count = 0
Counter.total_counters += 1
def increment(self):
self.count += 1
a = Counter("page_views")
b = Counter("clicks")
a.increment()
a.increment()
b.increment()
print(a.count) # 2
print(b.count) # 1
print(Counter.total_counters) # 2
Properties
Control access to attributes with @property:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
temp.celsius = 100
print(temp.fahrenheit) # 212.0
# temp.celsius = -300 # ValueError: Temperature below absolute zero
Properties let you add validation or computation while keeping the simple obj.attribute syntax.
Inheritance
Create specialized classes based on existing ones:
class Animal:
def __init__(self, name, sound):
self.name = name
self.sound = sound
def speak(self):
return f"{self.name} says {self.sound}!"
class Dog(Animal):
def __init__(self, name, breed):
super().__init__(name, "Woof") # call parent's __init__
self.breed = breed
def fetch(self):
return f"{self.name} fetches the ball!"
class Cat(Animal):
def __init__(self, name):
super().__init__(name, "Meow")
def purr(self):
return f"{self.name} purrs contentedly"
dog = Dog("Rex", "Lab")
cat = Cat("Whiskers")
print(dog.speak()) # Rex says Woof!
print(dog.fetch()) # Rex fetches the ball!
print(cat.speak()) # Whiskers says Meow!
print(cat.purr()) # Whiskers purrs contentedly
super() calls the parent class’s method. Use it to extend behavior rather than replace it entirely.
Dunder (Magic) Methods
Special methods that let your classes work with Python’s built-in operations:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
"""Developer-friendly string representation"""
return f"Vector({self.x}, {self.y})"
def __str__(self):
"""User-friendly string representation"""
return f"({self.x}, {self.y})"
def __add__(self, other):
"""Enable + operator"""
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
"""Enable == comparison"""
return self.x == other.x and self.y == other.y
def __len__(self):
"""Enable len()"""
return int((self.x**2 + self.y**2) ** 0.5)
a = Vector(3, 4)
b = Vector(1, 2)
print(a + b) # (4, 6)
print(a == b) # False
print(repr(a)) # Vector(3, 4)
print(len(a)) # 5
Common dunder methods
| Method | Enables |
|---|---|
__init__ |
Constructor |
__repr__ |
repr(obj), debugger display |
__str__ |
str(obj), print(obj) |
__eq__ |
== comparison |
__lt__, __gt__ |
<, > (enables sorting) |
__add__, __sub__ |
+, - operators |
__len__ |
len(obj) |
__getitem__ |
obj[key] |
__iter__ |
for item in obj |
__contains__ |
item in obj |
Dataclasses
For classes that are primarily data containers, @dataclass eliminates boilerplate:
from dataclasses import dataclass
@dataclass
class User:
name: str
email: str
age: int
active: bool = True
# Automatically generates __init__, __repr__, __eq__
alice = User("Alice", "alice@example.com", 28)
bob = User("Bob", "bob@example.com", 32, active=False)
print(alice) # User(name='Alice', email='alice@example.com', age=28, active=True)
print(alice == User("Alice", "alice@example.com", 28)) # True
Frozen dataclasses (immutable)
@dataclass(frozen=True)
class Point:
x: float
y: float
p = Point(3.0, 4.0)
# p.x = 5.0 # FrozenInstanceError
Composition vs Inheritance
Inheritance models “is-a” relationships. Composition models “has-a” relationships. Prefer composition when possible — it’s more flexible:
# Composition — Engine is a component of Car
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
return "Engine started"
class Car:
def __init__(self, make, model, horsepower):
self.make = make
self.model = model
self.engine = Engine(horsepower) # has-an engine
def start(self):
return f"{self.make} {self.model}: {self.engine.start()}"
car = Car("Toyota", "Camry", 203)
print(car.start()) # Toyota Camry: Engine started
Abstract Base Classes
Define interfaces that subclasses must implement:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
@abstractmethod
def perimeter(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
# shape = Shape() # TypeError: Can't instantiate abstract class
rect = Rectangle(5, 3)
print(rect.area()) # 15
A Practical Example
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class Task:
title: str
description: str = ""
completed: bool = False
created_at: datetime = field(default_factory=datetime.now)
def complete(self):
self.completed = True
def __str__(self):
status = "✓" if self.completed else "○"
return f"[{status}] {self.title}"
class TodoList:
def __init__(self, name):
self.name = name
self.tasks: list[Task] = []
def add(self, title, description=""):
task = Task(title, description)
self.tasks.append(task)
return task
def complete(self, index):
self.tasks[index].complete()
@property
def pending(self):
return [t for t in self.tasks if not t.completed]
@property
def progress(self):
if not self.tasks:
return 0
return sum(t.completed for t in self.tasks) / len(self.tasks) * 100
def __str__(self):
lines = [f"=== {self.name} ({self.progress:.0f}% done) ==="]
lines.extend(str(task) for task in self.tasks)
return "\n".join(lines)
# Usage
todo = TodoList("Sprint 12")
todo.add("Design API schema")
todo.add("Implement endpoints")
todo.add("Write tests")
todo.complete(0)
print(todo)
# === Sprint 12 (33% done) ===
# [✓] Design API schema
# [○] Implement endpoints
# [○] Write tests
When to Use Classes
- Use classes when you have data + behavior that belong together, when you need multiple instances with the same interface, or when inheritance/polymorphism simplifies your design
- Use functions + dicts for simple data transformations, scripts, and cases where a class would just be a single method wrapping a function
- Use dataclasses when your class is primarily a data container with minimal behavior
Don’t force OOP where it doesn’t help. Python is multi-paradigm — use the approach that makes your code clearest.