Functions

Reusable blocks of code.

Defining Functions

def greet(name):
    """Greet someone by name."""
    return f"Hello, {name}!"

# Call the function
message = greet("Alice")
print(message)  # Hello, Alice!

Docstrings

First string in a function documents it:

def calculate_area(width, height):
    """
    Calculate the area of a rectangle.

    Args:
        width: The width of the rectangle.
        height: The height of the rectangle.

    Returns:
        The area as a float.

    Raises:
        ValueError: If width or height is negative.
    """
    if width < 0 or height < 0:
        raise ValueError("Dimensions must be positive")
    return width * height

# Access docstring
print(calculate_area.__doc__)
help(calculate_area)

Return Values

# Single return
def add(a, b):
    return a + b

# Multiple returns (tuple)
def divide(a, b):
    quotient = a // b
    remainder = a % b
    return quotient, remainder

q, r = divide(17, 5)  # q=3, r=2

# No return = returns None
def greet(name):
    print(f"Hello, {name}")

result = greet("Bob")  # result is None

# Early return
def find_first_even(numbers):
    for n in numbers:
        if n % 2 == 0:
            return n
    return None

Parameters and Arguments

Positional Arguments

def greet(first, last):
    return f"Hello, {first} {last}"

greet("John", "Doe")        # Positional
greet(first="John", last="Doe")  # Keyword
greet("John", last="Doe")   # Mixed

Default Arguments

def greet(name, greeting="Hello"):
    return f"{greeting}, {name}!"

greet("Alice")              # Hello, Alice!
greet("Alice", "Hi")        # Hi, Alice!
greet("Alice", greeting="Hey")  # Hey, Alice!

Warning: Never use mutable defaults:

# WRONG - same list shared across calls
def add_item(item, items=[]):
    items.append(item)
    return items

# CORRECT - use None sentinel
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

*args - Variable Positional Arguments

def sum_all(*numbers):
    """Accept any number of positional arguments."""
    return sum(numbers)

sum_all(1, 2, 3)        # 6
sum_all(1, 2, 3, 4, 5)  # 15

# Unpack into function
nums = [1, 2, 3]
sum_all(*nums)          # Same as sum_all(1, 2, 3)

**kwargs - Variable Keyword Arguments

def print_info(**kwargs):
    """Accept any number of keyword arguments."""
    for key, value in kwargs.items():
        print(f"{key}: {value}")

print_info(name="Alice", age=30, city="NYC")

# Unpack dict into function
data = {"name": "Bob", "age": 25}
print_info(**data)

Combining Parameter Types

Order matters:

def func(pos1, pos2, *args, kw1, kw2="default", **kwargs):
    """
    pos1, pos2 - required positional
    *args - extra positional
    kw1 - required keyword-only
    kw2 - optional keyword-only
    **kwargs - extra keyword
    """
    pass

# Keyword-only without *args
def func(a, b, *, required_kw, optional_kw="default"):
    pass

func(1, 2, required_kw="value")  # Works
func(1, 2, "value")              # Error - required_kw is keyword-only

Positional-Only Parameters (3.8+)

def greet(name, /, greeting="Hello"):
    """name must be positional, greeting can be either."""
    return f"{greeting}, {name}"

greet("Alice")              # Works
greet("Alice", "Hi")        # Works
greet(name="Alice")         # Error - name is positional-only
greet("Alice", greeting="Hi")  # Works

Lambda Functions

Anonymous single-expression functions:

# Regular function
def add(a, b):
    return a + b

# Lambda equivalent
add = lambda a, b: a + b

# Common uses
numbers = [3, 1, 4, 1, 5, 9]
sorted(numbers, key=lambda x: -x)           # Descending
sorted(names, key=lambda s: s.lower())      # Case-insensitive
filter(lambda x: x > 0, numbers)            # Keep positives
map(lambda x: x ** 2, numbers)              # Square each

Lambdas are limited to one expression. For anything complex, use a regular function.

Scope

LEGB Rule

Python looks up names in this order:

  1. Local - Inside current function
  2. Enclosing - Inside enclosing functions
  3. Global - Module level
  4. Built-in - Python's built-ins
x = "global"

def outer():
    x = "enclosing"

    def inner():
        x = "local"
        print(x)  # local

    inner()
    print(x)  # enclosing

