DEV Community

Kaushikcoderpy
Kaushikcoderpy

Posted on • Originally published at logicandlegacy.blogspot.com

Python OOP Masterclass: Mastering "Self", Inheritance, and Abstract Classes (2026)

A diagram illustrating shared class variables across multiple objects and unique instance variables specific to each object in Python.

Day 11: The Architecture of State — Classes, OOP, and Dunders

38 min read
Series: Logic & Legacy
Day 11 / 30
Level: Beginner to Architect

In the wild, code is chaotic. Variables float aimlessly, and functions manipulate data they do not own. To build a legacy, you must bind your data (state) and your functions (behavior) into an unbreakable fortress. This is Object-Oriented Programming (OOP) in Python.

⚠️ The 3 Fatal Beginner Mistakes

Before we build, we must unlearn. Here is how beginners destroy their own architecture when attempting to write classes:

  • The Mutable Trap: Defining a list like inventory = [] directly under the class keyword. Result: Every user shares the exact same inventory. User A adds an item, and it magically appears in User B's cart. A catastrophic data bleed.
  • Forgetting self: Writing def attack(target): instead of def attack(self, target):. Result: TypeError: method takes 1 positional argument but 2 were given. Python secretly passes the object itself as the first argument. If you don't catch it with self, the system crashes.
  • Returning from __init__: Trying to write return True inside the setup function. Result: TypeError: __init__() should return None. The constructor's only job is to build the object, not hand back data.

LET'S UNDERSTAND CLASSES IN PYTHON

From practical blueprints to CPython memory allocation.

▶ Table of Contents 🕉️ (Click to Expand)

  1. Objects & The Problem Classes Solve
  2. Anatomy: self, Variables, and Methods
  3. Parampara: The Lineage of Inheritance
  4. The Power of super() and Overriding
  5. Abstract Classes & The Sacred Contract
  6. Polymorphism & Duck Typing
  7. The Trinity: Instance vs Class vs Static Methods
  8. The 10 Dunder (Magic) Methods
  9. Shielding State: Getters, Setters & @property
  10. The Modern Era: Dataclasses
  11. The Deep End: How Classes Work Internally > "Whatever action a great man performs, common men follow. And whatever standards he sets by exemplary acts, all the world pursues."Bhagavad Gita 3.21

A Class is the great standard. It is the architectural blueprint from which countless identical, yet independent, objects are forged.

1. Objects & The Problem Classes Solve

In Python, everything is an object. A string is an object. An integer is an object. They are blocks of memory containing data, equipped with built-in functions (methods) to manipulate that data.

Why do we need our own Classes?

Imagine building a system to track an army. Without classes, you are forced to pass massive, chaotic dictionaries around your application. You must write separate, disconnected functions to update health or change weapons. If the dictionary structure changes, every function breaks.

A diagram showing a Python class as a blueprint with attributes and methods, and multiple objects created from it with their own values.

A Class solves this by fusing the Data (State) and the Functions (Behavior) into a single, unbreakable entity. You no longer pass data to a function; you ask the data to modify itself.

2. Anatomy: self, Variables, and Methods

Let us forge our first blueprint. There are two types of data in a class:

  • Class Variables: Belong to the Blueprint itself. Shared by every single object created.
  • Instance Variables: Belong uniquely to the specific Object. Declared inside __init__ using self.

What is self?

When you call arjuna.attack(), Python secretly translates this to Warrior.attack(arjuna). The word self is the net that catches the object being passed in, allowing the method to know which specific warrior's data it is supposed to modify.

class Warrior:
    # CLASS VARIABLE: Shared by all warriors
    army_name = "Pandava Alliance"

    # THE CONSTRUCTOR: Initializes the unique state of a new object
    def __init__(self, name, weapon):
        # INSTANCE VARIABLES: Unique to 'self'
        self.name = name
        self.weapon = weapon
        self.health = 100

    # INSTANCE METHOD: Defines behavior
    def attack(self, target):
        print(f"{self.name} strikes {target} with {self.weapon}!")

