Skip to main content
This chapter introduces advanced Python concepts that make programs more modular, reusable, and memory efficient. These concepts are widely used in modern Python applications, web frameworks, data processing, and AI libraries. Since functions are objects, they can be assigned to variables, passed as arguments, returned from other functions, and stored in collections. These capabilities form the foundation for higher-order functions, decorators, closures, iterators and generators.

Topics Covered

In this module, you’ll learn:
  1. Functions are Objects
  2. Higher-Order Functions
  3. Closures
  4. Decorators
  5. Iterators
  6. Generators
  7. Context Managers
Try Yourself: 💻 VS Code | 🚀 Colab | 📥 Download
Verify Solutions: 💻 VS Code | 🚀 Colab | 📥 Download

Functions are Objects

Like integers, strings, lists, and dictionaries, functions are also objects. Therefore, a function can:
  • Be assigned to a variable.
  • Be passed as an argument.
  • Be returned from another function.
  • Be stored in a collection.
Languages that support these capabilities are said to support first-class functions.

Assigning a Function

Output ?
Notice the difference:

Storing Functions

Output ?

Exercise 2

What is the difference between the following statements?
  • f = greet assigns the function object.
  • f = greet() calls the function and stores its return value.

Higher-Order Functions

A higher-order function is a function that:
  • Accepts one or more functions as arguments.
  • Returns a function.
Since functions are objects, they can be passed to and returned from other functions.

Passing Functions as Arguments

Output ?

Returning Functions

Output ?

Example

Output ?

Exercise 2

Predict the output.
Output ?

Exercise 3

When is a function called a higher-order function?
A function is called a higher-order function if it:
  • Accepts one or more functions as arguments.
  • Returns a function.
Higher-order functions form the foundation for closures and decorators.

Closures

Sometimes we want a function to remember information from previous function calls. A normal function cannot do this because its local variables are destroyed when the function finishes executing.

Example: Normal Function

Output ?
Each time counter() is called, the local variable count is created again and initialized to 0. Therefore, the function cannot remember its previous state. To preserve the state between function calls, we can use a closure.

What is a Closure?

A closure is an inner function that remembers the variables of its enclosing function even after the enclosing function has finished executing. A closure is created when:
  • A function is defined inside another function.
  • The inner function uses variables from the outer function.
  • The inner function is returned.

Example

Output ?
Here, the variable count is preserved even after counter() has finished executing. Each call to increment() updates the same variable instead of creating a new one. The nonlocal keyword allows the inner function to modify a variable defined in the enclosing function.

Exercise 2

Predict the output.
Output ?
The variable count is preserved inside the closure and updated on each function call.

Exercise 3

Why do we use closures?
Closures allow a function to remember and preserve variables from its enclosing function even after the enclosing function has finished executing.
Closures are widely used for state preservation and form the foundation of decorators, where the wrapper function remembers the original function passed to the decorator.

Decorators

A decorator is a function that extends or modifies the behavior of another function without changing its original code. A decorator is a higher-order function because it:
  • Accepts a function as an argument.
  • Returns another function.

Creating a Decorator

Output ?
Instead of modifying greet(), the decorator returns a new function with additional behavior.

Using the @ Syntax

Python provides the @ syntax as a convenient way to apply decorators.
Output ?
The above code is equivalent to:

Exercise 2

Which statement is equivalent to the following code?
The @ syntax is a shorthand for applying a decorator.

Decorating Functions with Parameters

The previous decorator works only for functions that do not accept any arguments.
Suppose we decorate a function that accepts parameters.
Our decorator is:
When we call:
Output ?
Python actually executes:
Output ?
Since wrapper() does not accept any arguments, Python raises an error.
One solution is to make the wrapper accept the same parameters.
This works only for functions having exactly two parameters. To make the decorator work with any function, Python provides argument packing.
Here,
  • *args collects all positional arguments.
  • **kwargs collects all keyword arguments.
  • func(*args, **kwargs) forwards all arguments to the original function.
