4. Functions#
Why custom functions?
The real power of programming comes from creating your own functions. A function is a reusable block of code that performs a specific task. Functions help you organize code, avoid repetition, and make programs easier to understand and maintain. For example, you write a function and (re)use it everywhere in your project, and when you need to modify the function, you only need to modify it in one place.
It may not be clear yet why it is worth the trouble to divide a program into functions. There are several reasons:
Creating a new function gives you an opportunity to name a group of statements, which makes your program easier to read and debug.
Functions can make a program smaller by eliminating repetitive code.
If you make a change to the code, with a function, you only have to make it in one place.
Dividing a long program into functions allows you to debug the parts one at a time as a module and then assemble them into a working whole.
Well-designed functions are often useful for many programs. Once you write and debug one, you can reuse it.
While there are plenty of Python built-in functions and modules, we still need to learn to design our own custom functions to perform problem-specific tasks, whereas the functions shipped with Python are general-purpose tools. The comparison below of built-ins and custom functions should give you a clear rationale for learning to build custom functions.
Feature |
Built-in Functions |
Custom Functions |
|---|---|---|
Who defines them |
Python |
You |
Need |
No |
Yes |
Always available |
Yes |
No |
Reusable |
Yes |
Yes |
Problem-specific |
No |
Yes |
Can call other functions |
No |
Yes |
Can accept defaults |
Limited |
Yes |
Before we officially learn about functions, let us play with the Python turtle module.
4.1. jupyturtle#
Python Turtle graphics is an old module (created in 1967!) that has been used for decades for learning Python. Luciano Ramalho ported it to Jupyter Notebook while reviewing Allen Downey’s Python book . Turtle is fun to use and great for learning Python programming. Here we are using it to learn functions.
To use jupyturtle, you need to install and import the module.
%pip install jupyturtle ### comment it out after installation
import jupyturtle
# %pip install jupyturtle
import jupyturtle
jupyturtle.make_turtle()
<shared.jupyturtle.Turtle at 0x108209160>
make_turtle creates a canvas, which is a space on the screen where we can draw, and a turtle, which is represented by a circular shell and a triangular head.
The circle shows the turtle’s location, and the triangle indicates the direction it is facing.
Now we can use the functions defined in the module, like make_turtle and forward.
jupyturtle.make_turtle() ### create a new canvas
jupyturtle.forward(100)
forward moves the turtle a given distance in the direction it’s facing, drawing a line segment along the way.
The distance is in arbitrary units – the actual size depends on your computer’s screen.
We will use functions defined in the jupyturtle module many times, so it would be nice if we didn’t have to write the module name every time. That’s possible if we import the module like this.
from jupyturtle import make_turtle, forward
Note:
To check out the functions available in jupyturtle, use the dot operator (press the Tab keys after t to see.
This version of the import statement imports make_turtle and forward from the jupyturtle module, so we can call them like this.
make_turtle()
forward(100)
jupyturtle provides two other functions we’ll use, called left and right. We’ll import them like this.
from jupyturtle import left, right
left causes the turtle to turn left. It takes one argument, which is the angle of the turn in degrees. For example, we can make a 90-degree left turn like this.
make_turtle()
forward(50)
left(90)
forward(50)
jupyturtle provides two other functions we’ll use, called left and right. We’ll import them like this.
This program moves the turtle east and then north, leaving two line segments behind. Before you go on, see if you can modify the previous program to make a square.
4.1.1. Making a square#
Here’s one way to make a square.
make_turtle()
forward(50)
left(90)
forward(50)
left(90)
forward(50)
left(90)
forward(50)
left(90)
Because this program repeats the same pair of lines four times, we can do the same thing more concisely with a for loop.
make_turtle()
for i in range(4):
forward(50)
left(90)
4.1.2. Encapsulation#
Let’s take the square-drawing code from the previous section and put it in a function called square.
def square():
for i in range(4):
forward(50)
left(90)
Now we can call the function like this.
make_turtle() ### create a canvas object
square() ### call a custom function
### Exercise 1: Polygon
### Try producing the same output as the cell below.
### For now, use forward(50).
### Your code starts here:
### Your code ends here.
4.2. Defining Functions#
A function definition specifies the name of a new function and the sequence of statements that run when the function is called.
The syntax of a Python function is:
def function_name([parameters]):
"""
Docstring describing what the function does.
"""
[function body]
[return value] ### optional
We use the def keyword to define a function. The def line is called a header, and the rest of the lines are the body. The elements here are:
Header:
def: Keyword that starts a function definitionFunction name: Follows variable naming rules (snake_case)
Parameters: Input values (optional)
Colon
Body
Indentation (4 as recommended by PEP8)
Docstring: Documentation string (recommended)
Function body: Indented (instead of
{}) code that runs when the function is calledreturn: Sends a value back to the caller (optional, but almost always have)
def greet(name):
"""
Print a greeting message to the user with their name.
Parameters:
name (str): The name of the user to greet.
Returns:
None
"""
print(f"Hello, {name}!")
4.2.1. Parameters and Arguments#
With the elements in mind, we can already play with different patterns of functions. Let’s start with parameters. Python functions can have several types of parameters and arguments:
In the function definition:
Positional Parameters: These are matched to arguments based on their position in the function definition.
Default Parameters: These have a default value that’s used if no argument is provided. | Type | Matched By | Order Matters? | Can Use Keyword? | | ————– | ———- | ————– | —————————— | | Positional | Order | Yes | Yes | | Keyword | Name | No (must be after positional) | Yes |
*args (Variable Positional Arguments): Allows a function to accept any number of positional arguments as a tuple. Note that *args itself is a name by convention.
**kwargs (Variable Keyword Arguments): Allows a function to accept any number of keyword arguments as a dictionary. Note that **kwargs itself is a name by convention.
In a function call:
Keyword-Only Parameters: Parameters that can only be passed by name, not by position. They come after *args or a single *.
Keyword Arguments: You can pass arguments by specifying the parameter name, which allows any order.
Note that positional parameter/arguments must come before keyword parameter/arguments. The full precedence order is: positional => *args => keyword-only => **kwargs. Once you start naming arguments (e.g., age=35), everything after that must also be named.
The following example should make the ideas clear:
Now, some simpler patterns and examples:
no parameter
one parameter
multiple parameters
### 1. No Parameter
def greet():
"""Print a greeting message."""
print("Hello!")
greet() ### function call output: Hello, World!
Hello!
### 2. Single Parameters**
### Parameters allow functions to work with different inputs:
def greet_person(name):
"""Greet a person by name."""
print(f"Hello, {name}!")
greet_person("Alice") # Output: Hello, Alice!
greet_person("Bob") # Output: Hello, Bob!
Hello, Alice!
Hello, Bob!
### 3. Multiple Parameters**
def add_numbers(a, b):
"""Add two numbers and return the result."""
result = a + b
return result
total = add_numbers(5, 3) # Returns 8
print(total)
8
4.2.2. Default Parameters#
A default parameter has a default value. Parameters can have default values, and they can be overwritten by arguments.
def greet(name, greeting="Hello"):
"""Greet with a customizable greeting."""
return f"{greeting}, {name}!"
print(greet("Alice")) # Hello, Alice! ### default is "Hello"
print(greet("Bob", "Hi")) # Hi, Bob! ### now it's Hi
print(greet("Charlie", "Hey")) # Hey, Charlie! ### now it's Hey
Hello, Alice!
Hi, Bob!
Hey, Charlie!
### === EXERCISE 2: Functions with Default Parameters ===
### Write a function called greet_with_title() that takes two parameters:
### - name: a person's name
### - title: a title with a default value of "Doctor"
### The function should print: "Hello, Doctor Alice!"
### Call it twice: once with and once without the title parameter.
### Your code starts here:
### Your code stops here.
Hello, Doctor Alice!
Hello, Professor Bob!
def power(base, exponent=2):
"""Calculate base raised to exponent (default is 2)."""
return base ** exponent
print(f"\n5 squared: {power(5)}") # Uses default exponent=2
print(f"5 cubed: {power(5, 3)}") # Overrides default
print(f"2 to the 8th: {power(2, 8)}")
5 squared: 25
5 cubed: 125
2 to the 8th: 256
%%expect NameError
### === EXERCISE 3: Functions with Parameters ===
### Write a function called introduce() that takes two parameters: name and age
### The function should print a message like: "Hi, I'm Alice and I am 25 years old"
### Then call introduce() at least twice with different values.
### Your code starts here:
introduce("Alice", 25)
introduce("Bob", 30)
introduce("Charlie", 22)
### Your code stops here.
NameError: name 'introduce' is not defined
Hi, I'm Alice and I am 25 years old
Hi, I'm Bob and I am 30 years old
Hi, I'm Charlie and I am 22 years old
4.2.3. *args and **kwargs#
In Python, *args and **kwargs are special syntax used to pass a variable number of arguments to a function. The names args and kwargs are conventional but not mandatory; the single (*) and double (**) asterisks are the key elements that enable this functionality. * means “many values”, while ** means “many key–value pairs”.
The *args: The *args syntax allows a function to accept any number of non-keyword (positional) arguments. Inside the function, these arguments are collected into a tuple.
The **kwargs (Arbitrary Keyword Arguments) syntax allows a function to accept any number of keyword arguments (arguments in the format key=value). Inside the function, these arguments are collected into a dictionary.
Together, *args and **kwargs extend the basic parameter patterns:
regular positional parameters (like
name)parameters with default values (like
greeting="Hello")multiple parameters (like
a, b) that can now be followed by*argsand**kwargsfor extra values.
*args collects extra positional arguments into a tuple, while **kwargs collects extra keyword arguments into a dictionary. As a comparison:
Feature |
|
|
|---|---|---|
Accepts |
Positional arguments |
Keyword arguments |
Stored as |
Tuple |
Dictionary |
Order matters |
Yes |
No |
def describe_order(required, default=0, *args, **kwargs):
"""Mixes normal, default, *args, and **kwargs to show ordering."""
print(f'required = {required}')
print(f'default = {default}')
print(f'args = {args}')
print(f'kwargs = {kwargs}')
describe_order(10, 20, 30, 40, mode='fast', debug=True)
required = 10
default = 20
args = (30, 40)
kwargs = {'mode': 'fast', 'debug': True}
def sum_all(*args):
"""Calculates the sum of an arbitrary number of arguments."""
print(type(args)) ### testing; tuple
return sum(args) ### variable name: args
print(sum_all(1, 2, 3)) # 6
<class 'tuple'>
6
def print_details(**kwargs):
"""Prints the key-value pairs of the provided details."""
for key, value in kwargs.items():
# print(type(kwargs)) ### checking type
print(f"{key}: {value}")
print_details(name="John", age=30, city="New York")
name: John
age: 30
city: New York
### === EXERCISE 4: Write the add_numbers() function
### Write a function called add_numbers(*args) that:
### Takes any number of numeric arguments using *args
### Prints how many numbers were passed in
### Returns the sum of all the numbers
### Your code starts here:
### Your code stops here.
Number of values passed: 4
Result: 100
4.2.4. Return Value#
Functions can return values using the return statement. As you have probably noticed, we usually store return values in a variable for later use. Note that:
A function without
returnreturnsNoneYou can return multiple values:
return x, y, z, but still one thing: atuplereturnimmediately exits the function
def calculate_area(length, width):
"""Calculate the area of a rectangle."""
area = length * width
return area
print(f"Area: {calculate_area(5, 3)}") ### Area: 15
### or you may do
rect_area = calculate_area(5, 3)
print(f"Area: {rect_area}") ### Area: 15
Area: 15
Area: 15
### === EXERCISE 5: Functions with Return Values ===
### Write a function called add_tax() that takes two parameters:
### - price: the original price
### - tax_rate: the tax rate as a decimal (e.g., 0.08 for 8%)
### The function should calculate and return the total price including tax
### Then call the function and print results for different prices.
### Your code starts here:
### Your code stops here.
Price: $100, Tax Rate: 8%, Total: $108.0
def add_numbers(x, y):
"""Add two numbers and return the sum."""
total = x + y
return total
result = add_numbers(10, 5)
print(f"\nTotal: {result}")
Total: 15
4.2.5. Function Type Annotations#
A function can include type annotations for its parameters and return value. The parameter annotation goes after the parameter name, and the return annotation goes after -> in the function header.
def function_name(parameter: type) -> return_type:
...
These annotations are a lightweight contract. They tell readers and tools what kinds of values the function expects and returns, but Python does not enforce them automatically at runtime.
def greet(name: str) -> str:
return f"Hello, {name}"
message = greet("Ada")
print(message)
Hello, Ada
def area_rectangle(width: float, height: float) -> float:
return width * height
print(area_rectangle(3.0, 4.5))
13.5
Use -> None when a function performs an action but does not return a useful value. This is common for functions whose main job is printing, drawing, saving a file, or modifying an object.
def print_receipt(total: float) -> None:
print(f"Total: ${total:.2f}")
result = print_receipt(12.5)
print(result)
Total: $12.50
None
A function with no return statement still returns None; the annotation simply makes that intention explicit.
4.2.6. Unpacking#
Taking elements out of a collection (tuple, list, dict) and passing them into individual parameters.
Observe the multiple return values of the following example. It has two values enclosed in parentheses, which form a tuple.
def func(a, b):
return a, b
func(1, 2)
(1, 2)
Here we see that a tuple is returned, and we unpack the tuple into the variables when returned.
def get_stats(numbers):
"""Return min, max, and average of a list."""
minimum = min(numbers)
maximum = max(numbers)
average = sum(numbers) / len(numbers)
return minimum, maximum, average
data = [10, 20, 30, 40, 50] ### arguments
min_val, max_val, avg_val = get_stats(data) ### returned and "unpacking"
print(f"Return type: {type(get_stats(data))}")
print(f"Stats for {data}:")
print(f"Min: {min_val}, Max: {max_val}, Average: {avg_val}")
Return type: <class 'tuple'>
Stats for [10, 20, 30, 40, 50]:
Min: 10, Max: 50, Average: 30.0
### === EXERCISE 6: Functions Returning Multiple Values ===
### Write a function called rectangle_stats() that takes two parameters:
### - length: the length of a rectangle
### - width: the width of a rectangle
### The function should calculate and return both the area and perimeter
### Then call the function and unpack the results.
### Your code starts here:
### Your code stops here.
Rectangle (5x3): Area = 15, Perimeter = 16
4.2.7. Scopes#
Variables created inside functions are local to that function:
def my_function():
local_var = 10 # Only exists inside the function
print(local_var)
my_function() # Prints: 10
# print(local_var) # Error: local_var doesn't exist here
Variables outside functions are global:
global_var = 100 # Accessible everywhere
def show_global():
print(global_var) # Can read global variable
show_global() # Prints: 100
4.2.7.1. Variables and parameters are local#
When you create a variable inside a function, it is local, which means it exists only inside the function. For example, the following function takes two arguments, concatenates them, and prints the result twice.
%%expect NameError
def greet():
message = "Hello"
print(message)
greet()
print(message) ### can't do! "message" variable is local to the function definition.
Hello
Hello, Ada
%%expect NameError
def greet():
global message ### must declare first
message = "Hello"
print(message)
greet()
print(message) ### can do! "message" variable is now global in the function definition.
Hello
Hello
4.2.8. Function Composition#
Function composition is the process of using the output of one function as the input to another.
def double(x):
return x * 2
def square(x):
return x ** 2
result = square(double(3))
print(result)
36
### === EXERCISE 7: Function Composition ===
### Write three functions:
### - calculate_area(length, width): returns the area
### - format_result(value): returns 'Area is X square units'
### Then write report_rectangle() that uses both to calculate and format.
### Your code starts here:
### Your code stops here.
Area is 15 square units
Area is 20 square units
### === EXERCISE 9: Variable Scopes ===
### Write a function called modify_variables() that:
### - Takes a parameter x
### - Creates a local variable local_var = x * 2
### - Prints both x and local_var inside the function
### Then call it and try to print local_var outside (it should fail).
### Your code starts here:
Inside: x=5, local_var=10
When we run print_verse, it calls first_two_lines, which calls repeat, which calls print.
That’s a lot of functions.
Of course, we could have done the same thing with fewer functions, but the point of this example is to show how functions can work together.
4.2.9. Docstrings#
Docstrings (documentation strings) are used to document functions/methods, classes, and modules. They use triple quotes and should be the first statement after defining a function or class.
Always document your functions with docstrings:
### Function with docstring
def greet(name):
"""
This function does xxx and yyy. ### 1. what this function is about
Args: ### 2. input parameters
name: The person's name (string)
Returns: ### 3. what the function returns
A greeting message (string)
"""
return f"Hello, {name}!"
message = greet("Homer") ### call the function
As an example:
def calculate_bmi(weight, height):
"""
Calculate Body Mass Index (BMI).
Args:
weight: Weight in kilograms (float)
height: Height in meters (float)
Returns:
BMI value (float)
Example:
>>> calculate_bmi(70, 1.75)
22.86
"""
return weight / (height ** 2)
Good docstrings include:
What the function does
Parameters and their types
What the function returns
Usage examples (optional)
### Practical functions with docstrings
def calculate_discount(price, discount_percent):
"""
Calculate the final price after applying a discount.
Args:
price: Original price (float or int)
discount_percent: Discount percentage (0-100)
Returns:
Final price after discount (float)
"""
discount_amount = price * (discount_percent / 100)
final_price = price - discount_amount
return final_price
def is_valid_email(email):
"""
Check if email has basic valid format.
Args:
email: Email address to validate (str)
Returns:
True if email contains @ and ., False otherwise
"""
return '@' in email and '.' in email
def celsius_to_fahrenheit(celsius):
"""
Convert Celsius to Fahrenheit.
Args:
celsius: Temperature in Celsius
Returns:
Temperature in Fahrenheit
"""
return (celsius * 9/5) + 32
### Test the functions
print("Testing calculate_discount:")
original = 100
discount = 20
final = calculate_discount(original, discount)
print(f" ${original} with {discount}% off = ${final}")
print("\nTesting is_valid_email:")
print(f" 'user@example.com' is valid: {is_valid_email('user@example.com')}")
print(f" 'invalid-email' is valid: {is_valid_email('invalid-email')}")
print("\nTesting celsius_to_fahrenheit:")
print(f" 0°C = {celsius_to_fahrenheit(0)}°F")
print(f" 100°C = {celsius_to_fahrenheit(100)}°F")
print(f" 37°C = {celsius_to_fahrenheit(37):.1f}°F")
Testing calculate_discount:
$100 with 20% off = $80.0
Testing is_valid_email:
'user@example.com' is valid: True
'invalid-email' is valid: False
Testing celsius_to_fahrenheit:
0°C = 32.0°F
100°C = 212.0°F
37°C = 98.6°F
### === EXERCISE 10: Writing Good Docstrings ===
### Write a function called validate_password() that:
### - Takes a password string as a parameter
### - Checks if it's at least 8 characters long
### - Returns True if valid, False otherwise
### Include a comprehensive docstring with description, Args, and Returns sections.
### Your code starts here:
False
True
4.3. Lambda Functions#
Syntax: lambda arguments: expression
Lambda functions are small, anonymous functions that can have any number of arguments but can only have one expression. They are useful for short, simple functions that you don’t want to define formally.
4.3.1. Lambda Limitations#
Can only contain expressions, not statements
Cannot contain assignments, print statements, or other statements
Best for simple, one-line functions
For complex logic, use regular functions (
def)
If you find yourself writing a multi-line lambda, switch to a normal def function instead.
### Basic lambda function
square = lambda x: x ** 2
print(square(5)) # Output: 25
### Lambda with multiple arguments
add = lambda x, y: x + y
print(add(3, 4)) # Output: 7
25
7
4.3.2. Using Lambda with Built-in Functions#
Lambda functions are commonly used with map(), filter(), and sorted().
### Using lambda with map: apply a function to each item
nums = [1, 2, 3, 4]
squares = list(map(lambda x: x ** 2, nums))
print('squares from map:', squares)
### Using lambda with filter: keep only even numbers
evens = list(filter(lambda x: x % 2 == 0, nums))
print('evens from filter:', evens)
### Using lambda with sorted: sort words by length
words = ['spam', 'wonderful', 'eggs', 'SPAM']
by_length = sorted(words, key=lambda w: len(w))
print('sorted by length:', by_length)
squares from map: [1, 4, 9, 16]
evens from filter: [2, 4]
sorted by length: ['spam', 'eggs', 'SPAM', 'wonderful']
4.4. Applications#
4.4.1. Return values and conditionals#
If Python did not provide abs, we could write it like this.
def absolute_value(x):
if x < 0:
return -x
else:
return x
If x is negative, the first return statement returns -x and the function ends immediately.
Otherwise, the second return statement returns x and the function ends.
So this function is correct.
However, if you put return statements in a conditional, you must ensure that every possible path through the program hits a return statement (exhaustive).
For example, here’s an incorrect version of absolute_value.
def absolute_value_wrong(x):
if x < 0:
return -x
if x > 0:
return x
Here’s what happens if we call this function with 0 as an argument.
absolute_value_wrong(0)
We get nothing! Here’s the problem: when x is 0, neither condition is true, and the function ends without hitting a return statement, which means that the return value is None, so Jupyter displays nothing.
As another example, here’s a version of absolute_value with an extra return statement at the end.
def absolute_value_extra_return(x):
if x < 0:
return -x
else:
return x
return 'This is dead code'
If x is negative, the first return statement runs and the function ends.
Otherwise the second return statement runs and the function ends.
Either way, we never get to the third return statement – so it can never run.
Code that can never run is called dead code. In general, dead code doesn’t do any harm, but it often indicates a misunderstanding, and it might be confusing to someone trying to understand the program.
4.4.2. Boolean functions#
Functions can return the boolean values True and False, which is often convenient for encapsulating a complex test in a function.
For example, is_divisible checks whether x is divisible by y with no remainder.
def is_divisible(x, y):
if x % y == 0:
return True
else:
return False
Here’s how we use it.
is_divisible(6, 4)
False
is_divisible(6, 3)
True
Inside the function, the result of the == operator is a boolean, so we can write the
function more concisely by returning it directly.
def is_divisible(x, y):
return x % y == 0
Boolean functions are often used in conditional statements.
if is_divisible(6, 2):
print('divisible')
divisible
It might be tempting to write something like this:
if is_divisible(6, 2) == True:
print('divisible')
divisible
But the comparison is unnecessary.