sort() and sorted() (info) are used to sort lists in their natural order. Both use the __lt__ rich comparison method to compare the different objects in the list. In a custom implementation, you should overload that operator (example here; see the model solution for a “better” method) to make those functions work as intended. There are other methods to sort collections though, which are discussed below.

Functions as arguments

The sorted() function (and sort() as well) take a key argument which is a function that is used to compare items based on a different criterion.
Functions can also be defined within another function to keep their scope limited. These are usually helper functions which are not used anywhere else in the program (ex. functions for the key parameter discussed before).
ex.:

def sort_by_remaining_stock(items: list[tuple]):
    """ A helper is defined inside this function to help sort items by remaining stock """
    def order_by_remaining_stock(item: tuple):
        return item[2]
    return sorted(items, key=order_by_remaining_stock, reverse=True)

For sorting collections in your own classes, you could either use the __lt__ method as described earlier, or use something like in the above example, or even a lamnbda expression.

Lambda Expressions

Lambda expressions allow you to create small, anonymous functions which are created (and discarded) as they are needed in the code. The general syntax is as follows:
lambda <parameters> : <expression>
ex.

def most_goals(ballplayers: list[BallPlayer]) -> str:
    return (sorted(ballplayers, key= lambda player: player.goals)[-1]).name

another ex. which involves functions as arguments within your own function, (criterion is what you’d call a callable argument):

def copy_lines(source_file: str, target_file: str, criterion= lambda x: True):
    with open(source_file) as source, open(target_file, "w") as target:
        for line in source:
            # Remove any whitespace from beginning and end of line
            line = line.strip()
            if criterion(line):
                target.write(line + "\n")
 
if __name__ == "__main__":
    # If the third parameter is not given, copy all lines
    copy_lines("first.txt", "second.txt")
    # Copy all non-empty lines
    copy_lines("first.txt", "second.txt", lambda line: len(line) > 0)
    # Copy all lines which contain the word "Python"
    copy_lines("first.txt", "second.txt", lambda line: "Python" in line)

Decorators

Decorators allow you to modify or extend the behavior of functions and methods without changing their actual code. A decorator is a function that takes another function as an input and returns a modified version of it without changing its own code, so that you can add behavior before or after a wrapped function is run.
ex. a regular decorator:

def decorator(function: callable):
    """ Returns a wrapper function that executes the current function """
    def wrapper():
        print("Something is happening before the function is called")
        result = function()
        print("Something is happening after the function was called")
        return result
    return wrapper
 
def hello():
    print("Hello!")
    return 42
 
# Put simply, a decorator wraps a function, modifying its behavior.
if __name__ == "__main__":
    dec = decorator(hello) # a reference to the wrapper function defined inside the decorator is assigned to dec. So dec now points to the wrapper inner function
    dec() # dec, and hence the wrapper, is called via parantheses
    # adding @decorator before hello() is just doing the dec = decorator(hello) and dec() steps with syntactic sugar.

The above decorator has a wrapper that does not take any arguments, so executing functions that have arguments is not possible as you can’t call them with the arguments.You can add support for positional and keyword arguments by using *args and **kwargs respectively in the wrapper arguments.
ex. the timer decorator:

import time
 
def timer(function: callable):
    """ Returns a wrapper function that executes the current function """
    def wrapper(*args, **kwargs):
        t1 = time.perf_counter()
        result = function(*args, **kwargs)
        t2 = time.perf_counter()
        print(f"Time taken: {t2 - t1}")
        return result
 
    return wrapper
 
@timer
def add(a: int, b: int):
    time.sleep(1)
    return a + b
 
if __name__ == "__main__":
    add(56, 63)

The standard practice for writing your own decorators is using functools.wraps which preserves information about the original callable. People usually follow this format:

import functools
def decorator(function):
    @functools.wraps(function)
    def wrapper(*args, **kwargs):
	# ...

A

Positional arguments are matched by their order in a function call, the first value fills the first parameter, the secnod fills the second parameter, and so on, ex. add(21, 42). Keyword arguments, on the other hand, are matched by name regardless of order, ex. add(a=21, b=42). Once a keyword argument appears in a call every argument after it must also be a keyword argument because Python can’t resume positional counting once it’s been interrupted by name matching. * is being used here for tuple unpacking. args is just a name to represent the variable number of positional parameters, you are free to use *my_args or *integers. ** is being used here for dictionary unpacking that accepts keyword or named arguments. The unpacking operator is discussed in Object Oriented Programming section Misc.

