Magic Methods & Advanced OOP — Python Questions with Answers
Start with the study notes on Magic Methods & Advanced OOP (definitions, examples and a quick revision), then work through 30 practice questions, each with the correct answer and a clear explanation of why it is right. When you are ready, take this topic as an interactive quiz to test yourself.
4 easy · 14 medium · 12 hard · Part of Python: Master Python from basics to advanced - 20 topics.
Topic overview
Magic Methods and Advanced OOP – making objects feel built-in
1. What are magic methods?
Magic methods (or dunder methods, for “double underscore”) have names like __str__ and __len__. Python calls them automatically: print(obj) calls __str__, len(obj) calls __len__, and a + b calls __add__.
2. __str__ and __repr__
__str__ is the friendly text for users, used by print(), str() and f-strings.
__repr__ is the developer view, used in the shell and inside lists. By convention it looks like the code that recreates the object.
class Book:
def __init__(self, title):
self.title = title
def __str__(self):
return f"Book: {self.title}"
def __repr__(self):
return f"Book({self.title!r})"
b = Book("Dune")
print(b)
print([b])
OutputBook: Dune
[Book('Dune')]
Remember: If you define only one, define __repr__; str() falls back to it.
3. Operators: __add__, __eq__, __lt__
Defining these lets your objects work with +, == and <. This is called operator overloading. A single __lt__ also makes sorted() work.
_balance (one underscore) means “internal, please don't touch”. It is only a convention.
__pin (two underscores) triggers name mangling: Python renames it to _Account__pin.
Python has no truly private attributes.
class Account:
def __init__(self):
self.__pin = 1234
a = Account()
print(hasattr(a, "__pin"), a._Account__pin)
OutputFalse 1234
7. @property and setters
@property turns a method into a computed attribute, read without brackets. A matching setter can check values when they are assigned.
class Circle:
def __init__(self, r):
self.r = r
@property
def area(self):
return round(3.14159 * self.r ** 2, 2)
class Person:
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("age can't be negative")
self._age = value
print(Circle(2).area)
p = Person()
p.age = 30
print(p.age)
Output12.57
30
8. @staticmethod and @classmethod
A normal method receives self, the object.
A classmethod receives cls, the class. It is often used as an alternative constructor.
A staticmethod receives nothing automatically; it is just a function kept inside the class.
class Date:
def __init__(self, d, m, y):
self.d, self.m, self.y = d, m, y
@classmethod
def from_string(cls, text):
d, m, y = map(int, text.split("-"))
return cls(d, m, y)
@staticmethod
def is_leap(year):
return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
date = Date.from_string("03-07-2026")
print(date.y, Date.is_leap(2024))
Output2026 True
9. __new__, singletons and dataclasses
__new__creates the object, then __init__sets it up. You rarely need __new__, except for things like a singleton (a class with only one instance).
@dataclass writes __init__, __repr__ and __eq__ for you, from the fields you declare.
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(p)
print(p == Point(1, 2))
OutputPoint(x=1, y=2)
True
Remember:__enter__ and __exit__ make a class work with the with statement (see Advanced Concepts).
10. Common misconceptions
Misconception
Correct understanding
print([obj]) uses __str__.
Lists show their items with __repr__.
__pin makes an attribute truly private.
It is only name-mangled; obj._Class__pin still reaches it.
A property needs brackets: c.area().
A property is read like an attribute: c.area.
__init__ creates the object.
__new__ creates it; __init__ fills in its attributes.
You must write getters for every attribute.
Use plain attributes and add @property only when logic is needed.
A Methods with double underscores (__str__, __len__...) that hook your class into Python's built-in behavior
B Methods that run magically fast
C Hidden methods you can't call
D Deprecated Python 2 methods
Answer: A — Methods with double underscores (__str__, __len__...) that hook your class into Python's built-in behavior
'Dunder' = Double UNDERscore. Python calls them automatically: print(obj) calls __str__, len(obj) calls __len__, a + b calls __add__. Define them to make your objects behave like built-ins.
Dunder methodsProtocols
Q2.easy
Which method controls what print(obj) displays?
class Book:
def __init__(self, title):
self.title = title
def __str__(self):
return f"Book: {self.title}"
print(Book("Dune")) # Book: Dune
A __str__
B __print__
C __show__
D __display__
Answer: A — __str__
__str__ returns the human-readable text for your object - used by print(), str() and f-strings. Without it you get the ugly <__main__.X object at 0x...>.
__str__print()
Q21.easy
str(obj) and f"{obj}" both call which method?
A __str__
B __format__ only
C __text__
D __value__
Answer: A — __str__
str(), print() and f-string interpolation all route through __str__ (f-strings via __format__, which defaults to __str__). One method, consistent display everywhere.
v1 + v2 calls v1.__add__(v2) - operator overloading. Return a NEW object with the combined values. Siblings: __sub__ (-), __mul__ (*), __truediv__ (/).
Operator overloading__add__
Q8.medium
Which method makes square brackets work: playlist[0]?
class Playlist:
def __init__(self, songs):
self.songs = songs
def __getitem__(self, i):
return self.songs[i]
p = Playlist(["a", "b"])
print(p[1]) # b
for song in p: ... # even iteration works!
A __getitem__
B __index__
C __bracket__
D __at__
Answer: A — __getitem__
obj[key] calls obj.__getitem__(key). Bonus: defining it also enables for-loops and 'in' over your object (Python iterates by index automatically).
__getitem__Indexing
Q9.medium
Which method powers 'x in obj'?
class Playlist:
def __contains__(self, song):
return song in self.songs
A __contains__
B __in__
C __has__
D __member__
Answer: A — __contains__
'song in playlist' calls playlist.__contains__(song). Without it Python falls back to iterating and comparing - __contains__ lets you do it smarter/faster.
__contains__in operator
Q11.medium
What does a SINGLE leading underscore mean: self._balance?
class Account:
def __init__(self):
self._balance = 0 # internal by convention
A A convention: 'internal - please don't touch from outside' (not enforced)
B Truly private - Python blocks access
C A syntax error
D The attribute is constant
Answer: A — A convention: 'internal - please don't touch from outside' (not enforced)
_name is a gentleman's agreement - the language doesn't stop anyone, but tools and developers treat it as private API. Python's philosophy: 'we're all consenting adults'.
EncapsulationConventions
Q15.medium
What is a @staticmethod?
class MathUtils:
@staticmethod
def add(a, b): # no self!
return a + b
print(MathUtils.add(2, 3)) # 5
A A method that gets NO automatic self/cls - a plain function stored in the class for organization
B A method that can't be changed
C A method shared with other classes
D A method that runs at import
Answer: A — A method that gets NO automatic self/cls - a plain function stored in the class for organization
Static methods neither read nor modify instance/class state - they just belong logically to the class. Call via the class or an instance: MathUtils.add(2, 3).
@staticmethod
Q19.medium
Which method decides your object's truthiness in 'if obj:'?
class Cart:
def __init__(self):
self.items = []
def __len__(self):
return len(self.items)
if not Cart():
print("empty cart") # prints!
A __bool__ (falling back to __len__ != 0 if absent)
B __true__
C __if__
D Objects are always False
Answer: A — __bool__ (falling back to __len__ != 0 if absent)
if cart: calls cart.__bool__() - or, if undefined, treats the object as truthy unless __len__ returns 0. That's why empty custom containers naturally read as falsy once __len__ exists.
__bool__Truthiness
Q20.medium
Why is c.area (a @property) better Python style than c.get_area()?
A Attribute syntax is cleaner, and you can start as a plain attribute and add logic later WITHOUT breaking callers
B get_ methods are illegal
C Properties are faster
D No reason - Java style is preferred
Answer: A — Attribute syntax is cleaner, and you can start as a plain attribute and add logic later WITHOUT breaking callers
In Java you write getters defensively from day one. Python lets you expose a plain attribute now and transparently swap in a @property later - same c.area syntax for callers. So write simple attributes first; add properties only when logic is needed.
@propertyAPI design
Q23.medium
Does Python have truly PRIVATE attributes like Java's private keyword?
A No - only conventions (_x) and name mangling (__x); everything remains reachable
B Yes - the private keyword
C Yes - via @private
D Only in classes marked sealed
Answer: A — No - only conventions (_x) and name mangling (__x); everything remains reachable
Python chose openness: nothing is enforced-private. _x says 'don't', __x adds mangling, but a determined caller can reach anything. Discipline over locks.
+ triggers __add__ (returning Money(150)); print then triggers __str__ ('Rs.150'). Two magic methods cooperating - exactly how int and str themselves work.
Operator overloadingTracing
Q26.medium
Which dunder pair powers the WITH statement (context managers)?
class Timer:
def __enter__(self):
self.start = time.time()
return self
def __exit__(self, *args):
print(f"took {time.time() - self.start:.2f}s")
with Timer():
do_work()
A __enter__ and __exit__
B __open__ and __close__
C __start__ and __stop__
D __with__ and __without__
Answer: A — __enter__ and __exit__
with obj: calls obj.__enter__() at the start and obj.__exit__() at the end - ALWAYS, even on exceptions. Define both and your class works in with statements, like open() does.
Context managerswith statement
Q29.medium
What does @dataclass (from dataclasses) generate for you?
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
p = Point(1, 2)
print(p) # Point(x=1, y=2)
print(p == Point(1, 2)) # True
A __init__, __repr__ and __eq__ automatically from the declared fields
B A database table
C Only documentation
D Getters and setters
Answer: A — __init__, __repr__ and __eq__ automatically from the declared fields
For classes that mainly hold data, @dataclass writes the boilerplate: constructor, nice repr, equality by fields. Three lines replace fifteen.
dataclassesBoilerplate
Q30.medium
Why define __repr__ on every class you debug often?
A Debuggers, logs, lists and the REPL all show __repr__ - a good one makes object state visible at a glance
B Python requires it
C It speeds up the class
D It enables inheritance
Answer: A — Debuggers, logs, lists and the REPL all show __repr__ - a good one makes object state visible at a glance
When a log line says <Order object at 0x7f...> you know nothing; Order(id=42, status='paid') tells the whole story. One method, hours of debugging saved.
__repr__Debugging
Hard questions (12)
Q3.hard
What is the difference between __str__ and __repr__?
class Book:
def __repr__(self):
return f"Book({self.title!r})"
books = [Book("Dune")]
print(books) # [Book('Dune')] - repr used inside lists!
A __str__ is the friendly display for users; __repr__ is the unambiguous version for developers/debugging (shown in the REPL and inside lists)
B They are interchangeable aliases
C __repr__ repeats the string twice
D __str__ is Python 2 only
Answer: A — __str__ is the friendly display for users; __repr__ is the unambiguous version for developers/debugging (shown in the REPL and inside lists)
print(obj) uses __str__; typing obj in the shell, or printing a LIST of objects, uses __repr__. Convention: __repr__ looks like the code to recreate the object: Book('Dune'). If you define only one, define __repr__ - str falls back to it.
__repr__Debugging
Q7.hard
What does defining __lt__ (less-than) unlock for your class?
class Student:
def __init__(self, marks):
self.marks = marks
def __lt__(self, other):
return self.marks < other.marks
print(sorted([Student(90), Student(70)])[0].marks) # 70
A The < operator AND sorting: sorted() can order your objects
B Only printing
C Nothing without __gt__ too
D Subtraction
Answer: A — The < operator AND sorting: sorted() can order your objects
sorted() compares elements with < - so one __lt__ makes lists of your objects sortable. (functools.total_ordering can derive the other comparisons from __lt__ + __eq__.)
__lt__sorted()total_ordering
Q10.hard
What does __call__ do?
class Greeter:
def __call__(self, name):
return f"Hi {name}"
g = Greeter()
print(g("Ana"))
A Makes INSTANCES callable like functions - g("Ana") works, printing Hi Ana
B Calls the constructor again
C It's an error - objects can't be called
D Renames the object
Answer: A — Makes INSTANCES callable like functions - g("Ana") works, printing Hi Ana
__call__ turns objects into function-like things: g("Ana") runs g.__call__("Ana"). Useful for objects that act like configurable functions (and it's how decorator classes work).
__call__Callables
Q12.hard
What does a DOUBLE leading underscore do: self.__secret?
class Account:
def __init__(self):
self.__pin = 1234
a = Account()
# a.__pin # AttributeError
print(a._Account__pin) # 1234 - mangled name
A Name mangling - Python renames it to _ClassName__secret, making accidental outside access harder
B Makes it 100% inaccessible
C Deletes it after __init__
D Nothing different from one underscore
Answer: A — Name mangling - Python renames it to _ClassName__secret, making accidental outside access harder
__secret becomes _Account__secret internally - obj.__secret fails with AttributeError, though obj._Account__secret still works (so it's tamper-resistant, not secure). Main real use: avoiding name clashes in subclasses.
Name manglingEncapsulation
Q13.hard
What does @property do?
class Circle:
@property
def area(self):
return 3.14 * self.r ** 2
c = Circle()
c.r = 2
print(c.area) # 12.56 - no () needed
A Lets you access area WITHOUT parentheses - c.area - computed fresh each time like an attribute
B Caches the value forever
C Makes area static
D Prints the area
Answer: A — Lets you access area WITHOUT parentheses - c.area - computed fresh each time like an attribute
@property turns a method into a computed attribute: c.area (no parentheses!) runs the method. The Pythonic replacement for Java-style get_area() - callers see a simple attribute, you keep the logic.
@propertyGetters
Q14.hard
How do you add validation when SETTING obj.age = value?
class Person:
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("age can't be negative")
self._age = value
A Define @property for age plus an @age.setter method that checks the value before storing
B Python can't intercept assignment
C Override __init__ only
D Use a global validator
Answer: A — Define @property for age plus an @age.setter method that checks the value before storing
The property/setter pair intercepts both read and write. Assignment (p.age = -5) runs the setter, which can validate and raise. Callers still use plain attribute syntax.
SettersValidation
Q16.hard
What does @classmethod receive instead of self?
class Date:
def __init__(self, d, m, y):
self.d, self.m, self.y = d, m, y
@classmethod
def from_string(cls, text): # 'Date' arrives as cls
d, m, y = map(int, text.split("-"))
return cls(d, m, y) # builds the instance
date = Date.from_string("03-07-2026")
A cls - the CLASS itself, so it can read class attributes or build instances
B Nothing at all
C A copy of every instance
D The parent class
Answer: A — cls - the CLASS itself, so it can read class attributes or build instances
Class methods get the class as their first argument (named cls). Their killer use: ALTERNATIVE CONSTRUCTORS - building instances from different input formats.
@classmethodAlternative constructors
Q17.hard
staticmethod vs classmethod vs normal method - which first argument does each receive?
A normal: self (the instance); classmethod: cls (the class); staticmethod: nothing automatic
B All three receive self
C normal: cls; classmethod: self; staticmethod: both
D They're interchangeable
Answer: A — normal: self (the instance); classmethod: cls (the class); staticmethod: nothing automatic
The rule of thumb: needs instance data -> normal method. Needs the class (attributes, alternative constructor) -> classmethod. Needs neither but belongs thematically -> staticmethod.
Method typesChoosing
Q18.hard
What is the true division of labor between __new__ and __init__?
A __new__ CREATES the object; __init__ then INITIALIZES the already-created object
B __init__ creates, __new__ initializes
C They are aliases
D __new__ is Python 2 only
Answer: A — __new__ CREATES the object; __init__ then INITIALIZES the already-created object
Calling Dog() first runs __new__ (allocates the instance) then __init__ (fills in attributes). You almost never touch __new__ - exceptions: immutable subclasses and singletons. Strictly, __init__ is an initializer, not a constructor.
__new__Object creation
Q22.hard
print([Book("Dune")]) shows <Book object at 0x...> even though Book defines __str__. Why?
class Book:
def __repr__(self):
return f"Book({self.title!r})"
print([Book("Dune")]) # [Book('Dune')]
A Containers display their items using __repr__, not __str__ - define __repr__ too
B Lists erase methods
C __str__ has a typo
D print can't handle lists
Answer: A — Containers display their items using __repr__, not __str__ - define __repr__ too
A list's own str() calls repr() on every element - by design, to show unambiguous contents. This surprise is the #1 reason to always define __repr__ on your classes.
__repr__Containers
Q25.hard
v * 3 works on your Vector but 3 * v fails. Which method fixes the reversed order?
class Vector:
def __mul__(self, n):
return Vector(self.x * n, self.y * n)
__rmul__ = __mul__ # 3 * v now works too
A __rmul__
B __mul2__
C __xmul__
D It cannot be fixed
Answer: A — __rmul__
3 * v first tries (3).__mul__(v) - int doesn't know Vector, so Python then tries v.__rmul__(3). The r-prefixed 'reflected' methods handle your object on the RIGHT side of an operator.
Reflected operators__rmul__
Q28.hard
What is a SINGLETON, and which method is typically overridden to make one?
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
print(Config() is Config()) # True
A A class allowing only ONE instance ever - override __new__ to return the same stored instance
B A class with one method - override __init__
C A single-file module
D An unmarried class
Answer: A — A class allowing only ONE instance ever - override __new__ to return the same stored instance
__new__ controls creation, so it can hand back a cached instance instead of a fresh one - every Config() call returns the same object. (In practice, a module-level instance is often the simpler Python singleton.)