OOP I

Encapsulation, abstraction, polymorphism, and multiple inheritance — the four pillars

Why

These four pillars are the vocabulary of object-oriented system design. They determine whether you can design software, not just write it. Encapsulation tells you how to hide complexity; abstraction how to define contracts; polymorphism how to write code that works with types you haven't seen yet; inheritance how to build specialisation on top of existing behaviour without duplicating it.

Intuition

Encapsulation — expose a clean interface, hide the internals. Abstraction — define what must be done, not how. Polymorphism — same call, different behaviour per type. Inheritance — build specialised classes on top of existing ones without rewriting what already works.

Explanation
1. Encapsulation

Encapsulation bundles data and behaviour together and controls access to internals. Python uses _ (protected by convention) and __ (name mangling) to signal access levels. @property is the Pythonic way to add validation without changing the attribute access syntax callers use.

class BankAccount: def __init__(self, balance): self._balance = balance # _single: "protected" — internal use only self.__pin = 1234 # __double: name-mangled, harder to access @property def balance(self): # controlled read return self._balance @balance.setter def balance(self, amount): # validation on write if amount < 0: raise ValueError("Balance can't be negative") self._balance = amount acc = BankAccount(1000) acc.balance # 1000 — read via property acc.balance = 500 # write via setter — validation runs acc._balance # 500 — accessible but signals "don't rely on this" acc.__pin # AttributeError — mangled to _BankAccount__pin acc._BankAccount__pin # 1234 — still reachable if you insist

_name is a convention — Python doesn't enforce it, but it signals "internal, don't rely on this." __name triggers name mangling: the attribute is renamed to _ClassName__name, preventing accidental overrides in subclasses.

2. Abstraction

Abstraction hides implementation complexity behind a clean interface. An abstract base class defines what subclasses must implement — @abstractmethod enforces this at instantiation. You cannot create an instance of an abstract class directly.

from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: ... # subclasses MUST implement this @abstractmethod def perimeter(self) -> float: ... def describe(self): # concrete method — shared by all shapes return f"Area: {self.area():.2f}, Perimeter: {self.perimeter():.2f}" class Circle(Shape): def __init__(self, r): self.r = r def area(self): return 3.14159 * self.r ** 2 def perimeter(self): return 2 * 3.14159 * self.r class Square(Shape): def __init__(self, s): self.s = s def area(self): return self.s ** 2 def perimeter(self): return 4 * self.s Shape() # TypeError: Can't instantiate abstract class Shape Circle(5).describe() # "Area: 78.54, Perimeter: 31.42"

Any subclass that forgets to implement an abstract method raises TypeError at instantiation — not at the call site. Bugs surface early, at the point of definition rather than deep inside production code.

3. Polymorphism

Polymorphism means the same operation behaves differently depending on the type. Python supports two forms: method overriding (subclass replaces the parent's method) and duck typing (any object with the right method works, regardless of inheritance).

class Animal: def speak(self): return "..." class Dog(Animal): def speak(self): return "Woof" # overrides parent class Cat(Animal): def speak(self): return "Meow" def make_speak(animals): for a in animals: print(a.speak()) # same call — different result per type make_speak([Dog(), Cat()]) # Woof # Meow # Duck typing — no inheritance required class Robot: def speak(self): return "Beep" make_speak([Dog(), Robot()]) # works — Robot has .speak(), that's enough # Woof # Beep

"If it has the right method, it works" — Python doesn't require a shared base class. Duck typing makes polymorphism more flexible than in statically-typed languages. Use Protocol (from typing) to make the expectation explicit for tooling without forcing inheritance.

4. Inheritance

Inheritance lets a class acquire properties and methods of another class — building specialisation on top of existing behaviour without duplicating it. It's the "is-a" relationship: a Dog is an Animal. super() calls the parent's version of a method, keeping the chain intact.

class Animal: def __init__(self, name): self.name = name def speak(self): return f"{self.name} makes a sound" class Dog(Animal): def __init__(self, name, breed): super().__init__(name) # extend, don't duplicate self.breed = breed def speak(self): # override — replace parent behaviour return f"{self.name} barks" class GoldenRetriever(Dog): def speak(self): return super().speak() + " (friendly)" g = GoldenRetriever("Buddy", "Golden") g.speak() # "Buddy barks (friendly)" isinstance(g, Dog) # True isinstance(g, Animal) # True — inherits up the chain

Always call super().__init__() in subclass constructors — skipping it leaves parent state uninitialised. Use isinstance over type(x) == Y; it respects the full inheritance chain.