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?

  1. Reusability: Write once, use many times
  2. Abstraction: Hide complexity behind a simple interface
  3. Organization: Break programs into logical pieces
  4. Testing: Test pieces independently
  5. 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:

  1. Local - Inside the current function
  2. Enclosing - In outer functions (for nested functions)
  3. Global - At module level
  4. 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

  1. Base case: Condition that stops recursion
  2. Recursive case: Calls itself with a simpler problem
  3. 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

  1. Write a function is_even(n) that returns True if n is even.

  2. Write a function max_of_three(a, b, c) that returns the largest of three numbers.

  3. Write a function count_vowels(text) that returns the number of vowels in a string.

Intermediate

  1. 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]
  1. 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)
  1. 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

  1. Write a recursive function to flatten a nested list:
flatten([1, [2, 3], [4, [5, 6]]])  # [1, 2, 3, 4, 5, 6]
  1. Write a decorator @memoize that 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
  1. 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

Next Reading

Object-Oriented Programming →