Level 4 · Pythonic

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.

🏷️ Never assign a lambda to a name

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]
RHETORIC[Medium: Success]

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:

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.

Exercise 1

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']
Exercise 2

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
60

Same 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.

Exercise 3

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.

+100 XP