Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~30 min· topic 3 of 10

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 with statement that sets something up and always tears it down.
Bound method
A function with self already attached, such as obj.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".

Print the MRO before you answerdiagram
Rendering diagram…

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. 1

    Predict the MRO

    Add a class E(C, B) to the first example and then a class F(D, E). Before running, try to predict F.__mro__. Python will refuse with a TypeError because D wants B before C and E wants C before B. Read the message, then fix it by changing the order of the bases.

  2. 2

    Break the cleanup

    In the second example, remove the try/finally from opened and rerun. The 'close' line disappears when the body raises. That is the bug context managers exist to prevent.

Code & diagrams

MRO, __new__ and __slots__ New tab

Change `class D(B, C)` to `class D(C, B)` and predict how the hello chain changes.

Sign in to run this example in your browser.

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__: False
Descriptors, decorators, context managers and garbage collection New tab

The weak reference lets you ask 'is it still alive?' without keeping the object alive.

Sign in to run this example in your browser.

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: True
The GIL: threads for waiting, processes for computingpython

Not 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.

main.pywhole filepython
class Point:
    __slots__ = ("x", "y")

    def __init__(self, x, y):
        self.x = x
        self.y = y


p = Point(1, 2)
p.z = 3
terminal
$ python main.py
── what you'll see ──
Traceback (most recent call last):
File "main.py", line 10, in <module>
p.z = 3
^^^
AttributeError: 'Point' object has no attribute 'z' and no __dict__ for setting new attributes

Myth 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 ValueError and keeps the old value.
  • The same object serves every instance of every class.

The interviewer follows up

01

Why not store the value in a dict on the descriptor keyed by instance?

02

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() and gc.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. 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 with D.__mro__. In a diamond (D(B, C), both B and C extend A) the MRO is D, B, C, A, object, so super().hello() inside B calls **C**, a class B knows nothing about. That is what makes cooperative multiple inheritance work, and why every method in the chain must call super().

  2. 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 of cls, Python calls __init__(self, ...) to initialise it. Override __new__ for immutable types (a subclass of int or str), for singletons, and for caching instances. Override __init__ for everything else.

  3. 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 how obj.method becomes a bound method with self filled in. property, classmethod, staticmethod and __slots__ members are all descriptors, as are the validated fields in the example below.

  4. 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. 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: @logged above def add means add = logged(add). Use functools.wraps so the wrapper keeps the original's __name__ and docstring. An iterator has __iter__ (returning itself) and __next__. A generator is a function containing yield: calling it returns an iterator whose body runs lazily, pausing at each yield. All generators are iterators; not all iterators are generators.

  6. 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 like p.z = 3 an AttributeError. Recall from Topic 7.4: with runs 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. with is Python's version of try/finally made reusable.

Explain it without notes

01

Explain MRO and what super() does in a diamond. Give the MRO of class D(B, C) where both extend A.

02

What is the difference between __new__ and __init__? When would you override __new__?

03

What is a descriptor? Name three built-in features that are implemented with descriptors.

04

What does the GIL do, and when do threads still help? What are your options for CPU-bound work?

05

How does CPython free memory? Why can a reference cycle survive del?

06

What is the difference between an instance method, a @classmethod and a @staticmethod? When is each the right choice?

07

What happens to hashing when a class defines __eq__? Why does that matter for sets and dict keys?

Practice

01

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.

02

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.

03

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 with beats __del__.