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:
- Local - Inside current function
- Enclosing - Inside enclosing functions
- Global - Module level
- 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]