Docs

README

10 - Advanced Functions

📌 What You'll Learn

  • Closures
  • Decorators in depth
  • Generators and yield
  • Iterators and iterable protocol
  • Higher-order functions
  • Functional programming concepts

🔒 Closures

A closure is a function that remembers the values from its enclosing scope even after that scope has finished executing.

def outer_function(x):
    # x is "enclosed" in the inner function
    def inner_function(y):
        return x + y
    return inner_function

# Create closures
add_5 = outer_function(5)
add_10 = outer_function(10)

print(add_5(3))   # 8 (5 + 3)
print(add_10(3))  # 13 (10 + 3)

# x is remembered even after outer_function finished!

Practical Closure Example

def make_counter():
    count = 0

    def counter():
        nonlocal count  # Access outer variable
        count += 1
        return count

    return counter

# Create independent counters
counter1 = make_counter()
counter2 = make_counter()

print(counter1())  # 1
print(counter1())  # 2
print(counter2())  # 1 (independent!)

🎀 Decorators In Depth

Basic Decorator Pattern

def my_decorator(func):
    def wrapper(*args, **kwargs):
        # Before function call
        print("Before")

        # Call original function
        result = func(*args, **kwargs)

        # After function call
        print("After")

        return result
    return wrapper

@my_decorator
def say_hello(name):
    print(f"Hello, {name}!")
    return name

say_hello("Alice")

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

@my_decorator
def my_function():
    """This is my function."""
    pass

print(my_function.__name__)  # my_function (not 'wrapper')
print(my_function.__doc__)   # This is my function.

Decorator with Arguments

def repeat(times):
    """Decorator factory that takes arguments."""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(times=3)
def greet(name):
    print(f"Hello, {name}!")

greet("Alice")  # Prints 3 times

Class-Based Decorator

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Call {self.count} of {self.func.__name__}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print("Hello!")

say_hello()  # Call 1
say_hello()  # Call 2

Stacking Decorators

def bold(func):
    def wrapper(*args, **kwargs):
        return f"<b>{func(*args, **kwargs)}</b>"
    return wrapper

def italic(func):
    def wrapper(*args, **kwargs):
        return f"<i>{func(*args, **kwargs)}</i>"
    return wrapper

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

print(greet("World"))  # <b><i>Hello, World</i></b>

Common Decorators

import time
from functools import wraps

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

# Memoization decorator
def memoize(func):
    cache = {}
    @wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

# Retry decorator
def retry(max_attempts=3):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_attempts):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts - 1:
                        raise
                    print(f"Attempt {attempt+1} failed: {e}")
        return wrapper
    return decorator

🔄 Generators

Generators are functions that can pause and resume execution, yielding values one at a time.

Basic Generator

def count_up_to(n):
    i = 1
    while i <= n:
        yield i  # Pause and return value
        i += 1

# Use generator
for num in count_up_to(5):
    print(num)  # 1, 2, 3, 4, 5

# Or manually
gen = count_up_to(3)
print(next(gen))  # 1
print(next(gen))  # 2
print(next(gen))  # 3
# print(next(gen))  # StopIteration

Generator Benefits

# Memory efficient - generates values on demand

# List: stores everything in memory
def get_squares_list(n):
    return [x**2 for x in range(n)]

# Generator: generates one at a time
def get_squares_gen(n):
    for x in range(n):
        yield x**2

# For large n, generator uses constant memory

Generator Expression

# Like list comprehension but with ()
squares_gen = (x**2 for x in range(10))

# vs list comprehension
squares_list = [x**2 for x in range(10)]

for sq in squares_gen:
    print(sq)

Generator Pipeline

def numbers():
    for i in range(10):
        yield i

def square(nums):
    for n in nums:
        yield n ** 2

def filter_evens(nums):
    for n in nums:
        if n % 2 == 0:
            yield n

# Chain generators
pipeline = filter_evens(square(numbers()))
print(list(pipeline))  # [0, 4, 16, 36, 64]

yield from

def nested_generator():
    yield from range(3)      # 0, 1, 2
    yield from 'abc'         # 'a', 'b', 'c'
    yield from [10, 20, 30]  # 10, 20, 30

for item in nested_generator():
    print(item)

🔁 Iterators

An iterator is an object that implements __iter__ and __next__ methods.

Creating an Iterator Class

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1

# Use the iterator
for num in Countdown(5):
    print(num)  # 5, 4, 3, 2, 1

iter() and next()

# Make any iterable into an iterator
my_list = [1, 2, 3]
my_iter = iter(my_list)

print(next(my_iter))  # 1
print(next(my_iter))  # 2
print(next(my_iter))  # 3

🎯 Higher-Order Functions

Functions that take functions as arguments or return functions.

Built-in Higher-Order Functions

# map() - apply function to each element
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))

# filter() - keep elements matching condition
evens = list(filter(lambda x: x % 2 == 0, numbers))

# reduce() - accumulate values
from functools import reduce
total = reduce(lambda x, y: x + y, numbers)
product = reduce(lambda x, y: x * y, numbers)

sorted() with key

words = ['banana', 'apple', 'cherry', 'date']

# Sort by length
by_length = sorted(words, key=len)

# Sort by last character
by_last = sorted(words, key=lambda x: x[-1])

# Sort objects
students = [
    {'name': 'Alice', 'grade': 85},
    {'name': 'Bob', 'grade': 92},
]
by_grade = sorted(students, key=lambda x: x['grade'])

Creating Higher-Order Functions

def apply_operation(operation):
    """Returns a function that applies operation to a list."""
    def apply_to_list(lst):
        return [operation(x) for x in lst]
    return apply_to_list

double = apply_operation(lambda x: x * 2)
square = apply_operation(lambda x: x ** 2)

print(double([1, 2, 3]))  # [2, 4, 6]
print(square([1, 2, 3]))  # [1, 4, 9]

🧩 Functional Programming Concepts

Pure Functions

# Pure: same input always gives same output, no side effects
def pure_add(a, b):
    return a + b

# Impure: has side effects
total = 0
def impure_add(value):
    global total
    total += value
    return total

Partial Functions

from functools import partial

def power(base, exponent):
    return base ** exponent

# Create specialized functions
square = partial(power, exponent=2)
cube = partial(power, exponent=3)

print(square(5))  # 25
print(cube(5))    # 125

Function Composition

def compose(*functions):
    def inner(arg):
        result = arg
        for f in reversed(functions):
            result = f(result)
        return result
    return inner

add_one = lambda x: x + 1
double = lambda x: x * 2
square = lambda x: x ** 2

composed = compose(square, double, add_one)
print(composed(3))  # ((3+1)*2)^2 = 64

📋 Summary

ConceptDescription
ClosureFunction remembering enclosing scope
DecoratorFunction modifying another function
GeneratorFunction that yields values lazily
IteratorObject with __iter__ and __next__
map()Apply function to all elements
filter()Keep elements matching condition
reduce()Accumulate values
partial()Create specialized function

🎯 Next Steps

After mastering advanced functions, proceed to 11_advanced_data to learn about shallow vs deep copy, collections module, and memory management!

README - Python Tutorial | DeepML