# Python Functions Cheatsheet

## Basic Function Types

### Built-in Functions
```python
# Common built-in functions
len(sequence)        # Returns length of a sequence
print(*objects)      # Prints objects to console
type(object)         # Returns the type of an object
range(start, stop)   # Creates sequence of numbers
max(iterable)        # Returns maximum value
min(iterable)        # Returns minimum value
sum(iterable)        # Sums items in an iterable
```

### User-defined Functions
```python
# Basic function definition
def function_name(parameters):
    """Docstring: explains what function does"""
    # Function body
    return value    # Optional return statement

# Example
def square(x):
    """Returns the square of a number."""
    return x * x
```

### Lambda Functions (Anonymous)
```python
# lambda arguments: expression
square = lambda x: x * x
add = lambda x, y: x + y

# With higher-order functions
list(map(lambda x: x * 2, [1, 2, 3]))  # [2, 4, 6]
```

## Function Parameters

### No Parameters
```python
def say_hello():
    return "Hello, world!"

# Call with no arguments
say_hello()
```

### Required Parameters
```python
def multiply(a, b):
    return a * b

# Must provide all required arguments
result = multiply(5, 3)  # 15
```

### Default Parameters
```python
def power(base, exponent=2):
    return base ** exponent

# Default parameters are optional
power(5)      # 25 (uses default exponent=2)
power(5, 3)   # 125 (overrides default)
```

### Keyword Arguments
```python
def create_person(name, age, job):
    return f"{name} is {age} years old and works as a {job}"

# Order doesn't matter with keyword args
create_person(age=30, job="developer", name="Alice")
```

### Positional-Only and Keyword-Only Arguments (Python 3.8+)
```python
def process_data(pos1, pos2, /, pos_or_kwd, *, kwd1, kwd2):
    """
    pos1, pos2: positional-only (before /)
    pos_or_kwd: positional or keyword
    kwd1, kwd2: keyword-only (after *)
    """
    return f"Processing: {pos1}, {pos2}, {pos_or_kwd}, {kwd1}, {kwd2}"

# Valid calls:
process_data(1, 2, 3, kwd1=4, kwd2=5)  # All specified
process_data(1, 2, pos_or_kwd=3, kwd1=4, kwd2=5)  # pos_or_kwd as keyword

# Invalid calls:
# process_data(pos1=1, pos2=2, pos_or_kwd=3, kwd1=4, kwd2=5)  # Error: pos1, pos2 can't be keyword
# process_data(1, 2, 3, 4, 5)  # Error: kwd1, kwd2 can't be positional
```

### Variable-length Arguments (*args and **kwargs)
```python
def sum_all(*args):
    """Accept any number of positional arguments"""
    return sum(args)

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

# Usage
sum_all(1, 2, 3, 4, 5)  # 15
user_info(name="Alice", age=30, city="New York")

# Combination
def process_all(*args, **kwargs):
    print(f"Args: {args}")
    print(f"Kwargs: {kwargs}")

process_all(1, 2, 3, name="Alice", age=30)  # Args: (1, 2, 3), Kwargs: {'name': 'Alice', 'age': 30}
```

## Variable Scope and the LEGB Rule

```python
# The LEGB rule determines how Python looks up variable names:
# Local → Enclosing → Global → Built-in

x = 10  # Global scope

def outer_function():
    y = 20  # Enclosing scope for inner_function
    
    def inner_function():
        z = 30  # Local scope
        print(f"Local: {z}")
        print(f"Enclosing: {y}")
        print(f"Global: {x}")
        print(f"Built-in: {len}")  # Built-in function
    
    return inner_function

func = outer_function()
func()  # Prints all variables from different scopes

# Modifying variables in outer scopes
def modify_global():
    global x
    x = 100  # Modifies the global x

def modify_enclosing():
    y = 20
    
    def inner():
        nonlocal y
        y = 200  # Modifies the enclosing y
    
    inner()
    print(y)  # 200

modify_global()
print(x)  # 100
modify_enclosing()
```

## Advanced Function Concepts

### Recursive Functions
```python
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n-1)
```

### Closures
```python
def counter_factory():
    count = 0  # This variable is "enclosed" in the returned function
    
    def increment():
        nonlocal count
        count += 1
        return count
    
    return increment

counter = counter_factory()
print(counter())  # 1
print(counter())  # 2
print(counter())  # 3

# Another example: function with "memory"
def make_multiplier(factor):
    def multiply(number):
        return number * factor  # Uses factor from enclosing scope
    return multiply

double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))  # 10
print(triple(5))  # 15
```

