Python Defining Functions Practice Problems & Exercises
Practice: Defining Functions
← Back to lessonEasy
Write a function greet that takes a single parameter name and returns the string "Hello, <name>! Welcome aboard.".
This tests the most fundamental pattern: defining a function with def, accepting a parameter, and returning a value.
def greet(name):
return f"Hello, {name}! Welcome aboard."
# Test
print(greet("Alice"))
print(greet("Bob"))Solution
def greet(name):
return f"Hello, {name}! Welcome aboard."
Key points:
defcreates the function object at runtime and binds it to the namegreet.- The
returnstatement sends the string back to the caller. Without it, the function returnsNone. - f-strings (
f"...") embed expressions inside curly braces at runtime.
def greet(name):
# TODO: Return the string "Hello, <name>! Welcome aboard."
pass
# Test
print(greet("Alice"))
print(greet("Bob"))Expected Output
Hello, Alice! Welcome aboard.
Hello, Bob! Welcome aboard.Hints
Hint 1: Use an f-string to embed the name parameter into the greeting.
Hint 2: Make sure you return the string, not print it.
Write two functions:
rectangle_area(width, height)— returnswidth * heightcircle_area(radius)— returns3.14159 * radius * radius
This practices defining multiple functions with different parameter counts and returning numeric results.
def rectangle_area(width, height):
return width * height
def circle_area(radius):
return 3.14159 * radius * radius
# Test
print(rectangle_area(5, 3))
print(circle_area(10))Solution
def rectangle_area(width, height):
return width * height
def circle_area(radius):
return 3.14159 * radius * radius
Key points:
- Functions can take any number of parameters, separated by commas.
- Each function is an independent object on the heap — defining
circle_areadoes not affectrectangle_area. - Return values can be any Python object (here,
intandfloat).
def rectangle_area(width, height):
# TODO: Return the area of a rectangle
pass
def circle_area(radius):
# TODO: Return the area of a circle (use 3.14159 for pi)
pass
# Test
print(rectangle_area(5, 3))
print(circle_area(10))Expected Output
15
314.159Hints
Hint 1: Rectangle area is width multiplied by height.
Hint 2: Circle area is pi * radius * radius. Use 3.14159 as the value of pi.
Assign the function square to a new variable named sq without calling it. Both sq(4) and square(4) should return 16, and sq is square should be True.
This tests your understanding that functions are first-class objects — a function name without () is just a reference to the object.
def square(x):
return x * x
sq = square
# Test
print(sq(4))
print(sq(7))
print(sq is square)Solution
sq = square
Key points:
sq = squarebinds a new name to the same function object. No copy is made.sq is squarereturnsTruebecause both names reference the identical object in memory.sq = square()would be wrong — that callssquarewith no argument and raisesTypeError.
def square(x):
return x * x
# TODO: Assign the function 'square' to a new name 'sq'
# (do NOT call square — just reference it)
sq = None
# Test
print(sq(4))
print(sq(7))
print(sq is square)Expected Output
16
49
TrueHints
Hint 1: Assign the function object itself: sq = square (no parentheses).
Hint 2: sq and square should point to the exact same object, so 'sq is square' should be True.
Write a function celsius_to_fahrenheit that:
- Has the docstring
"Convert Celsius temperature to Fahrenheit." - Returns the Fahrenheit value using the formula:
celsius * 9 / 5 + 32
After calling the function, inspect its __name__ and __doc__ attributes to verify they are set correctly.
def celsius_to_fahrenheit(celsius):
"""Convert Celsius temperature to Fahrenheit."""
return celsius * 9 / 5 + 32
# Test
print(celsius_to_fahrenheit(0))
print(celsius_to_fahrenheit(100))
print(celsius_to_fahrenheit.__name__)
print(celsius_to_fahrenheit.__doc__)Solution
def celsius_to_fahrenheit(celsius):
"""Convert Celsius temperature to Fahrenheit."""
return celsius * 9 / 5 + 32
Key points:
- The docstring is stored in
__doc__and is accessible at runtime viafunc.__doc__orhelp(func). __name__is automatically set bydefto the function's name as a string.- These are real attributes on the function object — you can inspect and even modify them.
def celsius_to_fahrenheit(celsius):
# TODO: Add a one-line docstring, then implement the conversion
# Formula: fahrenheit = celsius * 9/5 + 32
pass
# Test
print(celsius_to_fahrenheit(0))
print(celsius_to_fahrenheit(100))
print(celsius_to_fahrenheit.__name__)
print(celsius_to_fahrenheit.__doc__)Expected Output
32.0
212.0
celsius_to_fahrenheit
Convert Celsius temperature to Fahrenheit.Hints
Hint 1: A docstring is the first string literal in the function body, written in triple quotes.
Hint 2: The formula is: celsius * 9 / 5 + 32. Make sure to return the result.
Medium
Build a dispatch table — a dictionary that maps operator strings to functions — and a calculate function that uses it.
- Create a dict
opsmapping"+","-","*","/"to the corresponding function objects. - Write
calculate(a, op, b)that looks upopinopsand calls the function. Return"Unknown operator"ifopis not found.
This is the same pattern used in interpreters, CLI tools, and API route dispatchers.
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
return "Error: division by zero"
return a / b
ops = {
"+": add,
"-": subtract,
"*": multiply,
"/": divide,
}
def calculate(a, op, b):
if op not in ops:
return "Unknown operator"
return ops[op](a, b)
# Test
print(calculate(10, "+", 3))
print(calculate(10, "-", 3))
print(calculate(10, "*", 3))
print(calculate(10, "/", 3))
print(calculate(10, "/", 0))
print(calculate(10, "%", 3))Solution
ops = {
"+": add,
"-": subtract,
"*": multiply,
"/": divide,
}
def calculate(a, op, b):
if op not in ops:
return "Unknown operator"
return ops[op](a, b)
Key points:
ops["+"]retrieves the function objectadd.ops["+"](10, 3)calls it.- Dict lookup is O(1), making this faster and cleaner than a chain of
if/elifstatements. - This is the dispatch table pattern — the foundation of plugin systems, command routers, and strategy pattern.
def add(a, b):
return a + b
def subtract(a, b):
return a - b
def multiply(a, b):
return a * b
def divide(a, b):
if b == 0:
return "Error: division by zero"
return a / b
# TODO: Create a dictionary called 'ops' that maps
# "+", "-", "*", "/" to the corresponding functions above.
ops = {}
def calculate(a, op, b):
# TODO: Look up 'op' in the ops dict and call the function.
# If op is not found, return "Unknown operator".
pass
# Test
print(calculate(10, "+", 3))
print(calculate(10, "-", 3))
print(calculate(10, "*", 3))
print(calculate(10, "/", 3))
print(calculate(10, "/", 0))
print(calculate(10, "%", 3))Expected Output
13
7
30
3.3333333333333335
Error: division by zero
Unknown operatorHints
Hint 1: The ops dict maps string keys to function objects: ops = {"+": add, "-": subtract, ...}.
Hint 2: In calculate, check if op is in ops first. If yes, call ops[op](a, b). If not, return the error string.
Write a higher-order function apply_to_list(func, items) that takes a function and a list, applies the function to every element, and returns a new list of results.
This is essentially what Python's built-in map() does under the hood. Test it with user-defined functions and a built-in (len).
def apply_to_list(func, items):
return [func(item) for item in items]
def double(x):
return x * 2
def negate(x):
return -x
def to_upper(s):
return s.upper()
# Test
print(apply_to_list(double, [1, 2, 3, 4]))
print(apply_to_list(negate, [10, -5, 3]))
print(apply_to_list(to_upper, ["hello", "world"]))
print(apply_to_list(len, ["cat", "elephant", "go"]))Solution
def apply_to_list(func, items):
return [func(item) for item in items]
Key points:
apply_to_listis a higher-order function — it takes a function as an argument.- Inside the list comprehension,
func(item)calls whatever function was passed in. - Built-in functions like
lenare also first-class objects and work identically here. - This is the same principle behind
map(func, iterable),sorted(data, key=func), andfilter(func, iterable).
def apply_to_list(func, items):
# TODO: Apply 'func' to each element in 'items'.
# Return a new list of results.
pass
def double(x):
return x * 2
def negate(x):
return -x
def to_upper(s):
return s.upper()
# Test
print(apply_to_list(double, [1, 2, 3, 4]))
print(apply_to_list(negate, [10, -5, 3]))
print(apply_to_list(to_upper, ["hello", "world"]))
print(apply_to_list(len, ["cat", "elephant", "go"]))Expected Output
[2, 4, 6, 8]
[-10, 5, -3]
['HELLO', 'WORLD']
[3, 8, 2]Hints
Hint 1: Use a list comprehension: [func(item) for item in items].
Hint 2: The last test passes the built-in 'len' as the function — built-in functions are first-class too.
Add type annotations to all three functions — annotate every parameter and the return type. The logic is already implemented; your job is to add the type hints.
After running, check __annotations__ on each function to verify the annotations were stored correctly.
def clamp(value: float, low: float, high: float) -> float:
"""Clamp a value between low and high bounds."""
if value < low:
return low
if value > high:
return high
return value
def repeat_string(text: str, times: int) -> str:
"""Return text repeated 'times' times, separated by spaces."""
return " ".join([text] * times)
def is_palindrome(word: str) -> bool:
"""Check if a word reads the same forwards and backwards."""
cleaned = word.lower().replace(" ", "")
return cleaned == cleaned[::-1]
# Test
print(clamp(15, 0, 10))
print(clamp(-3, 0, 10))
print(clamp(5, 0, 10))
print(repeat_string("hey", 3))
print(is_palindrome("racecar"))
print(is_palindrome("hello"))
# Verify annotations exist
print(clamp.__annotations__)
print(repeat_string.__annotations__)
print(is_palindrome.__annotations__)Solution
def clamp(value: float, low: float, high: float) -> float:
...
def repeat_string(text: str, times: int) -> str:
...
def is_palindrome(word: str) -> bool:
...
Key points:
- Type annotations use the syntax
param: Typefor parameters and-> Typefor return values. - Python stores annotations in
func.__annotations__as a dictionary mapping names to types. - Annotations are not enforced at runtime — they are metadata for tools like mypy, IDEs, and documentation generators.
- The
returnkey in__annotations__holds the return type annotation.
# TODO: Add type annotations to ALL parameters and return types.
def clamp(value, low, high):
"""Clamp a value between low and high bounds."""
if value < low:
return low
if value > high:
return high
return value
def repeat_string(text, times):
"""Return text repeated 'times' times, separated by spaces."""
return " ".join([text] * times)
def is_palindrome(word):
"""Check if a word reads the same forwards and backwards."""
cleaned = word.lower().replace(" ", "")
return cleaned == cleaned[::-1]
# Test
print(clamp(15, 0, 10))
print(clamp(-3, 0, 10))
print(clamp(5, 0, 10))
print(repeat_string("hey", 3))
print(is_palindrome("racecar"))
print(is_palindrome("hello"))
# Verify annotations exist
print(clamp.__annotations__)
print(repeat_string.__annotations__)
print(is_palindrome.__annotations__)Expected Output
10
0
5
hey hey hey
True
False
{'value': <class 'float'>, 'low': <class 'float'>, 'high': <class 'float'>, 'return': <class 'float'>}
{'text': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}
{'word': <class 'str'>, 'return': <class 'bool'>}Hints
Hint 1: Type annotations go after the parameter name with a colon: def func(x: int) -> str.
Hint 2: clamp works with floats, repeat_string takes str and int, is_palindrome takes str and returns bool.
Hint 3: The return type annotation goes after -> before the colon: def func(x: int) -> str:
Write a function factory make_validator(min_len, max_len) that returns a new function. The returned function takes a string and returns True if the string's length is between min_len and max_len (inclusive), False otherwise.
Each call to make_validator should produce a different function object (so short_validator is medium_validator should be False).
def make_validator(min_len, max_len):
def validate(s):
return min_len <= len(s) <= max_len
return validate
# Test
short_validator = make_validator(1, 5)
medium_validator = make_validator(6, 20)
print(short_validator("hi"))
print(short_validator("hello"))
print(short_validator("toolong"))
print(medium_validator("hello!"))
print(medium_validator("hi"))
print(short_validator is medium_validator)Solution
def make_validator(min_len, max_len):
def validate(s):
return min_len <= len(s) <= max_len
return validate
Key points:
make_validatoris a factory — each call executesdef validateand creates a new function object.- The inner function
validatecapturesmin_lenandmax_lenfrom the enclosing scope — this is a closure. short_validator is medium_validatorisFalsebecause each call produces a distinct object on the heap.- This pattern is used everywhere:
functools.partial,re.compile, Flask route handlers, and ML hyperparameter configs.
def make_validator(min_len, max_len):
# TODO: Return a function that takes a string and returns True
# if its length is between min_len and max_len (inclusive),
# False otherwise.
pass
# Test
short_validator = make_validator(1, 5)
medium_validator = make_validator(6, 20)
print(short_validator("hi"))
print(short_validator("hello"))
print(short_validator("toolong"))
print(medium_validator("hello!"))
print(medium_validator("hi"))
print(short_validator is medium_validator)Expected Output
True
True
False
True
False
FalseHints
Hint 1: Define an inner function inside make_validator that captures min_len and max_len.
Hint 2: The inner function should check: min_len <= len(s) <= max_len.
Hint 3: Return the inner function object (no parentheses).
Hard
Write a compose(*funcs) function that takes any number of functions and returns a new function that applies them in left-to-right order (pipeline style).
compose(f, g, h)(x) should compute h(g(f(x))).
This is a core functional programming pattern used in data pipelines, middleware chains, and scikit-learn's Pipeline.
def compose(*funcs):
def pipeline(value):
result = value
for func in funcs:
result = func(result)
return result
return pipeline
# Helper functions
def double(x):
return x * 2
def add_ten(x):
return x + 10
def to_string(x):
return str(x)
def exclaim(s):
return s + "!"
# Test
transform = compose(double, add_ten, to_string, exclaim)
print(transform(5))
print(transform(0))
print(transform(100))
# Edge case: single function
identity_double = compose(double)
print(identity_double(7))Solution
def compose(*funcs):
def pipeline(value):
result = value
for func in funcs:
result = func(result)
return result
return pipeline
Key points:
*funcscollects all positional arguments into a tuple of function objects.- The inner
pipelinefunction closes overfuncsand applies them sequentially. - Each function's output becomes the next function's input — this is function composition.
- This pattern underlies
functools.reduce, scikit-learnPipeline, Express.js middleware, and Unix pipes.
def compose(*funcs):
# TODO: Return a new function that applies all funcs
# in left-to-right order.
# compose(f, g, h)(x) should equal h(g(f(x)))
pass
# Helper functions
def double(x):
return x * 2
def add_ten(x):
return x + 10
def to_string(x):
return str(x)
def exclaim(s):
return s + "!"
# Test
transform = compose(double, add_ten, to_string, exclaim)
print(transform(5))
print(transform(0))
print(transform(100))
# Edge case: single function
identity_double = compose(double)
print(identity_double(7))Expected Output
20!
10!
210!
14Hints
Hint 1: Start with the input value and apply each function in sequence using a loop.
Hint 2: Use a for loop: for func in funcs: result = func(result).
Hint 3: The returned function should accept a single argument, apply all funcs left to right, and return the final result.
Write classify_callable(obj) that inspects an object and returns a string describing what kind of callable it is:
"function"— a regular function defined withdef"builtin"— a built-in function likelenorprint"class"— a class object (calling it creates an instance)"callable_obj"— an instance with__call__defined"not_callable"— not callable at all
This tests your understanding of the callable protocol and the difference between functions, classes, and callable objects.
def classify_callable(obj):
if not callable(obj):
return "not_callable"
type_name = type(obj).__name__
if type_name == "function":
return "function"
if type_name == "builtin_function_or_method":
return "builtin"
if isinstance(obj, type):
return "class"
return "callable_obj"
# Test objects
def my_func():
return 42
class MyClass:
def __call__(self):
return "called"
class EmptyClass:
pass
obj_with_call = MyClass()
obj_without_call = EmptyClass()
# Test
print(classify_callable(my_func))
print(classify_callable(len))
print(classify_callable(MyClass))
print(classify_callable(obj_with_call))
print(classify_callable(obj_without_call))
print(classify_callable(42))
print(classify_callable("hello"))Solution
def classify_callable(obj):
if not callable(obj):
return "not_callable"
type_name = type(obj).__name__
if type_name == "function":
return "function"
if type_name == "builtin_function_or_method":
return "builtin"
if isinstance(obj, type):
return "class"
return "callable_obj"
Key points:
callable(obj)checks ifobjimplements__call__at the C level — the fastest way to test.type(obj).__name__gives the string name of the object's type:"function","builtin_function_or_method", etc.isinstance(obj, type)returnsTruefor classes because classes are instances oftype(the metaclass).- The fallback
"callable_obj"catches any instance that has__call__defined — a common pattern in ML frameworks (PyTorchnn.Module, Keras layers).
def classify_callable(obj):
# TODO: Return a string describing what kind of callable obj is:
# - "function" if it is a regular function (type name is 'function')
# - "builtin" if it is a built-in function (type name is 'builtin_function_or_method')
# - "class" if it is a class (use isinstance(obj, type))
# - "callable_obj" if it is a callable instance (has __call__ but is not a function/class)
# - "not_callable" if it is not callable at all
pass
# Test objects
def my_func():
return 42
class MyClass:
def __call__(self):
return "called"
class EmptyClass:
pass
obj_with_call = MyClass()
obj_without_call = EmptyClass()
# Test
print(classify_callable(my_func))
print(classify_callable(len))
print(classify_callable(MyClass))
print(classify_callable(obj_with_call))
print(classify_callable(obj_without_call))
print(classify_callable(42))
print(classify_callable("hello"))Expected Output
function
builtin
class
callable_obj
not_callable
not_callable
not_callableHints
Hint 1: Use callable(obj) first to check if the object is callable at all.
Hint 2: Use type(obj).__name__ to get the type name string for distinguishing functions from builtins.
Hint 3: Use isinstance(obj, type) to check if something is a class.
Hint 4: Order matters: check 'not callable' first, then function, then builtin, then class, then callable_obj as fallback.
Build a complete plugin registry system using first-class functions and the decorator pattern:
- A module-level dict
_pluginsto store registered functions. - A
register(name)function that returns a decorator. The decorator stores the function in_pluginsand returns the original function unchanged. - A
run_plugin(name, *args, **kwargs)function that looks up and calls the registered function. RaiseKeyErrorwith a descriptive message if the plugin is not found. - A
list_plugins()function that returns a sorted list of all registered plugin names.
This is the exact pattern used by Flask (@app.route), Pytest (@pytest.fixture), and Click (@cli.command).
_plugins = {}
def register(name):
def decorator(func):
_plugins[name] = func
return func
return decorator
def run_plugin(name, *args, **kwargs):
if name not in _plugins:
raise KeyError(f"No plugin registered under '{name}'")
return _plugins[name](*args, **kwargs)
def list_plugins():
return sorted(_plugins.keys())
@register("uppercase")
def plugin_upper(text):
return text.upper()
@register("reverse")
def plugin_reverse(text):
return text[::-1]
@register("word_count")
def plugin_word_count(text):
return len(text.split())
# Test
print(list_plugins())
print(run_plugin("uppercase", "hello world"))
print(run_plugin("reverse", "hello world"))
print(run_plugin("word_count", "the quick brown fox"))
try:
run_plugin("missing", "test")
except KeyError as e:
print(e)Solution
_plugins = {}
def register(name):
def decorator(func):
_plugins[name] = func
return func
return decorator
def run_plugin(name, *args, **kwargs):
if name not in _plugins:
raise KeyError(f"No plugin registered under '{name}'")
return _plugins[name](*args, **kwargs)
def list_plugins():
return sorted(_plugins.keys())
Key points:
register("uppercase")is called first and returnsdecorator. Then@decoratoris applied toplugin_upper.registeris a decorator factory — a higher-order function that returns a decorator, which is itself a higher-order function._plugins[name] = funcstores the function object.return funcensures the original function remains usable by its own name.*argsand**kwargsinrun_pluginallow forwarding any arguments to the plugin function.- This three-tier pattern (factory -> decorator -> original function) is the backbone of Flask routes, Click commands, and pytest fixtures.
# TODO: Implement the registry system.
# 1. Create a module-level dict '_plugins' to store registered functions.
# 2. Write 'register(name)' that returns a decorator.
# The decorator stores the function in _plugins under 'name'.
# 3. Write 'run_plugin(name, *args, **kwargs)' that looks up and calls
# the plugin. Raise KeyError with a message if not found.
# 4. Write 'list_plugins()' that returns a sorted list of plugin names.
# Register plugins using decorator syntax
# @register("uppercase")
def plugin_upper(text):
return text.upper()
# @register("reverse")
def plugin_reverse(text):
return text[::-1]
# @register("word_count")
def plugin_word_count(text):
return len(text.split())
# Test
print(list_plugins())
print(run_plugin("uppercase", "hello world"))
print(run_plugin("reverse", "hello world"))
print(run_plugin("word_count", "the quick brown fox"))
try:
run_plugin("missing", "test")
except KeyError as e:
print(e)Expected Output
['reverse', 'uppercase', 'word_count']
HELLO WORLD
dlrow olleh
4
"No plugin registered under 'missing'"Hints
Hint 1: register(name) should return a decorator function. The decorator takes func, stores it in _plugins[name], and returns func unchanged.
Hint 2: run_plugin should do: _plugins[name](*args, **kwargs). Raise KeyError if name not in _plugins.
Hint 3: list_plugins returns sorted(_plugins.keys()). Remember to uncomment the @register decorators.
