Object-Oriented Programming in Python: Classes, Inheritance, and the Hidden Power of Dunder Methods
Discover the nuances of OOP in Python. Learn why the lack of access modifiers is a feature, how MRO works under the hood, how to leverage dunder methods, and when to use modern @dataclass.

- Python has no strict access modifiers; encapsulation relies on conventions and Name Mangling.
- Multiple inheritance is fully supported and governed by the MRO algorithm (C3 Linearization).
- Magic methods (dunder methods) let you integrate custom objects with built-in language mechanisms (e.g., operators, iteration).
- The @dataclass decorator drastically reduces boilerplate when creating data-centric classes.
Object-oriented programming (OOP) in Python differs from the approach known from languages like Java, C++, or C#. Being a dynamic and pragmatic language, Python foregoes rigid compiler restrictions in favor of flexibility and readability. Instead of forcing developers to erect strict protective barriers around code, Python relies on conventions and trust.
If you're coming to Python from another object-oriented language, some design choices made by its creators might surprise you. In this article, we'll examine how Python's object model really works, how to handle the lack of classic access modifiers, how to tame multiple inheritance, and how to harness the full power of magic methods (dunder methods) and modern dataclasses.
Classes and the Absence of "True" Encapsulation
In Python, everything is an object—from basic numeric types to classes themselves. Defining a class is remarkably simple, yet the first thing that strikes Java or C# developers is the absence of keywords like `private`, `protected`, or `public`. In Python, all class attributes and methods are public by default.
Rather than enforcing encapsulation at the interpreter level, the Python community embraces the principle "we are all consenting adults here". This means developers are expected to respect the class author's intent rather than being locked out by the compiler. Naming conventions are used to communicate visibility:
- No prefix (e.g., `name`): Public attribute or method, accessible from anywhere.
- Single underscore (e.g., `_db_connection`): Protected attribute/method. This signals to other developers: "This is an implementation detail; do not touch it directly from the outside unless you really know what you are doing."
- Double underscore (e.g., `__id`): Private attribute triggering the Name Mangling mechanism.
Let's take a closer look at how Name Mangling works in practice. It doesn't truly hide the variable; it simply transforms its name under the hood to prevent accidental overrides in subclasses.
class PaymentProcessor:
def __init__(self, api_key: str):
self.public_status = "active"
self._protected_connection = "Connected to gateway"
self.__api_key = api_key # Name mangling
processor = PaymentProcessor("secret_12345")
print(processor.public_status) # active
print(processor._protected_connection) # Connected to gateway (works, but breaks convention)
# print(processor.__api_key) # AttributeError: 'PaymentProcessor' object has no attribute '__api_key'
# Access is still possible via the mangled name:
print(processor._PaymentProcessor__api_key) # secret_12345Never treat Name Mangling (`__private`) as a cryptographic safeguard or a way to hide sensitive data. Any developer can easily read this value using the appropriately mangled attribute name on the object.
Inheritance and MRO (Method Resolution Order)
Python supports multiple inheritance, setting it apart from many other popular languages. While multiple inheritance offers tremendous flexibility, it introduces the risk of the diamond problem—a scenario where a base class is inherited by two intermediate classes, which in turn are both inherited by a single leaf class.
To resolve this unambiguously, Python uses the C3 Linearization algorithm, which defines the method lookup order, known as MRO (Method Resolution Order). You can inspect this order using the `__mro__` special attribute or the `mro()` method.
class A:
def execute(self):
print("A.execute")
class B(A):
def execute(self):
print("B.execute")
super().execute()
class C(A):
def execute(self):
print("C.execute")
super().execute()
class D(B, C):
def execute(self):
print("D.execute")
super().execute()
# Check MRO for class D
print([cls.__name__ for cls in D.__mro__])
# Result: ['D', 'B', 'C', 'A', 'object']
obj = D()
obj.execute()Notice how `super()` behaves. In class `B`, calling `super().execute()` does not directly call the method from class `A`, but rather from class `C`. This is a direct consequence of the order defined in the MRO. This way, Python ensures that every class in the hierarchy is visited exactly once.
The Hidden Power of Dunder Methods (Magic Methods)
Magic methods, commonly called dunder methods (short for double underscore), are methods that start and end with double underscores. They are the backbone of Python's power and flexibility, enabling operator overloading and seamless integration of custom objects with built-in language constructs.
When you write `len(my_object)`, Python calls `my_object.__len__()` behind the scenes. When you use the `+` operator, `__add__()` is invoked. By implementing the right dunder methods, you can make your classes behave like lists, dictionaries, numbers, or callable objects.
Let's implement a class representing a 2D vector to see how dunder methods transform how we interact with objects:
class Vector2D:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
# Developer representation (debugging)
def __repr__(self) -> str:
return f"Vector2D({self.x}, {self.y})"
# User-friendly representation (e.g. for print())
def __str__(self) -> str:
return f"({self.x}, {self.y})"
# Overloading the addition operator (+)
def __add__(self, other: 'Vector2D') -> 'Vector2D':
if not isinstance(other, Vector2D):
return NotImplemented
return Vector2D(self.x + other.x, self.y + other.y)
# Object equality (==)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector2D):
return NotImplemented
return self.x == other.x and self.y == other.y
v1 = Vector2D(2, 3)
v2 = Vector2D(4, 5)
print(v1) # (2, 3) -> uses __str__
print(repr(v1)) # Vector2D(2, 3) -> uses __repr__
v3 = v1 + v2 # uses __add__
print(v3) # (6, 8)
print(v1 == Vector2D(2, 3)) # True -> uses __eq__Always implement the `__repr__` method for your classes. As a best practice, it should return a string that, if pasted into a Python console, would recreate the exact same object (e.g., `Vector2D(2, 3)`).
A Modern Approach: @dataclass
The traditional approach to writing classes in Python that primarily serve as data containers (DTOs - Data Transfer Objects) involves writing a lot of repetitive boilerplate code. You have to manually implement `__init__`, `__repr__`, `__eq__`, and so on.
Since Python 3.7, the standard library includes the `dataclasses` module, which automatically generates these methods based on type annotations. It's a game-changer for code cleanliness.
from dataclasses import dataclass, field
@dataclass(frozen=True) # frozen=True makes the object immutable
class UserProfile:
username: str
email: str
roles: list[str] = field(default_factory=list) # Safe list initialization
user = UserProfile(username="jkowalski", email="jan@byteway.pl")
print(user) # Automatic repr: UserProfile(username='jkowalski', email='jan@byteway.pl', roles=[])
# user.username = "nowy" # FrozenInstanceError: cannot assign to field 'username'By setting `frozen=True`, our dataclass also becomes hashable, meaning we can use its instances as dictionary keys or store them in sets (`set`).
Comparison: Standard Class vs. @dataclass
To better illustrate the benefits of the modern approach, let's contrast the classic object-oriented pattern with the `@dataclass` decorator.
| Feature | Classic Class | @dataclass Class |
|---|---|---|
| Boilerplate Code | High (requires manually writing __init__, __repr__, __eq__) | Minimal (methods generated automatically) |
| Default Mutability | Always mutable | Mutable, but easily disabled with frozen=True |
| Type Hinting Support | Optional | Required (fields are generated based on annotations) |
| Use Case | Complex business logic, rich behaviors | Data structures, DTOs, configurations |
Summary
Object-oriented programming in Python favors pragmatism and flexibility. Instead of rigid barriers, the language offers conventions that work remarkably well in most scenarios. Understanding Name Mangling, the MRO algorithm, and mastering dunder methods empowers you to write code that integrates seamlessly with the Python ecosystem.
In day-to-day engineering and architecture, remember not to overuse inheritance where composition works better, and don't hesitate to reach for `@dataclass` when modeling data structures. It's a straightforward path to cleaner, more pythonic code.
Ready to get started?
Got something I could help with? Get in touch — happy to share what I know.
Get in touch