OOP & ModulesExtra· 40 min read

Inheritance & Magic Methods

Build new classes on top of old ones, share behaviour through inheritance, and make objects print nicely with dunder methods.

What you will learn

  • Create a child class that inherits from a parent
  • Override a method (polymorphism)
  • Protect data with encapsulation (private attributes and @property)
  • Use _str_ to control how an object prints

Inheritance: build on what exists

Imagine you already have a Animal class, and now you need a Dog. A dog is an animal — it shares everything an animal has, plus a few extras. Inheritance lets a new class (the child) automatically receive all the attributes and methods of an existing class (the parent), so you do not rewrite them. You name the parent in brackets after the child’s name.

Two new words: the parent class (also called the base or superclass) is the one you build from; the child class (also called the subclass) is the new one that inherits.

Dog inherits everything from Animal
class Animal:
    def __init__(self, name):
        self.name = name

    def describe(self):
        return f"{self.name} is an animal"

class Dog(Animal):          # Dog inherits from Animal
    def bark(self):
        return f"{self.name} says Woof!"

d = Dog("Rex")
print(d.describe())         # inherited from Animal
print(d.bark())             # Dog's own method

Here is the key move: class Dog(Animal): says “Dog is built from Animal.” We never wrote __init__ or describe inside Dog, yet d.describe() works — because Dog inherited them from Animal. On top of that, Dog adds its own bark() method. So the Dog object has the parent’s name and describe plus its own bark — old behaviour reused, new behaviour added.

Note: Output: Rex is an animal Rex says Woof!

Overriding: same name, new behaviour

Sometimes a child needs to do a shared action differently. If the child defines a method with the same name as one in the parent, the child’s version wins — this is called overriding. When different classes respond to the same method name in their own way, we call it polymorphism (“many shapes”).

Each class answers speak() its own way
class Animal:
    def speak(self):
        return "Some sound"

class Cat(Animal):
    def speak(self):                 # override
        return "Meow"

class Cow(Animal):
    def speak(self):                 # override
        return "Moo"

for pet in [Cat(), Cow()]:
    print(pet.speak())

Both Cat and Cow inherit from Animal but each defines its own speak(), replacing the parent’s generic version. The loop calls pet.speak() on each animal without caring which type it is — and Python automatically runs the right one, printing Meow then Moo. That is polymorphism: one method name, many behaviours, chosen automatically per object.

Note: Output: Meow Moo

super(): reuse the parent’s setup

Often a child wants the parent’s __init__ to run and add a little more. The built-in super() means “the parent class.” Call super().__init__(...) to run the parent’s setup, then add the child’s own attributes.

super() runs the parent constructor
class Person:
    def __init__(self, name):
        self.name = name

class Student(Person):
    def __init__(self, name, course):
        super().__init__(name)       # run Person's setup
        self.course = course         # then add our own

s = Student("Asha", "Python")
print(s.name, "-", s.course)

When we create the Student, its __init__ first calls super().__init__(name) — that runs Person’s constructor, which stores self.name. Then the child adds its own self.course. So the student ends up with both name (set by the parent) and course (set by the child), without copying the name-storing code. super() saves you from repeating the parent’s logic.

Note: Output: Asha - Python

Encapsulation: protect an object’s data

Encapsulation means keeping an object’s internal data bundled up and protected, so the outside world changes it only through methods you control — not by reaching in and setting any value directly. The idea is to guard against invalid states: a bank balance should never be set to a negative number by accident.

Python signals “this is internal, please do not touch directly” with underscores on an attribute name. A single leading underscore (_balance) is a polite “internal — leave it alone” convention. A double leading underscore (__balance) goes further: Python name-mangles it so it cannot be reached from outside by its plain name, giving stronger protection.

A private attribute guarded by methods
class Account:
    def __init__(self, balance):
        self.__balance = balance          # __ = private

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self.__balance = self.__balance + amount

    def get_balance(self):                # controlled read access
        return self.__balance

acc = Account(100)
acc.deposit(50)
print(acc.get_balance())                  # 150

The balance lives in self.__balance — the double underscore marks it private. Outside code cannot reliably do acc.__balance = -999 to corrupt it; the name is hidden. Instead, the only way in is through deposit(), which validates the amount first and rejects anything that is not positive. The only way to read it is the get_balance() method. So every change passes through your rules — that controlled access is the whole point of encapsulation.

Note: Output: 150

A cleaner, very Pythonic way to expose a guarded value is the @property decorator. It lets a method look like a plain attribute when read, while still running your code behind the scenes.

@property exposes a computed value as an attribute
class Circle:
    def __init__(self, radius):
        self.__radius = radius

    @property
    def area(self):                       # read it like an attribute
        return 3.14159 * self.__radius ** 2

c = Circle(10)
print(c.area)                             # no brackets needed

Marking area with @property means you read it as c.areano brackets, just like a stored attribute — even though it is really a method calculating the value from the private __radius each time. This gives a clean, read-only view of internal data: callers get the answer they need without being able to set a wrong area directly.

Note: Output: 314.159

Tip: Use a single underscore (_name) for “internal, please don’t touch”, double underscore (__name) for genuinely private data, and @property when you want a computed or guarded value to read like a simple attribute. Together these are how Python does encapsulation.

Magic (dunder) methods

Python has special methods whose names start and end with double underscores — nicknamed dunder (short for double underscore) or magic methods. You already met __init__. Python calls them automatically at certain moments. The most useful one to add is __str__, which decides what an object looks like when you print it.

_str_ controls how an object prints
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __str__(self):
        return f"₹{self.amount}"

wallet = Money(500)
print(wallet)               # Python calls __str__ for us

Without __str__, printing an object shows something ugly like <__main__.Money object at 0x...>. By defining __str__ to return f"₹{self.amount}", we tell Python exactly how a Money object should appear — so print(wallet) shows ₹500. Python calls __str__ behind the scenes whenever an object needs to become text.

Note: Output: ₹500

Tip: A related dunder is __repr__, the developer-facing text shown in the shell and in lists. A good habit is to define __repr__ for debugging and __str__ for friendly display; if you only write one, write __repr__.

Q. What does the super() function let a child class do?

Answer: super() refers to the parent class, so super()._init_(...) runs the parent’s constructor before the child adds its own setup.

✍️ Practice

  1. Make a Vehicle parent class and a Car child class that inherits from it and adds one method.
  2. Write a class with a private __balance attribute that can only be changed through a deposit() method.
  3. Give a class a __str__ method and print an object of it.

🏠 Homework

  1. Build a Shape parent with an area() method, and Circle and Rectangle children that override area() with their own formula.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →