Going Further with Functions

In this session we will look at ways to do more with functions, particularly covering how to write functions that are more reliable and easier to work with, and techniques that extend what functions can do.

This session will only present an overview of some of these methods, but the idea is just to show you what is possible, so that you know what to look for in th future.

Safer, Cleaner Functions

Functions are powerful and flexible, but while this means you can achieve a lot with them, it also means there are lots of ways things can go wrong (sometimes even silently). Luckily, there are methods for making your functions more robust, easier to read (and use), and safer.

Documenting Expected Types (Type Hints)

Type hints let you annotate the expected types of a function’s inputs and its return value. Python doesn’t enforce them at runtime. They are documentation. But tooling (e.g. VS Code) can use them to warn you when you pass the wrong type.

Here is a function without type hints:

def repeat_string(text, times):
    return text * times

And the same function with type hints:

def repeat_string(text: str, times: int) -> str:
    """Return text repeated a given number of times."""
    return text * times

print(repeat_string("hello ", 3))
hello hello hello 
  • parameter: type annotates inputs; -> type annotates the return value
  • Common types: int, float, str, bool, None
  • Python doesn’t raise an error if the wrong type is passed
    • Hints are for humans and tools, not the interpreter

Optional Return Values

When a function might return a value or None, use the | union syntax1:

def safe_divide(a: float, b: float) -> float | None:
    """Divide a by b, returning None if b is zero."""
    if b == 0:
        return None
    return a / b

print(safe_divide(10, 4))
print(safe_divide(10, 0))
2.5
None
  • float | None means “returns either a float or None”

Hints for Collections

def count_above_threshold(values: list[float], threshold: float) -> int:
    """Count how many values in a list exceed the threshold."""
    return sum(1 for v in values if v > threshold)

print(count_above_threshold([1.2, 4.5, 3.1, 7.8, 2.9], threshold=3.0))
3
  • list[float] means a list whose items are floats
  • Similarly: dict[str, int], tuple[int, str], set[float]
TipWhy Bother?

Type hints pay off when you return to code written months ago, or when sharing code with colleagues. They make functions self-documenting (plus they unlock better autocomplete in editors like VS Code).

Handling Errors Gracefully (Exception Handling)

When something goes wrong inside a function, Python raises an exception. Without handling, it crashes the program. Exception handling lets you decide what to do instead.

try / except

def safe_divide(a: float, b: float) -> float | None:
    """Divide two numbers, returning None on division by zero."""
    try:
        return a / b
    except ZeroDivisionError:
        print("Warning: cannot divide by zero, returning None")
        return None

print(safe_divide(10, 4))
print(safe_divide(10, 0))
2.5
Warning: cannot divide by zero, returning None
None
  • Code that might fail goes inside try
  • except ExceptionType specifies what to do if that exception is raised
  • Always catch a specific exception type
    • Bare except: catches everything silently and is a common source of hard-to-find bugs

Handling Multiple Exception Types

def parse_number(raw: str) -> float | None:
    """
    Parse a string into a float.
    Returns None for recognised missing-value tokens or non-numeric strings.
    """
    MISSING = {"", "n/a", "null", "na", "none"}

    try:
        if raw.strip().lower() in MISSING:
            return None
        return float(raw)
    except ValueError:
        print(f"  Could not convert '{raw}' to float")
        return None
    except AttributeError:
        print(f"  Expected a string, got {type(raw).__name__}")
        return None

test_values = ["3.14", "NULL", "oops", "2.71", None, "N/A", "1.41"]

for v in test_values:
    print(f"  parse_number({v!r}) -> {parse_number(v)}")
  parse_number('3.14') -> 3.14
  parse_number('NULL') -> None
  Could not convert 'oops' to float
  parse_number('oops') -> None
  parse_number('2.71') -> 2.71
  Expected a string, got NoneType
  parse_number(None) -> None
  parse_number('N/A') -> None
  parse_number('1.41') -> 1.41
  • Multiple except blocks handle different failure modes
  • Each block runs only if its specific exception type is raised
  • List the most specific exceptions first

else and finally

  • else runs only if try completed without raising an exception
  • finally always runs, and is used for clean-up regardless of outcome
def read_lines(filepath: str) -> list[str] | None:
    """Read all lines from a file, ensuring it is always closed."""
    file_handle = None
    try:
        file_handle = open(filepath, "r")
        lines = file_handle.readlines()
    except FileNotFoundError:
        print(f"File not found: {filepath}")
        return None
    else:
        print(f"Read {len(lines)} lines successfully")
        return lines
    finally:
        if file_handle:
            file_handle.close()
            print("File handle closed.")

read_lines("data/does_not_exist.csv")
File not found: data/does_not_exist.csv
  • finally is the right place for clean-up: closing files, releasing connections
  • In practice, file handling is usually done with with open(...) as f: which closes automatically, but finally is still the right pattern for other resources

Raising Exceptions

You can raise exceptions yourself to enforce rules about how a function is used.

