def repeat_string(text, times):
return text * timesGoing 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.
- Safer, cleaner functions
- Documenting expectations
- Handling errors
- Logging function outputs
- Extending functions
- Flexible inputs
- Specialised functions
- Applying functions across data
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:
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: typeannotates inputs;-> typeannotates 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 | Nonemeans “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]
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 ExceptionTypespecifies 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
- Bare
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
exceptblocks handle different failure modes - Each block runs only if its specific exception type is raised
- List the most specific exceptions first
else and finally
elseruns only iftrycompleted without raising an exceptionfinallyalways 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
finallyis 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, butfinallyis 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
ValueErrorfor bad argument values,TypeErrorfor 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")basicConfigsets up a simple logger that writes to the consolelevelcontrols the minimum severity shown, set toWARNINGin production to suppressDEBUGandINFOmessagesgetLogger(__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
INFOfor normal progress milestones,WARNINGfor skipped or unexpected items,ERRORfor 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 changinglevelinbasicConfig- There is no need to remove or add print statements
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
*argscollects all positional arguments into a tuple- The name
argsis a convention, the*is what matters; you could write*valuesor*numbers *argsis 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
**kwargscollects 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**kwargscan 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
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
partialcall produces a new function with the threshold already set - This avoids repeating keyword arguments and makes the intent clear from the function name
partial 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, ...)notmap(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
Falseare 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:
filterfirst, thenmapon the result - This pattern works well with named functions; for quick one-offs, a list comprehension may be cleaner
map()/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
On Python 3.9 or earlier, use
Optional[float]from thetypingmodule instead.↩︎