# Forging the Objects (Instances)
arjuna = Warrior("Arjuna", "Gandiva Bow")
bhima = Warrior("Bhima", "Heavy Mace")

# Calling a method
arjuna.attack("Kaurava Soldier")

# Accessing the shared Class Variable
print(f"Both fight for the: {Warrior.army_name}")
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Arjuna strikes Kaurava Soldier with Gandiva Bow!
Both fight for the: Pandava Alliance
Enter fullscreen mode Exit fullscreen mode

3. Parampara: The Lineage of Inheritance

A structured diagram showing parent and child classes in Python, including single inheritance and multiple inheritance relationships.

Inheritance is Parampara—the passing down of knowledge and traits from parent to child. Why rewrite code? A child class absorbs all methods and variables of its parent, reducing duplication.

  • Single Inheritance: A child inherits from one parent. (e.g., class Archer(Warrior):)
  • Multi-level Inheritance: A chain of descent. (EntityWarriorCommander)
  • Multiple Inheritance: A child inherits from two distinct parents simultaneously. Python supports this, unlike Java.
class Charioteer:
    def drive(self):
        print("Navigating the battlefield.")

class Archer:
    def shoot(self):
        print("Releasing arrows.")

# MULTIPLE INHERITANCE
class Maharathi(Archer, Charioteer):
    pass # Inherits both skillsets seamlessly

karna = Maharathi()
karna.drive()
karna.shoot()
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Navigating the battlefield.
Releasing arrows.
Enter fullscreen mode Exit fullscreen mode

4. The Power of super() and Overriding

When a child class defines its own __init__, it overwrites the parent's constructor. To avoid losing the parent's setup logic, we MUST call super(). This dynamically finds the parent class and executes its methods.

Method Overriding occurs when a child class provides a specific implementation of a method that is already provided by its parent class.

class Warrior:
    def __init__(self, name):
        self.name = name
        self.health = 100

    def warcry(self):
        print("Standard roar!")

class Commander(Warrior):
    def __init__(self, name, rank):
        # MUST DO THIS: Initialize the parent state safely
        super().__init__(name) 
        self.rank = rank # Add child-specific state

    # OVERRIDING the parent method
    def warcry(self):
        print(f"The {self.rank} demands silence!")

drona = Commander("Drona", "Supreme General")
print(f"Health inherited: {drona.health}")
drona.warcry()
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Health inherited: 100
The Supreme General demands silence!
Enter fullscreen mode Exit fullscreen mode

5. Abstract Classes & The Sacred Contract

Sometimes, a parent class is purely conceptual. You should never be able to instantiate a raw "Shape" or a generic "Warrior"—you should only instantiate specific implementations like "Circle" or "Archer".

An Abstract Base Class (ABC) enforces a contract. It forces all child classes to write their own version of a specific method before they are allowed to exist.

from abc import ABC, abstractmethod

class Weapon(ABC):
    @abstractmethod
    def deal_damage(self):
        pass

class Sword(Weapon):
    # If Sword does not write 'deal_damage', Python will crash upon creation.
    def deal_damage(self):
        print("Slashing for 50 damage!")

# generic = Weapon() -> TypeError: Can't instantiate abstract class
blade = Sword()
blade.deal_damage()
Enter fullscreen mode Exit fullscreen mode

6. Polymorphism & Duck Typing

A diagram demonstrating polymorphism where different objects with the same method can be used interchangeably without inheritance.

Polymorphism means "many forms." It allows different classes to have methods with the exact same name, allowing a single function to process totally different objects seamlessly.

In strongly typed languages (Java), an object must explicitly inherit from a specific interface to be valid. Python uses Duck Typing: "If it walks like a duck and quacks like a duck, it must be a duck." Python does not care about the object's lineage; it only checks if the required method exists at the exact moment it is called.

class Elephant:
    def advance(self): print("Trampling forward.")

class Cavalry:
    def advance(self): print("Galloping fast.")

# POLYMORPHISM & DUCK TYPING IN ACTION
# Python doesn't care what 'unit' is, as long as it has an 'advance' method.
def command_charge(unit):
    unit.advance()

