1. Core Language Deep Dive

Deepen your grasp of Python's data model, typing, and language features for production-grade code.

Question: What are "dunder" methods (like __init__ or __repr__) in Python, and why are they important?

Answer: Dunder (double-underscore) methods are special methods that allow Python objects to hook into and customize built-in language features and syntax. They are the foundation of Python's data model.

Explanation: Instead of being called directly, the interpreter invokes dunder methods in response to specific operations. For example, implementing __len__ lets an object work with the len() function, __iter__ makes it iterable in a for loop, and __enter__/__exit__ enable its use in a with statement. This is how Python achieves consistent and idiomatic APIs across different types.

Question: What is a decorator in Python, and can you show a practical example?

Answer: A decorator is a function that takes another function as input, adds some functionality to it, and returns the modified function without changing the original function's code. It provides syntactic sugar for applying this pattern.

Explanation: Decorators are commonly used for logging, timing, authentication, or retrying operations. The functools.wraps decorator should always be used on the inner wrapper function to preserve the original function's metadata (like its name and docstring).

from functools import wraps
import time

def retry(times: int = 3, delay: float = 1.0):
    def outer(fn):
        @wraps(fn)
        def inner(*args, **kwargs):
            last_exc = None
            for _ in range(times):
                try:
                    return fn(*args, **kwargs)
                except Exception as e:
                    last_exc = e
                    time.sleep(delay)
            raise last_exc
        return inner
    return outer

@retry(times=3)
def connect_to_database():
    # This might fail
    print("Connecting...")
    raise ValueError("Connection failed")

Question: What is the difference between an iterator and a generator in Python? When would you use yield?

Answer: An iterator is an object that implements the iterator protocol (__iter__ and __next__ methods). A generator is a simpler and more memory-efficient way to create an iterator using a function with the yield keyword.

Explanation: When a generator function is called, it returns a generator object but doesn't start execution. The code runs each time next() is called on the object, pausing at each yield statement and saving its state. This is ideal for working with large datasets or streams, as it processes one item at a time instead of loading everything into memory.

# A generator function for reading a large file line by line
def read_large_file(path: str):
    with open(path, "r") as f:
        for line in f:
            yield line.strip()

Question: What is a context manager and how do you create one?

Answer: A context manager is an object used with the with statement to manage resources deterministically. It guarantees that setup and cleanup operations are always performed, even if errors occur.

Explanation: You can create one by defining a class with __enter__ and __exit__ methods, or more commonly, by using the @contextlib.contextmanager decorator on a generator function. The generator should yield the resource. Code before the yield is the setup, and code in a finally block after the yield serves as the cleanup.

from contextlib import contextmanager

@contextmanager
def managed_resource():
    print("Acquiring resource...")
    res = {"data": "stuff"} # E.g., a database connection
    try:
        yield res
    finally:
        print("Closing resource...")
        # res.close()

Question: When should you use TypedDict, dataclass, or a plain dict?

Answer: Use TypedDict for typed dict-like payloads (often from JSON), dataclass for Pythonic objects with behavior, and plain dict for ad-hoc, untyped data.

from typing import TypedDict

class UserPayload(TypedDict):
    id: int
    name: str

Question: What are Literal, NewType, Final, and Annotated used for?

Answer: They refine types: Literal restricts to exact values, NewType creates distinct nominal types, Final marks non-overridable names, and Annotated attaches metadata.

from typing import Literal, NewType, Final, Annotated

UserId = NewType("UserId", int)
Env = Literal["dev", "prod"]
API_VERSION: Final = "v1"
Port = Annotated[int, "tcp_port"]

Question: In dataclasses, when do you use default vs default_factory?

Answer: Use default_factory for mutable defaults (new instance per object); use default for simple immutables.

from dataclasses import dataclass, field

@dataclass
class Config:
    tags: list[str] = field(default_factory=list)  # new list per instance
    retries: int = 3                               # immutable default

Question: What is the purpose of typing and dataclasses in modern Python?

Answer: The typing module provides a standard way to add static type hints, which improves code clarity and allows for static analysis. The dataclasses decorator automatically generates boilerplate methods like __init__, __repr__, and __eq__ for classes that primarily store data.