Note that in def wrapper(*args, **kwargs) the *s are packing arguments into a tuple or dictionary on the definition side, while in function(*args, **kwargs) they are being used for unpacking on the call side.

Generators

Generators are a way of producing the next item in a series when needed, where the generation is run only once. A generator function remembers its state and returns the next item in the series. A generator function must contain the keyword yield, which marks out the value which the function returns. yield is similar to return but it doesn’t “close” the function in the same sense as return.

NOTE: traversing a generator using a for loop only works if the generator terminates at some point. It just never terminates unless you break.
ex.

def prime_numbers():
    num = 2
    while num >= 2:
        if is_prime(num):
            yield num
        num += 1
 
def is_prime(num: int):
    for x in range(2, num):
        if num % x == 0:
            return False
    return True
 
if __name__ == "__main__":
    numbers = prime_numbers()
    for i in range(9):
        print(next(numbers))

Generator comprehensions can be implemented using round brackets.
ex.

squares = (x ** 2 for x in range(1, 64))

another ex. for a generator that counts till a certain limit:

def counter(max_value: int):
    """ Generator function for a counter """
    number = 0
    while number <= max_value:
        yield number
        number += 1
 
def get_nth(gen: Generator, n: int):
    for i in range(0, n-1):
        next(gen)
    return next(gen)
 
if __name__ == "__main__":
    numbers = counter(10)
    n = 9
    print(f"{n}th value: ")
    print(get_nth(numbers, n))

Functional programming

Functional programming refers to a programming paradigm which avoids changes in program state as much as possible. Variables are generally avoided. Instead, chains of function calls form the backbone of the program. In this we use lambda expressions and different types of comprehensions as they let you process data without storing it in variables.

A

Imperative programming refers to a paradigm where a program consists of a sequence of commands which is executed in order. In procedural programming the program is grouped into procedures or sub-programs. In object oriented programming a program and its state are stored in objects defined in classes. In functional programming we avoid changes to the state as much as possible.

Now we will look at some functional programming tools in python.

The map function

It executes some operation on each item in an iterable series. The general syntax is: map(<function>, <series>) where function is the operation we want to execute on each item in the series. The map function returns an iterator object of type map which can be converted to a list. Refer to the documentation.
NOTE: An iterator object is “depleted” when we pass through it using a for loop, much like a generator is depleted when a maximum value is reached. To traverse it multiple times you need to convert it to something else, like a set or a list.
ex.:

    mapped = map(lambda string: f"{string[0].upper()}{string[1:]}", my_list)
    # returns the first letter capitalised versions of strings in my_list

another ex.

def course_names(attempts: list[CourseAttempt]):
    return sorted(set(map(lambda course: course.course_name, attempts)))
    # retrieve the value of course name from the list and convert it to a set to remove duplicates. Now, sort it.

The filter function

This function is used to filter items using a criterion function; if the criterion function returns true the item is selected. The return value here is also an iterator which can get depleted, so convert that to list or something else to traverse it more than once. General syntax: filter(<function>, <series>).
ex.

def passed_students(attempts: list[CourseAttempt], course: str):
    """ Alphabetically sorted list of students who passed a particular course, but only including their names and nothing else """
    return sorted(map(lambda attempt: attempt.student_name, filter(lambda course_attempt: course_attempt.grade > 0 and course_attempt.course_name == course, attempts)))

The reduce function

future me: a good problem on reduce and filter that I solved
The reduce function from the functools module is used to reduce the items in a series to a single value. It starts with an operation and an initial value and then performs the operation on each item in series in turn, so that the value changes at each step. Once all items are processed, the reduced value is returned.
The arguments here are the reducing function, the list, and the initial sum. The third one is not necessary as if it’s omitted the function starts reducing from the second item in the list onwards.
It’s to be noted that if the items in the series are of a different type than the intended reduced result, the third argument becomes mandatory.
ex.:

my_list = [2, 2, 4, 3, 5, 2]
product_of_list = reduce(lambda product, item: product * item, my_list, 1)

Misc. and other utilities

These are not strictly related to functional programming and what we were discussing before, but are still some useful functions.

zip: it iterates over several iterables in parallel, producing tuples with an item from each one.
ex.

for item in zip([1, 2, 3], ['apple', 'banana', 'orange']):
    print(item)
# (1, 'apple')
# (2, 'banana')
# (3, 'orange')