command_charge(Elephant())
command_charge(Cavalry())
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Trampling forward.
Galloping fast.
Enter fullscreen mode Exit fullscreen mode

7. The Trinity: Instance vs Class vs Static Methods

Method Type First Argument Use Case
Instance Method (Default) self Modifying or reading data unique to a specific object.
@classmethod cls Modifying shared Class Variables, or creating "Alternative Constructors" (factory patterns).
@staticmethod None Utility functions that logically belong in the class but do not need access to self or cls data.
class MathEngine:
    pi = 3.14159

    @classmethod
    def update_pi(cls, new_pi):
        cls.pi = new_pi # Modifies state for ALL instances

    @staticmethod
    def add(a, b):
        # Needs neither 'self' nor 'cls'. Just a logical grouping.
        return a + b
Enter fullscreen mode Exit fullscreen mode

8. The 10 Dunder (Magic) Methods

A diagram showing how Python operations like addition, length, and print map to special dunder methods such as add, len, and str

Dunder (Double UNDERscore) methods allow your custom objects to interact with Python's built-in syntax (like +, ==, or len()). Without them, your objects are mute. With them, your objects become native Python citizens.

  • __init__(self): The Constructor. Initializes memory state.
  • __str__(self): Returns a human-readable string when you print(object).
  • __repr__(self): Returns a technical string for developers (used in logging/debugging).
  • __eq__(self, other): Defines what happens when you use == between two objects.
  • __lt__(self, other): Defines < (Less Than). Crucial if you want to .sort() a list of your objects!
  • __add__(self, other): Defines what happens when you use the + operator.
  • __len__(self): Defines what is returned when you call len(object).
  • __getitem__(self, index): Allows indexing like object[0] or dictionary keys.
  • __call__(self): Allows an instance to be executed like a function object().
  • __del__(self): The Destructor. Triggers right before the garbage collector destroys the object in RAM.
class GoldCoin:
    def __init__(self, weight):
        self.weight = weight

    def __add__(self, other):
        # Overloading the '+' operator to fuse two coins
        return GoldCoin(self.weight + other.weight)

    def __str__(self):
        return f"Coin({self.weight}g)"

coin1 = GoldCoin(10)
coin2 = GoldCoin(5)
huge_coin = coin1 + coin2  # Triggers __add__

print(huge_coin) # Triggers __str__
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Coin(15g)
Enter fullscreen mode Exit fullscreen mode

9. Shielding State: Getters, Setters & @property

In Java, developers write massive getKarma() and setKarma() functions. In Python, this is considered an anti-pattern. We prefer direct attribute access (user.karma). However, what if we need to validate the data to ensure karma never drops below zero?

We use the @property decorator. It acts like a variable when accessed, but executes like a function under the hood, protecting the internal state without breaking the external API.

class Soul:
    def __init__(self):
        self._karma = 100 # The underscore implies it is "private"

    @property
    def karma(self):
        # The Getter
        return self._karma

    @karma.setter
    def karma(self, value):
        # The Setter: Validation Logic
        if value < 0:
            print("Error: Karma cannot be negative.")
        else:
            self._karma = value

human = Soul()
human.karma = -50  # Triggers the setter validation!
print(human.karma) # Still 100
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Error: Karma cannot be negative.
100
Enter fullscreen mode Exit fullscreen mode

10. The Modern Era: Dataclasses

A side-by-side comparison showing a traditional Python class with boilerplate methods and a simplified dataclass version.

Writing __init__, __str__, and __eq__ for every class that just holds data is exhausting boilerplate. Introduced in Python 3.7, @dataclass automatically writes these Dunder methods for you via metaprogramming.

from dataclasses import dataclass

@dataclass
class WeaponRecord:
    name: str
    damage: int
    is_legendary: bool = False

# __init__ and __str__ are automatically generated!
astra = WeaponRecord("Brahmastra", 9999, True)
print(astra)
Enter fullscreen mode Exit fullscreen mode
[RESULT]
WeaponRecord(name='Brahmastra', damage=9999, is_legendary=True)
Enter fullscreen mode Exit fullscreen mode

11. The Deep End: How Classes Work Internally

