avatarYang Zhou

Summary

The functools module in Python provides powerful tools for enhancing code efficiency, maintainability, and professionalism through features like caching, property caching, type-specific method implementations, and metadata preservation.

Abstract

The functools module in Python is highlighted as a crucial toolset for developers aiming to write clean, efficient, and professional code. It includes utilities such as functools.cache for avoiding repetitive computations, functools.cached_property for making Python classes more efficient by caching expensive computations, and functools.total_ordering for simplifying the implementation of comparison methods in classes. Additionally, functools.partial allows for the customization of built-in functions with pre-defined parameters, while functools.singledispatch enables the definition of generic functions with type-specific implementations. The module also offers functools.reduce for processing iterables cumulatively, and functools.wraps for preserving the metadata of original functions when using decorators. The article emphasizes that mastering functools can significantly improve a Python developer's code quality and maintainability.

Opinions

  • The author suggests that Python's performance issues are often due to repetitive computations rather than the language's inherent speed, and functools.cache can address this.
  • The use of functools.cached_property is presented as a best practice for Python classes that perform costly value computations.
  • functools.total_ordering is touted as a smart and efficient way to implement comparison methods in Python classes, reducing the need for extensive if-else conditionals.
  • The author implies that using functools.partial to create customized versions of built-in functions can lead to cleaner and more readable code.
  • functools.singledispatch is described as an elegant solution for handling functions with parameters of different types, aligning with Python's philosophy of simplicity and beauty.
  • The article conveys that functools.reduce can significantly simplify code by processing iterables with a higher-order function, avoiding the need for explicit loops.
  • The author emphasizes the importance of using functools.wraps in decorators to maintain the original function's metadata, thus preventing potential bugs and ensuring code integrity.
  • Overall, the author expresses that familiarity with the functools module is essential for Python developers to write more sophisticated and maintainable code.

Python

7 Uses of Python Functools That Make Your Code More Professional

Elegance is the only beauty that never fades

Image from Wallhaven

One of the many advantages of Python is its abundant built-in modules which save us programmers from reinventing the wheel.

The functools module is a good example. Leveraging it well will make our Python pretty neat, clean, and professional.

This article will introduce 7 must-know uses of this outstanding module of Python. After reading, your position as “the Python guru” will be cemented for sure.

1. functools.cache: Avoid Replicate Computing

Python is not as slow as many people described. In fact, a common factor contributing to slow execution speeds is the repetitive computation rather than the language.

I acknowledge that the following Python code, which measures the time required to compute the 30th Fibonacci number over 50 iterations, is considerably slower than its Rust counterpart:

import time
from functools import cache

def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)


def main(test_times=50):
    start = time.time()
    for _ in range(test_times):
        fib(30)
    print(f"Total time spent: {time.time() - start} s")


main()
# Total time spent: 7.455092906951904 s

Applying Rust to rewrite the time-consuming fib function is a good idea, but not everyone is fond of using two languages for one project.

Since the root of all evil is the replicate computations. Why not cache the middle values?

There comes the @functools.cache decorator in Python. As clear as its name, it will help us do the caching stuff and speed up the whole execution of the code:

import time
from functools import cache


@cache
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)


def main(test_times=50):
    start = time.time()
    for _ in range(test_times):
        fib(30)
    print(f"Total time spent: {time.time() - start} s")


main()
# Total time spent: 1.5974044799804688e-05 s

1.5974044799804688e-05 s! Do you still think Python is slow?

2. functools.cached_property: Make a Python Class More Efficient

For Python classes with costly value computation methods, a practical approach is to cache the results as typical attributes, allowing it to persist for the instance’s entire existence.

The functools.cached_property, which was introduced by Python 3.8, is designed to help us do this easily.

from functools import cached_property


class Circle:
    def __init__(self, radius):
        self.radius = radius

    @cached_property
    def area(self):
        print("Calculating area...")
        return 3.14159265359 * self.radius ** 2

    @cached_property
    def circumference(self):
        print("Calculating circumference...")
        return 2 * 3.14159265359 * self.radius


# Create a Circle object
my_circle = Circle(5)

# Access the area property
print(my_circle.area)  # This computes and caches the area
print(my_circle.area)  # This retrieves the cached area

# Access the circumference property
print(my_circle.circumference)  # This computes and caches the circumference
print(my_circle.circumference)  # This retrieves the cached circumference

As the above example shows, there is no need to repeatedly calculate the area and circumference of my_circle each time as long as it has been calculated once. With the help of the simple Python decorator, @cached_property, our program is much more efficient and professional.

3. functools.total_ordering: Define One Comparison and Let Python Do the Rest

If there is something that can understand existing code and generate the rest of the code for you, you probably think I’m talking about ChatGPT.

Surprisingly, Python can do this as well in some cases.

The functools.total_ordering decorator is what I’m saying. Due to this, we can merely define one or more rich comparison ordering methods for a Python class, and this decorator will supply the rest intelligently. (Of course, it’s not AI, but the results are as intelligent as it is).

from functools import total_ordering


@total_ordering
class Leader:
    def __init__(self, first_name, last_name, age):
        self.first_name = first_name
        self.last_name = last_name
        self.age = age

    def __lt__(self, other):
        return self.age < other.age


leader1 = Leader("Yang", "Zhou", 30)
leader2 = Leader("Elon", "Musk", 52)

print(leader1 < leader2)  # True
print(leader1 > leader2)  # False
print(leader1 == leader2)  # False