Now the decorator can be applied to functions with any number of arguments.
Output ?
The wrapper() function is defined inside another function and remembers the original func even after the outer function has finished executing. This behavior was possible because of closure.

An iterator is an object that returns one value at a time from a collection, while a generator is a special type of iterator created using the yield keyword. They provide a memory-efficient way to process data without loading everything into memory at once.

Learning Objectives

After completing this lesson, you will be able to:
  • Understand iterables, iterators, and generators.
  • Create iterators using iter() and next().
  • Build custom iterators.
  • Create generators using yield.
  • Differentiate between yield and return.
  • Create generator expressions.
  • Compare iterators and generators.
  • Identify real-world use cases of generators.

What is an Iterator?

An iterator is an object that returns one element at a time from a collection. It remembers its current position and produces the next value only when requested. Python uses iterators internally whenever you iterate over a collection using a for loop.

Iterator Protocol

An iterator implements the following special methods:
  • __iter__() – Returns the iterator object.
  • __next__() – Returns the next element.
When no more elements are available, __next__() raises a StopIteration exception.

Creating an Iterator

Use the iter() function to create an iterator from an iterable.
Output ?

Retrieving Values

Use the next() function to retrieve values from an iterator.
Output ?

StopIteration

Once all elements are consumed, calling next() again raises a StopIteration exception.
Output ?

Exercise 2

Predict the output.
Output ?
Strings are iterable objects, so they can be converted into iterators using iter().

Exercise 3

What exception will be raised by the following code?
Output ?
A StopIteration exception is raised because the iterator has no more elements to return.

Creating a Custom Iterator

You can create your own iterator by implementing the __iter__() and __next__() methods.
  • __iter__() returns the iterator object.
  • __next__() returns the next value.
  • When all values are consumed, __next__() raises a StopIteration exception.

Example

Output ?

How It Works

  1. The Counter object is created.
  2. The for loop calls __iter__() to obtain the iterator.
  3. The loop repeatedly calls __next__().
  4. Each call returns the next value.
  5. When the limit is reached, StopIteration is raised, ending the loop.

Exercise 2

What happens if raise StopIteration is removed from the __next__() method?
The iterator will never indicate that it has finished, causing the loop to continue indefinitely or resulting in incorrect behavior.

Exercise 3

Which two special methods must every custom iterator implement?
Every custom iterator must implement:
  • __iter__()
  • __next__()

What is a Generator?

A generator is a special type of iterator created using a function that contains the yield keyword. Unlike a normal function that returns all values at once, a generator produces one value at a time and automatically remembers its execution state. Generators are easier to write than custom iterators because Python automatically implements the iterator protocol for you.

Creating a Generator

A function becomes a generator as soon as it contains a yield statement.
Output ?
Notice that calling the function does not execute it immediately. Instead, it returns a generator object.

Using next() with a Generator

The next() function starts the generator and retrieves one value at a time.
Output ?
The "Ending" message is not printed because the generator pauses after the third yield. It executes the remaining statements only when resumed again.

Using a Generator with a for Loop

Generators can be directly used in a for loop.
Output ?
The for loop automatically calls next() until the generator raises StopIteration.

Exercise 2

Predict the output.
Output ?
Calling a generator function does not execute its body immediately. It simply creates a generator object. The "Hello" message is printed only when the generator starts executing (for example, by calling next(g) or iterating over it).

Exercise 3

What is the output?
Output ?
The for loop automatically retrieves values from the generator until it is exhausted.

Understanding yield

The yield keyword is used to produce a value from a generator. Unlike return, which terminates a function, yield pauses the function and preserves its current state. The next time the generator is resumed, execution continues from the statement immediately after the previous yield.

yield vs return

Execution Flow

Output ?
Notice that the function resumes exactly where it paused after each yield.

State Preservation

One of the biggest advantages of generators is that they automatically preserve the values of local variables.
Output ?
The variable count is not reinitialized each time. Its value is preserved between successive calls to next().

Multiple yield Statements

A generator can contain multiple yield statements.
Output ?
Each yield produces one value before the generator pauses.

