Functions and Scope
Introduction
Functions are reusable blocks of code that perform specific tasks. They're the primary tool for organizing code, reducing repetition, and building abstractions.
Learning Objectives
By the end of this reading, you will be able to:
- Define and call functions
- Work with parameters and return values
- Understand variable scope
- Use default arguments and keyword arguments
- Write recursive functions
- Apply functional programming concepts
1. What are Functions?
A function is a named sequence of statements that performs a computation.
Why Functions?
- Reusability: Write once, use many times
- Abstraction: Hide complexity behind a simple interface
- Organization: Break programs into logical pieces
- Testing: Test pieces independently
- Readability: Self-documenting code
# Without functions - repetitive
print("Hello, Alice!")
print("How are you, Alice?")
print("Goodbye, Alice!")
print("Hello, Bob!")
print("How are you, Bob?")
print("Goodbye, Bob!")
# With functions - reusable
def greet(name):
print(f"Hello, {name}!")
print(f"How are you, {name}?")
print(f"Goodbye, {name}!")
greet("Alice")
greet("Bob")
2. Defining Functions
Basic Syntax
def function_name(parameters):
"""Docstring explaining what the function does."""
# Function body
return value # Optional
Examples
# No parameters, no return
def say_hello():
print("Hello!")
# With parameters
def greet(name):
print(f"Hello, {name}!")
# With return value
def add(a, b):
return a + b
# With docstring
def calculate_area(length, width):
"""
Calculate the area of a rectangle.
Args:
length: The length of the rectangle
width: The width of the rectangle
Returns:
The area of the rectangle
"""
return length * width
Calling Functions
say_hello() # Hello!
greet("Alice") # Hello, Alice!
result = add(3, 5) # result = 8
print(result)
# Functions can call other functions
def greet_twice(name):
greet(name)
greet(name)
3. Parameters and Arguments
Parameters vs Arguments
- Parameters: Variables in the function definition
- Arguments: Actual values passed when calling
def greet(name): # 'name' is a parameter
print(f"Hello, {name}!")
greet("Alice") # "Alice" is an argument
Positional Arguments
Arguments matched by position:
def describe(name, age, city):
print(f"{name} is {age} years old from {city}")
describe("Alice", 25, "Boston")
# Alice is 25 years old from Boston
Keyword Arguments
Arguments matched by name:
describe(name="Alice", age=25, city="Boston")
describe(city="Boston", name="Alice", age=25) # Order doesn't matter
# Mix positional and keyword (positional must come first)
describe("Alice", city="Boston", age=25)
Default Arguments
Parameters with default values:
def greet(name, greeting="Hello"):
print(f"{greeting}, {name}!")
greet("Alice") # Hello, Alice!
greet("Alice", "Hi") # Hi, Alice!
greet("Alice", greeting="Hey") # Hey, Alice!
Important: Default arguments are evaluated once at definition time:
# Bug! Same list used for all calls
def add_item(item, items=[]):
items.append(item)
return items
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] - not [2]!
# Fix: Use None as default
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Arbitrary Arguments
*args: Variable number of positional arguments
def sum_all(*args):
return sum(args)
print(sum_all(1, 2, 3)) # 6
print(sum_all(1, 2, 3, 4, 5)) # 15
**kwargs: Variable number of keyword arguments
def print_info(**kwargs):
for key, value in kwargs.items():
print(f"{key}: {value}")
print_info(name="Alice", age=25, city="Boston")
# name: Alice
# age: 25
# city: Boston
Combining all:
def complex_function(required, *args, default="value", **kwargs):
print(f"Required: {required}")
print(f"Args: {args}")
print(f"Default: {default}")
print(f"Kwargs: {kwargs}")
complex_function("first", 1, 2, 3, default="custom", x=10, y=20)
4. Return Values
Single Return
def square(x):
return x ** 2
result = square(5) # 25
Multiple Returns
def divide_with_remainder(a, b):
quotient = a // b
remainder = a % b
return quotient, remainder # Returns a tuple
q, r = divide_with_remainder(17, 5)
print(f"17 ÷ 5 = {q} remainder {r}") # 17 ÷ 5 = 3 remainder 2
No Return
Functions without return statement return None:
def greet(name):
print(f"Hello, {name}!")
result = greet("Alice")
print(result) # None
Early Return
def find_first_negative(numbers):
for num in numbers:
if num < 0:
return num
return None # No negative found
def process(value):
if value is None:
return # Exit early
# Rest of processing
print(f"Processing {value}")
5. Scope
Local Scope
Variables defined inside a function:
def my_function():
x = 10 # Local variable
print(x)
my_function()
# print(x) # Error! x doesn't exist here
Global Scope
Variables defined at the top level:
x = 10 # Global variable
def print_x():
print(x) # Can read global
print_x() # 10
Variable Shadowing
Local variables can shadow globals:
x = 10
def my_function():
x = 20 # Creates new local x, doesn't modify global
print(x)
my_function() # 20
print(x) # 10 (global unchanged)
The global Keyword
Modify global variables from within functions:
counter = 0
def increment():
global counter
counter += 1
increment()
increment()
print(counter) # 2
Generally avoid global variables - they make code harder to understand and test.
The nonlocal Keyword
For nested functions:
def outer():
x = 10
def inner():
nonlocal x
x = 20
inner()
print(x) # 20
outer()
LEGB Rule
Python looks for variables in this order:
- Local - Inside the current function
- Enclosing - In outer functions (for nested functions)
- Global - At module level
- Built-in - Python's built-in names
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # "local"
inner()
print(x) # "enclosing"
outer()
print(x) # "global"
6. Pure Functions and Side Effects
Pure Functions
A pure function:
- Always returns the same output for the same input
- Has no side effects (doesn't modify external state)
# Pure function
def add(a, b):
return a + b
# Always: add(2, 3) == 5
Side Effects
Modifications to external state:
results = []
def add_result(value):
results.append(value) # Side effect - modifies external list
return value * 2
add_result(5)
print(results) # [5]
Prefer pure functions when possible - they're easier to test, debug, and reason about.
7. Lambda Functions
Anonymous, single-expression functions:
# Regular function
def square(x):
return x ** 2
# Lambda equivalent
square = lambda x: x ** 2
print(square(5)) # 25
Use Cases
Most useful for short functions passed to other functions:
numbers = [1, 2, 3, 4, 5]
# With map
squared = list(map(lambda x: x**2, numbers))
# [1, 4, 9, 16, 25]
# With filter
evens = list(filter(lambda x: x % 2 == 0, numbers))
# [2, 4]
# With sorted
people = [("Alice", 25), ("Bob", 20), ("Charlie", 30)]
by_age = sorted(people, key=lambda p: p[1])
# [("Bob", 20), ("Alice", 25), ("Charlie", 30)]
8. Higher-Order Functions
Functions that take functions as arguments or return functions.
Functions as Arguments
def apply_twice(func, value):
return func(func(value))
def square(x):
return x ** 2
print(apply_twice(square, 2)) # 16 (square(square(2)) = square(4))
Built-in Higher-Order Functions
numbers = [1, 2, 3, 4, 5]
# map: Apply function to each element
squared = list(map(lambda x: x**2, numbers))
# filter: Keep elements where function returns True
evens = list(filter(lambda x: x % 2 == 0, numbers))
# reduce: Accumulate values
from functools import reduce
total = reduce(lambda acc, x: acc + x, numbers) # 15
Functions Returning Functions
def multiplier(n):
def multiply(x):
return x * n
return multiply
double = multiplier(2)
triple = multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
Decorators
Functions that modify other functions:
def log_calls(func):
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"Finished {func.__name__}")
return result
return wrapper
@log_calls
def greet(name):
print(f"Hello, {name}!")
greet("Alice")
# Calling greet
# Hello, Alice!
# Finished greet
9. Recursion
Functions that call themselves.
Basic Example
def factorial(n):
if n <= 1: # Base case
return 1
return n * factorial(n - 1) # Recursive case
print(factorial(5)) # 120 (5 * 4 * 3 * 2 * 1)
How It Works
factorial(5)
= 5 * factorial(4)
= 5 * 4 * factorial(3)
= 5 * 4 * 3 * factorial(2)
= 5 * 4 * 3 * 2 * factorial(1)
= 5 * 4 * 3 * 2 * 1
= 120
Key Components
- Base case: Condition that stops recursion
- Recursive case: Calls itself with a simpler problem
- Progress: Each call must move toward base case
Common Recursive Patterns
# Sum of list
def sum_list(lst):
if not lst:
return 0
return lst[0] + sum_list(lst[1:])
# Fibonacci
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
# Binary search
def binary_search(arr, target, low, high):
if low > high:
return -1
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
return binary_search(arr, target, mid + 1, high)
else:
return binary_search(arr, target, low, mid - 1)
Recursion vs Iteration
# Iterative factorial
def factorial_iter(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
# Recursive factorial
def factorial_rec(n):
if n <= 1:
return 1
return n * factorial_rec(n - 1)
Tradeoffs:
- Recursion: Often cleaner for recursive data structures (trees)
- Iteration: Usually more efficient (no call stack overhead)
Recursion Limit
Python has a recursion limit (usually 1000):
import sys
print(sys.getrecursionlimit()) # 1000
# Increase if needed (be careful!)
sys.setrecursionlimit(2000)
Exercises
Basic
Write a function
is_even(n)that returns True if n is even.Write a function
max_of_three(a, b, c)that returns the largest of three numbers.Write a function
count_vowels(text)that returns the number of vowels in a string.
Intermediate
- Write a function
apply_to_all(func, lst)that applies a function to every element of a list and returns the new list.
apply_to_all(lambda x: x * 2, [1, 2, 3]) # [2, 4, 6]
- Write a recursive function
sum_digits(n)that returns the sum of digits of a positive integer.
sum_digits(1234) # 10 (1 + 2 + 3 + 4)
- Write a function with a closure:
def make_counter():
# Returns a function that increments and returns a count
pass
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter()) # 3
Advanced
- Write a recursive function to flatten a nested list:
flatten([1, [2, 3], [4, [5, 6]]]) # [1, 2, 3, 4, 5, 6]
- Write a decorator
@memoizethat caches function results:
@memoize
def fibonacci(n):
if n <= 1:
return n
return fibonacci(n-1) + fibonacci(n-2)
# fibonacci(100) should now be fast
- Implement
compose(f, g)that returns a function h where h(x) = f(g(x)):
double = lambda x: x * 2
increment = lambda x: x + 1
double_then_increment = compose(increment, double)
print(double_then_increment(5)) # 11
Summary
- Functions encapsulate reusable code
- Parameters receive values; return sends values back
- Scope determines where variables are accessible (LEGB rule)
- Default arguments provide fallback values
- *args and **kwargs handle variable arguments
- Lambda functions are anonymous single-expression functions
- Higher-order functions operate on other functions
- Recursion solves problems by self-reference