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 plaindict
?
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
, andAnnotated
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 usedefault
vsdefault_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
anddataclasses
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