Python•5 min read•Oct 8, 2026

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.

Udostępnij:
Object-Oriented Programming in Python: Classes, Inheritance, and the Hidden Power of Dunder Methods
TL;DR - Executive Summary
  • 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.

python
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_12345

Never 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.

python
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:

python
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.

python
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.

FeatureClassic Class@dataclass Class
Boilerplate CodeHigh (requires manually writing __init__, __repr__, __eq__)Minimal (methods generated automatically)
Default MutabilityAlways mutableMutable, but easily disabled with frozen=True
Type Hinting SupportOptionalRequired (fields are generated based on annotations)
Use CaseComplex business logic, rich behaviorsData 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.

Let's work together

Ready to get started?

Got something I could help with? Get in touch — happy to share what I know.

Get in touch
OOP in Python: MRO, Name Mangling & Dunder Methods | byteway