def calculate_mean(values: list[float]) -> float:
    """
    Return the mean of a list of numbers.

    Raises:
        ValueError: if values is empty
    """
    if not values:
        raise ValueError("Cannot calculate mean of an empty list")
    return sum(values) / len(values)

print(calculate_mean([4.0, 7.5, 3.2, 9.1]))

try:
    calculate_mean([])
except ValueError as e:
    print(f"ValueError: {e}")
5.95
ValueError: Cannot calculate mean of an empty list
  • raise ExceptionType("message") triggers an exception from inside your function
  • Useful for validating inputs before any computation happens
  • Prefer ValueError for bad argument values, TypeError for wrong argument types

Recording What Functions Do (Logging)

print() is fine for quick checks, but Python’s logging module gives you a more controlled way to record what your functions are doing, with the ability to turn detail up or down without changing your code.

Log Levels

Level When to use
DEBUG Detailed diagnostic info
INFO Confirmation that things are working as expected
WARNING Something unexpected, but execution continues
ERROR Something failed

Basic Setup

import logging

logging.basicConfig(
    level=logging.DEBUG, # show all messages at DEBUG level and above
    format="%(levelname)s | %(message)s"
)

logger = logging.getLogger(__name__)

logger.debug("Detailed diagnostic info")
logger.info("Everything is working normally")
logger.warning("Something unexpected happened")
logger.error("Something failed")
  • basicConfig sets up a simple logger that writes to the console
  • level controls the minimum severity shown, set to WARNING in production to suppress DEBUG and INFO messages
  • getLogger(__name__) creates a logger named after the current module

Adding Logging to a Function

import logging

logging.basicConfig(
    level=logging.INFO,
    format="%(levelname)s | %(message)s"
)

logger = logging.getLogger(__name__)

def process_batch(items: list) -> list:
    """
    Process a batch of items, skipping any that are None.

    Args:
        items: list of values to process

    Returns:
        List of processed (doubled) values, with None items skipped.
    """
    logger.info(f"Starting batch of {len(items)} items")
    results = []

    for i, item in enumerate(items):
        if item is None:
            logger.warning(f"Item {i} is None, skipping")
            continue
        results.append(item * 2)

    logger.info(f"Done. {len(results)} items processed, {len(items) - len(results)} skipped.")
    return results


batch = [10, None, 30, None, 50, 60]
output = process_batch(batch)
print(output)
[20, 60, 100, 120]
  • Log at INFO for normal progress milestones, WARNING for skipped or unexpected items, ERROR for failures
  • Log messages give you a record of what happened without changing the function’s return value
  • Unlike print(), you can change how much is shown just by changing level in basicConfig
    • There is no need to remove or add print statements
TipLogging to Files

Add filename="app.log" to basicConfig to write log output to a file instead of the console. This is useful when running scripts overnight or in automated pipelines where you won’t see the console output.

Extending Functions

In previous sessions we have looked at a number of ways you can do more with functions, but there is lots we haven’t covered yet. This is a short overview of some of the ways you can extend the functionality of functions.

Flexible Function Inputs (*args & **kwargs)

We used flexible inputs in the Decorators session, but *args and **kwargs can both be useful in lots of contexts.

Positional Arguments (*args)

The *args syntax lets you pass any number of positional arguments into a function. Inside the function, they arrive as a tuple.

def summarise_numbers(*args):
    """Print a summary of any number of numbers passed in."""
    total = sum(args)
    print(f"Values: {args}")
    print(f"Total: {total}")
    print(f"Mean:  {total / len(args):.1f}")

summarise_numbers(10, 25, 7, 42, 3)
Values: (10, 25, 7, 42, 3)
Total: 87
Mean:  17.4
  • *args collects all positional arguments into a tuple
  • The name args is a convention, the * is what matters; you could write *values or *numbers
  • *args is useful when you don’t know how many inputs a function will receive at the time you write it

Keyword Arguments (**kwargs)

The **kwargs syntax (short for “keyword arguments”) lets you pass any number of named arguments. Inside the function, they arrive as a dictionary.

def display_record(**kwargs):
    """Print any number of named key-value pairs."""
    for key, value in kwargs.items():
        print(f"  {key}: {value}")

display_record(name="Alice", department="Finance", active=True, years=4)
  name: Alice
  department: Finance
  active: True
  years: 4
  • **kwargs collects all keyword arguments into a dictionary
  • Iterate over kwargs.items() to access the key-value pairs
  • Useful for functions that need to accept an open-ended set of named properties

Using Both *args & **kwargs

You can use *args and **kwargs together. If you do, *args must come before **kwargs, and any fixed required arguments must come first of all.

def build_record(record_id, *tags, **attributes):
    """
    Build a generic record dict.
    record_id: a single required argument
    *tags:     any number of string tags
    **attributes: any number of named attributes
    """
    return {
        "id":         record_id,
        "tags":       list(tags),
        "attributes": attributes
    }

record = build_record(
    "REC-001",
    "urgent", "reviewed", # positional -> tags
    status="open", # keyword -> attributes
    priority=1,
    assigned_to="Bob"
)

