Python - OOP

development python oop classes


OOP Concepts Quick Review

ConceptWhat It IsExample
ClassA blueprint/template for creating objectsclass Dog:
ObjectAn instance of a classmy_dog = Dog("Rex")
AttributeData stored on an objectmy_dog.name
MethodA function that belongs to a classmy_dog.bark()
EncapsulationBundling data + methods, hiding internalsPrivate attributes, properties
InheritanceA class that builds on another classclass Puppy(Dog):
PolymorphismDifferent classes sharing the same interfaceMultiple classes with a .speak() method
AbstractionHiding complexity behind a simple interfaceAbstract 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 AttributeClass Attribute
Defined in__init__ (on self)Class body (outside methods)
ScopeUnique per instanceShared by all instances
Accessself.name or obj.nameClassName.attr or obj.attr
Use casePer-object dataConstants, 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
TypeDecoratorFirst ArgCan Access Instance?Can Access Class?Use Case
InstanceNoneselfYesYes (via self.__class__)Most methods — operate on instance data
Class@classmethodclsNoYesAlternative constructors, class-level operations
Static@staticmethodNoneNoNoUtility 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 ValueError

Python Privacy Conventions

PrefixMeaningEnforcement
namePublicNone — accessible to everyone
_name”Private” by conventionNot enforced — a hint to other developers
__nameName-mangledBecomes _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 sound

Using 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.

MethodTriggered ByPurpose
__init__(self, ...)MyClass()Constructor — initialize instance
__str__(self)str(obj), print(obj)Human-readable string
__repr__(self)repr(obj), debuggerDeveloper-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] = valIndex/key assignment
__iter__(self)for x in objMake iterable
__next__(self)next(obj)Iterator protocol
__contains__(self, item)item in objMembership test
__eq__(self, other)obj == otherEquality comparison
__lt__(self, other)obj < otherLess than (enables sorting)
__add__(self, other)obj + otherAddition 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.023s

Abstract 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.42

When 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))   # True

Dataclass 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

FeatureDataclassRegular Class
__init__Auto-generatedWrite manually
__repr__Auto-generatedWrite manually
__eq__Auto-generated (by value)Default is is (identity)
BoilerplateMinimalMore verbose
Best forData containers, configs, DTOsComplex 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

UseWhen
InheritanceTrue “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 instance

Factory

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