Topic 17.3
Interview Deep Dive: OOP and Internals
In one line
How Python's object model really works, the way senior interviews probe it: method resolution order, descriptors, __new__ versus __init__, the GIL, garbage collection, decorators, generators versus iterators, __slots__ and context managers.
Think of it like this
A company phone directory with a fixed search order. If your name is not on your team's list, the directory checks your department, then the company, then the city. Method resolution order (MRO) is the same idea for classes: the exact, fixed order in which Python searches a class and its parents for a method. When two parents both offer hello, the MRO decides which one wins, and super() means "the next class in that order", not "my parent".
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- MRO
- Method resolution order: the list of classes Python searches, in order, when you call a method.
- super()
- A proxy that looks for the next class after the current one in the MRO.
- Descriptor
- A class attribute whose object has
__get__,__set__or__delete__and controls how that attribute is read and written. - GIL
- The global interpreter lock: in the default CPython build, one thread runs Python bytecode at a time.
- Reference count
- A number stored on every object: how many names and containers refer to it. At zero the object is freed.
- Cyclic garbage collector
- A background pass that frees groups of objects that refer to each other and are no longer reachable.
- Context manager
- An object used in a
withstatement that sets something up and always tears it down. - Bound method
- A function with
selfalready attached, such asobj.method.
Step by step
01Print the MRO before you answer
The safest habit in a multiple-inheritance question is to write out the MRO. For class D(B, C) where B and C both extend A, the rule "children before parents, bases in the order written" gives D, B, C, A, object. The first runnable example prints it and the chain of hello calls.
If no consistent order exists, Python refuses to build the class with a TypeError: for example class X(A, B) when B is a subclass of A. The error message mentions a "consistent method resolution order".
02super() is the next in line
Inside B.hello, super().hello() asks: "which class comes after B in the MRO of the object's real class?" For a D instance that is C. So B calls C, which calls A. Every class in the chain adds its name once. If C forgot to call super(), A would never run.
That is why super() is not "call my parent". The question "what is the parent?" has no answer in a diamond. The question "what is next?" always does.
03new creates, init fills in
Config() calls Config.__new__(Config) first. The example's __new__ keeps the one instance on the class and returns it every time, so Config() is Config(). Note that __init__ still runs on every call, so a real singleton must make __init__ safe to repeat.
Singletons are often the wrong answer. A module-level object, or passing one object around explicitly, is simpler and easier to test. In an interview, give the __new__ version and then say that.
04How a descriptor guards an attribute
Positive defines __set__, so it is a data descriptor. When you write item.price = -1, Python finds price on the class, sees the descriptor, and calls Positive.__set__, which raises ValueError. The real value is stored on the instance as _price (the name comes from __set_name__, called when the class is built).
Functions work the same way through __get__: Item.method is a plain function, but item.method calls function.__get__(item, Item) and gets a bound method. That one fact explains why the first parameter is self.
05Reference counting and the cycle collector
In the second runnable example, del n removes the only name, the count hits zero, and the object is freed at once, so the weak reference (weakref.ref, a pointer that does not keep its target alive) is None. Two objects that refer to each other have counts above zero even when nothing else can reach them, so they survive del until the cyclic collector runs. gc.collect() forces that pass.
Practical consequences: __del__ is not a reliable cleanup hook, files and locks should use with, and big cycles can hold memory until a collection happens.
06Decorators, context managers and iterators in one place
@functools.wraps(fn) copies __name__ and __doc__ onto the wrapper, which is why add.__name__ still says add in the output. @contextmanager turns a generator into a context manager: code before yield is __enter__, code after (in finally) is __exit__, and an exception in the with body is re-raised at the yield. Finally, iter(it) is it is True for an iterator, which is the definition of one.
Try it yourself
- 1
Predict the MRO
Add a class
E(C, B)to the first example and then a classF(D, E). Before running, try to predictF.__mro__. Python will refuse with aTypeErrorbecauseDwantsBbeforeCandEwantsCbeforeB. Read the message, then fix it by changing the order of the bases. - 2
Break the cleanup
In the second example, remove the
try/finallyfromopenedand rerun. The 'close' line disappears when the body raises. That is the bug context managers exist to prevent.
Code & diagrams
Change `class D(B, C)` to `class D(C, B)` and predict how the hello chain changes.
Expected output
MRO: ['D', 'B', 'C', 'A', 'object']
hello: ['D', 'B', 'C', 'A']
same object: True
AttributeError: 'Point' object has no attribute 'z' and no __dict__ for setting new attributes
has __dict__: FalseThe weak reference lets you ask 'is it still alive?' without keeping the object alive.
Expected output
ValueError: must be positive
price still: 5
calling add
5 | add | Add two numbers.
open db
close db
caught: boom
refcount hit zero, freed at once: True
cycle still alive: True
after gc.collect(): True
iterator is its own iterator: TrueNot runnable in the browser (threads and processes are not available there). Run it on your computer and time both versions yourself; the numbers depend on your machine and Python build.
import threading
import time
from concurrent.futures import ProcessPoolExecutor
def cpu_bound(n):
total = 0
for i in range(n):
total += i * i
return total
def wait_bound():
time.sleep(0.5) # the thread releases the GIL while it sleeps
if __name__ == "__main__":
# I/O-style waiting: 4 threads finish in about 0.5 s, not 2 s.
threads = [threading.Thread(target=wait_bound) for _ in range(4)]
[t.start() for t in threads]
[t.join() for t in threads]
# CPU-bound: threads would take about as long as one thread (default build).
# Processes use several cores:
with ProcessPoolExecutor() as pool:
print(list(pool.map(cpu_bound, [5_000_000] * 4)))Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Add an attribute that __slots__ does not allow
Create a class with __slots__ = ('x', 'y') and then assign p.z = 3.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
p.z = 3Myth vs fact
Myth
super() calls the parent class.
Fact
It calls the next class in the MRO of the instance's real class. With multiple inheritance that can be a sibling, not a parent.
Myth
Python threads are useless because of the GIL.
Fact
They are fine, and common, for I/O-bound work such as web requests, because waiting releases the lock. They do not speed up CPU-bound pure-Python code in the default build.
Myth
__init__ creates the object.
Fact
__new__ creates it. __init__ only sets it up afterwards.
Myth
Python frees memory only when the garbage collector runs.
Fact
Most objects are freed immediately when their reference count reaches zero. The collector exists for reference cycles.
Interview problem
The problem
Design a validated field that works on many classes
You have several model classes, each with fields that must be positive numbers (price, quantity, weight). Write the validation once, so each class just declares price = Positive(). Explain how it works and how it behaves on invalid input.
You're given
- Validation lives in one reusable object.
- Invalid assignment raises
ValueErrorand keeps the old value. - The same object serves every instance of every class.
The interviewer follows up
Why not store the value in a dict on the descriptor keyed by instance?
How is this different from property?
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
__init_subclass__, class decorators and metaclasses are three levels of hooking class creation. Prefer the simplest that works:__init_subclass__or a decorator covers most registry and validation needs without a metaclass. - ▸
sys.getrefcount,gc.get_stats()andgc.set_threshold()expose the collector. Production code rarely tunes it, but long-lived services that build large graphs sometimes freeze or tune collection (gc.freeze()before forking worker processes keeps long-lived objects out of later collections and reduces copy-on-write memory growth). Measure before you touch it. - ▸
The free-threaded build can run threads in parallel on several cores, but extension modules must support it, and single-thread speed can be lower. Check library support before choosing it. The default build keeps the GIL.
Remember this
- 1
**MRO and
super()(Topic 8.6). Python uses C3 linearization**: the MRO of a class lists the class, then its parents, merged so that every class comes before its own parents and the order of the bases you wrote is kept. You can print it withD.__mro__. In a diamond (D(B, C), bothBandCextendA) the MRO isD, B, C, A, object, sosuper().hello()insideBcalls **C**, a classBknows nothing about. That is what makes cooperative multiple inheritance work, and why every method in the chain must callsuper(). - 2
**
__new__versus__init__.** Creating an instance has two steps.__new__(cls, ...)is a static-like method that creates and returns the object. Then, if the result is an instance ofcls, Python calls__init__(self, ...)to initialise it. Override__new__for immutable types (a subclass ofintorstr), for singletons, and for caching instances. Override__init__for everything else. - 3
Descriptors (Topic 13.5). An object with
__get__(and optionally__set__or__delete__) stored as a class attribute takes over attribute access. A data descriptor (has__set__) beats the instance__dict__; a non-data descriptor (only__get__) loses to it. Functions are non-data descriptors: that is howobj.methodbecomes a bound method withselffilled in.property,classmethod,staticmethodand__slots__members are all descriptors, as are the validated fields in the example below. - 4
The GIL and memory (Topics 12.2 and 13.2). A thread is one line of work inside a program, and bytecode is the low-level instruction list Python compiles your code into (Topic 13.3). In the standard CPython build the global interpreter lock (GIL) lets only one thread run Python bytecode at a time. Threads still help for I/O-bound work (work that mostly waits for the network or disk), because a thread waiting on the network releases the lock. They do not speed up CPU-bound pure-Python loops; use processes (
multiprocessing,ProcessPoolExecutor) or native code such as NumPy. Python 3.13 added an experimental free-threaded build without the GIL, and 3.14 officially supports it, but the default build still has the GIL. Memory is managed by reference counting (an object is freed the instant its count reaches zero) plus a cyclic garbage collector that finds groups of objects that only reference each other. - 5
Decorators, generators, iterators (Topics 9.6, 9.2 and 9.1). A decorator is just a function that takes a function and returns a function:
@loggedabovedef addmeansadd = logged(add). Usefunctools.wrapsso the wrapper keeps the original's__name__and docstring. An iterator has__iter__(returning itself) and__next__. A generator is a function containingyield: calling it returns an iterator whose body runs lazily, pausing at eachyield. All generators are iterators; not all iterators are generators. - 6
**
__slots__and context managers.**__slots__ = ('x', 'y')removes the per-instance__dict__, saving memory when you create millions of small objects, and makes typos likep.z = 3anAttributeError. Recall from Topic 7.4:withruns setup, then your block, then cleanup. A context manager (anything with__enter__and__exit__, or a generator decorated with@contextlib.contextmanager) guarantees cleanup code runs, even when the body raises.withis Python's version of try/finally made reusable.
Explain it without notes
Explain MRO and what super() does in a diamond. Give the MRO of class D(B, C) where both extend A.
What is the difference between __new__ and __init__? When would you override __new__?
What is a descriptor? Name three built-in features that are implemented with descriptors.
What does the GIL do, and when do threads still help? What are your options for CPU-bound work?
How does CPython free memory? Why can a reference cycle survive del?
What is the difference between an instance method, a @classmethod and a @staticmethod? When is each the right choice?
What happens to hashing when a class defines __eq__? Why does that matter for sets and dict keys?
Practice
Write a context manager class called Collector with __enter__ returning a list and __exit__ that prints how many items were collected, even when an error happens inside the with.
Write a decorator count_calls that counts how many times a function was called and exposes the number as fn.calls, keeping the original name.
Create a class Meters(float) using __new__ so that Meters(3) is a float subclass instance that prints as 3.0 m when you call str() on it.
Trade-offs
- ↔
Multiple inheritance versus composition. Cooperative
super()chains are powerful but hard to reason about. Most teams prefer one base class plus small mixins, or composition (holding other objects) over deep inheritance. - ↔
**
__slots__versus flexibility.** Slots save memory and catch typos, but they block dynamic attributes, complicate inheritance and pickling, and are rarely worth it unless you create very many instances.
Done when you can
Done when you can write the MRO of a diamond and trace a
super()chain.Done when you can explain
__new__versus__init__and give two reasons to override__new__.Done when you can write a validating descriptor and explain why it stores values on the instance.
Done when you can describe the GIL honestly: what it blocks, what it does not, and the options for CPU work.
Done when you can explain reference counting, cycles and why
withbeats__del__.