Checking your session…
Module 06: Object-Oriented & Advanced Python

Advanced Python

Level: 10 | Version: v1.0 | Author: Meptrasoft

Real programs process large amounts of data — thousands of orders, huge files, endless data streams. Loading everything into memory at once is often wasteful, and repeating the same setup code (logging, timing, checks) around many functions is messy. Iterators, generators, and decorators solve exactly these problems. This module uses a Food Delivery Order System as the running example.

PYTHON
orders = [
    {"food": "Burger", "price": 200, "status": "Confirmed"},
    {"food": "Pizza", "price": 300, "status": "Pending"},
    {"food": "Pasta", "price": 250, "status": "Confirmed"},
]

Iterators

Why Iterators Matter

A for loop already lets us walk through a list. Behind the scenes, Python is using something called an iterator to do it — understanding that machinery is the foundation for generators later in this module.

PYTHON
foods = ["Burger", "Pizza", "Pasta"]
for food in foods:
    print(food)

Output:

TEXT
Burger
Pizza
Pasta

Iterable vs Iterator

An iterable is anything that can be looped over (a list, tuple, string, dictionary, set). An iterator is the object that actually remembers where it is and produces the values one at a time.

TEXT
ITERABLE → CAN BE LOOPED OVER      (list, tuple, string, dict, set)
ITERATOR → REMEMBERS POSITION      → PRODUCES ONE VALUE AT A TIME

You can get an iterator from an iterable by using the built-in iter() function.

The Iterator Protocol — iter() and next()

Iterablelist, str, dict ... Iteratortracks position iter() next() itemone value returned next() call next() again for the next item StopIterationraised when items run out when empty
The iterator protocol: iter() gives an iterator, next() returns items one at a time, and StopIteration signals the end.
PYTHON
foods = ["Burger", "Pizza", "Pasta"]
food_iterator = iter(foods)

print(next(food_iterator))   # Burger
print(next(food_iterator))   # Pizza
print(next(food_iterator))   # Pasta

Output:

TEXT
Burger
Pizza
Pasta

iter() returns an iterator; next() asks it for the next value.

StopIteration

Calling next() after the last value raises StopIteration — this is how Python knows a loop has ended.

PYTHON
foods = ["Burger"]
food_iterator = iter(foods)
next(food_iterator)  # Burger
try:
    next(food_iterator)  # Raises StopIteration
except StopIteration:
    print("StopIteration raised!")

Output:

TEXT
StopIteration raised!

A for loop hides this by catching StopIteration automatically:

PYTHON
foods = ["Burger", "Pizza", "Pasta"]
food_iterator = iter(foods)

while True:
    try:
        food = next(food_iterator)
        print(food)
    except StopIteration:
        break

Output:

TEXT
Burger
Pizza
Pasta
TEXT
for loop → calls iter() ONCE → calls next() REPEATEDLY → stops on StopIteration

Building a Custom Iterator

Any class can become an iterator by implementing two special methods: __iter__() (returns the iterator itself) and __next__() (returns the next value, or raises StopIteration).

PYTHON
class OrderIterator:
    def __init__(self, orders):
        self.orders = orders
        self.index = 0

    def __iter__(self):
        return self

    def __next__(self):
        if self.index >= len(self.orders):
            raise StopIteration
        order = self.orders[self.index]
        self.index += 1
        return order

foods = OrderIterator(["Burger", "Pizza", "Pasta"])
for food in foods:
    print(food)

Output:

TEXT
Burger
Pizza
Pasta

Because OrderIterator implements __iter__ and __next__, a normal for loop works on it exactly as it would on a list.

TEXT
__iter__() → RETURNS THE ITERATOR OBJECT
__next__() → RETURNS NEXT VALUE OR RAISES StopIteration

Iterable vs Iterator — Comparison

Iterable Iterator
Has __iter__() Has __iter__() and __next__()
Example: list, string, dict, tuple Example: object returned by iter(list)
Can be looped over many times Gets exhausted after one full pass
Does not track position itself Remembers its current position
TEXT
ITERABLE → iter() → ITERATOR → next() → VALUES, ONE AT A TIME

Generators

Why Generators Matter

Imagine the food delivery platform has to process one million orders. Building a list of one million processed orders keeps all of them in memory at once. A generator produces values one at a time, on demand, so only one processed order needs to exist in memory at a time.

TEXT
LIST      → ALL VALUES BUILT AND STORED IN MEMORY AT ONCE
GENERATOR → ONE VALUE PRODUCED AT A TIME, ONLY WHEN ASKED

A generator is the simplest way to create an iterator — Python writes the __iter__/__next__ machinery for you.

