4.1. Functions#

Hide code cell source

import sys
from pathlib import Path

current = Path.cwd()
for parent in [current, *current.parents]:
    if (parent / '_config.yml').exists():
        project_root = parent  # ← Add project root, not chapters
        break
else:
    project_root = Path.cwd().parent.parent

sys.path.insert(0, str(project_root))

from shared import thinkpython, diagram, jupyturtle

# Register as top-level module so 'import jupyturtle' and
# 'from jupyturtle import ...' work in subsequent cells
sys.modules['jupyturtle'] = jupyturtle

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 def

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.

Hide code cell source

def polygon(n):
    jupyturtle.make_turtle()
    
    angle = 360/n
    for i in range(n):
        jupyturtle.forward(50)
        jupyturtle.left(angle)

polygon(3)
polygon(4)
polygon(5)
'
'
'

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:

    1. def: Keyword that starts a function definition

    2. Function name: Follows variable naming rules (snake_case)

    3. Parameters: Input values (optional)

    4. Colon

  • Body

    1. Indentation (4 as recommended by PEP8)

    2. Docstring: Documentation string (recommended)

    3. Function body: Indented (instead of {}) code that runs when the function is called

    4. return: 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:

  1. Positional Parameters: These are matched to arguments based on their position in the function definition.

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

  3. *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.

  4. **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:

  1. Keyword-Only Parameters: Parameters that can only be passed by name, not by position. They come after *args or a single *.

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

Hide code cell source

### Solution:
def greet_with_title(name, title="Doctor"):
    print(f"Hello, {title} {name}!")

### Call with and without the title parameter
greet_with_title("Alice")
greet_with_title("Bob", "Professor")
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.

Hide code cell source

### Solution:
def introduce(name, age):
    print(f"Hi, I'm {name} and I am {age} years old")

### Test the function with different values
introduce("Alice", 25)
introduce("Bob", 30)
introduce("Charlie", 22)
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 *args and **kwargs for extra values.

*args collects extra positional arguments into a tuple, while **kwargs collects extra keyword arguments into a dictionary. As a comparison:

Feature

*args

**kwargs

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.

Hide code cell source

### Solution:
def add_numbers(*args):
    """Takes any number of arguments and returns their sum"""
    print(f"Number of values passed: {len(args)}")
    total = sum(args)
    return total


# Test it
result = add_numbers(10, 20, 30, 40)
print(f"Result: {result}")
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 return returns None

  • You can return multiple values: return x, y, z, but still one thing: a tuple

  • return immediately 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.

Hide code cell source

### Solution:
def add_tax(price, tax_rate):
    tax_amount = price * tax_rate
    return price + tax_amount

### Test with different prices
total1 = add_tax(100, 0.08)
print(f"Price: $100, Tax Rate: 8%, Total: ${total1}")

total2 = add_tax(50, 0.1)
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.