Lecture#

Introduction to Functions#

Reusable blocks of code that perform a specific task.

Create a file called functions_basics_demo.py to follow along with the examples below.

What Is a Function?

In Python, a function is a named block of organized, reusable code that is designed to perform a single, related action.

Why Use Functions?

  • Modularity – Break down complex processes into smaller, manageable pieces. This modular approach makes it easier to understand, develop, and test your code.

  • Code Reuse – Once a function is written, it can be used multiple times throughout your program, reducing the chances of errors and inconsistencies.

  • Easier Maintenance – Making a change in one function can affect the entire program if that function is used throughout. This centralized approach makes it easier to update and maintain your programs.

  • Foundation for Advanced Concepts – Functions form the basis for understanding closures, decorators, and concurrency.

Basic Structure

Anatomy of a Function

  • Function Definition – Begins with the def keyword, followed by the function name, parentheses () containing any parameters, and a colon :.

  • Parameters (Optional) – Variables listed inside the parentheses that act as placeholders for the values you pass into the function.

  • Docstring (Recommended) – A documentation string immediately following the function definition.

  • Function Body – The indented block of code that executes when the function is called.

  • Return Statement (Optional) – Sends a value back to the caller using return. If omitted, the function returns None.

Note

Syntax:

def function_name(param1, param2):
    """Documentation string."""
    # function body
    return something

Example

def greet(name):
    """
    Print a greeting message to the user.

    Args:
        name (str): The name of the user.
    """
    print(f"Hello, {name}!")

greet("Alice")  # Hello, Alice!

Tip

To access the docstring of any function, use print(greet.__doc__) or the built-in help(greet).

Best Practices and Conventions
  • Use snake_case for function names – All lowercase with underscores separating words. Class names use PascalCase, but functions and variables always use snake_case.

    def compute_distance(x1, y1, x2, y2):  # Good
    def ComputeDistance(x1, y1, x2, y2):   # Bad (PascalCase is for classes)
    
  • Start with a verb – Function names should describe an action: get_, compute_, is_, has_, create_, update_, validate_. Use is_/has_ for functions returning booleans.

    def compute_area(length, width):  # Good (verb + noun)
    def area(length, width):          # Acceptable but less descriptive
    def is_valid(sensor_id):          # Good (boolean return)
    
  • Always include a docstring – Every function should have a docstring explaining what it does, its parameters, and its return value.

  • Do one thing well – A function should perform a single, well-defined task. If a function does too many things, split it into smaller functions.

  • Keep functions short – If a function exceeds 20-30 lines, consider refactoring.

  • Add type hints – Annotate parameters and return types for clarity.

References: PEP 8: Function and Variable Names | PEP 257: Docstring Conventions | Google Python Style Guide

Google-Style Docstrings

When writing docstrings, use a consistent convention. The Google style is widely used in Python projects.

def compute_distance(x1, y1, x2, y2):
    """Compute the Euclidean distance between two 2D points.

    Args:
        x1 (float): X-coordinate of the first point.
        y1 (float): Y-coordinate of the first point.
        x2 (float): X-coordinate of the second point.
        y2 (float): Y-coordinate of the second point.

    Returns:
        float: The Euclidean distance between the two points.
    """
    return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5

print(compute_distance(0, 0, 3, 4))  # 5.0

Warning

For brevity, some examples in these slides omit docstrings. This is not good practice. Always include a docstring when you define a function, whether for assignments, projects, or professional code.

Function Calls#

Invoking functions, returning values, and understanding redefinitions.

Create a file called function_calls_demo.py to follow along with the examples below.

Calling a Function

To call a function, use the function’s name followed by parentheses containing the arguments that match the function’s parameters.

# function definition
def add(a, b):
    return a + b

# function call
result = add(1, 2)
print(f"{result=}")  # result=3

Steps Involved in a Function Call

  1. Name Resolution – Python looks up the function name to find its definition.

  2. Argument Matching – Arguments are matched with parameters in order.

  3. New Scope Creation – A new local scope is created for the function.

  4. Body Execution – The code inside the function runs.

  5. Return Value – The result (or None) is sent back to the caller.

  6. Cleanup – The local scope is discarded.

Multiple Return Values

A function can return multiple values as a tuple, which can be unpacked by the caller.

def rectangle_info(length, width):
    area = length * width
    perimeter = 2 * (length + width)
    return area, perimeter  # Returns a tuple