As demonstrated above, we just wrote one method for defining the “less than” relationship between two instances. However, thanks to the total_ordering decorator, Python is smart enough to know the “equal” and “larger than” comparisons already. This simplifies our code so much.

4. functools.partial: Customize Python Built-In Functions for Convenience

Python has many easy-to-use built-in functions, but sometimes we have to add specific parameters to them for special cases.

For instance, the int() function is to convert a string into an integer. If the string represents a binary integer, we must add the second argument for it:

print(int('10101', base=2))
# 21

It’s annoying if we need to use this function frequently but always write the same second parameter repeatedly.

The partial method from the functools is here to make our code clean again:

from functools import partial

basetwo = partial(int, base=2)
print(basetwo('10101'))
# 21
print(basetwo('1111111'))
# 127
print(basetwo('100101101'))
# 301

As the above code shows, we can define a “customized” Python built-in function with the pre-defined parameters. So anytime we need to use it again, we can just call the new function instead of the original one.

5. functools.singledispatch: Define a Generic Python Function and its Overloaded Implementations

Python is a dynamic typing language, which is convenient for us to write neater code.

However, sometimes we have to consider the type of a function’s parameter. For example, the following function connect() needs to know how to handle different types of receiving address:

def connect(address):
    if isinstance(address, str):
        ip, port = address.split(':')
    elif isinstance(address, tuple):
        ip, port = address
    else:
        print('Wrong address!')

Without a doubt, we can use many if-else conditions to handle different address formats. However, too much if-else code for a simple function is a symbol for ugly.

“Beautiful is better than ugly.” This is the first sentence of “The Zen of Python”. So Python definitely should provide an elegant solution for this scenario.

A beautiful approach came from functools since Python 3.4 — singledispatch.

from functools import singledispatch


@singledispatch
def connect(address):
    print('Wrong address format!')


@connect.register
def _(address: str):
    ip, port = address.split(':')
    print(f'IP:{ip}, Port:{port}')


@connect.register
def _(address: tuple):
    ip, port = address
    print(f'IP:{ip}, Port:{port}')


connect('yangzhou1993.medium.com:443')
# IP:yangzhou1993.medium.com, Port:443
connect(('yangzhou1993.medium.com', 443))
# IP:yangzhou1993.medium.com, Port:443
connect(2077)
# Wrong address format!

As shown above, we can use the @singledispatch decorator to define a generic function. Then using the register() attribute of the generic function as a decorator to its overloaded implementations. Each implementation handles one specific type of parameter. Finally, when we call the connect() function, Python will automatically find the proper implementation for execution.

Isn’t this method more professional than using numerous ‘if-else’ statements?

6. functools.reduce: Handle a Python Iterable Cumulatively

Reduce is one of the significant higher-order functions of Python which provides much convenience for developers to write elegant code.

It applies one function of two arguments cumulatively to the items of an iterable, from left to right, so as to reduce the iterable to a single result.

Talk is cheap, let’s see an intuitive example:

from functools import reduce

chars = ['L', 'o', 'n', 'd', 'o', 'n', 2, 0, 7, 7]
city = reduce(lambda x, y: str(x) + str(y), chars)
print(city)
# London2077

As the above program shows, with the help of functools.reduce function, we merely used one line of code to combine the characters into a whole string, no for-loops are needed.

7. functools.wraps: Keep the Metadata of Original Functions

Decorators in Python can help us write more low-coupling code. However, they are not easy to handle. Sometimes we may meet unexpected issues in normal code:

def add_author(func):
    def wrapper(*args, **kwargs):
        author = 'Yang Zhou'
        return author + '\n' + func(*args, **kwargs)
    return wrapper

@add_author
def get_title(title):
    """
    A func that receives and returns a title.
    """
    return title


print(get_title('7 Uses of Python Functools That Make Your Code More Professional'))
# Yang Zhou
# 7 Uses of Python Functools That Make Your Code More Professional
print(get_title.__name__)
# wrapper
print(get_title.__doc__)
# None

The above code successfully defined and applied the decorator @add_author to the function get_title.

However, it seems strange that it can’t print the right information of the get_title functions. This would result in serious bugs in production systems because you probably would call a wrong function if you can’t even get a correct name.

This is a side effect of using decorators. You will not only “wrap” the function, but also its metadata, such as name, doc, and so on.

To avoid it, we can write the correct metadata manually for each wrapped function. But there is a much easier way of using functools.wraps:

from functools import wraps


def add_author(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        author = 'Yang Zhou'
        return author + '\n' + func(*args, **kwargs)

    return wrapper


@add_author
def get_title(title):
    """
    A func that receives and returns a title.
    """
    return title


print(get_title('7 Uses of Python Functools That Make Your Code More Professional'))
# Yang Zhou
# 7 Uses of Python Functools That Make Your Code More Professional
print(get_title.__name__)
# get_title
print(get_title.__doc__)
# A func that receives and returns a title.

As demonstrated above, functools.wraps is a valuable tool for safeguarding the original function’s metadata. In my view, it is a good practice for Python developers to incorporate the @wraps decorator into every wrapper function to prevent unexpected outcomes.

Conclusion

The functools module in Python can not only streamline our code but also make it more professional and maintainable.

As Python developers, mastering the art of using functools effectively can make a significant difference in our programming journey.

Thanks for reading ❤️, feel free to connect with me:

X | Linkedin | Medium

References:

Python
Programming
Technology
Data Science
Software Development
Recommended from ReadMedium