Generator Functions and yield

A generator function looks like a normal function but uses yield to produce values instead of returning all values at once.

PYTHON
def order_generator(orders):
    for food in orders:
        yield food

foods = ["Burger", "Pizza", "Pasta"]
gen = order_generator(foods)
print(gen)

Output:

TEXT
<generator object order_generator at 0x000002B6AB249BE0>

Calling order_generator(foods) does not run the function body — it returns a generator object. The body only runs when values are pulled out of it with next() or a for loop.

PYTHON
def order_generator():
    yield "Burger"
    yield "Pizza"
    yield "Pasta"

gen = order_generator()
print(next(gen))
print(next(gen))
print(next(gen))

Output:

TEXT
Burger
Pizza
Pasta
TEXT
yield → PAUSES the function, remembers exactly where it stopped, resumes on the next request

return vs yield

return yield
Ends the function completely Pauses the function, keeps its state
Sends back one value, once Produces a value each time it is reached
Calling the function runs its body Calling the function returns a generator
Function → normal value Function → generator object

Looping Over a Generator Directly

Just like any iterator, a generator works naturally in a for loop:

PYTHON
def order_generator(orders):
    for food in orders:
        yield food

for food in order_generator(["Burger", "Pizza", "Pasta"]):
    print(food)

Output:

TEXT
Burger
Pizza
Pasta

Generator Expressions

A generator expression looks like a list comprehension but uses () instead of [], and produces values lazily instead of building a full list.

PYTHON
prices = [200, 300, 150]
priced_with_tax = (price * 1.05 for price in prices)
print(priced_with_tax)

Output:

TEXT
<generator object <genexpr> at 0x000002B6AB249E50>
PYTHON
prices = [100, 200, 350]
priced_with_tax = (p * 1.05 for p in prices)
print(sum(priced_with_tax))

Output:

TEXT
682.5

List Comprehension vs Generator Expression

List Comprehension [...] Generator Expression (...)
Builds the whole list immediately Produces values one at a time, lazily
Uses more memory for large data Uses far less memory for large data
Can be looped over many times Gets exhausted after one full pass
TEXT
[...] → LIST NOW, IN MEMORY
(...) → VALUES LATER, ON DEMAND

A Generator Runs Only Once

Once a generator has produced all its values, it is exhausted — looping over it again produces nothing.

PYTHON
def order_generator(items):
    yield from items

gen = order_generator(["Burger", "Pizza"])
print(list(gen))
print(list(gen))

Output:

TEXT
['Burger', 'Pizza']
[]

To iterate again, create a new generator by calling the function again.

TEXT
GENERATOR EXHAUSTED AFTER ONE FULL PASS → CALL THE FUNCTION AGAIN FOR A NEW ONE

Chaining Generators — an Order Processing Pipeline

Generators can be chained so each stage only pulls one order at a time from the previous stage — no full intermediate lists are ever built, which matters a lot for large order feeds.

PYTHON
def read_orders(orders):
    for order in orders:
        yield order

def filter_confirmed(orders):
    for order in orders:
        if order["status"] == "Confirmed":
            yield order

def add_delivery_fee(orders):
    for order in orders:
        order["final_price"] = order["price"] + 40
        yield order

orders = [
    {"food": "Burger", "price": 200, "status": "Confirmed"},
    {"food": "Pizza", "price": 300, "status": "Pending"},
    {"food": "Pasta", "price": 250, "status": "Confirmed"},
]

pipeline = add_delivery_fee(filter_confirmed(read_orders(orders)))

for order in pipeline:
    print(order["food"], "->", order["final_price"])

Output:

TEXT
Burger -> 240
Pasta -> 290

Each function only produces one order when the next one is requested — the pipeline never holds the full processed list in memory.

TEXT
read_orders() → filter_confirmed() → add_delivery_fee() → ONE ORDER AT A TIME

Iterator vs Generator

Iterator (class-based) Generator (yield-based)
Written with __iter__ and __next__ Written with a normal function and yield
More code, full manual control Less code, simpler for most cases
Custom state stored on self State kept automatically by Python
TEXT
GENERATOR → THE EASIEST WAY TO BUILD AN ITERATOR

Decorators

Functions Are First-Class Objects

In Python, functions can be assigned to variables, passed as arguments, and returned from other functions — this is exactly what makes decorators possible.

PYTHON
def show_order():
    print("Order Received")

send_notification = show_order
send_notification()

Output:

TEXT
Order Received
PYTHON
def show_order():
    print("Order Received")

def run_function(func):
    func()

run_function(show_order)

