Python Higher-Order Functions Practice Problems & Exercises
Practice: Higher-Order Functions
← Back to lessonEasy
Use map() with math.sqrt to compute the square root of each number in the list. Print the result as a list.
import math numbers = [1, 4, 9, 16, 25] roots = list(map(math.sqrt, numbers)) print(roots)
Solution
import math
numbers = [1, 4, 9, 16, 25]
roots = list(map(math.sqrt, numbers))
print(roots)
Output:
[1.0, 2.0, 3.0, 4.0, 5.0]
How it works: map(math.sqrt, numbers) applies math.sqrt to each element in numbers and returns a lazy map object. Wrapping it in list() materializes all the results. Since math.sqrt is already a named function, there is no need for a lambda wrapper — map(math.sqrt, numbers) is cleaner than map(lambda x: math.sqrt(x), numbers).
Key insight: When you already have a named function that matches the signature you need, pass it directly to map. This is the scenario where map is more readable than a list comprehension.
import math
numbers = [1, 4, 9, 16, 25]
# Use map() with math.sqrt to compute square roots
# Print the result as a list
Expected Output
[1.0, 2.0, 3.0, 4.0, 5.0]Hints
Hint 1: map(func, iterable) applies func to each element and returns a lazy iterator.
Hint 2: You need to wrap the result in list() to materialize it.
Hint 3: math.sqrt is already a named function — pass it directly to map without a lambda.
Use filter() to keep only valid-looking email addresses from the list. An address is valid if it contains "@" and has a "." somewhere after the "@".
emails = [
"[email protected]",
"not-an-email",
"[email protected]",
"",
"[email protected]",
"missing-at-sign.com",
]
valid = list(filter(lambda e: "@" in e and "." in e.split("@")[-1], emails))
print(valid)Solution
emails = [
"not-an-email",
"",
"missing-at-sign.com",
]
valid = list(filter(lambda e: "@" in e and "." in e.split("@")[-1], emails))
print(valid)
Output:
How it works: filter() applies the lambda to each element and keeps only those for which it returns True. The lambda checks two conditions with short-circuit and: first that "@" exists in the string, then that "." appears in the domain part (everything after the last "@").
Key insight: filter() returns a lazy iterator just like map(). If you only need to iterate once (e.g., in a for loop), you do not need list(). Converting to a list is only necessary when you need indexed access or want to print the full result.
emails = [
"[email protected]",
"not-an-email",
"[email protected]",
"",
"[email protected]",
"missing-at-sign.com",
]
# Use filter() to keep only strings that contain "@"
# and have a "." after the "@"
# Print the result as a list
Expected Output
Hints
Hint 1: filter(func, iterable) keeps elements where func returns True.
Hint 2: A basic email check: the string must contain "@" and have a "." after the "@".
Hint 3: You can use a lambda or define a named function.
Use functools.reduce() to compute the product of all numbers in the list. Provide an initial value of 1.
from functools import reduce numbers = [1, 2, 3, 4, 5] product = reduce(lambda acc, x: acc * x, numbers, 1) print(product)
Solution
from functools import reduce
numbers = [1, 2, 3, 4, 5]
product = reduce(lambda acc, x: acc * x, numbers, 1)
print(product)
Output:
120
How it works: reduce(func, iterable, initial) starts with acc = 1 (the initial value), then repeatedly applies func(acc, x) for each element. The accumulation proceeds as: 1 * 1 = 1, 1 * 2 = 2, 2 * 3 = 6, 6 * 4 = 24, 24 * 5 = 120.
Why provide an initial value? Without initial, reduce uses the first element as the starting accumulator. That works here, but if the list were empty, it would raise TypeError. Always provide an initial value for safety — 1 for multiplication, 0 for addition, [] for list concatenation.
from functools import reduce
numbers = [1, 2, 3, 4, 5]
# Use reduce() to compute the product of all numbers
# Provide an initial value of 1
# Print the result
Expected Output
120Hints
Hint 1: reduce is in the functools module — import it with: from functools import reduce.
Hint 2: The function takes two arguments: the accumulator and the current element.
Hint 3: For multiplication, the initial value should be 1 (the multiplicative identity).
Use sorted() with a key function to sort the list of words by length (shortest first).
words = ["banana", "apple", "cherry", "kiwi", "grape"] by_length = sorted(words, key=len) print(by_length)
Solution
words = ["banana", "apple", "cherry", "kiwi", "grape"]
by_length = sorted(words, key=len)
print(by_length)
Output:
['kiwi', 'apple', 'grape', 'banana', 'cherry']
How it works: sorted(words, key=len) applies len to each word before comparing. The comparison values are [6, 5, 6, 4, 5], so "kiwi" (4) comes first, then "apple" and "grape" (5), then "banana" and "cherry" (6). Words with equal length retain their original relative order because Python's sort is stable.
Key insight: sorted(), min(), and max() are all higher-order functions because they accept a key= argument that is a function. When the key function already exists as a built-in (like len, str.lower, abs), pass it directly — no lambda needed.
words = ["banana", "apple", "cherry", "kiwi", "grape"]
# Sort the words by their length (shortest first)
# Print the sorted list
Expected Output
['kiwi', 'apple', 'grape', 'banana', 'cherry']Hints
Hint 1: sorted() accepts a key= argument — a function applied to each element before comparison.
Hint 2: Use len as the key function to sort by string length.
Hint 3: sorted() returns a new list and does not modify the original.
Medium
Chain filter() and map() to first keep only languages with more than 4 characters, then convert them to uppercase. Do not create any intermediate lists — use lazy chaining.
languages = ["go", "python", "c", "javascript", "rust", "typescript", "r"] long_langs = filter(lambda s: len(s) > 4, languages) uppered = map(str.upper, long_langs) print(list(uppered))
Solution
languages = ["go", "python", "c", "javascript", "rust", "typescript", "r"]
long_langs = filter(lambda s: len(s) > 4, languages)
uppered = map(str.upper, long_langs)
print(list(uppered))
Output:
['PYTHON', 'JAVASCRIPT', 'TYPESCRIPT']
How it works: filter() returns a lazy iterator that yields only elements where len(s) > 4. map() wraps that iterator and applies str.upper to each yielded element. No intermediate list is created — at any point, only one string is in memory per pipeline stage. The entire chain only executes when list() consumes the final iterator.
Key insight: Lazy chaining with filter and map is memory-efficient for large datasets. Each element flows through the full pipeline before the next element is processed. This is the same principle behind Unix pipes and Spark transformations.
languages = ["go", "python", "c", "javascript", "rust", "typescript", "r"]
# Step 1: filter() to keep only languages with more than 4 characters
# Step 2: map() to convert the filtered results to uppercase
# Step 3: materialize with list() and print
Expected Output
['PYTHON', 'JAVASCRIPT', 'TYPESCRIPT']Hints
Hint 1: Chain filter() then map() — both return lazy iterators.
Hint 2: filter keeps elements where the predicate is True. Use len to check length.
Hint 3: map applies a transformation — str.upper converts a string to uppercase.
Use functools.partial() to create two specialized JSON serializers from json.dumps:
pretty_json— indented with 2 spaces, keys sortedcompact_json— no extra whitespace (separators(",", ":"))
import json
from functools import partial
data = {"name": "Alice", "role": "engineer"}
pretty_json = partial(json.dumps, indent=2, sort_keys=True)
compact_json = partial(json.dumps, separators=(",", ":"))
print(pretty_json(data))
print(compact_json(data))Solution
import json
from functools import partial
data = {"name": "Alice", "role": "engineer"}
pretty_json = partial(json.dumps, indent=2, sort_keys=True)
compact_json = partial(json.dumps, separators=(",", ":"))
print(pretty_json(data))
print(compact_json(data))
Output:
{
"name": "Alice",
"role": "engineer"
}
{"name":"Alice","role":"engineer"}
How it works: partial(json.dumps, indent=2, sort_keys=True) creates a new callable that remembers indent=2 and sort_keys=True. When you call pretty_json(data), it effectively calls json.dumps(data, indent=2, sort_keys=True). The stored keyword arguments are merged with any new arguments provided at call time.
Why partial over lambda? partial objects are picklable (lambdas are not), which matters for multiprocessing. You can also inspect the original function and pre-filled arguments via pretty_json.func, pretty_json.args, and pretty_json.keywords.
import json
from functools import partial
data = {"name": "Alice", "role": "engineer"}
# Create pretty_json: partial of json.dumps with indent=2, sort_keys=True
# Create compact_json: partial of json.dumps with separators=(",", ":")
# Print pretty_json(data)
# Print compact_json(data)
Expected Output
{"name": "Alice", "role": "engineer"}
{"name":"Alice","role":"engineer"}Hints
Hint 1: functools.partial(func, *args, **kwargs) returns a new callable with some arguments pre-filled.
Hint 2: json.dumps accepts indent and sort_keys keyword arguments.
Hint 3: Create two partial functions: one for pretty printing and one for compact output.
Write a function make_multiplier(factor) that returns a new function. The returned function should take a single number and return that number multiplied by factor. Then create triple and quadruple from it.
def make_multiplier(factor):
def multiply(n):
return n * factor
return multiply
triple = make_multiplier(3)
quadruple = make_multiplier(4)
print(triple(5))
print(quadruple(7))
print(make_multiplier(10)(5))Solution
def make_multiplier(factor):
def multiply(n):
return n * factor
return multiply
triple = make_multiplier(3)
quadruple = make_multiplier(4)
print(triple(5)) # 15
print(quadruple(7)) # 28
print(make_multiplier(10)(5)) # 50
Output:
15
28
50
How it works: make_multiplier is a higher-order function because it returns a function. The inner function multiply captures factor from the enclosing scope — this is a closure. Each call to make_multiplier creates a new closure with its own factor value. triple permanently holds factor=3, and quadruple holds factor=4.
Key insight: This is category B of higher-order functions — functions that return functions. The returned function carries state (the factor) without using a class or global variable. This pattern is the foundation of decorators and functools.partial.
# Define make_multiplier(factor) that returns a function
# The returned function should take a number and return number * factor
# Create triple = make_multiplier(3)
# Create quadruple = make_multiplier(4)
# Print triple(5)
# Print quadruple(7)
# Print make_multiplier(10)(5)
Expected Output
15
28
50Hints
Hint 1: A higher-order function can return a new function.
Hint 2: The inner function captures the factor variable from the enclosing scope (closure).
Hint 3: Each call to make_multiplier creates a separate closure with its own factor.
Implement a pipeline() function that takes any number of functions and returns a new function that applies them left-to-right. Use it to build a name-cleaning pipeline that strips, lowercases, replaces spaces with underscores, and adds a "user_" prefix.
def pipeline(*funcs):
def run(value):
for f in funcs:
value = f(value)
return value
return run
clean_name = pipeline(
str.strip,
str.lower,
lambda s: s.replace(" ", "_"),
lambda s: "user_" + s,
)
print(clean_name(" Alice Smith "))Solution
def pipeline(*funcs):
def run(value):
for f in funcs:
value = f(value)
return value
return run
clean_name = pipeline(
str.strip,
str.lower,
lambda s: s.replace(" ", "_"),
lambda s: "user_" + s,
)
print(clean_name(" Alice Smith "))
Output:
user_alice_smith
How it works: pipeline() returns a closure (run) that iterates over the stored functions in order, feeding each result into the next function. The data flows left-to-right: " Alice Smith " is stripped to "Alice Smith", lowered to "alice smith", spaces become underscores "alice_smith", and finally the prefix is added: "user_alice_smith".
Pipeline vs compose: pipeline(f, g, h)(x) gives h(g(f(x))) — left-to-right order matches how you read the code. compose(f, g, h)(x) gives f(g(h(x))) — right-to-left, matching mathematical notation. Most engineers prefer pipeline because the execution order matches the reading order.
def pipeline(*funcs):
# Return a function that applies funcs left-to-right
pass
# Create a name cleaner that:
# 1. Strips whitespace
# 2. Converts to lowercase
# 3. Replaces spaces with underscores
# 4. Adds "user_" prefix
# Test with: " Alice Smith "
Expected Output
user_alice_smithHints
Hint 1: A pipeline applies functions left-to-right: pipeline(f, g, h)(x) means h(g(f(x))).
Hint 2: Use a loop inside the inner function: for each func, apply it to the current value.
Hint 3: The pipeline should accept any number of functions via *funcs.
Hard
Combine map() and reduce() to count the frequency of log levels. First use map to extract the log level from each entry (the word before the colon, lowercased), then use reduce to accumulate them into a frequency dictionary.
from functools import reduce
logs = [
"ERROR: connection timeout",
"WARNING: disk space low",
"ERROR: null pointer",
"INFO: server started",
"WARNING: memory high",
"ERROR: auth failed",
]
levels = map(lambda entry: entry.split(":")[0].strip().lower(), logs)
freq = reduce(
lambda acc, level: dict(list(acc.items()) + [(level, acc.get(level, 0) + 1)]),
levels,
{}
)
print(freq)Solution
from functools import reduce
logs = [
"ERROR: connection timeout",
"WARNING: disk space low",
"ERROR: null pointer",
"INFO: server started",
"WARNING: memory high",
"ERROR: auth failed",
]
levels = map(lambda entry: entry.split(":")[0].strip().lower(), logs)
freq = reduce(
lambda acc, level: dict(list(acc.items()) + [(level, acc.get(level, 0) + 1)]),
levels,
{}
)
print(freq)
Output:
{'error': 3, 'warning': 2, 'info': 1}
How it works: The map step extracts and normalizes log levels: "ERROR: connection timeout" becomes "error". The reduce step accumulates a dictionary. On each step, it creates a new dict with the updated count for the current level. acc.get(level, 0) + 1 safely handles the first occurrence of any level.
Alternative (more readable) approach using a named function:
def count_level(acc, level):
updated = dict(acc)
updated[level] = updated.get(level, 0) + 1
return updated
freq = reduce(count_level, levels, {})
When to use this pattern vs collections.Counter: In production, use Counter(levels) — it is faster and clearer. The reduce approach is valuable when your accumulation logic is more complex than simple counting, or when you need to build a custom data structure incrementally.
from functools import reduce
logs = [
"ERROR: connection timeout",
"WARNING: disk space low",
"ERROR: null pointer",
"INFO: server started",
"WARNING: memory high",
"ERROR: auth failed",
]
# Step 1: Use map() to extract the log level (word before the colon)
# and convert it to lowercase
# Step 2: Use reduce() to build a frequency dict from the log levels
# Initial value should be an empty dict
# Print the frequency dict
Expected Output
{'error': 3, 'warning': 2, 'info': 1}Hints
Hint 1: Use reduce with an initial value of an empty dict.
Hint 2: The accumulator function receives the dict so far and the current log level.
Hint 3: Use dict.get(key, 0) to safely handle keys that do not exist yet.
Implement both compose() and pipeline() using reduce. Then demonstrate that compose(f, g, h) and pipeline(h, g, f) produce the same result for any input.
from functools import reduce
import math
def compose(*funcs):
def composed(value):
return reduce(lambda acc, f: f(acc), reversed(funcs), value)
return composed
def pipeline(*funcs):
def run(value):
return reduce(lambda acc, f: f(acc), funcs, value)
return run
transform = compose(math.sqrt, abs, lambda x: x * x - 100)
print(transform(6))
clean_c = compose(
lambda s: "user_" + s,
lambda s: s.replace(" ", "_"),
str.lower,
str.strip,
)
clean_p = pipeline(
str.strip,
str.lower,
lambda s: s.replace(" ", "_"),
lambda s: "user_" + s,
)
print("result of compose:", clean_c(" Hello World "))
print("result of pipeline:", clean_p(" Hello World "))Solution
from functools import reduce
import math
def compose(*funcs):
"""compose(f, g, h)(x) == f(g(h(x))) — right-to-left."""
def composed(value):
return reduce(lambda acc, f: f(acc), reversed(funcs), value)
return composed
def pipeline(*funcs):
"""pipeline(f, g, h)(x) == h(g(f(x))) — left-to-right."""
def run(value):
return reduce(lambda acc, f: f(acc), funcs, value)
return run
# Test compose: x=6 -> 6*6-100 = -64 -> abs(-64) = 64 -> sqrt(64) = 8.0
transform = compose(math.sqrt, abs, lambda x: x * x - 100)
print(transform(6))
# Both produce the same result — just reversed argument order
clean_c = compose(
lambda s: "user_" + s,
lambda s: s.replace(" ", "_"),
str.lower,
str.strip,
)
clean_p = pipeline(
str.strip,
str.lower,
lambda s: s.replace(" ", "_"),
lambda s: "user_" + s,
)
print("result of compose:", clean_c(" Hello World "))
print("result of pipeline:", clean_p(" Hello World "))
Output:
8.0
result of compose: user_hello_world
result of pipeline: user_hello_world
How it works: Both compose and pipeline use reduce to chain function applications. The only difference is compose reverses the function list so the rightmost function runs first (mathematical convention: f(g(h(x)))), while pipeline applies them in the order given (engineering convention: data flows left-to-right).
The key line: reduce(lambda acc, f: f(acc), funcs, value) starts with value as the initial accumulator, then for each function f in the sequence, it replaces the accumulator with f(acc). This is a fold over functions rather than over data — a powerful pattern.
When to use which: pipeline is more intuitive for data processing (ETL, feature engineering) because the execution order matches reading order. compose is conventional in mathematics and in libraries that follow Haskell-style functional programming.
from functools import reduce
def compose(*funcs):
# compose(f, g, h)(x) == f(g(h(x)))
# Use reduce and reversed to apply right-to-left
pass
def pipeline(*funcs):
# pipeline(f, g, h)(x) == h(g(f(x)))
# Use a for loop to apply left-to-right
pass
# Test compose: sqrt of (1 + double(x)) where x = 3
import math
transform = compose(math.sqrt, lambda x: x + 1, lambda x: x * 2)
# x=3 -> double: 6 -> add 1: 7 -> wait, let's recalculate...
# Actually: compose applies right-to-left
# x=3 -> rightmost first: 3*2=6 -> 6+1=7 -> sqrt(7)?
# Hmm, let's pick: compose(math.sqrt, lambda x: x*x, lambda x: x+1)
# x=3 -> 3+1=4 -> 4*4=16 -> sqrt(16)=4.0...
# Let's just use: compose(math.sqrt, lambda x: x**2, abs)
# x=-3 -> abs: 3 -> square: 9 -> sqrt: 3.0... boring
# Better: sqrt(abs(x*x - 100)) for x=6 -> 36-100=-64 -> abs: 64 -> sqrt: 8
transform = compose(math.sqrt, abs, lambda x: x * x - 100)
print(transform(6))
# Test both compose and pipeline give same result with same order
clean_c = compose(
lambda s: "user_" + s,
lambda s: s.replace(" ", "_"),
str.lower,
str.strip,
)
clean_p = pipeline(
str.strip,
str.lower,
lambda s: s.replace(" ", "_"),
lambda s: "user_" + s,
)
print("result of compose:", clean_c(" Hello World "))
print("result of pipeline:", clean_p(" Hello World "))
Expected Output
8.0
result of compose: user_hello_world
result of pipeline: user_hello_worldHints
Hint 1: compose(f, g, h)(x) means f(g(h(x))) — rightmost function runs first.
Hint 2: Use reduce with reversed(funcs) to apply right-to-left.
Hint 3: For pipeline, apply funcs left-to-right (no reversal needed).
Implement a retry(max_attempts) higher-order function that returns a decorator. The decorator should wrap any function with retry logic: if the function raises an exception, retry up to max_attempts times. Print a message on each failure and on success. If all attempts fail, re-raise the last exception.
from functools import wraps
def retry(max_attempts=3):
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
result = func(*args, **kwargs)
print(f"Attempt {attempt} for {func.__name__}: success")
return result
except Exception as e:
last_error = e
print(f"Attempt {attempt} for {func.__name__} failed: {e}")
raise last_error
return wrapper
return decorator
# Test 1: function that succeeds on attempt 3
call_count = 0
@retry(max_attempts=3)
def fetch_data():
global call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Connection error")
return "data from API"
result = fetch_data()
print(f"Got result: {result}")
# Test 2: function that always fails
@retry(max_attempts=3)
def always_fails():
raise RuntimeError("Service unavailable")
try:
always_fails()
except RuntimeError as e:
print(f"Caught expected error: {e}")Solution
from functools import wraps
def retry(max_attempts=3):
"""Higher-order function: returns a decorator with retry logic."""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_error = None
for attempt in range(1, max_attempts + 1):
try:
result = func(*args, **kwargs)
print(f"Attempt {attempt} for {func.__name__}: success")
return result
except Exception as e:
last_error = e
print(f"Attempt {attempt} for {func.__name__} failed: {e}")
raise last_error
return wrapper
return decorator
call_count = 0
@retry(max_attempts=3)
def fetch_data():
global call_count
call_count += 1
if call_count < 3:
raise ConnectionError("Connection error")
return "data from API"
result = fetch_data()
print(f"Got result: {result}")
@retry(max_attempts=3)
def always_fails():
raise RuntimeError("Service unavailable")
try:
always_fails()
except RuntimeError as e:
print(f"Caught expected error: {e}")
Output:
Attempt 1 for fetch_data failed: Connection error
Attempt 2 for fetch_data failed: Connection error
Attempt 3 for fetch_data: success
Got result: data from API
Attempt 1 for always_fails failed: Service unavailable
Attempt 2 for always_fails failed: Service unavailable
Attempt 3 for always_fails failed: Service unavailable
Caught expected error: Service unavailable
How it works: retry(max_attempts=3) is a higher-order function that returns decorator. decorator takes func and returns wrapper. This is the "decorator factory" pattern — category C of higher-order functions (takes a function and returns a function). The @wraps(func) preserves the original function's __name__ and __doc__, which is why the print statements correctly show fetch_data and always_fails.
Three levels of functions:
retry(max_attempts)— the factory, configures retry behaviordecorator(func)— the decorator, receives the target functionwrapper(*args, **kwargs)— the replacement function that actually runs with retry logic
Production improvements: In real code, you would add exponential backoff (delay * 2 ** attempt), configurable exception types (only retry on network errors, not on ValueError), and logging instead of print statements. Libraries like tenacity provide battle-tested implementations of this pattern.
from functools import wraps
def retry(max_attempts=3):
# Return a decorator that wraps func with retry logic
# On each failed attempt, print which attempt failed
# After all attempts exhausted, re-raise the last exception
pass
# Test 1: function that succeeds on attempt 3
call_count = 0
@retry(max_attempts=3)
def fetch_data():
# Simulates a flaky API
pass
# Test 2: function that always fails
@retry(max_attempts=3)
def always_fails():
raise RuntimeError("Service unavailable")
Expected Output
Attempt 1 for fetch_data failed: Connection error
Attempt 2 for fetch_data failed: Connection error
Attempt 3 for fetch_data: success
Got result: data from API
All 3 attempts for always_fails failed
Caught expected error: Service unavailableHints
Hint 1: retry(max_attempts) is a higher-order function that returns a decorator.
Hint 2: The decorator takes a function and returns a wrapper that catches exceptions and retries.
Hint 3: Use functools.wraps to preserve the original function name and docstring.
