Advanced Python
Type hints, @dataclass, Protocol structural typing, ABC.register(), descriptors (the mechanism behind @property), metaclasses, decorators that take arguments, functools.wraps, generator pipelines and send(), contextlib.contextmanager, functools.partial, and Python's reference-counting + garbage-collected memory model.
This file covers advanced Python features that professional codebases rely on — tools that make code safer, cleaner, and more expressive. These build directly on the OOP and Intermediate Python files, so make sure you're comfortable with those first.
1. Type Hints
What is it?
Type hints let you annotate what type a variable, parameter, or return value is expected to be — Python doesn't enforce them at runtime, but tools (and other developers) use them to catch mistakes early.
Definition: Type hints are optional annotations indicating the expected data types of variables, function parameters, and return values.
Simple Example
pythondef add(a: int, b: int) -> int: return a + b name: str = "Aditi" age: int = 21 scores: list[int] = [85, 90, 78] student_info: dict[str, int] = {"age": 21, "marks": 85}
Explanation of the Code
a: int, b: intdocuments that both parameters are expected to be integers;-> intdocuments the expected return type.- Python itself does not enforce these — calling
add("5", "3")still runs, and would concatenate the strings instead of erroring. Type hints are for tooling and readability, not runtime validation.
Checking Types with mypy
bashpip install mypy mypy my_script.py
Explanation: mypy is a separate static type-checking tool that reads your type hints and flags mismatches before you even run the code — catching a whole category of bugs early, especially valuable in larger codebases.
Common Mistakes
- Assuming type hints are enforced by Python itself at runtime — they aren't, unless you use an external validation tool (like
mypy, or Pydantic, covered in the Web Development file).
Important Points
- Type hints are optional but strongly encouraged in professional, larger codebases.
Optional[str](from thetypingmodule) indicates a value can be either that type orNone.
2. Dataclasses
What is it?
The @dataclass decorator automatically generates common boilerplate methods (__init__, __repr__, __eq__) for classes that are mainly used to store data — saving you from writing repetitive code by hand.
Simple Example — Without @dataclass
pythonclass Student: def __init__(self, name, age, course): self.name = name self.age = age self.course = course def __repr__(self): return f"Student(name={self.name}, age={self.age}, course={self.course})" def __eq__(self, other): return self.name == other.name and self.age == other.age and self.course == other.course
The Same Class — With @dataclass
pythonfrom dataclasses import dataclass @dataclass class Student: name: str age: int course: str student1 = Student("Aditi", 21, "CS") print(student1) # Student(name='Aditi', age=21, course='CS') print(student1 == Student("Aditi", 21, "CS")) # True
Explanation of the Code
@dataclassautomatically generates__init__,__repr__, and__eq__based purely on the type-hinted attributes listed — dramatically reducing repetitive boilerplate.- The generated
__repr__gives a clean, readable printout without you writing it manually.
Adding Default Values
python@dataclass class Student: name: str age: int course: str = "Undeclared" # default value student2 = Student("Rohan", 22) print(student2.course) # Undeclared
Important Points
- Dataclasses are ideal for simple, data-holding classes — for classes with significant custom logic/behavior, a regular class may still be more appropriate.
- Default values work exactly like default function arguments — non-default fields must come before default ones.
Practice
- Create a
Productdataclass withname,price, and a defaultin_stock: bool = True.
3. Protocols (Structural Typing)
What is it?
A Protocol defines an expected shape (which methods/attributes an object must have) without requiring formal inheritance — Python calls this "structural typing," informally known as "duck typing" ("if it walks like a duck and quacks like a duck...").
Simple Example
pythonfrom typing import Protocol class Flyable(Protocol): def fly(self) -> str: ... class Bird: def fly(self) -> str: return "Bird is flying" class Airplane: def fly(self) -> str: return "Airplane is flying" def make_it_fly(flyer: Flyable) -> None: print(flyer.fly()) make_it_fly(Bird()) # works - Bird has a matching fly() method make_it_fly(Airplane()) # also works - Airplane also has a matching fly() method
Explanation of the Code
- Neither
BirdnorAirplaneexplicitly inherits fromFlyable— they just happen to have a matchingfly()method, which is all aProtocolcares about. - This is different from traditional inheritance-based abstraction (like the
ABCclass from the OOP file), which requires an explicitclass Bird(Flyable):relationship.
Important Points
- Protocols are checked primarily by static type checkers (like
mypy), not enforced at runtime by Python itself. - Useful when you want to describe "any object with this behavior," without forcing unrelated classes into an artificial inheritance hierarchy.
4. Abstract Base Classes — Deeper Look
Recap and Extension
The OOP file covered ABC and @abstractmethod for forcing subclasses to implement specific methods. Beyond that basic use, Python's abc module also supports registering existing classes as "virtual subclasses," without requiring them to actually inherit.
pythonfrom abc import ABC, abstractmethod class Serializable(ABC): @abstractmethod def to_json(self): pass class ThirdPartyClass: def to_json(self): return "{}" Serializable.register(ThirdPartyClass) print(isinstance(ThirdPartyClass(), Serializable)) # True, even without direct inheritance
Important Points
.register()lets you treat an already-existing, unrelated class as if it fulfills an abstract interface, forisinstance()checks — useful when you can't (or don't want to) modify that class's original code.
5. Descriptors
What is it?
A descriptor is a class that controls what happens when an attribute is accessed, set, or deleted on another class — this is actually the underlying mechanism that powers @property (from the Intermediate Python file).
Simple Example
pythonclass PositiveNumber: def __set_name__(self, owner, name): self.name = "_" + name def __get__(self, instance, owner): return getattr(instance, self.name) def __set__(self, instance, value): if value < 0: raise ValueError(f"{self.name} cannot be negative") setattr(instance, self.name, value) class Product: price = PositiveNumber() # a descriptor controlling this attribute def __init__(self, name, price): self.name = name self.price = price # this actually goes through PositiveNumber.__set__ item = Product("Shirt", 500) print(item.price) # 500 item.price = -100 # raises ValueError!
Explanation of the Code
__get__and__set__intercept every read and write toprice, letting thePositiveNumberdescriptor enforce validation centrally, reusable across any class that uses it.- This is genuinely advanced — most day-to-day code uses
@propertyinstead (which is simpler for single-class use cases); descriptors shine when you need the same validation logic reused across many different classes/attributes.
Important Points
- Descriptors are the mechanism underlying
@property, but are more powerful/reusable across multiple classes. - Rarely written from scratch in everyday code, but understanding them deepens your understanding of how Python's attribute access actually works.
6. Metaclasses
What is it?
A metaclass is "the class of a class" — just as objects are instances of classes, classes themselves are instances of a metaclass (by default, type). Metaclasses let you customize how classes themselves are created.
Definition: A metaclass defines the behavior of a class itself, controlling how classes are constructed, similar to how a class controls how objects are constructed.
Simple Example
pythonclass LoggingMeta(type): def __new__(mcs, name, bases, namespace): print(f"Creating class: {name}") return super().__new__(mcs, name, bases, namespace) class MyClass(metaclass=LoggingMeta): pass
Output (printed the moment MyClass is defined, not when an object is created):
Creating class: MyClassExplanation of the Code
LoggingMetainherits fromtype(the default metaclass every class ultimately uses).__new__intercepts the actual creation of the class itself, letting you inject custom logic (here, just a print statement) whenever any class using this metaclass is defined.
Real-World Example
Metaclasses are used internally by frameworks like Django's ORM to automatically register model classes and their database fields the moment they're defined.
Important Points
- Metaclasses are a genuinely advanced, rarely-needed-in-everyday-code feature — as the well-known Python saying goes, "if you're wondering whether you need metaclasses, you don't."
- Understanding they exist helps explain "magic" behavior in frameworks like Django, even if you rarely write your own.
7. Advanced Decorators
Decorators That Accept Arguments
pythondef repeat(times): def decorator(func): def wrapper(*args, **kwargs): for _ in range(times): func(*args, **kwargs) return wrapper return decorator @repeat(times=3) def greet(name): print(f"Hello, {name}!") greet("Aditi")
Output:
Hello, Aditi!
Hello, Aditi!
Hello, Aditi!Explanation: This adds an extra layer of nesting compared to the basic decorators covered in the Intermediate Python file — repeat(times=3) first returns the actual decorator, which is then applied to greet.
Preserving Function Metadata with functools.wraps
pythonfrom functools import wraps def my_decorator(func): @wraps(func) # preserves func's original name and docstring def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @my_decorator def add(a, b): """Adds two numbers.""" return a + b print(add.__name__) # add (without @wraps, this would incorrectly show "wrapper") print(add.__doc__) # Adds two numbers.
Explanation: Without @wraps(func), the decorated function would lose its original name and docstring, showing wrapper instead of add — this makes debugging and introspection (and automatically generated documentation) confusing. @wraps is considered a best practice for every decorator you write.
Important Points
- Always use
@functools.wraps(func)inside any decorator you write in real code. - A decorator that accepts arguments requires an extra level of function nesting.
8. Advanced Generators
Generator Pipelines
Generators can be chained together, each one processing and passing along data lazily, without ever building a full intermediate list.
pythondef read_numbers(numbers): for n in numbers: yield n def square_all(numbers): for n in numbers: yield n ** 2 def filter_even(numbers): for n in numbers: if n % 2 == 0: yield n pipeline = filter_even(square_all(read_numbers(range(1, 11)))) print(list(pipeline)) # [4, 16, 36, 64, 100]
Explanation: Each generator function processes one value at a time and passes it to the next stage — nothing is fully computed until the very end (list(pipeline)), making this extremely memory-efficient even for huge input sequences.
send() — Sending Values Into a Generator
pythondef running_total(): total = 0 while True: value = yield total total += value gen = running_total() next(gen) # "prime" the generator (runs up to the first yield) print(gen.send(10)) # 10 print(gen.send(5)) # 15 print(gen.send(20)) # 35
Explanation: yield can also receive a value sent in via .send(), allowing two-way communication with a running generator — an advanced pattern occasionally used for building coroutine-style logic.
Important Points
- Generator pipelines are a memory-efficient, elegant way to process large data streams in stages.
.send()is an advanced, less commonly used feature — most everyday generator use only needsyieldto produce values.
9. Advanced Context Managers with contextlib
What is it?
The Intermediate Python file showed writing a context manager as a full class with __enter__/__exit__. The contextlib.contextmanager decorator offers a much shorter way to write one using a generator function.
Simple Example
pythonfrom contextlib import contextmanager import time @contextmanager def timer(): start = time.time() yield end = time.time() print(f"Took {end - start:.4f} seconds") with timer(): total = sum(range(1_000_000))
Explanation of the Code
- Everything before
yieldacts like__enter__(setup); everything afteryieldacts like__exit__(cleanup) — and it all runs automatically as part of thewithblock. - This is far more concise than writing a full class with two separate methods for simple cases.
Important Points
@contextmanageris generally preferred for simple context managers; a full class is still useful for more complex cases needing extra state or methods.
10. Functional Programming Tools
functools.partial
pythonfrom functools import partial def power(base, exponent): return base ** exponent square = partial(power, exponent=2) cube = partial(power, exponent=3) print(square(5)) # 25 print(cube(5)) # 125
Explanation: partial creates a new function with some arguments already "locked in" — square is just power with exponent permanently set to 2.
Immutability as a Functional Programming Principle
python# Prefer creating new data over mutating existing data original = (1, 2, 3) updated = original + (4,) # creates a NEW tuple, rather than modifying in place print(original) # (1, 2, 3) - unchanged print(updated) # (1, 2, 3, 4)
Explanation: Functional programming favors avoiding changes to existing data (mutation), instead producing new values — this makes code easier to reason about, since data can't unexpectedly change somewhere else in a program.
Important Points
functoolsprovides several tools (partial,reduce,lru_cache, covered earlier) supporting a more functional programming style in Python.- Favoring immutable data structures (tuples over lists, when data shouldn't change) reduces a whole class of subtle bugs.
11. Memory Management and Garbage Collection
What is it?
Python automatically manages memory for you — allocating memory for new objects and freeing it once objects are no longer needed, primarily using a technique called reference counting.
Reference Counting (Conceptual)
pythonimport sys a = [1, 2, 3] print(sys.getrefcount(a)) # shows how many references currently point to this list b = a # now TWO references point to the same list print(sys.getrefcount(a)) # count increased
Explanation: Python tracks how many variables/references point to each object in memory. When that count drops to zero (no more references exist), Python automatically frees the memory.
The Garbage Collector (For Circular References)
Reference counting alone can't detect circular references (two objects referencing each other, with nothing else pointing to either) — Python's separate garbage collector (the gc module) periodically scans for and cleans up exactly this situation.
pythonimport gc class Node: def __init__(self): self.reference = None node1 = Node() node2 = Node() node1.reference = node2 node2.reference = node1 # circular reference: they point to each other del node1 del node2 # even after deleting both variables, the objects still reference each other in memory # Python's garbage collector will eventually detect and clean this up gc.collect() # manually trigger garbage collection (rarely needed in normal code)
Weak References
pythonimport weakref class Student: def __init__(self, name): self.name = name student = Student("Aditi") weak_ref = weakref.ref(student) print(weak_ref()) # <Student object> - still accessible del student print(weak_ref()) # None - the object has been garbage collected
Explanation: A weak reference doesn't count toward an object's reference count — allowing the object to still be garbage collected normally, while still letting you check whether it's still alive. Useful for caches that shouldn't prevent memory from being freed.
Important Points
- Python handles memory management automatically — manual intervention (
gc.collect()) is rarely needed in everyday code. - Circular references are the main scenario where the garbage collector's extra cleanup (beyond simple reference counting) matters.
12. A Brief Note on Python Internals
CPython
CPython is the standard, most widely used implementation of Python (the one you almost certainly have installed) — written in the C programming language. Other implementations exist (like PyPy, focused on speed via just-in-time compilation), but CPython is what "Python" means for the vast majority of users.
The Global Interpreter Lock (GIL) — Brief Preview
CPython has a mechanism called the GIL, which allows only one thread to execute Python bytecode at a time, even on a multi-core processor. This has significant implications for concurrent programming, covered in full detail in the next file, Concurrency & Performance.
Important Points
- Knowing that CPython is the standard implementation, and that alternatives exist, is useful general knowledge, especially for interviews.
- The GIL is a foundational concept for understanding Python's concurrency behavior — introduced briefly here, explained fully next.
Common Beginner Mistakes — Summary for This Section
- Assuming type hints are enforced by Python at runtime.
- Forgetting
@functools.wrapsinside custom decorators. - Reaching for metaclasses or descriptors when a simpler tool (a regular class, or
@property) would do. - Assuming
gc.collect()needs to be called manually in typical everyday code.
Cheat Sheet — Advanced Python
python# Type hints def func(x: int, y: str = "default") -> bool: ... # Dataclass from dataclasses import dataclass @dataclass class Point: x: int y: int # Protocol from typing import Protocol class HasArea(Protocol): def area(self) -> float: ... # Advanced decorator with functools.wraps from functools import wraps def decorator(func): @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper # contextlib context manager from contextlib import contextmanager @contextmanager def my_context(): # setup yield # cleanup # functools.partial from functools import partial new_func = partial(original_func, fixed_arg=value)
Interview Questions
Q1. Are Python type hints enforced at runtime? Answer: No — they're purely informational/documentational by default. Enforcement requires an external tool like mypy, or a library that validates them explicitly, like Pydantic.
Q2. What does the `@dataclass` decorator do? Answer: It automatically generates common boilerplate methods (__init__, __repr__, __eq__) for a class based on its type-hinted attributes, reducing repetitive code for data-holding classes.
Q3. What is a metaclass? Answer: "The class of a class" — it controls how classes themselves are constructed, similar to how a class controls how its objects are constructed. Rarely needed in everyday code, but used internally by some frameworks.
Q4. What is the difference between a descriptor and a property? Answer: A @property is a simpler, single-class way to control attribute access. A descriptor is the more general, reusable mechanism underlying @property, allowing the same validation/access logic to be shared across multiple different classes and attributes.
Q5. Why should `functools.wraps` be used inside custom decorators? Answer: Without it, the decorated function loses its original name and docstring (showing the wrapper's instead), making debugging and introspection confusing.
Q6. How does Python primarily manage memory? Answer: Primarily through reference counting — automatically freeing an object's memory once nothing references it anymore. A separate garbage collector additionally handles circular references, which reference counting alone cannot detect.
Q7. What is CPython? Answer: The standard, most widely used implementation of Python, written in C — what most people mean when they simply say "Python."
Practice Questions
Beginner
- Add type hints to a function that takes two floats and returns their average.
- Convert a regular class with 3 attributes into a
@dataclass. - Write a decorator using
@functools.wrapsand confirm the decorated function's__name__is preserved. - Write a simple
contextlib.contextmanager-based context manager that prints "Starting" and "Finished" around a block of code. - Use
functools.partialto create a specialized version of a general function.
Intermediate
- Write a
Protocoldescribing objects with asave()method, and demonstrate two unrelated classes both satisfying it. - Write a decorator that accepts an argument (e.g., a number of retries) and applies it to a function.
- Build a generator pipeline with at least 3 stages (e.g., read numbers → filter → transform).
- Create a simple descriptor that validates a string attribute is never empty.
- Demonstrate a weak reference to an object, showing it becomes
Noneafter the original object is deleted.
Challenge
- Write a custom metaclass that automatically adds a
created_attimestamp attribute to every class that uses it. - Build a small "functional" data pipeline using
map,filter, andfunctools.reducetogether to process a list of numbers. - Research and explain, in your own words (with a code example), how
@propertyis essentially syntactic sugar over the descriptor protocol.