top of page

Understanding Functions, Scope, and Closures in Python


Python, like other high-level programming languages, provides a wide range of features that allow for modular, readable, and maintainable code. Among these features are functions, which serve as the building blocks of our programs. This tutorial aims to dive into the intricacies of functions in Python, the concept of scope, and closures. Buckle up and get ready to explore these concepts with engaging explanations, illustrative analogies, and plenty of code examples.


I. Understanding Functions as Objects


In Python, everything is an object - integers, strings, lists, and even functions. You can think of objects as boxes in a warehouse. Some boxes may contain clothes, while others may contain books or electronic gadgets. Similarly, in Python, different objects can contain different types of data, including functions. This unique characteristic opens a whole new world of possibilities.


The Nature of Functions in Python


Let's consider a basic function:

def greet():
    return "Hello, world!"

The function greet() when invoked, returns the string "Hello, world!". But what if we refer to it without the parentheses?

print(greet)

Output:

<function greet at 0x7f4e6f3dfb80>

It shows that the function greet is an object located at a specific memory address.


Assigning Functions to Variables


Let's assign our greet function to a new variable and call it:

hello = greet
print(hello())

Output:

Hello, world!

We see that the function behaves just like any other object and can be assigned to a variable.


Adding Functions to Data Structures


What if we try adding this function to a data structure like a list?

functions = [greet]
print(functions[0]())

Output:

Hello, world!

Just like integers or strings, we can add a function to a list and call it from there.


Referencing a Function


You may have noticed the difference between calling a function with parentheses and referencing it without parentheses.

Imagine a function as a vending machine. Using parentheses is like pressing the button on the machine to get a soda can (invoking the function to get its return value). On the other hand, referring to a function without parentheses is like pointing at the vending machine itself (referencing the function object).

print(greet())  # Hello, world!
print(greet)    # <function greet at 0x7f4e6f3dfb80>


Functions as Arguments


Since functions are objects, we can pass them as arguments to other functions. Consider a function that takes another function as an argument and calls it:

def call_func(func):
    return func()

print(call_func(greet))  # Hello, world!

This concept forms the basis for callbacks, higher-order functions, and decorators in Python.


Nested Functions


Let's take our understanding a step further and define a function within another function:

def outer_func():
    def inner_func():
        return "Hello from the inside!"
    return inner_func()

print(outer_func())  # Hello from the inside!

Nested functions can enhance the readability and efficiency of our code, especially when dealing with complex logic that requires multiple steps.


Functions as Return Values


Finally, a function can return another function as its result:

def outer_func():
    def inner_func():
        return "Hello from the inside!"
    return inner_func

returned_func = outer_func()
print(returned_func())  # Hello from the inside!

Instead of calling inner_func() inside outer_func(), we simply return inner_func without invoking it. The returned function can then be called outside outer_func(), and this is where we start exploring closures in Python, but more on that later.


II. Understanding Scope in Python


Having explored functions in Python, it's time to delve into another critical concept - 'scope'. The scope of a variable defines its visibility throughout the code, and understanding it will help us manage variables efficiently.


Scope Overview


Think of Python scope as a set of nesting boxes. Each box (or scope) can see its own contents and the contents of the boxes it is inside. However, a box cannot directly see what is in another box at the same level or in the outer boxes.


Naming Conventions


The proper naming of variables is crucial in Python as it helps manage variable scope. Python uses the LEGB (Local, Enclosing, Global, Built-in) rule to resolve names.


For instance, consider that we have a hamster named "Hammy". Now, imagine there are hamsters in every scope level, each named "Hammy". When we call out "Hammy", Python will present us with the hamster from the nearest scope level. If no hamster exists in the current scope, Python will go to the enclosing scope to find "Hammy", and so on until it reaches the global or built-in scope. If no "Hammy" is found, a NameError occurs.


Scope Resolution


Let's demonstrate the LEGB rule with some code:

# Global scope
Hammy = "Global Hammy"

def locate_hammy():
    # Enclosing scope
    Hammy = "Enclosing Hammy"

    def inner_locate_hammy():
        # Local scope
        Hammy = "Local Hammy"
        return Hammy

    return inner_locate_hammy()

print(locate_hammy())  # Local Hammy


In this example, Python uses the LEGB rule to resolve the name "Hammy". It starts at the local scope, finds a "Hammy", and presents us with the "Local Hammy".


Understanding Local, Global, and Built-in Scopes