# Tuple unpacking
area, perimeter = rectangle_info(5, 3)
print(f"{area=}, {perimeter=}")  # area=15, perimeter=16

Note

return area, perimeter is equivalent to return (area, perimeter). Python implicitly creates a tuple.

Function Redefinitions

In Python, function names are just variables bound to function objects. Redefining a function replaces the old binding.

def greet():
    print("Hello!")

greet()  # Hello!

# Redefining the function
def greet():
    print("Hi there!")

greet()  # Hi there!  (the original version is gone)

Warning

Python does not warn you when you redefine a function. The new definition silently replaces the previous one. Be careful with naming!

Function Arguments#

Understanding how data flows into functions.

Create a file called function_arguments_demo.py to follow along with the examples below.

Positional Arguments

Positional arguments are arguments that must be passed in the correct order when calling a function. The order must match the order in which the parameters were defined.

def display_info(name, age):
    print(f"{name=}, {age=}")

# Correct usage
display_info("Alice", 30)  # name='Alice', age=30

# Incorrect usage - values get swapped
display_info(30, "Alice")  # name=30, age='Alice'
Default Arguments

Default arguments allow you to specify values that parameters take if no argument is provided during the function call.

def display_info(name, age=35):
    print(f"{name=}, {age=}")

display_info("Bob")        # name='Bob', age=35
display_info("Alice", 30)  # name='Alice', age=30

Ordering Rule

Parameters with default values must come after those without default values.

# Correct
def func(a, b, c=10, d=20):
    pass

# SyntaxError: non-default argument follows default argument
# def func(a, b=10, c, d=20):
#     pass
Danger: Mutable Default Values

Default values are evaluated once when the function is defined, not each time it is called. Using a mutable object (like a list) as a default value can lead to surprising behavior.

# BAD: Mutable default value
def add_item_bad(item, items=[]):
    items.append(item)
    return items

