Lambda expressions and closures

Lambda expressions

Lambda expressions are anonymous functions (they have no name). They are defined using the lambda keyword. They are generally discouraged because they make debugging hard and code less readable, but sometimes they are necessary, so it is worth knowing how to create them.

Lambda expressions allow shortening simple functions that only return a value (the return statement is implicit in lambdas).


l = lambda a, b: a + b
print(l(5, 5))    
                                    

We can also use them directly in the print() method.


print((lambda a, b: a * 2 + b * 2)(5, 6))
                                    

# Checking the squares of numbers in a given range and sorting them ​​from smallest to largest
list1 = sorted(range(-3, 12), key = lambda x: x ** 2)
print(list1)
                                    

# Sorting a list by the second element of each tuple inside it
data = [(1, 3), (2, 1), (3, 2)]
data.sort(key = lambda x: x[1])
print(data)

# Sorting by first element, then by second element
data2 = [(1, 3), (2, 1), (3, 2), (2, 0)]
data.sort(key = lambda x: (x[0], x[1])) # adding a minus before "x[0]" would cause sorting by first element in descending order, then by second element in ascending order
print(data)
                                    

Using lambdas, we can easily sort objects of our classes inside a list.


class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __repr__(self):
        return f"{self.name}, {self.age}"

people = [Person("Alice", 30), Person("Bob", 25), Person("Charlie", 35)]

sorted_people = sorted(people, key = lambda p: p.age) # sorting by ages
names = [p.name for p in sorted_people] # getting only the names

print(sorted_people)
print(names)
                                    

Closures

A closure is a function inside a function that retains access to variables from its enclosing scope, even after the outer function has finished executing. This behavior occurs because the inner function "remembers" the environment in which it was created. Closures allow for the creation of functions with private variables and can be used to preserve variable states across multiple function calls. They are also used for creating function factories, memoization, and function composition.

inner_function() from the example below is a closure because it uses the x variable from the enclosing scope of outer_function(). Even after outer_function() has finished executing, inner_function() retains access to x.


def outer_function(x): # the enclosing function
    def inner_function(y): # the closure function
        return x + y
    return inner_function

add_five = outer_function(5) # creating a closure
print(add_five(3)) # calling the closure (output: 8)
                                    

Creating functions with private variables

The private variable below can be accessed only through the inner method (encapsulation like in OOP).


def outer():
    private = "Private"
    def inner():
        return private
    return inner

private_function = outer()
print(private_function())
                                    

Preserving variable states across multiple function calls


def counter():
    count = 0 # this variable is preserved across all calls
    def increment():
        nonlocal count # referring to the "count" variable in the enclosing scope
        count += 1
        return count
    return increment

counter_func = counter() # creating a closure

# Calling the closure multiple times
print(counter_func()) # 1
print(counter_func()) # 2
print(counter_func()) # 3
                                    

Using lambdas as closure functions

The lambda expression below remembers the n variable from the enclosing function() even after it has finished executing.


def function(n):
    return lambda x: x + n      
f = function(10)
print(f(11))
                                    

Function factories

A function factory is a higher-order function that creates and returns other functions, customized based on the input parameters provided to the factory.


def power_of(exponent):
    return lambda base: base ** exponent

square = power_of(2)
cube = power_of(3)

print(square(5))
print(cube(3))
                                    

Function composition

Function compositions are used to compose multiple functions together, where the output of one function becomes the input of another.


def compose(f, g):
    return lambda x: f(g(x))

def double(x):
    return x * 2

def add_three(x):
    return x + 3

composed_function = compose(double, add_three)
print(composed_function(5)) # 16 (5 + 3 = 8, 8 * 2 = 16)
                                    

Memoization will be discussed in a lesson about writing clean code.