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 MLRITMThe 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 instanceThree 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__toNone. 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 05H4super() 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 reachableA 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 # ValueErrorThis 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")) # TrueYou 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.selfis the instance, passed explicitly.s.f()isClass.f(s).- Class attributes are shared. A mutable class attribute is shared state and almost always a bug.
@classmethodgives alternate constructors;@staticmethodneeds 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.
_xis convention;__xis name-mangled, not protected. @propertyadds behaviour to attribute access without changing callers.