Output:

TEXT
Order Received

Closures

A closure is an inner function that remembers variables from its enclosing function, even after the outer function has finished running.

PYTHON
def make_discount_applier(discount):
    def apply_discount(price):
        return price - (price * discount / 100)
    return apply_discount

ten_percent_off = make_discount_applier(10)
print(ten_percent_off(200))

Output:

TEXT
180.0

apply_discount keeps access to discount (10) even though make_discount_applier already returned. Closures are the mechanism decorators are built on.

TEXT
CLOSURE → INNER FUNCTION + REMEMBERED OUTER VARIABLE(S)

Building a Basic Decorator

A decorator is a function that takes a function and returns a new function that adds extra behavior around it — without changing the original function's code.

PYTHON
def log_order(func):
    def wrapper():
        print("Order function starting...")
        func()
        print("Order function finished.")
    return wrapper

def show_order():
    print("Order Received")

show_order = log_order(show_order)
show_order()

Output:

TEXT
Order function starting...
Order Received
Order function finished.

Using @ Syntax

Python provides @decorator_name as shorthand for reassigning a function to its decorated version.

PYTHON
def log_order(func):
    def wrapper():
        print("Order function starting...")
        func()
        print("Order function finished.")
    return wrapper

@log_order
def show_order():
    print("Order Received")

show_order()

Output:

TEXT
Order function starting...
Order Received
Order function finished.
TEXT
@log_order
def show_order(): ...
     ↓ exactly the same as ↓
show_order = log_order(show_order)

Decorating Functions That Take Arguments

The wrapper must accept *args and **kwargs so it works with any decorated function, and it should return the original function's result so callers still receive the value.

PYTHON
def log_order(func):
    def wrapper(*args, **kwargs):
        print("Order function starting...")
        result = func(*args, **kwargs)
        print("Order function finished.")
        return result
    return wrapper

@log_order
def calculate_total(price, quantity):
    return price * quantity

print(calculate_total(200, 2))

Output:

TEXT
Order function starting...
Order function finished.
400

Preserving Metadata — functools.wraps

A wrapper hides the original function's name and docstring, which can confuse debugging tools and documentation.

