Free GuideIntermediate
45 min read
#python#oop#classes#objects#inheritance#polymorphism#encapsulation#dunder methods

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.
  • self refers 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 write rex.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.

ConventionMeaning
namePublic — freely accessible
_nameProtected (by convention) — "internal use," not enforced
__nameName-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.

MethodTriggered 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
DecoratorFirst parameterTypical use
(regular method)selfOperates on a specific instance
@classmethodclsOperates on the class itself; alternative constructors
@staticmethodnoneUtility 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 object unless another base class is specified.
  • self is 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) == Class checks the exact type only — prefer isinstance() 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 @property instead of plain getter/setter methods for validated or computed attributes
  • Prefer super().__init__(...) over duplicating parent initialization logic
  • Use abc.ABC and @abstractmethod to enforce a required interface across subclasses
  • Reach for @dataclass for 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
Knowledge Checkpoint

Object-Oriented Programming (OOP) Checkpoint

Q1.What is the fundamental difference between `__new__` and `__init__` in Python class instantiation?
A`__new__` initializes attributes; `__init__` allocates the memory.
B`__new__` is the static constructor that creates and returns a new instance; `__init__` is the initializer that configures attributes on the newly created instance.
C`__new__` is only called for subclasses; `__init__` is only called for base classes.
DThere is no difference; `__new__` is deprecated in Python 3.
Q2.What algorithm does Python use to compute the Method Resolution Order (MRO) in multiple inheritance hierarchies?
ADepth-First Search (DFS)
BBreadth-First Search (BFS)
CC3 Linearization algorithm
DDijkstra Shortest Path
Q3.What is the primary performance benefit of defining `__slots__` in a Python class?
AIt compiles methods directly to C extensions.
BIt replaces the per-instance dynamic `__dict__` with a fixed-size array of descriptor references, significantly reducing memory footprint.
CIt enables multi-threading execution.
DIt makes class attributes immutable.
Track Your Learning

Finished studying this notebook?

Mark this guide as completed to update your course progress roadmap.