outer()
print(x)  # global

global and nonlocal

count = 0

def increment():
    global count    # Modify global variable
    count += 1

# For enclosing scope (not global)
def outer():
    count = 0

    def inner():
        nonlocal count  # Modify enclosing variable
        count += 1

    inner()
    return count

Best practice: Avoid global. Pass values as arguments and return results instead.

Decorators

Functions that modify other functions:

Basic Decorator

def uppercase(func):
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        return result.upper()
    return wrapper

@uppercase
def greet(name):
    return f"Hello, {name}"

greet("world")  # HELLO, WORLD

Decorator with Arguments

def repeat(times):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello():
    print("Hello!")

say_hello()  # Prints "Hello!" 3 times

Preserving Function Metadata

from functools import wraps

def my_decorator(func):
    @wraps(func)  # Preserves __name__, __doc__, etc.
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

Common Built-in Decorators

class MyClass:
    @staticmethod
    def static_method():
        """No self, no cls. Just a namespaced function."""
        pass

    @classmethod
    def class_method(cls):
        """Receives class, not instance."""
        return cls()

    @property
    def computed(self):
        """Access like attribute, computed on demand."""
        return self._value * 2

# functools decorators
from functools import lru_cache, cached_property

@lru_cache(maxsize=128)
def expensive_computation(n):
    """Results are cached."""
    return sum(range(n))

Generators

Functions that yield values one at a time:

def countdown(n):
    while n > 0:
        yield n
        n -= 1

for x in countdown(5):
    print(x)  # 5, 4, 3, 2, 1

# Generator expression (like list comprehension)
squares = (x**2 for x in range(10))

# Generators are memory efficient
# This doesn't create a huge list in memory
sum(x**2 for x in range(1_000_000))

yield vs return

def get_values_return():
    return [1, 2, 3]  # Creates and returns entire list

def get_values_yield():
    yield 1           # Produces values one at a time
    yield 2
    yield 3

# yield from - delegate to another generator
def chain(*iterables):
    for it in iterables:
        yield from it

list(chain([1, 2], [3, 4]))  # [1, 2, 3, 4]

Type Hints

def greet(name: str) -> str:
    return f"Hello, {name}"

def process(items: list[int]) -> dict[str, int]:
    return {"sum": sum(items), "count": len(items)}

# Optional values
from typing import Optional

def find(key: str) -> Optional[str]:
    return None  # or a string

# Union types (3.10+)
def process(data: str | bytes) -> str:
    pass

# Callable type
from typing import Callable

def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
    return func(a, b)

Best Practices

Keep Functions Small

# Too many things at once
def process_user_data(user_id):
    # fetch user
    # validate data
    # transform data
    # save to database
    # send notification
    pass

# Better - separate concerns
def fetch_user(user_id): ...
def validate_user(user): ...
def transform_user(user): ...
def save_user(user): ...
def notify_user(user): ...

def process_user_data(user_id):
    user = fetch_user(user_id)
    validate_user(user)
    transformed = transform_user(user)
    save_user(transformed)
    notify_user(user)

Pure Functions

Prefer functions without side effects:

# Impure - modifies external state
total = 0
def add_to_total(n):
    global total
    total += n

# Pure - same inputs always give same output
def add(a, b):
    return a + b

Avoid Deep Nesting

# Hard to read
def process(data):
    if data:
        if is_valid(data):
            if has_permission():
                # finally do something
                pass

# Better - early returns
def process(data):
    if not data:
        return
    if not is_valid(data):
        return
    if not has_permission():
        return
    # do something

Practice

# 1. Function with *args and **kwargs
def log(message, *args, level="INFO", **kwargs):
    formatted = message.format(*args)
    print(f"[{level}] {formatted}")
    for k, v in kwargs.items():
        print(f"  {k}={v}")

log("Processing {} items", 42, level="DEBUG", user="alice")

# 2. Decorator
def timer(func):
    import time
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f"{func.__name__} took {time.time() - start:.2f}s")
        return result
    return wrapper

# 3. Generator
def fibonacci(n):
    a, b = 0, 1
    for _ in range(n):
        yield a
        a, b = b, a + b

list(fibonacci(10))  # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]