print(record)
{'id': 'REC-001', 'tags': ['urgent', 'reviewed'], 'attributes': {'status': 'open', 'priority': 1, 'assigned_to': 'Bob'}}
  • Fixed arguments, *args, and **kwargs can all appear in the same function signature, in that order
  • The combination is common in wrapper functions that need to forward arguments to another function
NoteWhen Would I Actually Use This?

A common real-world case is writing wrapper functions. Wrapper functions call other functions and need to pass arguments through without knowing in advance what those arguments will be.

Creating Specialised Functions (functools.partial)

functools.partial lets you take an existing function and “fix” one or more of its arguments, producing a new, more specific function.

from functools import partial

def power(base, exponent):
    """Raise base to a given exponent."""
    return base ** exponent

# create specialised versions with exponent fixed
square = partial(power, exponent=2)
cube   = partial(power, exponent=3)

print(square(4))
print(cube(3))
16
27
  • partial(func, ...) returns a new callable
    • The original function is unchanged
  • Any argument (positional or keyword) can be fixed; the remaining ones are supplied later
  • The result behaves exactly like a normal function

Practice Example

partial becomes especially useful when you want to apply the same function with different fixed parameters across a dataset.

from functools import partial

def exceeds_threshold(value, threshold):
    """Return True if value is at or above threshold."""
    return value >= threshold

# fix the threshold for different use cases
flag_above_100 = partial(exceeds_threshold, threshold=100)
flag_above_500 = partial(exceeds_threshold, threshold=500)
flag_above_1000 = partial(exceeds_threshold, threshold=1000)

readings = [85, 120, 450, 510, 990, 1020]

print(list(map(flag_above_100,  readings)))
print(list(map(flag_above_500,  readings)))
print(list(map(flag_above_1000, readings)))
[False, True, True, True, True, True]
[False, False, False, True, True, True]
[False, False, False, False, False, True]
  • Each partial call produces a new function with the threshold already set
  • This avoids repeating keyword arguments and makes the intent clear from the function name
Notepartial vs lambda

lambda x: exceeds_threshold(x, threshold=100) does the same thing inline. partial is generally preferred when you want a named, reusable callable. It is more readable and plays better with other functools tools.

Applying Functions Across Data (map() & filter())

map() and filter() are Python built-ins that let you apply a function across an iterable without writing an explicit for loop.

map()

map(function, iterable) applies a function to every item and returns a lazy iterator. Wrap it in list() to get the results.

def celsius_to_fahrenheit(c):
    """Convert a temperature from Celsius to Fahrenheit."""
    return round(c * 9/5 + 32, 1)

temps_c = [0, 20, 37, 100, -10]

temps_f = list(map(celsius_to_fahrenheit, temps_c))

print(temps_f)
[32.0, 68.0, 98.6, 212.0, 14.0]
  • Pass the function without calling it: map(celsius_to_fahrenheit, ...) not map(celsius_to_fahrenheit(), ...)
  • map() calls the function once per item
  • The result is a lazy iterator (similar to a generator) so wrap with list() to materialise it

Multiple Iterables

map() can take more than one iterable. It pairs them up element-by-element and passes each pair to the function.

def weighted_average(value, weight):
    """Multiply a value by its weight (used before summing for a weighted mean)."""
    return value * weight

scores  = [88, 72, 95, 60]
weights = [0.4, 0.2, 0.3, 0.1]

weighted = list(map(weighted_average, scores, weights))
print(weighted)
print(f"Weighted mean: {sum(weighted):.1f}")
[35.2, 14.4, 28.5, 6.0]
Weighted mean: 84.1
  • Iterables are consumed in parallel; stops at the shortest one
  • Useful when two lists are paired by position and you want to combine them element-wise

filter()

filter(function, iterable) keeps only the items for which the function returns True.

def is_positive(x):
    """Return True if x is greater than zero."""
    return x > 0

values = [10, -3, 0, 7, -1, 4, -8, 2]

positive_values = list(filter(is_positive, values))

print(positive_values)
[10, 7, 4, 2]
  • The function passed to filter() must return a boolean (or something truthy/falsy)
  • Items where the function returns False are dropped
  • Like map(), returns a lazy iterator

map() & filter() Together

They compose naturally. Pipe the output of filter() into map().

def double(x):
    return x * 2

def is_even(x):
    return x % 2 == 0

numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# keep only even numbers, then double them
result = list(map(double, filter(is_even, numbers)))

print(result)
[4, 8, 12, 16, 20]
  • Read from the inside out: filter first, then map on the result
  • This pattern works well with named functions; for quick one-offs, a list comprehension may be cleaner
Notemap()/filter() vs list comprehensions

Both are valid. If you only need a one-off inline transformation, list comprehension is probably better, but when you have named functions you want to apply, use map()/filter(), since the code reads as a description of what is happening (“map this function over this data”).

Footnotes

  1. On Python 3.9 or earlier, use Optional[float] from the typing module instead.↩︎