Python - OOP
development python oop classes
OOP Concepts Quick Review
| Concept | What It Is | Example |
|---|---|---|
| Class | A blueprint/template for creating objects | class Dog: |
| Object | An instance of a class | my_dog = Dog("Rex") |
| Attribute | Data stored on an object | my_dog.name |
| Method | A function that belongs to a class | my_dog.bark() |
| Encapsulation | Bundling data + methods, hiding internals | Private attributes, properties |
| Inheritance | A class that builds on another class | class Puppy(Dog): |
| Polymorphism | Different classes sharing the same interface | Multiple classes with a .speak() method |
| Abstraction | Hiding complexity behind a simple interface | Abstract base classes |
Defining Classes
class Dog:
# Class attribute — shared by ALL instances
species = "Canis familiaris"
def __init__(self, name: str, age: int):
# Instance attributes — unique to each instance
self.name = name
self.age = age
def bark(self) -> str:
return f"{self.name} says Woof!"
def describe(self) -> str:
return f"{self.name} is {self.age} years old"
# Create instances
rex = Dog("Rex", 5)
bella = Dog("Bella", 3)
print(rex.bark()) # Rex says Woof!
print(Dog.species) # Canis familiaris (accessed via class)
print(rex.species) # Canis familiaris (also works via instance)Instance vs Class Attributes
| Instance Attribute | Class Attribute | |
|---|---|---|
| Defined in | __init__ (on self) | Class body (outside methods) |
| Scope | Unique per instance | Shared by all instances |
| Access | self.name or obj.name | ClassName.attr or obj.attr |
| Use case | Per-object data | Constants, defaults, counters |
Methods
Three Types of Methods
class MyClass:
class_var = 0
def instance_method(self):
"""Has access to instance (self) and class."""
return f"instance: {self}"
@classmethod
def class_method(cls):
"""Has access to class (cls) but NOT instance."""
cls.class_var += 1
return f"class: {cls}"
@staticmethod
def static_method(x, y):
"""No access to instance or class. Just a function in the class namespace."""
return x + y| Type | Decorator | First Arg | Can Access Instance? | Can Access Class? | Use Case |
|---|---|---|---|---|---|
| Instance | None | self | Yes | Yes (via self.__class__) | Most methods — operate on instance data |
| Class | @classmethod | cls | No | Yes | Alternative constructors, class-level operations |
| Static | @staticmethod | None | No | No | Utility functions that belong logically to the class |
Class Method Example: Alternative Constructor
class Date:
def __init__(self, year: int, month: int, day: int):
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, date_str: str):
"""Create a Date from 'YYYY-MM-DD' string."""
year, month, day = map(int, date_str.split("-"))
return cls(year, month, day)
@classmethod
def today(cls):
"""Create a Date for today."""
import datetime
t = datetime.date.today()
return cls(t.year, t.month, t.day)
d1 = Date(2024, 3, 15)
d2 = Date.from_string("2024-03-15")
d3 = Date.today()Properties
Properties let you control access to attributes with getter/setter logic while keeping clean obj.attr syntax.
class Circle:
def __init__(self, radius: float):
self._radius = radius # Convention: underscore = "private"
@property
def radius(self) -> float:
"""Getter — called when you read circle.radius"""
return self._radius
@radius.setter
def radius(self, value: float):
"""Setter — called when you assign circle.radius = x"""
if value < 0:
raise ValueError("Radius cannot be negative")
self._radius = value
@property
def area(self) -> float:
"""Read-only computed property."""
import math
return math.pi * self._radius ** 2
c = Circle(5)
print(c.radius) # 5 (uses getter)
c.radius = 10 # Uses setter (validates)
print(c.area) # 314.159... (computed on access)
c.radius = -1 # Raises ValueErrorPython Privacy Conventions
| Prefix | Meaning | Enforcement |
|---|---|---|
name | Public | None — accessible to everyone |
_name | ”Private” by convention | Not enforced — a hint to other developers |
__name | Name-mangled | Becomes _ClassName__name — harder to access accidentally |
Python doesn’t have true private attributes — it’s all convention. Use _prefix for internal APIs.
Inheritance
class Animal:
def __init__(self, name: str):
self.name = name
def speak(self) -> str:
return f"{self.name} makes a sound"
class Dog(Animal):
def speak(self) -> str:
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self) -> str:
return f"{self.name} says Meow!"
# Polymorphism — same interface, different behavior
animals = [Dog("Rex"), Cat("Whiskers"), Animal("Unknown")]
for animal in animals:
print(animal.speak())
# Rex says Woof!
# Whiskers says Meow!
# Unknown makes a soundUsing super()
super() calls the parent class method — essential for extending (not just replacing) parent behavior.
class Pet(Animal):
def __init__(self, name: str, owner: str):
super().__init__(name) # Call Animal.__init__
self.owner = owner
def describe(self) -> str:
return f"{self.name} belongs to {self.owner}"Multiple Inheritance & MRO
Python supports inheriting from multiple classes. Method Resolution Order (MRO) determines which method gets called.
class A:
def greet(self):
return "Hello from A"
class B(A):
def greet(self):
return "Hello from B"
class C(A):
def greet(self):
return "Hello from C"
class D(B, C): # Inherits from both B and C
pass
d = D()
print(d.greet()) # "Hello from B" — B comes first in MRO
print(D.mro()) # [D, B, C, A, object]MRO follows C3 linearization: left to right in the parent list, depth-first, but each class appears only once. In practice: leftmost parent wins.
Tip: Multiple inheritance can get confusing. Prefer composition (see below) for complex cases.
Magic / Dunder Methods
“Dunder” = double underscore. These special methods let your classes work with Python’s built-in operations.
| Method | Triggered By | Purpose |
|---|---|---|
__init__(self, ...) | MyClass() | Constructor — initialize instance |
__str__(self) | str(obj), print(obj) | Human-readable string |
__repr__(self) | repr(obj), debugger | Developer-readable string (should be unambiguous) |
__len__(self) | len(obj) | Return length |
__getitem__(self, key) | obj[key] | Index/key access |
__setitem__(self, key, val) | obj[key] = val | Index/key assignment |
__iter__(self) | for x in obj | Make iterable |
__next__(self) | next(obj) | Iterator protocol |
__contains__(self, item) | item in obj | Membership test |
__eq__(self, other) | obj == other | Equality comparison |
__lt__(self, other) | obj < other | Less than (enables sorting) |
__add__(self, other) | obj + other | Addition operator |
__enter__ / __exit__ | with obj: | Context manager protocol |
__call__(self, ...) | obj() | Make instance callable |
__hash__(self) | hash(obj) | Hash (for sets/dict keys) |
Example: Custom Collection
class Playlist:
def __init__(self, name: str, songs: list[str] = None):
self.name = name
self.songs = songs or []
def __len__(self):
return len(self.songs)
def __getitem__(self, index):
return self.songs[index]
def __contains__(self, song):
return song in self.songs
def __str__(self):
return f"{self.name} ({len(self)} songs)"
def __repr__(self):
return f"Playlist(name={self.name!r}, songs={self.songs!r})"
pl = Playlist("Road Trip", ["Song A", "Song B", "Song C"])
print(len(pl)) # 3
print(pl[0]) # Song A
print("Song B" in pl) # True
print(pl) # Road Trip (3 songs)
for song in pl: # Works because __getitem__ is defined
print(song)Context Manager Example
class Timer:
def __enter__(self):
import time
self.start = time.time()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
import time
self.elapsed = time.time() - self.start
print(f"Elapsed: {self.elapsed:.3f}s")
return False # Don't suppress exceptions
with Timer():
# code to time
sum(range(1_000_000))
# Elapsed: 0.023sAbstract Classes
Abstract classes define an interface that subclasses must implement. You can’t instantiate an abstract class directly.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float:
"""Subclasses must implement this."""
pass
@abstractmethod
def perimeter(self) -> float:
pass
def describe(self) -> str:
"""Concrete method — inherited as-is."""
return f"Area: {self.area():.2f}, Perimeter: {self.perimeter():.2f}"
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
import math
return math.pi * self.radius ** 2
def perimeter(self) -> float:
import math
return 2 * math.pi * self.radius
# shape = Shape() # TypeError: Can't instantiate abstract class
circle = Circle(5)
print(circle.describe()) # Area: 78.54, Perimeter: 31.42When to use: When you want to enforce a contract — “every Shape must have area() and perimeter().”
Dataclasses
The @dataclass decorator auto-generates __init__, __repr__, __eq__, and more. Great for classes that are primarily data holders.
from dataclasses import dataclass, field
@dataclass
class User:
name: str
email: str
age: int = 0 # Default value
tags: list[str] = field(default_factory=list) # Mutable default
# Auto-generated __init__, __repr__, __eq__
user = User("Alice", "alice@example.com", 30)
print(user) # User(name='Alice', email='alice@example.com', age=30, tags=[])
print(user == User("Alice", "alice@example.com", 30)) # TrueDataclass Features
@dataclass(frozen=True) # Immutable (can't change attributes after creation)
class Point:
x: float
y: float
@dataclass(order=True) # Auto-generates __lt__, __le__, __gt__, __ge__
class Priority:
level: int
name: str
@dataclass
class Config:
host: str
port: int = 8080
def __post_init__(self):
"""Runs after __init__ — for validation or computed fields."""
if self.port < 0 or self.port > 65535:
raise ValueError(f"Invalid port: {self.port}")Dataclass vs Regular Class
| Feature | Dataclass | Regular Class |
|---|---|---|
__init__ | Auto-generated | Write manually |
__repr__ | Auto-generated | Write manually |
__eq__ | Auto-generated (by value) | Default is is (identity) |
| Boilerplate | Minimal | More verbose |
| Best for | Data containers, configs, DTOs | Complex behavior, heavy logic |
Composition vs Inheritance
Inheritance = “is-a” relationship. Composition = “has-a” relationship.
# INHERITANCE — Dog IS an Animal
class Animal:
def eat(self):
return "eating"
class Dog(Animal):
def bark(self):
return "woof"
# COMPOSITION — Car HAS an Engine
class Engine:
def start(self):
return "vroom"
class Car:
def __init__(self):
self.engine = Engine() # Car contains an Engine
def start(self):
return self.engine.start()When to Use Which
| Use | When |
|---|---|
| Inheritance | True “is-a” relationship (Dog is an Animal), shared interface, abstract base classes |
| Composition | ”Has-a” relationship (Car has an Engine), mixing capabilities, avoiding deep inheritance trees |
Rule of thumb: Favor composition over inheritance. Deep inheritance hierarchies become fragile and hard to reason about. Composition is more flexible — you can swap components without changing the class hierarchy.
# Composition example — mixing capabilities
class Logger:
def log(self, message: str):
print(f"[LOG] {message}")
class Database:
def query(self, sql: str):
return f"results for: {sql}"
class UserService:
def __init__(self):
self.logger = Logger()
self.db = Database()
def get_user(self, user_id: int):
self.logger.log(f"Fetching user {user_id}")
return self.db.query(f"SELECT * FROM users WHERE id = {user_id}")Common Patterns
Singleton
Ensure only one instance of a class exists.
class Database:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
db1 = Database()
db2 = Database()
print(db1 is db2) # True — same instanceFactory
Create objects without specifying the exact class.
class Shape:
@staticmethod
def create(shape_type: str, **kwargs):
shapes = {"circle": Circle, "square": Square}
cls = shapes.get(shape_type)
if cls is None:
raise ValueError(f"Unknown shape: {shape_type}")
return cls(**kwargs)
shape = Shape.create("circle", radius=5)Observer
Objects subscribe to events and get notified of changes.
class EventEmitter:
def __init__(self):
self._listeners: dict[str, list] = {}
def on(self, event: str, callback):
self._listeners.setdefault(event, []).append(callback)
def emit(self, event: str, *args, **kwargs):
for callback in self._listeners.get(event, []):
callback(*args, **kwargs)
emitter = EventEmitter()
emitter.on("user_created", lambda user: print(f"Welcome, {user}!"))
emitter.on("user_created", lambda user: print(f"Sending email to {user}"))
emitter.emit("user_created", "Alice")
# Welcome, Alice!
# Sending email to Alice