A diagram visualizing how Python resolves method lookup order across multiple inherited classes using a defined sequence.

We must pierce the final veil of Maya. At the CPython level, there is no magic. A Class is not a metaphysical concept; it is a highly optimized Dictionary attached to an executable type object.

The Matrix of __dict__

A diagram showing how Python stores object attributes internally using a dictionary-like structure called dict.

When you write arjuna.weapon = "Bow", Python does not create a dedicated memory register for a "weapon". It secretly stores this string inside a hidden dictionary attached to the object, called __dict__. You can literally bypass standard OOP syntax and hack the dictionary directly.

class Warrior:
    army = "Pandava Alliance"

    def __init__(self, name):
        self.name = name

arjuna = Warrior("Arjuna")

# 1. Viewing the internal state
print(f"Object Memory: {arjuna.__dict__}")
print(f"Class Memory contains army: {'army' in Warrior.__dict__}")

# 2. Hacking the Matrix directly
arjuna.__dict__['weapon'] = "Gandiva"
print(f"Direct access: {arjuna.weapon}")
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Object Memory: {'name': 'Arjuna'}
Class Memory contains army: True
Direct access: Gandiva
Enter fullscreen mode Exit fullscreen mode

⚙️ The MRO (Method Resolution Order)

When you call a method or variable like arjuna.attack(), Python performs a brutal lookup sequence using the C3 Linearization algorithm:

  1. Checks the instance's __dict__.
  2. If not found, it checks the Class's __dict__.
  3. If not found, it traverses up the Inheritance Tree sequentially.
  4. If it reaches the base object class and finds nothing, it throws an AttributeError.
# Unveiling the Lineage (MRO) in Multiple Inheritance
class Deity: pass
class Human: pass
class DemiGod(Deity, Human): pass

# The .mro() method reveals the exact search path Python uses.
print([cls.__name__ for cls in DemiGod.mro()])
Enter fullscreen mode Exit fullscreen mode
[RESULT]
['DemiGod', 'Deity', 'Human', 'object']
Enter fullscreen mode Exit fullscreen mode

Classes Are Objects Too (Metaclasses)

If arjuna is an object created by the Warrior class, who created the Warrior class? Classes are objects created by Python's internal type class. Because classes are just objects in RAM, you can forge them dynamically during runtime without ever using the class keyword.

# Forging a Class purely out of raw data
# type('ClassName', (ParentClasses,), {'dictionary_of_attributes'})

def dynamic_attack(self):
    return "A strike from the void!"

# We create the class dynamically
PhantomWarrior = type('PhantomWarrior', (object,), {
    'health': 500,
    'attack': dynamic_attack
})

# Instantiate the dynamic class
ghost = PhantomWarrior()
print(f"Ghost Health: {ghost.health}")
print(ghost.attack())
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Ghost Health: 500
A strike from the void!
Enter fullscreen mode Exit fullscreen mode

This is the absolute bleeding edge. When you control how classes themselves are created (using Metaclasses), you control the very fabric of your application's physics.

12. The Forge: The Army Roster (Challenge)

The Challenge: Theory is useless without execution. Build an ArmyRoster using the modern @dataclass. You must implement the __len__ dunder method so we can call len(roster), and forge a @classmethod factory that generates a default Pandava army.

from dataclasses import dataclass, field
from typing import List

@dataclass
class Warrior:
    name: str
    power: int

@dataclass
class ArmyRoster:
    faction: str
    warriors: List[Warrior] = field(default_factory=list)

    # TODO: Implement the __len__ dunder method

    # TODO: Implement a @classmethod factory named 'create_pandavas'

# Execution test:
# my_army = ArmyRoster.create_pandavas()
# print(f"Troop count: {len(my_army)}")
Enter fullscreen mode Exit fullscreen mode

▶ Show Architectural Solution & Output

from dataclasses import dataclass, field
from typing import List

@dataclass
class Warrior:
    name: str
    power: int

