Functions and Scope
Functions are reusable blocks of code that perform specific tasks. They're the primary tool for organizing code, reducing repetition, and building abstractions.
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