To clarify these concepts, let's consider the following examples:

  1. Local scope: A variable defined inside a function is only visible within that function. It's like your personal diary, visible only to you.

def local_example():
    local_var = "I'm local!"
    print(local_var)

local_example()  # I'm local!
print(local_var)  # Raises a NameError

  1. Global scope: A variable defined outside any function is visible everywhere within the program. It's like a public notice, visible to everyone.

global_var = "I'm global!"

def global_example():
    print(global_var)

global_example()  # I'm global!
print(global_var)  # I'm global!

  1. Built-in scope: This scope contains Python's built-in names, such as predefined functions (print, len, etc.). They're like laws, applicable everywhere.

def built_in_example():
    print(len)  # <built-in function len>

built_in_example()  # <built-in function len>
print(len)  # <built-in function len>


Understanding Nonlocal Scope


The nonlocal scope refers to variables in the nearest enclosing scope that is not global. Imagine a multi-story building. Being on the third floor, you can't directly access the ground floor (global scope), but you can access the second floor (nonlocal scope).

def outer():
    nonlocal_var = "I'm nonlocal!"

    def inner():
        print(nonlocal_var)

    inner()

outer()  # I'm nonlocal!


Using the 'global' Keyword


The 'global' keyword allows us to modify global variables from within a function:

global_var = "I'm global!"

def modify_global():
    global global_var
    global_var = "I've been modified!"

print(global_var)  # I'm global!
modify_global()
print(global_var)  # I've been modified!


Using the 'nonlocal' Keyword


Similarly, the 'nonlocal' keyword allows us to modify variables in the nonlocal scope:

def outer():
    nonlocal_var = "I'm nonlocal!"

    def inner():
        nonlocal nonlocal_var
        nonlocal_var = "I've been modified!"

    inner()
    print(nonlocal_var)

outer()  # I've been modified!


III. Closures in Python


Now that we've established a solid understanding of functions and scope in Python, let's move on to a concept that beautifully intertwines these two: closures. A closure in Python is a function object that remembers values in the enclosing lexical scope, even if they are not present in memory.


Closure Introduction


A closure is like a music box; it plays a song (a function) that retains a melody (nonlocal variables) from the time it was created, even if the original melody (variable) is altered or forgotten.

Closures have three key components:

  1. A nested function (the music): This is the function inside an enclosing function.

  2. Reference to a value in the enclosing scope (the melody): The nested function must use a value that was defined in the enclosing function.

  3. The enclosing function must return the nested function: This is how the melody is kept alive after the music box is closed.

If these conditions are met, the nested function becomes a closure. Let's see how it works in code:

def enclosing_func(x):
    def nested_func(y):
        return x + y  # x is a nonlocal variable used by nested_func
    return nested_func  # enclosing_func returns nested_func

closure = enclosing_func(10)
print(closure(5))  # Outputs: 15


In this case, nested_func is a closure that remembers the value of x from its enclosing scope, even after enclosing_func has finished execution.


Attaching Nonlocal Variables to Nested Functions


But how does Python attach nonlocal variables to a function object? Let's demonstrate this:

def enclosing_func(x):
    def nested_func(y):
        return x + y
    return nested_func

closure = enclosing_func(10)
print(closure.__closure__)  # Outputs: (<cell at 0x..., int object at 0x...>,)
print(closure.__closure__[0].cell_contents)  # Outputs: 10


Here, Python uses the __closure__ attribute to attach nonlocal variables to the nested function. The cell_contents of the first (and only) cell in __closure__ contains the value of x.


The Impact of Deleting or Overwriting Variables


Closures maintain access to variables that have been deleted or overwritten in the original scope. It's like a time capsule, preserving a snapshot of the environment at the moment of its creation.

def enclosing_func(x):
    def nested_func(y):
        return x + y
    return nested_func

closure = enclosing_func(10)
del x
print(closure(5))  # Outputs: 15


Despite the deletion of x, closure can still access its value, as it was preserved at the moment closure was created.


Key Definitions


Let's recap the key concepts before moving on:

  • Nested functions: Functions defined inside other functions.

  • Nonlocal variables: Variables defined in the nearest enclosing scope (not global).

  • Closures: Nested functions that remember and have access to variables from their enclosing scope, even if they are not present in memory.


The Importance of these Concepts


Understanding these concepts is fundamental for using advanced Python features like decorators, which we'll explore in future tutorials. Decorators are a powerful tool that allows us to wrap a function with another function to extend the behavior of the wrapped function, and they heavily rely on the concept of closures.

bottom of page