Python Return Semantics Practice Problems & Exercises
Practice: Return Semantics
← Back to lessonEasy
Write a function that returns the absolute value of an integer without using the built-in abs(). This tests the most basic return pattern: computing a value and sending it back to the caller.
Test with inputs -5, 0, and 7.
def absolute_value(n):
if n < 0:
return -n
return n
print(absolute_value(-5))
print(absolute_value(0))
print(absolute_value(7))Solution
def absolute_value(n):
if n < 0:
return -n
return n
Every path through the function must reach a return statement. Here the negative case returns early, and the non-negative case falls through to the final return. The RETURN_VALUE bytecode instruction pops the result from the evaluation stack and hands it to the caller. If you forget the second return, Python implicitly returns None for non-negative inputs.
def absolute_value(n):
"""Return the absolute value of n without using abs().
If n is negative, return -n. Otherwise return n.
"""
# TODO: implement
passExpected Output
5
0
7Hints
Hint 1: Check whether n is less than zero.
Hint 2: If negative, return -n (negation flips the sign). Otherwise return n as-is.
Two functions are provided: add_print prints the sum, add_return returns it. Write demonstrate_difference that calls both with (3, 4), captures each return value, and returns them as a tuple.
This reveals the fundamental difference: print sends text to the console and the function returns None; return sends a value to the caller.
def add_print(a, b):
print(a + b)
def add_return(a, b):
return a + b
def demonstrate_difference():
result_print = add_print(3, 4)
result_return = add_return(3, 4)
return (result_print, result_return)
print(demonstrate_difference())Solution
def demonstrate_difference():
result_print = add_print(3, 4)
result_return = add_return(3, 4)
return (result_print, result_return)
add_print outputs 7 to the console but returns None. add_return returns the integer 7 without printing anything. The tuple (None, 7) proves that print is a side effect, not a return value. This is the most common beginner mistake: using print inside a function when you need return, then wondering why the caller gets None.
def add_print(a, b):
"""This function PRINTS the sum. Do not change it."""
print(a + b)
def add_return(a, b):
"""This function RETURNS the sum. Do not change it."""
return a + b
def demonstrate_difference():
"""Call both functions with (3, 4).
Capture the return value of each into variables
result_print and result_return.
Return a tuple: (result_print, result_return)
"""
# TODO: implement
passExpected Output
7
(None, 7)Hints
Hint 1: Assign the result of add_print(3, 4) to a variable. What value does it hold?
Hint 2: Functions that print but do not return produce None. Functions that return give you a usable value.
Write a function that takes a list of numbers and returns the minimum, maximum, and arithmetic mean as a "multiple return value" (which is actually a tuple).
Test by unpacking into three variables and also by checking type() on the raw return value.
def min_max_mean(numbers):
return min(numbers), max(numbers), sum(numbers) / len(numbers)
lo, hi, avg = min_max_mean([1, 5, 3, 9, 2])
print(lo)
print(hi)
print(avg)
print(type(min_max_mean([1, 5, 3, 9, 2])))Solution
def min_max_mean(numbers):
return min(numbers), max(numbers), sum(numbers) / len(numbers)
return a, b, c constructs one tuple (a, b, c) and returns it. The disassembly would show BUILD_TUPLE 3 followed by RETURN_VALUE. When you write lo, hi, avg = min_max_mean(...), that is tuple unpacking on the caller side, completely separate from the return mechanism. The raw return value is always a single tuple object.
def min_max_mean(numbers):
"""Return the minimum, maximum, and mean of a list of numbers.
Return them as a single expression: min, max, mean.
The caller should be able to unpack into three variables.
"""
# TODO: implement
passExpected Output
1
9
5.0
<class 'tuple'>Hints
Hint 1: Use return min_val, max_val, mean_val — the comma builds a tuple automatically.
Hint 2: mean = sum(numbers) / len(numbers)
Write a function that splits a list into its first element and the remaining elements. Handle the empty-list edge case by returning (None, []).
Test with [1, 2, 3, 4, 5], [], and [42].
def head_tail(lst):
if not lst:
return (None, [])
first, *rest = lst
return first, rest
print(head_tail([1, 2, 3, 4, 5]))
print(head_tail([]))
print(head_tail([42]))Solution
def head_tail(lst):
if not lst:
return (None, [])
first, *rest = lst
return first, rest
Extended unpacking with *rest captures all remaining elements as a list. For [42], first is 42 and *rest captures an empty list []. The guard clause at the top prevents a ValueError from unpacking an empty sequence. Note that return first, rest returns a tuple (first, rest) where the second element is always a list, keeping the return type consistent.
def head_tail(lst):
"""Return the first element and the rest of the list.
If the list is empty, return (None, []).
Use extended unpacking (star expression) for non-empty lists.
"""
# TODO: implement
passExpected Output
(1, [2, 3, 4, 5])
(None, [])
(42, [])Hints
Hint 1: For an empty list, return (None, []) as a guard clause.
Hint 2: For a non-empty list, use first, *rest = lst and then return first, rest.
Medium
The function below has deeply nested conditionals. Rewrite it using guard clauses so the happy path has zero nesting. Every invalid input should return -1 via an early return.
Test with: (100, "regular", None), (100, "premium", None), (100, "vip", "SAVE5"), (-10, "regular", None), (100, "unknown", None).
def calculate_discount(price, customer_type, coupon_code):
if price <= 0:
return -1
if customer_type not in ('regular', 'premium', 'vip'):
return -1
discounts = {'regular': 0, 'premium': 0.10, 'vip': 0.20}
final = price * (1 - discounts[customer_type])
if coupon_code == 'SAVE5':
final = final * 0.95
return round(final, 2)
print(calculate_discount(100, "regular", None))
print(calculate_discount(100, "premium", None))
print(calculate_discount(100, "vip", "SAVE5"))
print(calculate_discount(-10, "regular", None))
print(calculate_discount(100, "unknown", None))Solution
def calculate_discount(price, customer_type, coupon_code):
if price <= 0:
return -1
if customer_type not in ('regular', 'premium', 'vip'):
return -1
discounts = {'regular': 0, 'premium': 0.10, 'vip': 0.20}
final = price * (1 - discounts[customer_type])
if coupon_code == 'SAVE5':
final = final * 0.95
return round(final, 2)
Guard clauses validate inputs at the top and return early on failure. The happy path runs at the base indentation level with zero nesting. This reduces cyclomatic complexity: each guard is an independent, testable check. Compare this to nesting if price > 0: if customer_type in ...: if ... which creates a deeply indented pyramid that is harder to read and modify.
def calculate_discount(price, customer_type, coupon_code):
"""Refactor using guard clauses. Return the final price.
Rules:
- price must be positive (return -1 for invalid)
- customer_type must be 'regular', 'premium', or 'vip'
(return -1 for invalid)
- discount: regular=0%, premium=10%, vip=20%
- coupon_code 'SAVE5' gives extra 5% off (applied after tier discount)
- Return the final discounted price rounded to 2 decimals
"""
# TODO: rewrite with guard clauses (no nesting)
passExpected Output
100.0
90.0
76.0
-1
-1Hints
Hint 1: Put all validation checks at the top, each returning -1 immediately if the condition fails.
Hint 2: After the guards, the happy path is flat: look up discount, check coupon, compute price.
Write a function that tests common list methods and determines which ones return None (in-place mutators) vs a useful value. For each operation in the input list, run it on a fresh copy of [3, 1, 4, 1, 5] and record whether the return value is None.
def find_none_traps(operations):
results = {}
args_map = {
'sort': (), 'append': (99,), 'reverse': (),
'pop': (), 'extend': ([6, 7],), 'remove': (1,),
}
for op in operations:
sample = [3, 1, 4, 1, 5]
method = getattr(sample, op)
result = method(*args_map[op])
results[op] = result is None
return results
ops = ['sort', 'append', 'reverse', 'pop', 'extend', 'remove']
print(find_none_traps(ops))Solution
def find_none_traps(operations):
results = {}
args_map = {
'sort': (), 'append': (99,), 'reverse': (),
'pop': (), 'extend': ([6, 7],), 'remove': (1,),
}
for op in operations:
sample = [3, 1, 4, 1, 5]
method = getattr(sample, op)
result = method(*args_map[op])
results[op] = result is None
return results
Five of the six operations return None because they mutate the list in place. Only pop returns a useful value (the removed element). This is Python's Command-Query Separation convention: methods that change state return nothing; methods that compute return a value. The classic bug is sorted_list = my_list.sort() which gives you None instead of a sorted list.
def find_none_traps(operations):
"""Given a list of strings representing list operations,
return which ones produce None when their return value
is captured.
Test these operations on a sample list [3, 1, 4, 1, 5]:
'sort', 'append', 'reverse', 'pop', 'extend', 'remove'
Return a dict mapping operation name to True if it
returns None, False otherwise.
"""
# TODO: implement
passExpected Output
{'sort': True, 'append': True, 'reverse': True, 'pop': False, 'extend': True, 'remove': True}Hints
Hint 1: Create a fresh copy of the sample list for each operation so mutations do not interfere.
Hint 2: Call getattr(lst, op)(...) with appropriate arguments: append needs a value, extend needs a list, remove needs an element, pop takes no args.
The provided lookup_user_broken returns three different types: bool, NoneType, and dict. Write lookup_user_fixed that raises ValueError for invalid input, returns None for not-found, and returns the user dict when found. This gives a clean dict | None return type.
Test with a sample user list.
def lookup_user_fixed(users, user_id):
if user_id < 0:
raise ValueError(
"user_id must be non-negative, got " + str(user_id)
)
for u in users:
if u['id'] == user_id:
return u
return None
users = [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
result = lookup_user_fixed(users, 1)
if result is not None:
print("Found:", result['name'])
result = lookup_user_fixed(users, 99)
if result is None:
print("Not found")
try:
lookup_user_fixed(users, -1)
except ValueError as e:
print("Caught:", e)Solution
def lookup_user_fixed(users, user_id):
if user_id < 0:
raise ValueError(
"user_id must be non-negative, got " + str(user_id)
)
for u in users:
if u['id'] == user_id:
return u
return None
Never return different types for the same semantic case. The broken version forces callers to check is False, then is None, then assume dict -- a three-way branch that static analysis tools cannot verify. The fixed version uses exceptions for misuse (invalid ID) and None for expected absence (user not in list). The caller only needs one check: if result is None. This is the standard T | None pattern that type checkers understand.
def lookup_user_broken(users, user_id):
"""BROKEN: returns different types.
- Returns False if user_id is negative
- Returns None if user not found
- Returns the user dict if found
DO NOT MODIFY THIS FUNCTION.
"""
if user_id < 0:
return False
for u in users:
if u['id'] == user_id:
return u
return None
def lookup_user_fixed(users, user_id):
"""FIXED version: raise ValueError for invalid input,
return None if not found, return the user dict if found.
Return type is consistently dict or None.
"""
# TODO: implement
passExpected Output
Found: Alice
Not found
Caught: user_id must be non-negative, got -1Hints
Hint 1: Replace the False return with a raised ValueError for invalid input.
Hint 2: The function should only ever return dict or None, never bool.
Define a TextStats NamedTuple with fields word_count, char_count, avg_word_length, and longest_word. Then write analyze_text that returns a TextStats instance.
Show that the result supports both attribute access and tuple unpacking.
from typing import NamedTuple
class TextStats(NamedTuple):
word_count: int
char_count: int
avg_word_length: float
longest_word: str
def analyze_text(text):
if not text.strip():
return TextStats(0, 0, 0.0, '')
words = text.split()
wc = len(words)
cc = sum(len(w) for w in words)
avg = round(cc / wc, 2)
longest = max(words, key=len)
return TextStats(wc, cc, avg, longest)
stats = analyze_text("the quick brown fox jumped high")
print(stats)
print(stats.word_count)
print(stats.longest_word)
print(isinstance(stats, tuple))Solution
from typing import NamedTuple
class TextStats(NamedTuple):
word_count: int
char_count: int
avg_word_length: float
longest_word: str
def analyze_text(text):
if not text.strip():
return TextStats(0, 0, 0.0, '')
words = text.split()
wc = len(words)
cc = sum(len(w) for w in words)
avg = round(cc / wc, 2)
longest = max(words, key=len)
return TextStats(wc, cc, avg, longest)
NamedTuple is the right choice when returning 3+ related values with semantic meaning. Unlike a plain tuple, stats.longest_word is self-documenting. Unlike a dict, the fields are typed and order-guaranteed. NamedTuples are still tuples under the hood, so isinstance(stats, tuple) returns True and you can unpack them: wc, cc, avg, lw = stats. Use NamedTuple for public APIs and plain tuples for transient, two-element returns like coordinates.
from typing import NamedTuple
# TODO: define a NamedTuple class called TextStats
# Fields: word_count (int), char_count (int),
# avg_word_length (float), longest_word (str)
def analyze_text(text):
"""Analyze the given text and return a TextStats NamedTuple.
Words are split on whitespace.
avg_word_length is rounded to 2 decimal places.
If text is empty, return TextStats(0, 0, 0.0, '').
"""
# TODO: implement
passExpected Output
TextStats(word_count=6, char_count=28, avg_word_length=4.67, longest_word='jumped')
6
jumped
TrueHints
Hint 1: Define TextStats as a class inheriting from NamedTuple with four typed fields.
Hint 2: Split on whitespace, compute each stat, and return a TextStats instance.
Hard
Build a validation system using factory functions. make_range_validator(name, lo, hi) returns a validator function. make_pipeline(validators_dict) returns a function that validates an entire dictionary of values against their respective validators.
This tests returning callables, closures, and composing functions.
def make_range_validator(name, lo, hi):
def validate(value):
if not (lo <= value <= hi):
raise ValueError(
name + ": " + str(value) +
" is not in range [" + str(lo) + ", " + str(hi) + "]"
)
return value
return validate
def make_pipeline(validators):
def run(data):
result = {}
for field, validator in validators.items():
result[field] = validator(data[field])
return result
return run
validate_age = make_range_validator("age", 0, 150)
print(validate_age(25))
try:
validate_age(200)
except ValueError as e:
print("Caught:", e)
pipeline = make_pipeline({
'age': make_range_validator("age", 0, 150),
'score': make_range_validator("score", 0, 100),
})
print(pipeline({'age': 25, 'score': 87}))
try:
pipeline({'age': 25, 'score': 150})
except ValueError as e:
print("Caught:", e)Solution
def make_range_validator(name, lo, hi):
def validate(value):
if not (lo <= value <= hi):
raise ValueError(
name + ": " + str(value) +
" is not in range [" + str(lo) + ", " + str(hi) + "]"
)
return value
return validate
def make_pipeline(validators):
def run(data):
result = {}
for field, validator in validators.items():
result[field] = validator(data[field])
return result
return run
Factory functions return new functions that close over their creation parameters. Each call to make_range_validator produces a distinct closure with its own captured name, lo, and hi. make_pipeline composes these into a higher-order function. This is the foundation of decorators and dependency injection in Python. The key insight is that return validate returns the function object itself, not the result of calling it.
def make_range_validator(name, lo, hi):
"""Return a function that validates whether a value
is within [lo, hi] inclusive.
The returned function should:
- Return the value if valid
- Raise ValueError with a message including the field
name, the value, and the valid range
"""
# TODO: implement
pass
def make_pipeline(*validators):
"""Return a function that runs a dict of values
through the corresponding validators.
validators is a dict mapping field names to validator functions.
The returned function takes a dict, validates each field,
and returns a dict of validated values.
If any validator raises, let the exception propagate.
"""
# TODO: implement
passExpected Output
25
Caught: age: 200 is not in range [0, 150]
{'age': 25, 'score': 87}
Caught: score: 150 is not in range [0, 100]Hints
Hint 1: make_range_validator returns an inner function that closes over name, lo, and hi.
Hint 2: make_pipeline takes a dict of validators and returns a function that applies each to the corresponding field.
Implement Fibonacci two ways: one using return (builds full list), one using yield (lazy generator). Then write compare_types that demonstrates the behavioral difference.
Show that both produce the same values for n=8, but have different types and the generator is single-use.
def fibonacci_return(n):
fibs = []
a, b = 0, 1
for _ in range(n):
fibs.append(a)
a, b = b, a + b
return fibs
def fibonacci_yield(n):
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b
def compare_types():
ret = fibonacci_return(5)
gen = fibonacci_yield(5)
type_ret = str(type(ret))
type_gen = str(type(gen))
consumed = list(gen)
exhausted = list(gen) == []
return (type_ret, type_gen, exhausted)
print(fibonacci_return(8))
print(list(fibonacci_yield(8)))
print(compare_types())Solution
def fibonacci_return(n):
fibs = []
a, b = 0, 1
for _ in range(n):
fibs.append(a)
a, b = b, a + b
return fibs
def fibonacci_yield(n):
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b
def compare_types():
ret = fibonacci_return(5)
gen = fibonacci_yield(5)
type_ret = str(type(ret))
type_gen = str(type(gen))
consumed = list(gen)
exhausted = list(gen) == []
return (type_ret, type_gen, exhausted)
return sends one value and terminates the function. yield suspends the function and produces a value, resuming on the next iteration. A function with yield becomes a generator function -- calling it returns a generator object, not the values themselves. The generator is lazy (computes values on demand) and single-use (once exhausted, iterating again produces nothing). Use return when you need all values at once; use yield when the sequence is large or infinite, or when the caller may not need all values.
def fibonacci_return(n):
"""Return a LIST of the first n Fibonacci numbers.
Uses return -- builds the entire list in memory.
"""
# TODO: implement
pass
def fibonacci_yield(n):
"""YIELD the first n Fibonacci numbers one at a time.
Uses yield -- lazy, generates on demand.
"""
# TODO: implement
pass
def compare_types():
"""Return a tuple of:
- type of fibonacci_return(5) as a string
- type of fibonacci_yield(5) as a string
- whether fibonacci_yield(5) is exhausted after
converting to list (bool)
"""
# TODO: implement
passExpected Output
[0, 1, 1, 2, 3, 5, 8, 13]
[0, 1, 1, 2, 3, 5, 8, 13]
("<class 'list'>", "<class 'generator'>", True)Hints
Hint 1: fibonacci_return builds a list with append and returns it at the end.
Hint 2: fibonacci_yield uses yield inside a loop to produce values lazily. A generator is exhausted after one full iteration.
Implement the Result pattern with Ok and Err dataclasses. Add a map method that chains operations: Ok.map(f) applies f and wraps the result; Err.map(f) propagates the error unchanged.
Build a pipeline: parse_int -> validate_positive -> double that gracefully handles errors at any stage.
from dataclasses import dataclass
@dataclass
class Ok:
value: object
is_ok: bool = True
def map(self, func):
try:
return Ok(func(self.value))
except Exception as e:
return Err(str(e))
@dataclass
class Err:
error: str
is_ok: bool = False
def map(self, func):
return self
def parse_int(text):
try:
return Ok(int(text))
except ValueError:
return Err("Cannot parse '" + text + "' as int")
def validate_positive(n):
if n <= 0:
raise ValueError("Value must be positive, got " + str(n))
return n
def double(n):
return n * 2
print(parse_int("42").map(validate_positive).map(double))
print(parse_int("abc").map(validate_positive).map(double))
print(parse_int("-5").map(validate_positive).map(double))
print(parse_int("10").map(validate_positive).map(double))Solution
from dataclasses import dataclass
@dataclass
class Ok:
value: object
is_ok: bool = True
def map(self, func):
try:
return Ok(func(self.value))
except Exception as e:
return Err(str(e))
@dataclass
class Err:
error: str
is_ok: bool = False
def map(self, func):
return self
def parse_int(text):
try:
return Ok(int(text))
except ValueError:
return Err("Cannot parse '" + text + "' as int")
def validate_positive(n):
if n <= 0:
raise ValueError("Value must be positive, got " + str(n))
return n
def double(n):
return n * 2
The Result pattern makes failure explicit in the return type. Ok.map(f) applies the function and wraps the result; if f raises, it catches the exception and converts it to Err. Err.map(f) ignores f entirely and propagates the error. This lets you chain operations with .map().map().map() and only check for errors at the end. The pattern is borrowed from Rust's Result and Haskell's Either. It is most valuable at system boundaries -- parsing config, handling user input, making API calls -- where failures are expected and should not unwind the call stack.
from dataclasses import dataclass
@dataclass
class Ok:
value: object
is_ok: bool = True
def map(self, func):
# TODO: apply func to self.value
# If func raises, return Err with the error message
pass
@dataclass
class Err:
error: str
is_ok: bool = False
def map(self, func):
# TODO: propagate error unchanged
pass
def parse_int(text):
"""Parse text to int. Return Ok or Err."""
# TODO: implement
pass
def validate_positive(n):
"""Return n if positive, raise ValueError otherwise."""
# TODO: implement
pass
def double(n):
"""Return n * 2."""
# TODO: implement
passExpected Output
Ok(value=84, is_ok=True)
Err(error="Cannot parse 'abc' as int", is_ok=True)
Err(error='Value must be positive, got -5', is_ok=True)
Ok(value=20, is_ok=True)Hints
Hint 1: Ok.map should try/except: on success return Ok(func(value)), on exception return Err(str(e)).
Hint 2: Err.map should return self unchanged -- errors short-circuit the chain.
Hint 3: Chain with: parse_int(text).map(validate_positive).map(double)