### Higher-order Functions
```python
# Functions that take/return other functions
def apply(func, value):
    return func(value)

# Usage
apply(lambda x: x*2, 5)  # 10
```

### Generator Functions
```python
def count_up_to(n):
    i = 1
    while i <= n:
        yield i
        i += 1

# Creates a generator object
counter = count_up_to(3)
next(counter)  # 1
next(counter)  # 2
next(counter)  # 3
```

### Decorators
```python
from functools import wraps

def my_decorator(func):
    @wraps(func)  # Preserves metadata of the original function
    def wrapper(*args, **kwargs):
        print("Before function call")
        result = func(*args, **kwargs)
        print("After function call")
        return result
    return wrapper

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

# The metadata is preserved
print(greet.__name__)  # 'greet' (not 'wrapper')
print(greet.__doc__)   # 'Greet someone by name.'
```

### Type Hints (PEP 484)
```python
from typing import List, Dict, Tuple, Optional, Union, Any, Callable

def headline(text: str, align: bool = True) -> str:
    """Format text as a headline."""
    if align:
        return f"{text.title()}\n{'-' * len(text)}"
    else:
        return f"{text.title()}"

# More complex type hints
def process_items(items: List[str]) -> Dict[str, int]:
    return {item: len(item) for item in items}

# Optional parameters
def greeting(name: Optional[str] = None) -> str:
    if name is None:
        return "Hello, Guest!"
    return f"Hello, {name}!"

# Union types
def process_id(id: Union[int, str]) -> str:
    return str(id)

# Function type hints
def apply_formatter(value: str, formatter: Callable[[str], str]) -> str:
    return formatter(value)
```

## Docstring Conventions (PEP 257)

```python
def calculate_discount(price: float, rate: float = 0.1) -> float:
    """
    Calculate the discounted price.
    
    Args:
        price: The original price
        rate: The discount rate (default: 0.1)
        
    Returns:
        The price after applying the discount
        
    Raises:
        ValueError: If price or rate is negative
    """
    if price < 0 or rate < 0:
        raise ValueError("Price and rate must be non-negative")
    return price * (1 - rate)

# Google style
def fetch_data(resource_id: str, max_results: int = 100) -> List[Dict]:
    """Fetches data from the specified resource.
    
    Args:
        resource_id: The ID of the resource to fetch.
        max_results: The maximum number of results to return.
    
    Returns:
        A list of dictionaries containing the fetched data.
        
    Raises:
        ConnectionError: If unable to connect to the data source.
    """
    pass
```

## Class Methods

### Instance Methods
```python
class MyClass:
    def instance_method(self, x):
        """Access instance attributes with self"""
        self.value = x
```

### Class Methods
```python
class MyClass:
    @classmethod
    def class_method(cls, x):
        """Access class attributes with cls"""
        return cls(x)  # Can create instances
```

### Static Methods
```python
class MyClass:
    @staticmethod
    def static_method(x):
        """No access to instance or class"""
        return x * 2
```

## Functional Programming

### map()
```python
# Apply function to all items in an iterable
numbers = [1, 2, 3, 4]
squares = list(map(lambda x: x**2, numbers))  # [1, 4, 9, 16]
```

### filter()
```python
# Filter items based on a function
numbers = [1, 2, 3, 4, 5, 6]
evens = list(filter(lambda x: x % 2 == 0, numbers))  # [2, 4, 6]
```

### reduce()
```python
from functools import reduce
# Apply function cumulatively to items
numbers = [1, 2, 3, 4]
product = reduce(lambda x, y: x * y, numbers)  # 24
```

### Other functools Utilities
```python
from functools import partial, lru_cache

# partial: Create new functions with pre-filled arguments
def power(base, exponent):
    return base ** exponent

square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(4))  # 16
print(cube(4))    # 64

# lru_cache: Memoization decorator (caches results)
@lru_cache(maxsize=128)
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

# First call computes result, subsequent calls with same args use the cache
print(fibonacci(30))  # Fast, even for large values
```

## Function Design Best Practices

1. **Single Responsibility**: Functions should do one thing well
2. **Pure Functions**: Avoid side effects when possible
3. **Descriptive Names**: Use clear, descriptive function names
4. **Parameter Count**: Aim for fewer than 4 parameters
5. **Default Values**: Use sensible defaults for optional parameters
6. **Docstrings**: Document what the function does, parameters, and return values
7. **Type Hints**: Use type annotations for better tooling and clarity
8. **Error Handling**: Validate inputs and handle errors gracefully
9. **Return Values**: Be consistent with return types
10. **Early Returns**: Use early returns to avoid deep nesting