PYTHON
def log_order(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@log_order
def calculate_total(price, quantity):
    """Calculates the order total."""
    return price * quantity

print(calculate_total.__name__)

Output:

TEXT
wrapper

functools.wraps copies the original function's name and docstring onto the wrapper, fixing this.

PYTHON
from functools import wraps

def log_order(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@log_order
def calculate_total(price, quantity):
    """Calculates the order total."""
    return price * quantity

print(calculate_total.__name__)

Output:

TEXT
calculate_total
TEXT
@wraps(func) → KEEPS THE ORIGINAL FUNCTION'S NAME AND DOCSTRING

In real projects, use @wraps(func) when writing decorators.

Decorators That Accept Their Own Arguments

Sometimes the decorator itself needs configuration — for example, how many times to retry a payment that may fail. This needs three levels of functions. This simple retry stops as soon as the call succeeds, and only tries again when the call raises an error.

PYTHON
from functools import wraps

def retry(times):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, times + 1):
                print(f"Attempt {attempt}")
                try:
                    return func(*args, **kwargs)   # success → stop retrying
                except Exception as error:
                    print(f"Failed: {error}")
            print("All attempts failed")
        return wrapper
    return decorator

attempts_so_far = 0

@retry(times=3)
def charge_card():
    global attempts_so_far
    attempts_so_far += 1
    if attempts_so_far < 3:
        raise ValueError("network timeout")
    print("Payment successful")

charge_card()

Output:

TEXT
Attempt 1
Failed: network timeout
Attempt 2
Failed: network timeout
Attempt 3
Payment successful

The loop retries only after a failure and returns immediately on success — so a working call runs the real logic just once.

TEXT
retry(times)        → returns decorator
decorator(func)      → returns wrapper
wrapper(*args, **kw) → runs the real logic

Chaining Multiple Decorators

Several decorators can be stacked on one function. Python applies them bottom-up when building the final function. When the final function is called, the outer wrapper starts first.

PYTHON
from functools import wraps

def log_order(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Logging order...")
        return func(*args, **kwargs)
    return wrapper

def check_auth(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Checking authentication...")
        return func(*args, **kwargs)
    return wrapper

@log_order
@check_auth
def place_order(food):
    print("Order placed for", food)

place_order("Burger")

Output:

TEXT
Logging order...
Checking authentication...
Order placed for Burger
TEXT
@log_order
@check_auth
def place_order(): ...
     ↓ exactly the same as ↓
place_order = log_order(check_auth(place_order))

Real-World Uses of Decorators

Use Case What the Decorator Does
Logging Prints/records details before and after a call
Timing Measures how long a function takes to run
Authentication Checks permissions before allowing a call
Caching Stores results so repeated calls are faster
Retry Re-attempts a failing operation
Validation Checks arguments before the real function runs
PYTHON
import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} took {end - start:.4f} seconds")
        return result
    return wrapper

@timer
def process_orders(orders):
    return sum(order["price"] for order in orders)

orders = [{"price": 200}, {"price": 300}, {"price": 150}]
print(process_orders(orders))

Output:

TEXT
process_orders took 0.0000 seconds
650

(The exact number will differ on your machine and on every run — only the format X.XXXX seconds matters.)

Built-in Decorators on Classes — Quick Look

Python also provides a few built-in decorators used inside classes.

Decorator Purpose
@staticmethod A method that needs neither self nor the class
@classmethod A method that receives the class (cls) instead of an instance
@property Lets a method be accessed like a plain attribute
TEXT
@staticmethod → NO self, NO cls
@classmethod  → RECEIVES cls (the class itself)
@property     → METHOD USED LIKE AN ATTRIBUTE (no parentheses)

Complete Example — Iterators, Generators, and Decorators Together

PYTHON
from functools import wraps

def log_order(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print("Starting order report...\n")
        result = func(*args, **kwargs)
        print("\nReport complete.")
        return result
    return wrapper

def read_orders(orders):
    for order in orders:
        yield order

def filter_confirmed(orders):
    for order in orders:
        if order["status"] == "Confirmed":
            yield order

@log_order
def generate_report(orders):
    total = 0
    for order in filter_confirmed(read_orders(orders)):
        print(order["food"], "-", order["price"])
        total += order["price"]
    return total

orders = [
    {"food": "Burger", "price": 200, "status": "Confirmed"},
    {"food": "Pizza", "price": 300, "status": "Pending"},
    {"food": "Pasta", "price": 250, "status": "Confirmed"},
]

print("Grand Total:", generate_report(orders))

Output:

TEXT
Starting order report...

Burger - 200
Pasta - 250

Report complete.
Grand Total: 450

generate_report is wrapped by a decorator (log_order), consumes a lazy generator pipeline (read_ordersfilter_confirmed), and each stage is walked using the same iterator protocol that powers every for loop in Python.

Common Fresher Mistakes

Mistake Correct Understanding
Thinking a list and an iterator are the same thing A list is iterable; an iterator is what iter() produces from it
Calling next() after exhaustion and being surprised by an error StopIteration is expected — that is how loops know to stop
Thinking calling a generator function runs its body immediately It only returns a generator object; the body runs on next()/iteration
Reusing an already-exhausted generator Create a new generator by calling the function again
Using [...] when data is huge Prefer (...) (generator expression) to save memory
Forgetting *args, **kwargs in a decorator's wrapper Without them the decorator breaks on any decorated function with arguments
Forgetting to return the result inside wrapper Callers get None instead of the real value
Forgetting @wraps(func) in a real decorator The decorated function's name/docstring get overwritten
Confusing decorator order when chaining Decorators are applied bottom-up; during the call, the outer wrapper starts first

Placement Quick Points

  • An iterable can be looped over; an iterator remembers its position and produces values one at a time.
  • iter() returns an iterator; next() asks it for the next value; StopIteration signals the end.
  • A custom class becomes an iterator by implementing __iter__() and __next__().
  • A generator function uses yield and is the simplest way to build an iterator.
  • Calling a generator function returns a generator object — the body runs lazily, on demand.
  • A generator expression uses (...) and produces values lazily, unlike a list comprehension's [...].
  • A generator is exhausted after one full pass; a new one must be created to iterate again.
  • Generators can be chained into a pipeline that processes one item at a time, saving memory.
  • A decorator is a function that takes a function and returns a new function with added behavior.
  • @decorator above a function is shorthand for function = decorator(function).
  • A decorator's wrapper should accept *args, **kwargs and return the wrapped function's result.
  • functools.wraps preserves the original function's name and docstring.
  • Decorators that take their own arguments need three nested function levels.
  • Chained decorators are applied bottom-up when the function is built; when called, the outer wrapper starts first.

Interview Questions

What is an iterable?
An object that can be looped over, such as a list, tuple, string, or dictionary.
What is an iterator?
An object that remembers its position and produces values one at a time using __next__().
What is the difference between an iterable and an iterator?
An iterable can be looped over; an iterator is created from it with iter() and tracks position while producing values.
What does next() do?
It returns the next value from an iterator, or raises StopIteration when there are no more values.
What is StopIteration?
The exception an iterator raises to signal that no more values remain.
What two methods make a class an iterator?
__iter__(), which returns the iterator itself, and __next__(), which returns the next value.
What is a generator?
An iterator-like object that produces values one at a time. A generator function creates it using yield.
What happens when a generator function is called?
It does not run the function body; it returns a generator object. The body runs as values are requested.
What is the difference between return and yield?
return ends the function and sends back one value. yield pauses the function, remembers its state, and can produce more values later.
What is a generator expression?
An expression like (x for x in iterable) that produces values lazily, similar to a list comprehension but memory-efficient.
Can a generator be iterated over more than once?
No. Once exhausted, a new generator must be created by calling the function again.
Why are generators memory-efficient?
They produce one value at a time instead of building the entire result in memory upfront.
What is a closure?
An inner function that remembers variables from its enclosing function even after that function has finished running.
What is a decorator?
A function that takes another function and returns a new function that adds behavior around it.
What does @decorator_name above a function do?
It reassigns the function to the value returned by decorator_name(function).
Why should a decorator's wrapper accept *args, **kwargs?
So the decorator works with any function regardless of how many arguments it takes.
Why should wrapper return the result of the original function?
So callers of the decorated function still receive the correct return value.
What does functools.wraps do?
It copies the original function's name and docstring onto the wrapper function.
How does a decorator accept its own arguments?
By adding an extra outer function level: the outer function takes the decorator's arguments and returns the actual decorator.
In what order are chained decorators applied?
They are applied bottom-up when Python builds the final function. When the function is called, the outer wrapper starts first.

Practice

Use a Food Delivery Order System.

  1. Create a list of food items and loop over it with a for loop.
  2. Create an iterator from that list using iter().
  3. Call next() on the iterator three times and print each result.
  4. Call next() one time too many and observe StopIteration.
  5. Rewrite the loop manually using while True, next(), and try/except StopIteration.
  6. Build a custom OrderIterator class with __iter__ and __next__.
  7. Use the custom iterator in a for loop.
  8. Explain the difference between an iterable and an iterator.
  9. Write a generator function order_generator() that yields each food item.
  10. Print the generator object before consuming it.
  11. Consume the generator with next() and then with a for loop.
  12. Write a generator expression that adds 5% tax to a list of prices.
  13. Compare the generator expression to an equivalent list comprehension.
  14. Try iterating over the same generator twice and observe the result.
  15. Build a three-stage generator pipeline: read orders, filter confirmed orders, add a delivery fee.
  16. Explain why the pipeline does not build an intermediate list at each stage.
  17. Write a closure make_discount_applier(discount) that returns a function applying that discount.
  18. Write a basic decorator log_order that prints messages before and after calling a function.
  19. Apply it using @log_order syntax.
  20. Update the decorator's wrapper to accept *args, **kwargs.
  21. Verify the decorated function still returns the correct value.
  22. Add functools.wraps and confirm __name__ is preserved.
  23. Write a decorator retry(times) that accepts its own argument.
  24. Chain two decorators (log_order and check_auth) on one function and predict the print order.
  25. Write a timer decorator that prints how long a function took to run.
  26. Combine a decorator, a generator pipeline, and a for loop in one complete order-report example.

Predict the output:

PYTHON
def numbers():
    yield 1
    yield 2
    yield 3

gen = numbers()
print(next(gen))
print(next(gen))
print(list(gen))
PYTHON
def log(func):
    def wrapper(*args, **kwargs):
        print("before")
        result = func(*args, **kwargs)
        print("after")
        return result
    return wrapper

@log
def add(a, b):
    return a + b

print(add(2, 3))
PYTHON
gen = (x * 2 for x in [1, 2, 3])
print(list(gen))
print(list(gen))

Find the mistake:

PYTHON
def log(func):
    def wrapper():
        print("before")
        func()
        print("after")
    return wrapper

@log
def show_total(total):
    print(total)

show_total(400)

Final task: Build a small Food Delivery Order Report that reads a list of orders through a chained generator pipeline (filter confirmed orders, add a delivery fee), wraps the reporting function with a @log_order decorator that prints a start/end message, and prints each order followed by the grand total.

Output:

TEXT
ITERATOR         → REMEMBERS POSITION, ONE VALUE AT A TIME
GENERATOR        → SIMPLEST WAY TO BUILD AN ITERATOR, USES yield
DECORATOR        → WRAPS A FUNCTION TO ADD BEHAVIOR AROUND IT

End of Level 10