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?

  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 →