Skip to main content

Python Defining Functions Practice Problems & Exercises

Practice: Defining Functions

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Greeting GeneratorEasy
def syntaxreturn valuesstring formatting

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.

Python
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:

  • def creates the function object at runtime and binds it to the name greet.
  • The return statement sends the string back to the caller. Without it, the function returns None.
  • 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.

#2Area CalculatorEasy
def syntaxmultiple parametersarithmetic

Write two functions:

  1. rectangle_area(width, height) — returns width * height
  2. circle_area(radius) — returns 3.14159 * radius * radius

This practices defining multiple functions with different parameter counts and returning numeric results.

Python
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_area does not affect rectangle_area.
  • Return values can be any Python object (here, int and float).
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.159
Hints

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.

#3Function AliasEasy
first-class functionsassignmentidentity

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.

Python
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 = square binds a new name to the same function object. No copy is made.
  • sq is square returns True because both names reference the identical object in memory.
  • sq = square() would be wrong — that calls square with no argument and raises TypeError.
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
True
Hints

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.

#4Docstring InspectorEasy
docstrings__doc____name__

Write a function celsius_to_fahrenheit that:

  1. Has the docstring "Convert Celsius temperature to Fahrenheit."
  2. 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.

Python
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 via func.__doc__ or help(func).
  • __name__ is automatically set by def to 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

#5Dispatch Table CalculatorMedium
first-class functionsdict of functionsdispatch table

Build a dispatch table — a dictionary that maps operator strings to functions — and a calculate function that uses it.

  1. Create a dict ops mapping "+", "-", "*", "/" to the corresponding function objects.
  2. Write calculate(a, op, b) that looks up op in ops and calls the function. Return "Unknown operator" if op is not found.

This is the same pattern used in interpreters, CLI tools, and API route dispatchers.

Python
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 object add. ops["+"](10, 3) calls it.
  • Dict lookup is O(1), making this faster and cleaner than a chain of if/elif statements.
  • 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 operator
Hints

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.

#6Higher-Order ApplyMedium
higher-order functionspassing functionsmap pattern

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).

Python
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_list is 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 len are also first-class objects and work identically here.
  • This is the same principle behind map(func, iterable), sorted(data, key=func), and filter(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.

#7Type-Annotated ToolkitMedium
type hintsannotationsreturn types

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.

Python
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: Type for parameters and -> Type for 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 return key 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:

#8Function FactoryMedium
returning functionsclosuresfactory pattern

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).

Python
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_validator is a factory — each call executes def validate and creates a new function object.
  • The inner function validate captures min_len and max_len from the enclosing scope — this is a closure.
  • short_validator is medium_validator is False because 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
False
Hints

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

#9Pipeline ComposerHard
higher-order functionsfunction compositionpipeline

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.

Python
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:

  • *funcs collects all positional arguments into a tuple of function objects.
  • The inner pipeline function closes over funcs and applies them sequentially.
  • Each function's output becomes the next function's input — this is function composition.
  • This pattern underlies functools.reduce, scikit-learn Pipeline, 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!
14
Hints

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.

#10Callable DetectorHard
callable__call__type checkingfirst-class objects

Write classify_callable(obj) that inspects an object and returns a string describing what kind of callable it is:

  • "function" — a regular function defined with def
  • "builtin" — a built-in function like len or print
  • "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.

Python
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 if obj implements __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) returns True for classes because classes are instances of type (the metaclass).
  • The fallback "callable_obj" catches any instance that has __call__ defined — a common pattern in ML frameworks (PyTorch nn.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_callable
Hints

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.

#11Plugin Registry SystemHard
first-class functionsdecoratorsregistry patterndesign

Build a complete plugin registry system using first-class functions and the decorator pattern:

  1. A module-level dict _plugins to store registered functions.
  2. A register(name) function that returns a decorator. The decorator stores the function in _plugins and returns the original function unchanged.
  3. A run_plugin(name, *args, **kwargs) function that looks up and calls the registered function. Raise KeyError with a descriptive message if the plugin is not found.
  4. 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).

Python
_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 returns decorator. Then @decorator is applied to plugin_upper.
  • register is a decorator factory — a higher-order function that returns a decorator, which is itself a higher-order function.
  • _plugins[name] = func stores the function object. return func ensures the original function remains usable by its own name.
  • *args and **kwargs in run_plugin allow 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.

© 2026 EngineersOfAI. All rights reserved.