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