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 timesStacking 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 ** 2Iterators
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 4Generators
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)) # 60Lambda 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)
# 5050Recursion
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