Skip to content

Python Higher-Order Functions

This article covers Python’s advanced functional features: decorators, iterators, generators, lambda expressions, and built-in higher-order functions such as map, filter, and reduce.

Decorators

Decorators add extra functionality to a function without modifying its source code or how it is called. They follow the open/closed principle.

Decorators Without Arguments

import time
import functools

def timer(func):
    @functools.wraps(func)   # preserve the original function's __name__ and __doc__
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

@timer
def slow_add(a: int, b: int) -> int:
    """Return the sum of two numbers."""
    time.sleep(0.1)
    return a + b

print(slow_add(1, 2))   # slow_add took 0.1001s → 3
print(slow_add.__name__)  # slow_add (not wrapper)

Decorators With Arguments

When the decorator itself needs parameters, wrap it in an extra function layer:

def repeat(times: int):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say(message: str) -> None:
    print(message)

say("Hello!")   # prints Hello! three times

Stacking Decorators

When multiple decorators are stacked, they are applied bottom-up (the closest one is applied first) but execute outside-in:

@deco_a
@deco_b
@deco_c
def func(): ...
# equivalent to func = deco_a(deco_b(deco_c(func)))

Class-Based Decorators

A class can also act as a decorator by implementing the __call__ method:

class Retry:
    def __init__(self, max_tries: int = 3):
        self.max_tries = max_tries

    def __call__(self, func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, self.max_tries + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == self.max_tries:
                        raise
                    print(f"Attempt {attempt} failed: {e}, retrying...")
        return wrapper

@Retry(max_tries=3)
def unstable_request(url: str) -> str:
    import random
    if random.random() < 0.7:
        raise ConnectionError("Connection failed")
    return "success"

Common Standard Library Decorators

from functools import cache, lru_cache, cached_property

# @cache: unlimited cache (Python 3.9+, equivalent to @lru_cache(maxsize=None))
@cache
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(50))   # completes instantly

# @lru_cache: Least Recently Used cache
@lru_cache(maxsize=128)
def expensive(x: int) -> int:
    return x ** 2

# @cached_property: property-level cache (computed only once)
class Circle:
    def __init__(self, r: float):
        self.r = r

    @cached_property
    def area(self) -> float:
        import math
        return math.pi * self.r ** 2

Iterators

Iterables vs. Iterators

  • Iterable: implements __iter__() (e.g. list, str, dict).
  • Iterator: implements both __iter__() and __next__(), supports retrieving values one at a time.
lst = [1, 2, 3]

# create an iterator
it = iter(lst)   # equivalent to lst.__iter__()
print(next(it))  # 1
print(next(it))  # 2
print(next(it))  # 3
# next(it)       # StopIteration

# a for loop is essentially: call iter() to get an iterator, then repeatedly call next()
for x in lst:
    print(x)

Custom Iterators

class CountUp:
    def __init__(self, start: int, stop: int):
        self.current = start
        self.stop = stop

    def __iter__(self):
        return self

    def __next__(self) -> int:
        if self.current >= self.stop:
            raise StopIteration
        val = self.current
        self.current += 1
        return val

for n in CountUp(1, 5):
    print(n)   # 1 2 3 4

Generators

Generators are special iterators defined with the yield keyword. They use lazy evaluation, saving memory.

Generator Functions

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

gen = fibonacci()
print([next(gen) for _ in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]

# yield from: delegate to another iterable (Python 3.3+)
def chain(*iterables):
    for it in iterables:
        yield from it

print(list(chain([1, 2], [3, 4], [5])))   # [1, 2, 3, 4, 5]

Generator Expressions

# parentheses → generator (lazy)
gen = (x ** 2 for x in range(1_000_000))

# square brackets → list (evaluated immediately, uses memory)
lst = [x ** 2 for x in range(1_000_000)]

# no extra parentheses needed when passed as a function argument
total = sum(x ** 2 for x in range(100))

send() and Two-Way Communication

def accumulator():
    total = 0
    while True:
        value = yield total   # yield both returns a value and receives an externally sent value
        if value is None:
            break
        total += value

gen = accumulator()
next(gen)          # start the generator, run to the first yield
print(gen.send(10))  # 10
print(gen.send(20))  # 30
print(gen.send(30))  # 60

Lambda Anonymous Functions

# lambda parameters: expression (only a single expression is allowed)
square = lambda x: x ** 2
print(square(5))   # 25

# commonly used with sorted/map/filter
students = [("Alice", 90), ("Bob", 85), ("Charlie", 95)]
sorted_students = sorted(students, key=lambda s: s[1], reverse=True)
# [('Charlie', 95), ('Alice', 90), ('Bob', 85)]
lambda is only suitable for simple expressions. For functions with a name and logic, use def instead — don’t cram complex logic into a lambda.

Built-in Higher-Order Functions

map

# map(func, iterable) → lazy iterator
nums = [1, 2, 3, 4, 5]
squares = list(map(lambda x: x ** 2, nums))
# [1, 4, 9, 16, 25]

# modern style: list comprehension is clearer
squares = [x ** 2 for x in nums]

filter

# filter(func, iterable) → keeps elements where func returns True
evens = list(filter(lambda x: x % 2 == 0, range(10)))
# [0, 2, 4, 6, 8]

# modern style
evens = [x for x in range(10) if x % 2 == 0]

reduce

from functools import reduce

# reduce(func, iterable[, initial])
# accumulates elements in the sequence
product = reduce(lambda x, y: x * y, [1, 2, 3, 4, 5])
# 120 (equivalent to ((((1*2)*3)*4)*5))

total = reduce(lambda acc, x: acc + x, range(1, 101), 0)
# 5050

Recursion

def factorial(n: int) -> int:
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(factorial(10))   # 3628800

# Python's default recursion depth limit is 1000
import sys
print(sys.getrecursionlimit())   # 1000
# sys.setrecursionlimit(5000)    # can be increased, but not recommended

# Optimization tip: use @cache to avoid redundant computation
@cache
def fib(n: int) -> int:
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

Advanced Usage of sorted

data = [
    {"name": "Charlie", "age": 30},
    {"name": "Alice", "age": 25},
    {"name": "Bob", "age": 28},
]

# sort by age ascending
by_age = sorted(data, key=lambda d: d["age"])

# multi-key sort (first by age ascending, then by name alphabetically)
from operator import itemgetter
multi = sorted(data, key=itemgetter("age", "name"))

# operator module (more efficient than lambda)
from operator import attrgetter
# sorted(objects, key=attrgetter("attr1", "attr2"))
Last updated on