4.1. 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.
Jupyter Turtle
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 0x10c8e17f0>
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.
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)
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.1.1. 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.1.2. 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
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
### === 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.
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
*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.1.3. Return Values#
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.1.4. 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.1.5. Return Values in Applications#
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.
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.