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
defkeyword, 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 returnsNone.
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_casefor function names – All lowercase with underscores separating words. Class names usePascalCase, but functions and variables always usesnake_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_. Useis_/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
Name Resolution – Python looks up the function name to find its definition.
Argument Matching – Arguments are matched with parameters in order.
New Scope Creation – A new local scope is created for the function.
Body Execution – The code inside the function runs.
Return Value – The result (or
None) is sent back to the caller.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:
Standard positional parameters
*args(variable-length positional)Keyword-only parameters (after
*args)**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,NoneException 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')andfruits[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 += 1creates a new int locally; the originalageis 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
mypycan 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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 Basics –
def, parameters, docstrings,return, multiple return valuesArguments – Positional, default, keyword,
*args,**kwargsPacking/Unpacking –
*for sequences,**for dicts
Scopes – LEGB rule,
global,nonlocalPass-by-Assignment – Mutable vs immutable behavior in function calls
Type Hints – Annotations for parameters and return types
Recursion – Base case, recursive case, call stack
Argument Type |
Syntax |
Key Rule |
|---|---|---|
Positional |
|
Order matters |
Default |
|
Must follow non-defaults |
Keyword |
|
Order does not matter |
|
|
Packed into a tuple |
|
|
Packed into a dict |
Note
Reminder: Review and experiment with all provided code before next class.
Preview: What’s Next in L5#
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.