Functional Tools 🧮
Python borrowed a handful of ideas from functional programming. Some are used constantly, some are quietly discouraged, and knowing which is which marks you out.
lambda: a function with no name
square = lambda x: x * x # legal, and not recommended
print(square(5))
def square_properly(x):
return x * x
print(square_properly(5))
# where lambda genuinely earns its place: as a throwaway argument
crew = [("Guybrush", 8), ("Elaine", 3), ("Otis", 12)]
print(sorted(crew, key=lambda pair: pair[1]))
print(max(crew, key=lambda pair: pair[1]))
25
25
[('Elaine', 3), ('Guybrush', 8), ('Otis', 12)]
('Otis', 12)
A lambda is a single expression with an implicit return. No statements, no loops, no
multiple lines. That limitation is deliberate: if you need more, you need a
def.
square = lambda x: x * x gets you a function called <lambda> in every traceback, no docstring, and no annotations. PEP 8 says use def. Lambdas are for the moment you need a function for one line and then never again.
map and filter
numbers = [1, 2, 3, 4, 5, 6]
print(list(map(lambda n: n * n, numbers)))
print(list(filter(lambda n: n % 2 == 0, numbers)))
# the comprehension versions, which most Python programmers prefer
print([n * n for n in numbers])
print([n for n in numbers if n % 2 == 0])
# map is genuinely nice with an existing named function
print(list(map(str.upper, ["grog", "map"])))
print(list(map(int, ["1", "2", "3"])))
[1, 4, 9, 16, 25, 36]
[2, 4, 6]
[1, 4, 9, 16, 25, 36]
[2, 4, 6]
['GROG', 'MAP']
[1, 2, 3]
Guido van Rossum wanted to remove map and filter from Python 3 entirely, on the grounds that comprehensions do the same job more readably. They survived.
The house rule that emerged: use a comprehension when there is a lambda involved, and use map when you already have a named function to apply. list(map(int, parts)) is lovely. list(map(lambda x: x.strip().lower(), parts)) is not.
functools.reduce
from functools import reduce
numbers = [1, 2, 3, 4, 5]
print(reduce(lambda a, b: a + b, numbers))
print(sum(numbers)) # just use this
# reduce earns its place when there is no built-in for the operation
print(reduce(lambda a, b: a * b, numbers))
import math
print(math.prod(numbers)) # since 3.8, so use this too
words = ["the", "rubber", "chicken"]
print(reduce(lambda a, b: a if len(a) > len(b) else b, words))
15
15
120
120
chicken
reduce folds a sequence down to one value. It is powerful, it is famously
hard to read, and Python has built-ins for nearly every common case:
sum, math.prod, max, min,
any, all, "".join. Reach for it only when none of
those fit.
functools.partial: freezing arguments
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
print(square(5), cube(5))
# genuinely useful for callbacks and configuration
def log(level, message):
return f"[{level}] {message}"
info = partial(log, "INFO")
error = partial(log, "ERROR")
print(info("all is well"))
print(error("the hull has failed"))
25 125
[INFO] all is well
[ERROR] the hull has failed
operator: named versions of the symbols
from operator import itemgetter, attrgetter, methodcaller
from dataclasses import dataclass
crew = [{"name": "Otis", "pay": 40}, {"name": "Elaine", "pay": 250}]
print(sorted(crew, key=itemgetter("pay"), reverse=True)[0]["name"])
print(list(map(itemgetter("name"), crew)))
@dataclass
class P:
name: str
pay: int
people = [P("Otis", 40), P("Elaine", 250)]
print(max(people, key=attrgetter("pay")).name)
print(list(map(methodcaller("upper"), ["grog", "map"])))
Elaine
['Otis', 'Elaine']
Elaine
['GROG', 'MAP']
itemgetter("pay") is a slightly faster and arguably clearer
lambda d: d["pay"]. Use whichever your team reads more easily; both are
idiomatic.
Pure functions, and why they are worth preferring
# Impure: reaches outside itself and changes something
total = 0
def add_impure(n):
global total
total += n
return total
# Pure: same input, same output, no side effects, ever
def add_pure(running_total, n):
return running_total + n
print(add_impure(5), add_impure(5)) # different answers, same call
print(add_pure(0, 5), add_pure(0, 5)) # identical, always
5 10
5 5
Pure functions are:
- testable without any setup;
- cacheable, since the answer cannot change (this is exactly why
lru_cacherequires it); - safe to run in parallel, because there is nothing to race over;
- readable, because everything affecting the result is visible in the signature.
You cannot write a whole program this way: something has to touch a file eventually. The practical goal is a pure core with a thin impure shell around it, and that idea will improve your code more than any syntax in this level.
The one that catches everybody
# A classic: building functions in a loop
makers = [lambda: i for i in range(3)]
print([f() for f in makers]) # all 2! not 0, 1, 2
# The fix: bind the value now, with a default argument
makers = [lambda i=i: i for i in range(3)]
print([f() for f in makers])
[2, 2, 2]
[0, 1, 2]
The lambdas captured the variable i, not its value. By the time
they ran, the loop had finished and i was 2. This is called late binding,
it exists in JavaScript too, and the default-argument trick is the standard workaround.
Sort records three ways
Given a list of dictionaries, sort by pay descending, then by name, then by the length of the name, using the tool you find clearest each time.
Reveal solution
from operator import itemgetter
crew = [
{"name": "Guybrush", "pay": 100},
{"name": "Otis", "pay": 40},
{"name": "Elaine", "pay": 250},
]
print([c["name"] for c in sorted(crew, key=itemgetter("pay"), reverse=True)])
print([c["name"] for c in sorted(crew, key=itemgetter("name"))])
print([c["name"] for c in sorted(crew, key=lambda c: len(c["name"]))])
['Elaine', 'Guybrush', 'Otis']
['Elaine', 'Guybrush', 'Otis']
['Otis', 'Elaine', 'Guybrush']Rewrite the functional pile
This works and nobody can read it. Rewrite it clearly.
from functools import reduce
result = reduce(lambda a, b: a + b,
map(lambda x: x * 2,
filter(lambda x: x % 2 == 0, range(1, 11))))Reveal solution
result = sum(n * 2 for n in range(1, 11) if n % 2 == 0)
print(result)
# or, if the steps deserve names
def is_even(n):
return n % 2 == 0
evens = [n for n in range(1, 11) if is_even(n)]
doubled = [n * 2 for n in evens]
print(sum(doubled))
60
60Same answer, and you can see what it means without reading inside-out. Nested map/filter/reduce is the classic sign of someone applying a style rather than solving a problem.
Build a small pipeline
Write compose that takes any number of functions and returns one function applying them left to right. Use it to clean up some text.
Reveal solution
from functools import reduce
def compose(*functions):
"""Return a function applying each of these in order, left to right."""
def piped(value):
return reduce(lambda acc, f: f(acc), functions, value)
return piped
def strip(text):
return text.strip()
def lower(text):
return text.lower()
def collapse_spaces(text):
return " ".join(text.split())
clean = compose(strip, lower, collapse_spaces)
print(repr(clean(" The SECRET of Monkey Island ")))
'the secret of monkey island'This is one of the few places reduce genuinely reads well, because folding a list of functions over a value is the operation. Named pipeline stages also make each step separately testable.