Explanation: Type hints make code easier to understand and maintain. typing.Protocol allows for structural typing, where you can define an interface that objects can satisfy implicitly—this is like "duck typing" but with support for static analysis. Dataclasses reduce boilerplate for data-holding objects and can be optimized with slots=True to reduce memory usage or made immutable with frozen=True.

from dataclasses import dataclass
from typing import Protocol

class Cache(Protocol):
    def get(self, key: str) -> str | None: ...
    def set(self, key: str, value: str, ttl: int | None = None) -> None: ...

@dataclass(slots=True, frozen=True)
class Settings:
    db_url: str
    debug: bool = False

Question: What is structural pattern matching (match/case) and when should you use it?

Answer: Structural pattern matching (Python 3.10+) lets you branch on the shape and values of data succinctly. Use it to handle variants (like tagged unions), command routing, or parsing structured inputs; avoid it for trivial if/elif conditions.

Explanation: match/case can match literals, types, sequences, and mappings, and bind variables when a pattern matches. It improves readability for complex branching on structured data.

def handle(event: dict) -> str:
    match event:
        case {"type": "created", "id": id}:
            return f"created {id}"
        case {"type": "deleted", "id": id}:
            return f"deleted {id}"
        case _:
            return "ignored"

Question: What is a metaclass in Python and when might you use one?

Answer: A metaclass is a "class of a class"—it defines how a class behaves. The default metaclass is type. You would rarely need to create a custom metaclass in application code, but it's a powerful tool used in frameworks.

Explanation: When you define a class, Python uses a metaclass to create that class object. By creating a custom metaclass, you can intercept the class creation process to automatically modify the class—for example, by adding methods, registering the class in a central registry, or enforcing coding conventions. ORMs like SQLAlchemy and Django use metaclasses extensively to create database models from class definitions. For most problems, decorators or class inheritance are simpler and preferred solutions.

Question: How does memory management work in CPython?

Answer: CPython's memory management is primarily based on reference counting, supplemented by a cyclic garbage collector to handle reference cycles.

Explanation: Every object in Python has a reference count, which is the number of variables or objects that point to it. When this count drops to zero, the object's memory is immediately deallocated. This is efficient but cannot handle reference cycles (e.g., two objects that refer to each other). To solve this, Python has a separate garbage collector that periodically runs to detect and clean up these cycles.

Question: What is __slots__ and how does it optimize memory?

Answer: __slots__ is a class variable that allows you to explicitly declare the attributes an object can have. By defining __slots__, you prevent the creation of a __dict__ for each instance, which significantly reduces the memory footprint of objects.

Explanation: By default, Python uses a dictionary (__dict__) to store an object's attributes. This is flexible but memory-intensive. When __slots__ is defined, Python uses a more compact, fixed-size array for the attributes instead. This is particularly useful when you need to create thousands or millions of instances of a class. The main trade-off is that you can no longer add new attributes to instances at runtime.

class MyClass:
    __slots__ = ['name', 'identifier']
    def __init__(self, name, identifier):
        self.name = name
        self.identifier = identifier

Question: Why are mutable default arguments dangerous, and how do you avoid issues?

Answer: Mutable defaults like [] or {} are evaluated once at definition time and reused across calls. Use None and create a new object inside.

Explanation: The function object holds a single default instance; mutations persist between calls.

def add_item(item: str, bucket: list[str] | None = None) -> list[str]:
    if bucket is None:
        bucket = []
    bucket.append(item)
    return bucket

Question: What are closures in Python and when do you need nonlocal?

Answer: A closure captures variables from an enclosing scope. Use nonlocal to rebind a variable from the nearest enclosing non-global scope.

Explanation: Without nonlocal, an assignment would create a new local variable, shadowing the outer one.

def make_counter():
    count = 0
    def inc() -> int:
        nonlocal count
        count += 1
        return count
    return inc

Question: What is a descriptor, and how is it related to property?

Answer: A descriptor implements __get__, __set__, or __delete__ to control attribute access. property is a built-in descriptor factory.

Explanation: Descriptors enable reusable attribute behavior like validation or lazy loading across classes.

class Positive:
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, objtype=None):
        return obj.__dict__[self.name]
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError("must be >= 0")
        obj.__dict__[self.name] = value

class Account:
    balance = Positive()
    def __init__(self, balance: int):
        self.balance = balance