TechLead
Lesson 8 of 25
5 min read
Python

Object-Oriented Python

Master classes, inheritance, polymorphism, special methods, and OOP design patterns in Python

Classes and Objects

Python is a multi-paradigm language, but its object-oriented features are powerful and Pythonic. Everything in Python is an object — integers, strings, functions, and even classes themselves. Understanding OOP in Python means understanding the class keyword, self, special (dunder) methods, and Python's approach to encapsulation.

class Dog:
    """A simple Dog class demonstrating OOP basics."""
    
    # Class attribute (shared by all instances)
    species = "Canis familiaris"
    
    def __init__(self, name: str, age: int, breed: str):
        """Initialize a Dog instance."""
        # Instance attributes (unique to each instance)
        self.name = name
        self.age = age
        self.breed = breed
    
    def bark(self) -> str:
        return f"{self.name} says Woof!"
    
    def description(self) -> str:
        return f"{self.name} is a {self.age}-year-old {self.breed}"
    
    def birthday(self) -> None:
        self.age += 1
        print(f"Happy birthday, {self.name}! Now {self.age} years old.")

# Creating instances
buddy = Dog("Buddy", 3, "Golden Retriever")
max_dog = Dog("Max", 5, "German Shepherd")

print(buddy.bark())          # "Buddy says Woof!"
print(max_dog.description()) # "Max is a 5-year-old German Shepherd"
print(Dog.species)           # "Canis familiaris"
buddy.birthday()             # "Happy birthday, Buddy! Now 4 years old."

Special (Dunder) Methods

Python uses double-underscore (dunder) methods to implement operator overloading and define how objects behave with built-in functions and syntax. Implementing these methods makes your classes feel like native Python types.

class Vector:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y
    
    def __repr__(self) -> str:
        """Developer-friendly string representation."""
        return f"Vector({self.x}, {self.y})"
    
    def __str__(self) -> str:
        """User-friendly string representation."""
        return f"({self.x}, {self.y})"
    
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    
    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)
    
    def __mul__(self, scalar: float):
        return Vector(self.x * scalar, self.y * scalar)
    
    def __abs__(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5
    
    def __eq__(self, other) -> bool:
        return self.x == other.x and self.y == other.y
    
    def __len__(self) -> int:
        return 2
    
    def __getitem__(self, index):
        if index == 0: return self.x
        if index == 1: return self.y
        raise IndexError("Vector index out of range")

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)      # (4, 6)
print(v1 * 3)       # (9, 12)
print(abs(v1))       # 5.0
print(v1 == Vector(3, 4))  # True
print(v1[0])         # 3

Inheritance and Polymorphism

Python supports single and multiple inheritance. Subclasses inherit all methods and attributes from their parent class and can override or extend them. Python uses the Method Resolution Order (MRO) to determine which method to call in multiple inheritance scenarios.

from abc import ABC, abstractmethod

class Shape(ABC):
    """Abstract base class for shapes."""
    
    @abstractmethod
    def area(self) -> float:
        pass
    
    @abstractmethod
    def perimeter(self) -> float:
        pass
    
    def describe(self) -> str:
        return f"{self.__class__.__name__}: area={self.area():.2f}, perimeter={self.perimeter():.2f}"

class Circle(Shape):
    def __init__(self, radius: float):
        self.radius = radius
    
    def area(self) -> float:
        return 3.14159 * self.radius ** 2
    
    def perimeter(self) -> float:
        return 2 * 3.14159 * self.radius

class Rectangle(Shape):
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height
    
    def area(self) -> float:
        return self.width * self.height
    
    def perimeter(self) -> float:
        return 2 * (self.width + self.height)

# Polymorphism - same interface, different behavior
shapes: list[Shape] = [Circle(5), Rectangle(4, 6)]
for shape in shapes:
    print(shape.describe())
# Circle: area=78.54, perimeter=31.42
# Rectangle: area=24.00, perimeter=20.00

# super() for calling parent methods
class Square(Rectangle):
    def __init__(self, side: float):
        super().__init__(side, side)

s = Square(5)
print(s.area())  # 25.0

Properties and Encapsulation

Python does not have true private attributes. By convention, a single underscore prefix means "protected" and a double underscore prefix triggers name mangling. The @property decorator lets you define getters, setters, and deleters for controlled attribute access.

class BankAccount:
    def __init__(self, owner: str, balance: float = 0):
        self.owner = owner
        self._balance = balance  # "protected" by convention
        self._transactions: list[float] = []
    
    @property
    def balance(self) -> float:
        """Read-only balance property."""
        return self._balance
    
    def deposit(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self._balance += amount
        self._transactions.append(amount)
    
    def withdraw(self, amount: float) -> None:
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        self._transactions.append(-amount)
    
    @property
    def transaction_history(self) -> list[float]:
        return self._transactions.copy()  # Return a copy to prevent mutation
    
    def __repr__(self) -> str:
        return f"BankAccount(owner='{self.owner}', balance={self._balance:.2f})"

account = BankAccount("Alice", 1000)
account.deposit(500)
account.withdraw(200)
print(account.balance)              # 1300.0
print(account.transaction_history)  # [500, -200]
# account.balance = 999999  # AttributeError - read-only property

Dataclasses

The dataclasses module (Python 3.7+) automatically generates __init__, __repr__, __eq__, and other methods, reducing boilerplate for data-holding classes.

from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    email: str
    age: int
    roles: list[str] = field(default_factory=list)
    active: bool = True

user = User("Alice", "alice@example.com", 30)
print(user)  # User(name='Alice', email='alice@example.com', age=30, roles=[], active=True)

user2 = User("Alice", "alice@example.com", 30)
print(user == user2)  # True (auto-generated __eq__)

@dataclass(frozen=True)  # Immutable dataclass
class Point:
    x: float
    y: float

p = Point(3.0, 4.0)
# p.x = 5.0  # FrozenInstanceError

Key Takeaways

  • Use dunder methods: Make your classes behave like built-in types
  • ABC for interfaces: Use abstract base classes to define contracts
  • Properties over getters/setters: Use @property for controlled access
  • Dataclasses reduce boilerplate: Use them for data-holding classes

Continue Learning