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.
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 methodHere 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”).
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.
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.
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()) # 150The 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.
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 neededMarking area with @property means you read it as c.area — no 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.
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 usWithout __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?
✍️ Practice
- Make a
Vehicleparent class and aCarchild class that inherits from it and adds one method. - Write a class with a private
__balanceattribute that can only be changed through adeposit()method. - Give a class a
__str__method and print an object of it.
🏠 Homework
- Build a
Shapeparent with anarea()method, andCircleandRectanglechildren that overridearea()with their own formula.