Advanced · Lesson 54 of 94

Functional tools

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Functions in Python are values. You can store one in a variable, put it in a list, pass it to another function, and return it from one; decorators and closures already relied on that. The functional tools are the built-ins and the functools module that make working with functions as values convenient. They do not replace loops and classes; they shorten the places where you would otherwise write a tiny function only to hand it to something.

lambda creates a function without a name in one expression: lambda x: x * 2. It has no statements, no docstring, and no name in tracebacks, which is why the rule is short: use a lambda when the function is trivial and used once, right where it is written, most often as a key=. The moment it needs a second line, a name, or a test, write a def. Assigning a lambda to a name, square = lambda x: x * x, is the worst of both worlds; write def square(x). lambda is one of a small family of tools that take or return functions, and the table beside it is the whole family: what each one does, and the plainer thing to reach for when it does not fit.

Tool Does Prefer instead when
lambda a: expr one-expression function, inline it needs a name, a statement, or a test
map(f, items) lazy f(x) for each item a comprehension reads more clearly
filter(pred, items) lazy items where pred(x) is truthy a comprehension with if
sorted(items, key=f) order by f(x) (this is the idiomatic use)
functools.partial(f, a) f with some arguments pre-filled a lambda would need to restate them
functools.lru_cache memoize a pure function's results inputs are unhashable or unbounded
functools.reduce(f, xs) fold items into one value sum, max, any, or a loop exists
functools.singledispatch choose an implementation by argument type an isinstance chain would grow

map and filter are the lazy ancestors of comprehensions: map(str.upper, words) yields each word upper-cased, filter(None, items) yields the truthy ones. Comprehensions are usually clearer, but map shines when you already have the function, especially a method like str.strip, and both are iterators you can feed to join or sum. key= functions for sorted, min, and max are where most lambdas live, and the operator module supplies ready-made ones: itemgetter("price") and attrgetter("name") beat lambda p: p["price"] for both speed and readability.

Functional tools: items → map(f, ·) → filter(pred, ·) → sorted(·, key=k) → result → def f, def pred, lambda k
Functional tools · Figure 1 · Open for full size

functools.partial takes a function and some of its arguments and returns a new function waiting for the rest: partial(int, base=2) is a binary parser. It is the right tool for adapting a function to an interface that calls with fewer arguments. functools.lru_cache (and its unbounded form cache) remembers the return value for each set of arguments, turning an expensive pure function, a recursive one, or a repeated lookup into a dictionary hit; it requires hashable arguments and a function whose result depends only on them.

functools.singledispatch turns a function into one that picks an implementation based on the type of its first argument, registered with @name.register(type). It is polymorphism for functions when you cannot or should not add methods to the classes involved, such as built-in types or classes from a library, and it replaces the isinstance chain the polymorphism lesson warned about. One call goes in, and the type of the first argument decides which registered implementation runs.

Functional tools: describe(x) → describe_int → describe_str → describe_list → default describe
Functional tools · Figure 2 · Open for full size

functools.reduce folds a sequence into one value with a two-argument function: it calls f(acc, item) for every item, carrying the running result forward. It exists, and you should know it, but sum, max, "".join, and a plain loop cover almost every case more readably.

Code

lambda in its natural habitat, key=, and operator doing the same job with a name. map and filter beside the comprehensions that usually replace them.

# keys_and_maps.py
from operator import itemgetter, attrgetter

products = [{"name": "pen", "price": 1.5}, {"name": "book", "price": 12.0}, {"name": "ink", "price": 6.0}]

print(sorted(products, key=lambda p: p["price"])[0]["name"])
print(max(products, key=itemgetter("price"))["name"])
print([p["name"] for p in sorted(products, key=itemgetter("name"))])

words = ["  Ada ", "grace", " LINUS"]
print(list(map(str.strip, words)))
print(list(map(str.upper, map(str.strip, words))))
print(list(filter(None, [0, "", "x", None, 3])))
print([w.strip() for w in words if len(w.strip()) > 3])
Output
pen
book
['book', 'ink', 'pen']
['Ada', 'grace', 'LINUS']
['ADA', 'GRACE', 'LINUS']
['x', 3]
['grace', 'LINUS']

partial and lru_cache: a pre-filled function, and a recursive function that becomes fast because every result is remembered.

# partial_cache.py
import functools
import time

parse_binary = functools.partial(int, base=2)
print(parse_binary("1010"), parse_binary("1111"))

log_error = functools.partial(print, "[ERROR]", sep=" ")
log_error("disk full")


@functools.lru_cache(maxsize=None)
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)


start = time.perf_counter()
print(fib(80))
print("fast:", time.perf_counter() - start < 0.5)
print(fib.cache_info().hits > 0, fib.cache_info().currsize)
Output
10 15
[ERROR] disk full
23416728348467685
fast: True
True 81

singledispatch replacing an isinstance chain, with a default for unregistered types.

# dispatch.py
from functools import singledispatch


@singledispatch
def describe(value):
    return f"something of type {type(value).__name__}"


@describe.register(int)
def _(value):
    return f"an integer, {'even' if value % 2 == 0 else 'odd'}"


@describe.register(str)
def _(value):
    return f"a string of {len(value)} characters"


@describe.register(list)
def _(value):
    return f"a list of {len(value)} items"


for thing in (42, "hello", [1, 2, 3], 3.5):
    print(describe(thing))
Output
an integer, even
a string of 5 characters
a list of 3 items
something of type float

reduce next to the built-ins that usually replace it. Both compute the same values; the second column is what you should write.

# reduce.py
import functools
import operator

numbers = [3, 7, 2, 9]

print(functools.reduce(operator.add, numbers), "==", sum(numbers))
print(functools.reduce(lambda a, b: a if a > b else b, numbers), "==", max(numbers))
print(functools.reduce(operator.mul, numbers))
print(functools.reduce(lambda acc, s: acc + [s.upper()], ["a", "b"], []), "==", [s.upper() for s in ["a", "b"]])
Output
21 == 21
9 == 9
378
['A', 'B'] == ['A', 'B']

Try it yourself

Create functional.py with a list of employee dictionaries with name, department, and salary. Sort them by department then salary descending using itemgetter, find the highest-paid person with max and a key, use map to produce a list of "name (department)" strings, and filter to keep salaries above a threshold. Then write a cached slow_square(n) that sleeps briefly and show the second call of the same argument is instant. Finally write a format_value with singledispatch for int, float, and str.

Common mistakes

What's next

Your function toolkit is complete. Next you will make the with statement work for your own resources, with context managers.

Course overview