Exercise 2

Predict the output.
Output ?
The value of x is preserved between the two yield statements.

Exercise 3

What is the main difference between return and yield?
  • return terminates the function and returns a value.
  • yield pauses the function, returns a value, preserves its state, and resumes execution when requested again.

Generator Expressions

A generator expression provides a concise way to create generators. It is similar to a list comprehension but uses parentheses () instead of square brackets []. Generator expressions generate values only when required, making them memory efficient.

Syntax

Output ?

Example

Output ?

Generator Expression vs List Comprehension

  • A list comprehension stores all values in memory.
  • A generator expression generates values one at a time.

Exercise 2

Which symbol is used to create a generator expression?
Generator expressions use parentheses (), whereas list comprehensions use square brackets [].

Infinite Generators

Generators can produce infinite sequences because values are generated only when requested.

Example

Output ?


Fibonacci Generator

Generators are commonly used to generate mathematical sequences.

Example

Output ?


Memory Efficiency

One of the biggest advantages of generators is memory efficiency.

List Example

The above statement creates one million values in memory.

Generator Example

The generator creates only one value at a time, significantly reducing memory usage.

When to Use Generators

Use generators when:
  • Working with large datasets.
  • Reading large files.
  • Processing streaming data.
  • Producing values on demand.
  • Creating infinite sequences.


Iterator vs Generator

Remember: Every generator is an iterator, but not every iterator is a generator.

Iterable vs Iterator vs Generator

  • Iterable → An object that can produce an iterator.
  • Iterator → Produces one value at a time.
  • Generator → A special iterator created using the yield keyword.

Real-World Applications

Generators are commonly used for:
  • Reading large files line by line.
  • Processing large datasets.
  • Streaming data from APIs.
  • Log processing.
  • Data pipelines.
  • Machine learning workflows.
  • Infinite sequences.

Example: Reading a File

Output ?
Instead of loading the entire file into memory, one line is processed at a time.

Key Takeaways

  • An iterable is an object that can produce an iterator.
  • An iterator returns one value at a time using next().
  • A generator is a simpler way to create an iterator using yield.
  • The yield keyword pauses execution and preserves the function’s state.
  • Generator expressions provide a concise syntax for creating generators.
  • Generators are ideal for processing large datasets because they use lazy evaluation.
  • Every generator is an iterator, but not every iterator is a generator.

Check Your Understanding

Question 1 What is the purpose of the iter() function?
The iter() function converts an iterable into an iterator.
Question 2 Which special methods make an object an iterator?
__iter__() and __next__()
Question 3 What is the purpose of the yield keyword?
The yield keyword pauses a generator, returns a value, preserves its state, and resumes execution from the same point when requested again.
Question 4 What is the difference between yield and return?
  • return terminates the function.
  • yield pauses the function and allows it to continue later.
Question 5 What is a generator expression?
A generator expression is a concise way to create a generator using parentheses ().
Question 6 Why are generators memory efficient?
Generators create values only when they are requested instead of storing all values in memory.
Question 7 Can generators be used in a for loop?
Yes. A generator is an iterator and can be directly used in a for loop.
Question 8 True or False: Every iterator is a generator.
False. Every generator is an iterator, but not every iterator is a generator.
Question 9 Name two real-world use cases of generators.
Examples include:
  • Reading large files
  • Processing large datasets
  • Streaming API data
  • Log processing
  • Infinite sequences

Context Managers

Whenever we open external resources such as files or database connections, they should be closed properly. Python provides the with statement to handle this automatically.

Without a Context Manager

Output ?
If an exception occurs before close(), the file may remain open.

Using a Context Manager

Output ?
The file is automatically closed after leaving the with block.

Custom Context Manager

A context manager implements two special methods:
  • __enter__()
  • __exit__()

Exercise 1

Open a file using the with statement and display its contents. Sample Input
Expected Output

Exercise 2

Create a context manager that prints "Start" on entry and "End" on exit. Sample Input
Expected Output