print(add_item_bad("apple"))   # ['apple']
print(add_item_bad("banana"))  # ['apple', 'banana'] -- Surprise!
# GOOD: Use None and create a new list each time
def add_item_good(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

print(add_item_good("apple"))   # ['apple']
print(add_item_good("banana"))  # ['banana']
Keyword Arguments

Keyword arguments allow you to pass arguments by explicitly naming the parameter, regardless of their position in the function call.

def create_robot(name, robot_type, speed):
    print(f"{name}: {robot_type}, max speed={speed} m/s")

# Using keyword arguments (order does not matter)
create_robot(speed=0.26, name="TurtleBot3", robot_type="mobile")

Mixing Positional and Keyword Arguments

# Positional args must come before keyword args
create_robot("TurtleBot3", speed=0.26, robot_type="mobile")

# SyntaxError: positional argument follows keyword argument
# create_robot(name="TurtleBot3", "mobile", speed=0.26)

Warning

Once you use a keyword argument in a function call, all subsequent arguments must also be keyword arguments.

Variable-Length Positional Arguments (*args)

*args (args is a convention) allows a function to accept any number of positional arguments. Inside the function, args is a tuple.

Note

Syntax: def function_name(*args):

def compute_sum(*args):
    print(f"args = {args}")   # args is a tuple
    print(f"type = {type(args)}")
    return sum(args)

print(compute_sum(1, 2, 3))        # 6
print(compute_sum(10, 20, 30, 40)) # 100
Variable-Length Keyword Arguments (**kwargs)

**kwargs (kwargs is a convention) allows a function to accept any number of keyword arguments. Inside the function, kwargs is a dictionary.

Note

Syntax: def function_name(**kwargs):

def print_robot_config(**kwargs):
    """Print all keyword arguments as key-value pairs."""
    for key, value in kwargs.items():
        print(f"  {key}: {value}")

print_robot_config(name="UR5", joints=6, payload=5.0)
# name: UR5
# joints: 6
# payload: 5.0
Combining *args and **kwargs
def log_message(level, *args, **kwargs):
    print(f"[{level}] args={args}, kwargs={kwargs}")

log_message("INFO", 1, 2, 3, user="Alice", action="login")
# [INFO] args=(1, 2, 3), kwargs={'user': 'Alice', 'action': 'login'}

Parameter Ordering Rules

When defining a function, parameters must appear in this order:

  1. Standard positional parameters

  2. *args (variable-length positional)

  3. Keyword-only parameters (after *args)

  4. **kwargs (variable-length keyword)

def example(a, b, *args, option=True, **kwargs):
    print(f"{a=}, {b=}, {args=}, {option=}, {kwargs=}")

example(1, 2, 3, 4, option=False, x=10)
# a=1, b=2, args=(3, 4), option=False, kwargs={'x': 10}
Argument Packing

Argument packing is the process of collecting multiple arguments into a single parameter. This is what *args and **kwargs do: they pack multiple values into a tuple or dictionary.

# *args packs positional arguments into a tuple
def show_args(*args):
    print(f"Packed into tuple: {args}")

show_args(1, 2, 3)  # Packed into tuple: (1, 2, 3)

# **kwargs packs keyword arguments into a dict
def show_kwargs(**kwargs):
    print(f"Packed into dict: {kwargs}")

show_kwargs(x=10, y=20)  # Packed into dict: {'x': 10, 'y': 20}
Argument Unpacking

Argument unpacking is the reverse: spreading elements of a sequence or mapping into individual arguments during a function call.

def add(a, b, c):
    return a + b + c

# Unpack a list/tuple with *
values = [1, 2, 3]
print(add(*values))  # print(add(1,2,3)) -> 6

# Unpack a dictionary with **
params = {"a": 10, "b": 20, "c": 30}
print(add(**params))  # print(add(a=10, b=20, c=30)) -> 60

Note

* unpacks sequences into positional arguments. ** unpacks dictionaries into keyword arguments. The dictionary keys must match the parameter names.

Scopes#

Where variables live and how Python resolves names.

Create a file called scopes_demo.py to follow along with the examples below.

The LEGB Rule

A scope is the region of a program where a variable is accessible. Python resolves names using the LEGB rule, searching in this order:

  • L – Local: Names defined inside the current function.

  • E – Enclosing: Names in the scope of any enclosing (outer) functions.

  • G – Global: Names defined at the top level of the module.

  • B – Built-in: Names pre-defined in Python (len, print, True, etc.).

x = "global"          # Global scope

def outer():
    x = "enclosing"   # Enclosing scope
    def inner():
        x = "local"   # Local scope
        print(x)       # "local" (L found first)
    inner()

outer()
Local Scope (L)

The local scope includes names defined inside the current function, including its parameters.

def local_test(local_param):
    local_var = "I am local var"
    print(local_param)  # Accessible
    print(local_var)    # Accessible

local_test("I am local param")
# print(local_var)    # NameError: name 'local_var' is not defined
# print(local_param)  # NameError: name 'local_param' is not defined

Note

Variables created inside a function exist only while the function is executing. They are discarded when the function returns.

Enclosing Scope (E)

The enclosing scope applies to nested functions. The inner function can access variables from the outer function.

def outer_func():
    enclosing_var = "I am enclosing"

    def inner_func():
        local_var = "I am local"
        print(enclosing_var)  # Accessible as an enclosing variable
        print(local_var)      # Accessible as a local variable

    inner_func()

outer_func()
Nested Functions

A nested function (or inner function) is a function defined inside another function. The inner function has access to variables in the enclosing (outer) function’s scope.

Why Use Nested Functions?

  • Encapsulation – Hide helper logic that is only relevant inside the outer function. The inner function is invisible to the rest of the program.

  • Closures – The inner function can “remember” variables from the enclosing scope even after the outer function has returned (covered in L5).

  • Decorators – Built on nested functions that wrap and extend other functions (covered in L5).

The nonlocal Keyword

To modify an enclosing variable from the inner function, use nonlocal. Without it, assigning creates a new local variable.

1def outer_func():
2    count = 0
3    def inner_func():
4        nonlocal count
5        count += 1
6    inner_func()
7    print(count)  # 1
8
9outer_func()

Tip

Question: What happens if you comment out line 4?

Global Scope (G)

The global scope includes names defined at the top level of a Python file.

global_var = "I am global"

def my_func():
    print(global_var)  # Accessible (reading is fine)

my_func()

The global Keyword

To rebind a global variable inside a function, use the global keyword.

global_var = "I am global"

def my_func():
    global global_var
    global_var = "Modified inside function"

my_func()
print(global_var)  # "Modified inside function"

Warning

Overusing global makes code harder to debug and test. Prefer passing values as arguments and returning results instead.

Mutable Globals: No global Needed for In-Place Modifications

You do not need the global keyword to modify a mutable global object in place, because you are not rebinding the name.

fruits = ["apple", "banana", "cherry"]

def add_to_list(item):
    fruits.append(item)  # In-place mutation, no rebinding

add_to_list("orange")
print(fruits)  # ['apple', 'banana', 'cherry', 'orange']

Note

Why? fruits.append(item) modifies the existing list object. The name fruits still points to the same object. The global keyword is only needed when you want to reassign the name itself (e.g., fruits = [...]).

Built-in Scope (B)

The built-in scope includes names pre-defined in Python:

  • Functions: len, print, id, range, type, etc.

  • Types: int, str, list, dict, etc.

  • Constants: True, False, None

  • Exception classes: ValueError, TypeError, etc.

# List all built-in names
print(dir(__builtins__))

Warning

Avoid shadowing built-in names! For example, never name a variable list, dict, type, or id. This hides the built-in and can cause confusing errors.

list = [1, 2, 3]          # Shadows the built-in list()
print(list)                # [1, 2, 3] -- works fine here

nums = list(range(5))      # TypeError: 'list' object is not callable
# Python thinks 'list' is [1, 2, 3], not the built-in list()

Pass-by-Assignment#

How Python passes arguments to functions.

Create a file called pass_by_assignment_demo.py to follow along with the examples below.

How Python Passes Arguments

Python’s argument-passing mechanism is called pass-by-assignment (sometimes called “pass-by-object-reference”). It is neither purely pass-by-value nor pass-by-reference.

  • Function arguments are passed by object reference – When you pass an argument, Python passes a reference to the object, not a copy.

  • Immutable objects (int, str, tuple) – Changes inside the function create a new object; the original remains unchanged.

  • Mutable objects (list, dict, set) – In-place modifications inside the function affect the original object.

  • Reassignment creates a new binding – If you reassign a parameter to a new object inside a function, this does not affect the original variable outside.

Immutable vs Mutable Examples
def update_number(x):
    x = 10  # Creates a new local binding

num = 5
update_number(num)
print(num)  # 5 (unchanged)
def update_list(a_list):
    a_list.append(4)  # Modifies the original object in place

my_list = [1, 2, 3]
update_list(my_list)
print(my_list)  # [1, 2, 3, 4] (modified!)

Note

a_list.append(4) mutates the existing list. But a_list = [10, 20] would rebind the local name a_list to a new list, leaving the original unchanged.

Predict the Outputs
def edit_inputs(fruits, animals, age):
    fruits.append('cherry')
    fruits[0] = 'mango'
    fruits = ['quince', 'pear']
    animals = ['elephant']
    age += 1

fruits = ["apple", "banana"]
animals = ["bear", "tiger"]
age = 40

print(fruits, animals, age)
edit_inputs(fruits, animals, age)
print(fruits, animals, age)
Reveal Answer
['apple', 'banana'] ['bear', 'tiger'] 40
['mango', 'banana', 'cherry'] ['bear', 'tiger'] 40
  • fruits.append('cherry') and fruits[0] = 'mango' mutate the original list.

  • fruits = ['quince', 'pear'] rebinds the local name, so the original is not affected by this line.

  • animals = ['elephant'] rebinds the local name; the original list is unchanged.

  • age += 1 creates a new int locally; the original age is unchanged.

Type Hints#

Annotating functions with expected types for clarity and tooling.

Create a file called type_hints_demo.py to follow along with the examples below.

Introduction to Type Hints

Type hints (also called type annotations) allow you to specify the expected types of function parameters and return values. They are not enforced at runtime but improve readability and enable static analysis tools like mypy.

Note

Syntax: def func(param: type) -> return_type:

def add(a: int, b: int) -> int:
    return a + b

def greet(name: str) -> None:
    print(f"Hello, {name}!")

# Type hints are NOT enforced at runtime
result = add("hello", " world")  # Works! Returns "hello world"
Why Use Type Hints?
  • Documentation – Makes function signatures self-documenting.

  • IDE Support – Enables better autocompletion and error detection.

  • Static Analysis – Tools like mypy can catch type errors before runtime.

  • Team Communication – Makes the expected interface clear to other developers.

Optional – A Value That Might Be None

Optional[X] means the value is either of type X or None. This is common for functions that may fail to find a result.

from typing import Optional

def find_index(items: list[str], target: str) -> Optional[int]:
    """Return the index of target in items, or None."""
    for i, item in enumerate(items):
        if item == target:
            return i
    return None

sensors = ["lidar", "camera", "imu"]
result = find_index(sensors, "camera")
print(result)  # 1

result = find_index(sensors, "radar")
print(result)  # None

Note

Optional[int] is equivalent to Union[int, None]. It signals to the caller that the return value must be checked before use.

Union – One of Several Types

Union[X, Y] means the value can be of type X or type Y. This is useful when a function accepts or returns more than one type.

from typing import Union

def normalize(data: Union[str, list]) -> list[str]:
    """Convert input to a list of strings."""
    if isinstance(data, str):
        return [data]
    return [str(item) for item in data]

print(normalize("hello"))       # ['hello']
print(normalize([1, 2, 3]))     # ['1', '2', '3']

Note

Use Union when a function genuinely needs to handle multiple types. If you find yourself using Union with many types, consider whether the function is doing too much.

Python 3.10+ Simplified Syntax

Starting with Python 3.10, you can use the | operator instead of importing Optional and Union from the typing module.

# Before Python 3.10
from typing import Optional, Union

def find_item(name: str) -> Optional[int]:
    ...

def process(data: Union[str, list]) -> str:
    ...
# Python 3.10+ (no import needed)
def find_item(name: str) -> int | None:
    ...

def process(data: str | list) -> str:
    ...

Tip

Use the | syntax if your project targets Python 3.10 or later. It is more readable and requires no imports.

Collection Type Hints
# Python 3.9+ - use built-in types directly
def process_readings(readings: list[float]) -> float:
    return sum(readings) / len(readings)

def get_config() -> dict[str, int]:
    return {"timeout": 30, "retries": 3}

def get_coordinates() -> tuple[float, float]:
    return (3.14, 2.72)

# Variable-length tuple (all same type)
def get_ids() -> tuple[int, ...]:
    return (1, 2, 3, 4, 5)

# Combining with functions
def transform(items: list[str],
              func: callable) -> list[str]:
    return [func(item) for item in items]

Recursive Functions#

Functions that call themselves to solve problems.

Create a file called recursion_demo.py to follow along with the examples below.

What Is Recursion?

A recursive function is a function that calls itself within its own definition. Every recursive function needs:

  • A base case – the condition that stops the recursion.

  • A recursive case – the function calling itself with a simpler input.

Example: Factorial

def factorial(n: int) -> int:
    """Compute n! recursively."""
    if n == 0:           # Base case
        return 1
    else:                # Recursive case
        return n * factorial(n - 1)

print(factorial(5))  # 120  (5 * 4 * 3 * 2 * 1)

Warning

Without a proper base case, recursion leads to infinite calls and a RecursionError. Python’s default recursion limit is 1000 calls.

Tracing the Call Stack

Each recursive call creates a new frame on the call stack:

Call

Returns

factorial(5)

5 * factorial(4)

factorial(4)

4 * factorial(3)

factorial(3)

3 * factorial(2)

factorial(2)

2 * factorial(1)

factorial(1)

1 * factorial(0)

factorial(0)

1 (base case)

The results then propagate back up: 1 -> 1 -> 2 -> 6 -> 24 -> 120

Note

Each call to factorial has its own local variable n. This is possible because each call gets its own scope on the call stack.

Summary#

  • Function Basicsdef, parameters, docstrings, return, multiple return values

  • Arguments – Positional, default, keyword, *args, **kwargs

  • Packing/Unpacking* for sequences, ** for dicts

  • Scopes – LEGB rule, global, nonlocal

  • Pass-by-Assignment – Mutable vs immutable behavior in function calls

  • Type Hints – Annotations for parameters and return types

  • Recursion – Base case, recursive case, call stack

Table 2 Argument Types at a Glance#

Argument Type

Syntax

Key Rule

Positional

func(a, b)

Order matters

Default

func(a, b=10)

Must follow non-defaults

Keyword

func(b=10, a=5)

Order does not matter

*args

func(*args)

Packed into a tuple

**kwargs

func(**kwargs)

Packed into a dict

Note

Reminder: Review and experiment with all provided code before next class.

Preview: What’s Next in L5#

L5: Advanced Functions
  • Programming paradigms (procedural, functional, OOP)

  • First-class functions and lambdas

  • Closures and callables

  • Decorators

  • Partial functions

Note

Today’s lecture gives you the function fundamentals that are essential for understanding the advanced function concepts in L5 and object-oriented programming later in the course.