@dataclass
class ArmyRoster:
    faction: str
    # Notice the field(default_factory=list) avoids the Mutable Default Trap!
    warriors: List[Warrior] = field(default_factory=list)

    def __len__(self):
        return len(self.warriors)

    @classmethod
    def create_pandavas(cls):
        # Factory method creates an instance of itself (cls)
        roster = cls("Pandavas")
        roster.warriors = [
            Warrior("Arjuna", 100), 
            Warrior("Bhima", 100)
        ]
        return roster

my_army = ArmyRoster.create_pandavas()
print(f"Troop count for {my_army.faction}: {len(my_army)}")
Enter fullscreen mode Exit fullscreen mode
[RESULT]
Troop count for Pandavas: 2
Enter fullscreen mode Exit fullscreen mode

💡 Production Standard Upgrade

Elevate this architecture by adding:

  • The __add__ dunder method so you can merge two armies using army_3 = army_1 + army_2.
  • The __getitem__ dunder method so you can select a warrior directly using my_army[0] instead of my_army.warriors[0].

13. The Vyuhas – Key Takeaways

  • The Mutable Trap: Never assign lists or dictionaries directly inside a class body. Initialize them inside __init__ using self to prevent data bleeding across users.
  • Duck Typing over Lineage: Python cares about what an object can do, not what it is. If it has an attack() method, it can be passed into any function expecting an attacker.
  • Dunder Magic: Double-underscore methods (__len__, __add__) are the secret interface that allows your custom objects to seamlessly integrate with native Python syntax.
  • Shielding State: Never use Java-style get/set methods. Use the @property decorator to validate internal state while preserving clean object.attribute syntax.
  • The MRO Matrix: Method Resolution Order follows the C3 Linearization algorithm. Python checks the instance, then the class, then traverses the parent hierarchy sequentially.

FAQ: Classes, OOP & Metaclasses

Architectural OOP questions answered — optimised for quick lookup.

What is the difference between \_\_str\_\_ and \_\_repr\_\_?

__str__ is for the end-user. It returns a clean, human-readable string when you use print(obj). __repr__ is for the developer. It should ideally return a string that represents the exact Python code required to recreate the object (e.g., Warrior(name='Arjuna')). If __str__ is missing, Python falls back to __repr__.

When should I use @classmethod vs @staticmethod?

Use @classmethod when your method needs to interact with the Class itself (modifying shared class variables or building factory constructors like create_from_json(cls, data)). Use @staticmethod when your method is just a standard utility function that logically belongs inside the class blueprint but requires no access to self or cls data to function.

What is Duck Typing in Python?

"If it walks like a duck and quacks like a duck, it must be a duck." Unlike Java, where an object must explicitly implement an interface to be valid, Python dynamically checks for the presence of a method at runtime. If you pass an object to a function that calls .swim(), Python doesn't care if the object is a Fish or a Human, as long as the swim() method exists.

What is the MRO (Method Resolution Order)?

MRO is the specific path Python takes to find a method or variable in a class hierarchy, especially during Multiple Inheritance. It uses the C3 Linearization algorithm to search the current instance, then the current class, and then parents sequentially from left to right, preventing infinite loops and ensuring deterministic execution. You can view it by calling ClassName.mro().

What is a Metaclass in Python?

If objects are instances created by Classes, then Classes are instances created by Metaclasses. The default metaclass in Python is type. A metaclass intercepts the creation of a class blueprint in memory, allowing senior architects to automatically inject methods, enforce coding standards, or modify class variables across entire frameworks before the class even finishes loading into RAM.

The Infinite Game: Join the Vyuha

If you are building an architectural legacy, hit the Follow button in the sidebar to receive the remaining days of this 30-Day Series directly to your feed.

💬 Have you ever fallen into the Mutable Default Argument trap? Drop your bug-hunt war story in the comments below.

The Architect's Protocol: To master the architecture of logic, read The Architect's Intent.

[← Previous

Day 10: The Akashic Records — Production File Handling](https://logicandlegacy.blogspot.com/2026/03/day-10-production-file-handling.html)
[Next →

Day 12: Memory Mastery — Garbage Collection & WeakRefs](#)


Originally published at https://logicandlegacy.blogspot.com

Top comments (0)