The Four Principles of OOP in Python
Encapsulation, abstraction, inheritance and polymorphism - what each one means in Python specifically, where Python differs from Java or C++, and when each is worth using.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
The four, in one table
| Principle | Means | In Python |
|---|---|---|
| Encapsulation | Keep data and the code that changes it together, and control access | By convention, using underscores and @property |
| Abstraction | Expose what something does, hide how | Methods, and abc for enforced interfaces |
| Inheritance | A class reuses and extends another | Full support, including multiple inheritance |
| Polymorphism | One interface, many implementations | Duck typing - no shared base class required |
Encapsulation
class Thermostat:
def __init__(self, target=20):
self._target = target # internal by convention
@property
def target(self):
return self._target
@target.setter
def target(self, value):
if not 5 <= value <= 30:
raise ValueError("target must be between 5 and 30")
self._target = value
t = Thermostat()
t.target = 22 # goes through the setter and is validated
print(t.target) # reads through the getter
try:
t.target = 90
except ValueError as error:
print(error)The object protects its own rules. A caller cannot put it into an impossible state through the public interface.
Python does not enforce privacy. t._target = 90 works. The convention is understood and respected: an underscore means "this is not part of the interface, and it may change without warning". Python trusts the programmer rather than the compiler.Do not write Java in Python
# Unnecessary in Python
class Point:
def __init__(self, x):
self._x = x
def get_x(self):
return self._x
def set_x(self, value):
self._x = value
# Idiomatic: start with a plain attribute
class Point:
def __init__(self, x):
self.x = xGetters and setters that do nothing add noise. Start with a public attribute; if validation becomes necessary later, convert it to a property without changing a single line of calling code. That ability is exactly why Python does not need defensive accessors up front.
Abstraction
class EmailSender:
def send(self, to, subject, body):
self._connect()
self._authenticate()
self._transmit(to, subject, body)
self._disconnect()
return True
def _connect(self): ...
def _authenticate(self): ...
def _transmit(self, to, subject, body): ...
def _disconnect(self): ...
sender = EmailSender()
sender.send("a@example.com", "Hello", "Text") # four steps, one callThe caller sees send. Everything else is an implementation detail that can be rewritten without breaking anyone.
from abc import ABC, abstractmethod
class Storage(ABC):
"""Anything that can save and load notes."""
@abstractmethod
def save(self, key, value):
...
@abstractmethod
def load(self, key):
...
class MemoryStorage(Storage):
def __init__(self):
self._data = {}
def save(self, key, value):
self._data[key] = value
def load(self, key):
return self._data.get(key)
# Storage() # TypeError: cannot instantiate an abstract class
store = MemoryStorage()
store.save("a", "first note")
print(store.load("a"))An abstract base class states the contract. Any subclass that forgets a method cannot be instantiated at all, so the failure happens at creation rather than at the first call.
Inheritance
class Employee:
def __init__(self, name, salary):
self.name = name
self.salary = salary
def describe(self):
return f"{self.name} earns {self.salary:,}"
def annual_pay(self):
return self.salary * 12
class Manager(Employee):
def __init__(self, name, salary, reports):
super().__init__(name, salary)
self.reports = reports
def describe(self): # overrides the parent
base = super().describe()
return f"{base}, managing {len(self.reports)} people"
def annual_pay(self):
return super().annual_pay() + 100_000 # a bonus
m = Manager("Meera", 90_000, ["arun", "sara"])
print(m.describe())
print(f"{m.annual_pay():,}")
print(isinstance(m, Employee)) # True - a Manager IS an EmployeeUse inheritance only when the "is a" relationship is genuinely true, and when the subclass can be used anywhere the parent is expected.
# A classic misuse
class Stack(list): # a Stack is NOT a list
def push(self, item):
self.append(item)
s = Stack()
s.push(1)
s.insert(0, 99) # inherited, and it breaks the stack rules
print(s)
# Composition instead
class Stack:
def __init__(self):
self._items = [] # a Stack HAS a list
def push(self, item):
self._items.append(item)
def pop(self):
if not self._items:
raise IndexError("pop from an empty stack")
return self._items.pop()
def __len__(self):
return len(self._items)Polymorphism
class Dog:
def speak(self):
return "Woof"
class Cat:
def speak(self):
return "Meow"
class Robot:
def speak(self):
return "Beep"
for thing in [Dog(), Cat(), Robot()]:
print(thing.speak())These three classes share no base class and no interface declaration. The loop works because each one has a speak method. That is duck typing: if it walks like a duck and quacks like a duck, Python treats it as a duck.
def total_length(items):
return sum(len(item) for item in items)
print(total_length(["ab", "cde"])) # strings
print(total_length([[1, 2], [3]])) # lists
print(total_length([{"a": 1}, {"b": 2, "c": 3}])) # dictionaries
print(total_length([(1,), range(5)])) # tuples and rangesThis function was never written for any of those types. It requires only that each item supports len. In a statically typed language it would need an interface and every type would have to declare it.
Polymorphism through dunder methods
class Money:
def __init__(self, amount):
self.amount = amount
def __add__(self, other):
return Money(self.amount + other.amount)
def __len__(self):
return len(str(self.amount))
def __str__(self):
return f"Rs {self.amount:,.2f}"
print(Money(100) + Money(250)) # Rs 350.00
print(len(Money(1000))) # 4Built in operations such as +, len, str, in and iteration are all polymorphic. Implementing the right dunder method makes your class work with syntax it never knew about.
The four together
from abc import ABC, abstractmethod
class Shape(ABC): # abstraction
@abstractmethod
def area(self):
...
def describe(self): # shared behaviour
return f"{type(self).__name__} with area {self.area():.2f}"
class Rectangle(Shape): # inheritance
def __init__(self, width, height):
self._width = width # encapsulation
self._height = height
@property
def width(self):
return self._width
def area(self):
return self._width * self._height
class Circle(Shape):
def __init__(self, radius):
self._radius = radius
def area(self):
return 3.14159 * self._radius ** 2
shapes = [Rectangle(3, 4), Circle(5), Rectangle(2, 2)]
for shape in shapes: # polymorphism
print(shape.describe())
print(f"total area {sum(s.area() for s in shapes):.2f}")
print(max(shapes, key=lambda s: s.area()).describe())Common mistakes
- Writing getters and setters for every attribute out of habit.
- Using inheritance for code reuse when the "is a" relationship is false.
- Building deep hierarchies; three levels is usually already too many.
- Checking types with
isinstancewhere duck typing would have worked. - Assuming a leading underscore prevents access.
- Making everything a class when functions would be clearer.
Best practices
- Start with public attributes; add
propertyonly when validation is needed. - Prefer composition to inheritance, and inherit only for a true "is a".
- Rely on duck typing; ask what an object can do, not what it is.
- Use
abcwhen several classes must implement the same contract. - Keep hierarchies shallow and each class focused.
Practice
- Convert a class with
get_andset_methods into one using properties, without changing the callers. - Write three unrelated classes with a
rendermethod and process them in one loop. - Define an abstract base class and show the error when a subclass omits a method.
- Take a class that inherits from
listand rewrite it using composition, explaining what improved. - Implement
__add__and__eq__on a class so it works withsumand in a set.
Conclusion
Encapsulation keeps rules with data, abstraction hides how, inheritance shares behaviour where "is a" holds, and polymorphism lets one piece of code work with many types. In Python the last one needs no declarations at all - which is why duck typing, not inheritance, is the usual answer.