Lesson 10 · 11 min read · Python for placements

Classes and objects in Python

self, __init__ and the dunder methods that make your object behave like a built-in; class against instance attributes, inheritance and MRO, and properties.

Object-oriented questions in a placement interview are usually asked in Java's vocabulary and answered in Python's, so it is worth knowing both. This lesson does Python properly and names the Java equivalents where they differ.

A class, and what self is

class Student:
    def __init__(self, name, roll):
        self.name = name
        self.roll = roll

    def label(self):
        return f"{self.roll} — {self.name}"

s = Student("Raja", "21B81A05H4")
print(s.label())        # 21B81A05H4 — Raja

__init__ is not a constructor in the Java sense — the object already exists by the time it runs. It is an initialiser: __new__ creates the object, __init__ fills it in. You will almost never write __new__.

self is the instance, passed explicitly as the first parameter. It is not a keyword; it is a convention so strong that breaking it will confuse every reader. s.label() is exactly Student.label(s) — the dot syntax passes the instance for you.

Class attributes versus instance attributes

class Student:
    college = "MLRIT"           # class attribute — one, shared

    def __init__(self, name):
        self.name = name        # instance attribute — one per object

a = Student("Raja")
b = Student("Anu")
print(a.college, b.college)     # MLRIT MLRIT

Student.college = "MLRITM"      # changes it for everybody
print(a.college)                # MLRITM

a.college = "Other"             # creates an INSTANCE attribute shadowing the class one
print(a.college, b.college)     # Other MLRITM

The mutable version of this is a real bug, and the same shape as the mutable default argument from lesson 7:

class Basket:
    items = []                  # WRONG — shared by every basket

    def add(self, x):
        self.items.append(x)

p, q = Basket(), Basket()
p.add("a")
print(q.items)                  # ['a'] — q sees p's item

class Basket:
    def __init__(self):
        self.items = []         # right — one list per instance

Three kinds of method

class Marks:
    scale = 100

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

    def percent(self):                    # instance method — needs the object
        return self.value / self.scale * 100

    @classmethod
    def from_percent(cls, pct):           # gets the CLASS — used for alternate constructors
        return cls(pct / 100 * cls.scale)

    @staticmethod
    def is_valid(value):                  # gets nothing — a plain function that belongs here
        return 0 <= value <= 100

m = Marks.from_percent(87)
print(m.percent(), Marks.is_valid(87))    # 87.0 True

@classmethod is the idiomatic way to offer a second way of constructing an object, since Python has no constructor overloading. @staticmethod is for a function that is logically part of the class but needs neither the instance nor the class.

Dunder methods

"Dunder" is double-underscore. These are how Python's syntax reaches your object — the equivalent of operator overloading, and the mechanism behind almost every "how do I make my class work with print / len / == / sorted" question.

class Marks:
    def __init__(self, value):
        self.value = value

    def __repr__(self):                       # for developers — unambiguous
        return f"Marks({self.value})"

    def __str__(self):                        # for users — readable
        return f"{self.value}/100"

    def __eq__(self, other):
        return isinstance(other, Marks) and self.value == other.value

    def __hash__(self):                       # needed if __eq__ is defined and you want set/dict use
        return hash(self.value)

    def __lt__(self, other):                  # enables <, and therefore sorted()
        return self.value < other.value

    def __add__(self, other):
        return Marks(self.value + other.value)

    def __len__(self):
        return 1

    def __bool__(self):
        return self.value > 0

m = Marks(87)
print(m)                       # 87/100        → __str__
print([m])                     # [Marks(87)]   → __repr__ inside a container
print(m == Marks(87))          # True
print(sorted([Marks(90), Marks(80)]))          # uses __lt__
print(Marks(40) + Marks(50))   # Marks(90)

Two rules that are asked about directly:

  • __str__ is for humans, __repr__ is for developers. If you define only __repr__, str() falls back to it — so if you write one, write __repr__.
  • Defining __eq__ sets __hash__ to None. Your object becomes unhashable and cannot go in a set or be a dict key until you define __hash__ too. This mirrors Java's equals/hashCode contract exactly, and the reasoning is the same: two objects that are equal must hash equally.

Inheritance

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

    def greet(self):
        return f"I am {self.name}"

class Student(Person):
    def __init__(self, name, roll):
        super().__init__(name)      # run the parent's initialiser
        self.roll = roll

    def greet(self):                # override
        return f"{super().greet()}, roll {self.roll}"

print(Student("Raja", "05H4").greet())
# I am Raja, roll 05H4

super() finds the next class in the resolution order — not necessarily the literal parent, which matters with multiple inheritance. Forgetting super().__init__() is the most common inheritance bug: the parent's attributes never get set, and the failure shows up much later as an AttributeError.

Python allows multiple inheritance, and resolves ambiguity with a defined method resolution order:

class A:
    def who(self): return "A"
class B(A):
    def who(self): return "B"
class C(A):
    def who(self): return "C"
class D(B, C):
    pass

print(D().who())                        # B
print([c.__name__ for c in D.__mro__])  # ['D', 'B', 'C', 'A', 'object']

Left to right, depth-first, without visiting a class before its subclasses — the C3 linearisation. The one-line answer to "how does Python avoid the diamond problem?" is: it defines a deterministic MRO, which you can inspect with __mro__.

There is no private

Python has convention rather than enforcement:

class Account:
    def __init__(self):
        self.balance = 0        # public
        self._internal = 0      # "please don't" — convention only
        self.__secret = 0       # name-mangled to _Account__secret

a = Account()
print(a._internal)             # works
print(a.__secret)              # AttributeError
print(a._Account__secret)      # 0 — still reachable

A single underscore is a request. A double underscore triggers name mangling, which exists to prevent accidental clashes in subclasses, not to provide security. Nothing in Python is truly private, and saying so plainly is the correct answer.

@property

A property is how you add validation to an attribute without changing the calling code — Python's answer to writing getters and setters by default:

class Marks:
    def __init__(self, value):
        self.value = value          # goes through the setter below

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        if not 0 <= v <= 100:
            raise ValueError(f"out of range: {v}")
        self._value = v

m = Marks(87)
print(m.value)      # 87 — attribute syntax, method behaviour
m.value = 200       # ValueError

This is why Python code does not have get_value() / set_value() everywhere: start with a plain attribute, and add a property later if you need one. The callers never change.

dataclass

For a class that mostly holds data, the standard library writes the boilerplate:

from dataclasses import dataclass, field

@dataclass
class Student:
    name: str
    roll: str
    marks: list = field(default_factory=list)     # NOT marks: list = []

s = Student("Raja", "05H4")
print(s)                       # Student(name='Raja', roll='05H4', marks=[])
print(s == Student("Raja", "05H4"))               # True

You get __init__, __repr__ and __eq__ for free. Note field(default_factory=list) — the mutable default trap again, and @dataclass actually raises an error if you write = [], which is a rare case of Python protecting you from it.

What to take into an interview

  • __init__ initialises an object that already exists; __new__ creates it.
  • self is the instance, passed explicitly. s.f() is Class.f(s).
  • Class attributes are shared. A mutable class attribute is shared state and almost always a bug.
  • @classmethod gives alternate constructors; @staticmethod needs neither instance nor class.
  • __str__ for users, __repr__ for developers; define __repr__ if you define only one.
  • Defining __eq__ without __hash__ makes the class unhashable.
  • super() follows the MRO — C3 linearisation, inspectable via __mro__.
  • Nothing is private. _x is convention; __x is name-mangled, not protected.
  • @property adds behaviour to attribute access without changing callers.
Lesson 10 of 12 · all 12 are free to read, no account. Course contents