Object-Oriented Programming
Comprehensive guide on Object-Oriented Programming.
Object-Oriented Programming
Overview#
Object-oriented programming (OOP) organizes code around objects — bundles of data (attributes) and behavior (methods) — rather than just functions operating on separate data. Python is a fully object-oriented language: everything, including numbers and functions, is an object. This notebook covers defining classes, the four pillars of OOP (encapsulation, inheritance, polymorphism, abstraction), special "dunder" methods, properties, class vs static methods, dataclasses, and common design pitfalls.
1. Classes and Objects#
A class is a blueprint; an object (or instance) is a concrete thing built from that blueprint.
Python 3.12 (WASM)class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
def bark(self):
return f"{self.name} says Woof!"
rex = Dog("Rex", "Labrador")
print(rex.name) # Rex
print(rex.bark()) # Rex says Woof!
__init__is the constructor — it runs automatically when a new object is created, and is used to set up initial attribute values.selfrefers to the specific instance the method is being called on. It must be the first parameter of every instance method, though Python passes it automatically — you never writerex.bark(rex).
Instance attributes vs class attributes#
Python 3.12 (WASM)class Dog:
species = "Canis familiaris" # class attribute: shared by all instances
def __init__(self, name):
self.name = name # instance attribute: unique per object
rex = Dog("Rex")
fido = Dog("Fido")
print(rex.species, fido.species) # Canis familiaris Canis familiaris
print(rex.name, fido.name) # Rex Fido
Dog.species = "Modified" # changes the class attribute for ALL instances
print(rex.species) # Modified
Be careful with mutable class attributes (like a list) — since they're shared across all instances, modifying one instance's copy can accidentally affect every other instance.
Python 3.12 (WASM)class Team:
members = [] # DANGER: shared across every Team instance
def add(self, name):
self.members.append(name)
a = Team()
b = Team()
a.add("Alice")
print(b.members) # ['Alice'] — unexpected! Both instances share the same list
2. Encapsulation#
Encapsulation bundles data and the methods that operate on it, and controls access to internal state. Python doesn't have true private attributes like some languages, but uses naming conventions.
| Convention | Meaning |
|---|---|
name | Public — freely accessible |
_name | Protected (by convention) — "internal use," not enforced |
__name | Name-mangled — harder to access accidentally from outside |
Python 3.12 (WASM)class BankAccount:
def __init__(self, balance):
self._balance = balance # convention: internal use
self.__pin = "1234" # name-mangled
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
def get_balance(self):
return self._balance
account = BankAccount(100)
account.deposit(50)
print(account.get_balance()) # 150
# print(account.__pin) # AttributeError
print(account._BankAccount__pin) # '1234' — name mangling isn't true privacy
Properties: controlled attribute access#
The @property decorator lets you expose a method as if it were a plain
attribute, adding validation or computed values without changing how
callers use the class.
Python 3.12 (WASM)class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self): # read-only computed property
return 3.14159 * self._radius ** 2
c = Circle(5)
print(c.radius) # 5 (calls the getter, looks like plain attribute access)
c.radius = 10 # calls the setter, validates the new value
print(c.area) # 314.159 (computed on the fly)
# c.area = 100 # AttributeError: no setter defined
3. Inheritance#
Inheritance lets a class (the subclass or child) reuse and extend the behavior of another class (the superclass or parent).
Python 3.12 (WASM)class Animal:
def __init__(self, name):
self.name = name
def speak(self):
raise NotImplementedError("Subclasses must implement speak()")
def describe(self):
return f"{self.name} is an animal"
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"
rex = Dog("Rex")
whiskers = Cat("Whiskers")
print(rex.speak()) # Rex says Woof!
print(rex.describe()) # Rex is an animal (inherited, unchanged)
print(whiskers.speak()) # Whiskers says Meow!
super(): calling the parent's implementation#
Python 3.12 (WASM)class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def annual_salary(self):
return self.salary * 12
class Manager(Employee):
def __init__(self, name, salary, bonus):
super().__init__(name, salary) # reuse the parent's __init__
self.bonus = bonus
def annual_salary(self):
base = super().annual_salary() # reuse parent's method, then extend
return base + self.bonus
m = Manager("Priya", 80000, 15000)
print(m.annual_salary()) # 975000
Multiple inheritance and the MRO#
Python supports inheriting from more than one class. When methods are looked up, Python follows the Method Resolution Order (MRO), computed using the C3 linearization algorithm.
Python 3.12 (WASM)class Flyable:
def move(self):
return "flying"
class Swimmable:
def move(self):
return "swimming"
class Duck(Flyable, Swimmable):
pass
d = Duck()
print(d.move()) # flying — Flyable comes first in the class definition
print(Duck.__mro__) # shows the exact lookup order
Multiple inheritance is powerful but can get confusing quickly. It's often used carefully through mixins — small classes that add one specific capability and are designed to be combined.
Python 3.12 (WASM)class JSONSerializableMixin:
def to_json(self):
import json
return json.dumps(self.__dict__)
class User(JSONSerializableMixin):
def __init__(self, name, age):
self.name = name
self.age = age
u = User("Alice", 30)
print(u.to_json()) # {"name": "Alice", "age": 30}
4. Polymorphism#
Polymorphism means different classes can be used interchangeably if they implement the same interface — the caller doesn't need to know the exact type, only that the expected method exists.
Python 3.12 (WASM)animals = [Dog("Rex"), Cat("Whiskers")]
for animal in animals:
print(animal.speak()) # each object responds in its own way
This is often called "duck typing" in Python: if an object walks like a duck and quacks like a duck, it's treated as a duck — Python cares about whether an object has the right method, not what class it officially belongs to.
Python 3.12 (WASM)class Duck:
def speak(self):
return "Quack!"
class Robot:
def speak(self):
return "Beep boop, I am speaking"
for thing in [Dog("Rex"), Duck(), Robot()]:
print(thing.speak()) # works for all three, no shared base class needed
5. Abstraction#
Abstraction hides implementation details behind a simpler interface.
Python provides abc (Abstract Base Classes) to formally define methods
that subclasses must implement.
Python 3.12 (WASM)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)
# shape = Shape() # TypeError: can't instantiate abstract class
rect = Rectangle(4, 5)
print(rect.area()) # 20
If Rectangle failed to implement area() or perimeter(), attempting to
instantiate it would raise a TypeError immediately — this catches
incomplete implementations early, at object creation time rather than
when the missing method is eventually called.
6. Dunder (Magic) Methods#
Methods surrounded by double underscores let your objects integrate with Python's built-in syntax and functions.
| Method | Triggered by |
|---|---|
__init__ | Object creation (MyClass()) |
__str__ | str(obj), print(obj) — user-friendly display |
__repr__ | repr(obj) — unambiguous, developer-facing display |
__eq__ | obj1 == obj2 |
__lt__, __le__, __gt__, __ge__ | <, <=, >, >= |
__len__ | len(obj) |
__getitem__ | obj[key] |
__setitem__ | obj[key] = value |
__iter__ | for x in obj: |
__contains__ | x in obj |
__add__ | obj1 + obj2 |
__call__ | obj(...) — makes an instance callable like a function |
Python 3.12 (WASM)class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __eq__(self, other):
return self.x == other.x and self.y == other.y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __len__(self):
return int((self.x ** 2 + self.y ** 2) ** 0.5)
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6) — uses __add__
print(v1 == Vector(1, 2)) # True — uses __eq__
print(len(Vector(3, 4))) # 5 — uses __len__
__str__ vs __repr__#
Python 3.12 (WASM)class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f"Point(x={self.x}, y={self.y})" # for developers, debugging
def __str__(self):
return f"({self.x}, {self.y})" # for end users
p = Point(1, 2)
print(p) # (1, 2) -> uses __str__
print([p]) # [Point(x=1, y=2)] -> list display falls back to __repr__
If __str__ is not defined, Python falls back to __repr__. Always
define at least __repr__ — it makes debugging significantly easier.
7. Class Methods and Static Methods#
Python 3.12 (WASM)class Pizza:
def __init__(self, toppings):
self.toppings = toppings
@classmethod
def margherita(cls): # alternative constructor
return cls(["tomato", "mozzarella"])
@staticmethod
def is_valid_topping(topping): # doesn't need self or cls at all
return topping in ["cheese", "tomato", "mozzarella", "basil", "pepperoni"]
p = Pizza.margherita()
print(p.toppings) # ['tomato', 'mozzarella']
print(Pizza.is_valid_topping("cheese")) # True
| Decorator | First parameter | Typical use |
|---|---|---|
| (regular method) | self | Operates on a specific instance |
@classmethod | cls | Operates on the class itself; alternative constructors |
@staticmethod | none | Utility function grouped with the class, no instance/class access needed |
8. Dataclasses#
For classes that primarily hold data, @dataclass (Python 3.7+) auto-
generates __init__, __repr__, and __eq__, dramatically reducing
boilerplate.
Python 3.12 (WASM)from dataclasses import dataclass, field
@dataclass
class Product:
name: str
price: float
tags: list = field(default_factory=list) # avoids the mutable default trap
p1 = Product("Laptop", 999.99, ["electronics"])
p2 = Product("Laptop", 999.99, ["electronics"])
print(p1) # Product(name='Laptop', price=999.99, tags=['electronics'])
print(p1 == p2) # True — auto-generated __eq__ compares field values
Use field(default_factory=list) instead of tags: list = [] to avoid
the same mutable-default pitfall that applies to function arguments.
9. Composition vs Inheritance#
Inheritance models an "is-a" relationship; composition models a "has-a" relationship — building complex objects out of smaller, focused ones. Composition is often more flexible and easier to test.
Python 3.12 (WASM)# Inheritance: "a Car is a Vehicle"
class Vehicle:
def start(self):
return "Starting engine"
class Car(Vehicle):
pass
# Composition: "a Car has an Engine"
class Engine:
def start(self):
return "Engine starting"
class Car:
def __init__(self):
self.engine = Engine()
def start(self):
return self.engine.start()
A common guideline: prefer composition over inheritance when there isn't a clear, stable "is-a" relationship, since deep inheritance hierarchies can become rigid and hard to change safely.
Notes#
- Every class implicitly inherits from
objectunless another base class is specified. selfis a convention, not a keyword — but breaking the convention (using a different name) is strongly discouraged since it hurts readability for everyone else.- Name mangling (
__name) is meant to avoid accidental attribute clashes in subclasses, not to provide real security or privacy. isinstance(obj, Class)checks whether an object is an instance of a class (or subclass);type(obj) == Classchecks the exact type only — preferisinstance()in most cases, since it correctly handles inheritance.- Overriding
__eq__without also overriding__hash__makes instances unhashable by default — if you need instances in a set or as dict keys, define both consistently.
Best Practices Checklist#
- Use
__init__to set up all instance attributes an object needs - Avoid mutable class attributes unless the sharing is intentional
- Define
__repr__on every meaningful class to aid debugging - Use
@propertyinstead of plain getter/setter methods for validated or computed attributes - Prefer
super().__init__(...)over duplicating parent initialization logic - Use
abc.ABCand@abstractmethodto enforce a required interface across subclasses - Reach for
@dataclassfor simple data-holding classes instead of writing boilerplate__init__/__repr__/__eq__by hand - Use
field(default_factory=...)for mutable dataclass defaults - Prefer composition over deep inheritance hierarchies when there's no clear "is-a" relationship
- Use
isinstance()rather than exact type comparisons for polymorphic code