Classes and Object-Oriented Programming

May 18, 2026
#python #oop #classes #intermediate

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 instance
  • self refers to the specific instance (like this in 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.