Lesson 5 - Advanced Function Concepts

Introduction

Functions are one of Python’s most powerful features. We’ve already learned the basics of creating and using functions, but there’s much more to discover. In this lesson, we’ll explore advanced function concepts that will make your code more flexible and expressive:

  • Closures: Functions that remember their enclosing scope
  • LEGB scope rule: How Python looks up variable names
  • Variable-length arguments: *args and **kwargs
  • Partial functions: Pre-filling function arguments
  • Type hints: Documenting expected types

These concepts are the foundation for understanding decorators (next lesson) and writing more sophisticated Python code.

Understanding Scope: The LEGB Rule

Before we dive into closures, we need to understand how Python resolves variable names. Python follows the LEGB rule:

  • Local: Variables defined inside the current function
  • Enclosing: Variables in enclosing functions (for nested functions)
  • Global: Variables defined at the module level
  • Built-in: Python’s built-in names (like len, print, etc.)
x = "global x"  # Global

def outer():
    x = "outer x"  # Enclosing

    def inner():
        x = "inner x"  # Local
        print(f"Inside inner: {x}")

    inner()
    print(f"Inside outer: {x}")

outer()
print(f"Global: {x}")

Output:

Inside inner: inner x
Inside outer: outer x
Global: global x

Each scope has its own x, and Python uses the LEGB rule to find the right one.

The nonlocal and global Keywords

To modify variables from outer scopes, use nonlocal (for enclosing scope) or global (for global scope):

counter = 0  # Global variable

def increment():
    global counter  # Access global counter
    counter += 1
    return counter

print(increment())  # 1
print(increment())  # 2
print(counter)      # 2

# Example with nonlocal
def outer():
    count = 0  # Enclosing scope

    def increment():
        nonlocal count  # Access outer's count
        count += 1
        return count

    print(increment())  # 1
    print(increment())  # 2
    print(f"Final count: {count}")  # 2

outer()

Closures: Functions That Remember

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

Simple Closure Example

def make_multiplier(n):
    def multiplier(x):
        return x * n  # 'n' comes from enclosing scope
    return multiplier

# Create different multiplier functions
times_2 = make_multiplier(2)
times_5 = make_multiplier(5)

print(times_2(10))  # 20 (10 * 2)
print(times_5(10))  # 50 (10 * 5)

Even though make_multiplier has finished executing, the returned multiplier function still remembers the value of n.

Practical Closure: Book Discount Calculator

def create_discount_calculator(discount_percent):
    """Create a function that calculates discounted prices"""

    def calculate_price(original_price):
        discount = original_price * (discount_percent / 100)
        return original_price - discount

    return calculate_price

# Create different discount calculators
student_discount = create_discount_calculator(20)  # 20% off
member_discount = create_discount_calculator(15)   # 15% off
sale_discount = create_discount_calculator(30)     # 30% off

book_price = 50.00

print(f"Original price: ${book_price}")
print(f"Student price: ${student_discount(book_price):.2f}")
print(f"Member price: ${member_discount(book_price):.2f}")
print(f"Sale price: ${sale_discount(book_price):.2f}")

Output:

Original price: $50.0
Student price: $40.00
Member price: $42.50
Sale price: $35.00

Closures with State

Closures can maintain state across multiple calls:

def create_book_counter():
    count = 0

    def add_book(title):
        nonlocal count
        count += 1
        print(f"Added '{title}' - Total books: {count}")
        return count

    return add_book

counter = create_book_counter()
counter("Python Basics")      # Total books: 1
counter("Python Advanced")    # Total books: 2
counter("Data Science 101")   # Total books: 3

Output:

Added 'Python Basics' - Total books: 1
Added 'Python Advanced' - Total books: 2
Added 'Data Science 101' - Total books: 3

Variable-Length Arguments: *args and **kwargs

Sometimes you want functions to accept any number of arguments. Python provides *args and **kwargs for this.

*args: Variable Positional Arguments

*args collects any number of positional arguments into a tuple:

def calculate_total(*prices):
    """Calculate total of any number of prices"""
    total = sum(prices)
    return total

print(calculate_total(10, 20, 30))           # 60
print(calculate_total(5.99, 12.99, 8.49))    # 27.47
print(calculate_total(100))                   # 100

The name args is just a convention—you can use any name (but keep the *):

def print_books(*book_titles):
    print(f"You have {len(book_titles)} books:")
    for title in book_titles:
        print(f"  - {title}")

print_books("Python Basics", "Python Advanced", "Data Science 101")

Output:

You have 3 books:
  - Python Basics
  - Python Advanced
  - Data Science 101

**kwargs: Variable Keyword Arguments

**kwargs collects any number of keyword arguments into a dictionary:

def create_book(**book_info):
    """Create a book from keyword arguments"""
    print("Book Information:")
    for key, value in book_info.items():
        print(f"  {key}: {value}")

create_book(title="Python Basics", author="John Doe", price=29.99, pages=350)

Output:

Book Information:
  title: Python Basics
  author: John Doe
  price: 29.99
  pages: 350

Combining Regular Arguments, *args, and **kwargs

You can combine all three, but they must follow this order:

  1. Regular positional arguments
  2. *args
  3. Regular keyword arguments (with defaults)
  4. **kwargs
def process_order(customer_name, *items, discount=0, **customer_info):
    """
    Process a book order
    - customer_name: required positional argument
    - *items: any number of book titles
    - discount: optional keyword argument with default
    - **customer_info: any additional customer information
    """
    print(f"Order for: {customer_name}")
    print(f"Items ({len(items)}):")
    for item in items:
        print(f"  - {item}")

    if discount > 0:
        print(f"Discount: {discount}%")

    if customer_info:
        print("Customer info:")
        for key, value in customer_info.items():
            print(f"  {key}: {value}")

process_order(
    "Alice Johnson",
    "Python Basics",
    "Python Advanced",
    "Data Science 101",
    discount=10,
    email="[email protected]",
    membership="Gold"
)

Output:

Order for: Alice Johnson
Items (3):
  - Python Basics
  - Python Advanced
  - Data Science 101
Discount: 10%
Customer info:
  email: [email protected]
  membership: Gold

Unpacking with * and **

You can use * and ** to unpack sequences and dictionaries when calling functions:

def calculate_book_total(base_price, tax_rate, shipping):
    total = base_price * (1 + tax_rate) + shipping
    return total

# Unpack a list
prices = [29.99, 0.08, 5.99]
total = calculate_book_total(*prices)  # Same as calculate_book_total(29.99, 0.08, 5.99)
print(f"Total: ${total:.2f}")

# Unpack a dictionary
price_info = {'base_price': 29.99, 'tax_rate': 0.08, 'shipping': 5.99}
total = calculate_book_total(**price_info)
print(f"Total: ${total:.2f}")

Output:

Total: $38.38
Total: $38.38

Partial Functions

The functools.partial function lets you create a new function with some arguments pre-filled:

from functools import partial

def calculate_price(base_price, tax_rate, discount_percent):
    """Calculate final price with tax and discount"""
    discounted = base_price * (1 - discount_percent / 100)
    final = discounted * (1 + tax_rate)
    return final

# Create specialized pricing functions
california_price = partial(calculate_price, tax_rate=0.0725)
student_price = partial(calculate_price, tax_rate=0.08, discount_percent=20)

book_price = 100

print(f"California price: ${california_price(book_price, discount_percent=0):.2f}")
print(f"Student price: ${student_price(book_price):.2f}")

Output:

California price: $107.25
Student price: $86.40

Partial functions are useful when you have a general function but frequently call it with the same arguments.

Type Hints

Type hints (introduced in Python 3.5+) let you document what types your function expects and returns. They don’t enforce types at runtime but help with code documentation and enable better IDE support.

Basic Type Hints

def calculate_discount(price: float, discount_percent: float) -> float:
    """Calculate discounted price

    Args:
        price: Original price
        discount_percent: Discount percentage (e.g., 20 for 20%)

    Returns:
        Discounted price
    """
    return price * (1 - discount_percent / 100)

result = calculate_discount(100.0, 20.0)
print(f"Discounted price: ${result:.2f}")

The syntax:

  • price: float means price should be a float
  • -> float means the function returns a float

Type Hints with Collections

For collections, use the typing module:

from typing import List, Dict, Tuple, Optional

def process_books(titles: List[str]) -> Dict[str, int]:
    """Count characters in each book title

    Args:
        titles: List of book titles

    Returns:
        Dictionary mapping titles to character counts
    """
    return {title: len(title) for title in titles}

def find_book(books: List[str], title: str) -> Optional[int]:
    """Find the index of a book, or None if not found

    Args:
        books: List of book titles
        title: Title to search for

    Returns:
        Index of book if found, None otherwise
    """
    try:
        return books.index(title)
    except ValueError:
        return None

books = ["Python Basics", "Python Advanced", "Data Science 101"]
result = process_books(books)
print(result)

index = find_book(books, "Python Advanced")
print(f"Found at index: {index}")

Output:

{'Python Basics': 13, 'Python Advanced': 15, 'Data Science 101': 16}
Found at index: 1

Common type hints:

  • List[str]: List of strings
  • Dict[str, int]: Dictionary with string keys and int values
  • Tuple[str, float]: Tuple with a string and a float
  • Optional[int]: Either an int or None

Type Hints with Custom Classes

class Book:
    def __init__(self, title: str, price: float):
        self.title = title
        self.price = price

def apply_discount(book: Book, discount: float) -> Book:
    """Apply discount to a book and return new Book object"""
    new_price = book.price * (1 - discount / 100)
    return Book(book.title, new_price)

def get_total_price(books: List[Book]) -> float:
    """Calculate total price of multiple books"""
    return sum(book.price for book in books)

# Type hints help IDEs provide better autocomplete
my_book = Book("Python Basics", 29.99)
discounted = apply_discount(my_book, 20)
print(f"Original: ${my_book.price:.2f}")
print(f"Discounted: ${discounted.price:.2f}")

A Comprehensive Example: Book Pricing System

Let’s combine everything we’ve learned:

from typing import List, Dict, Optional
from functools import partial

class Book:
    def __init__(self, title: str, author: str, base_price: float):
        self.title = title
        self.author = author
        self.base_price = base_price

    def __repr__(self) -> str:
        return f"Book('{self.title}', ${self.base_price})"

def create_price_calculator(tax_rate: float):
    """Closure that remembers the tax rate"""

    def calculate_price(base_price: float, discount: float = 0) -> float:
        discounted = base_price * (1 - discount / 100)
        return discounted * (1 + tax_rate)

    return calculate_price

def process_order(
    customer: str,
    *books: Book,
    discount: float = 0,
    **customer_info: str
) -> Dict[str, float]:
    """
    Process a book order with flexible arguments

    Args:
        customer: Customer name (required)
        *books: Any number of Book objects
        discount: Discount percentage (optional)
        **customer_info: Additional customer information

    Returns:
        Dictionary with order details
    """
    print(f"\nOrder for: {customer}")

    if customer_info:
        print("Customer info:")
        for key, value in customer_info.items():
            print(f"  {key}: {value}")

    # Use California tax rate
    calc_price = create_price_calculator(0.0725)

    total = 0
    print(f"\nBooks ({len(books)}):")
    for book in books:
        final_price = calc_price(book.base_price, discount)
        total += final_price
        print(f"  {book.title}: ${final_price:.2f}")

    if discount > 0:
        print(f"  (with {discount}% discount)")

    return {
        'subtotal': sum(b.base_price for b in books),
        'total': total,
        'savings': sum(b.base_price for b in books) - total + (sum(b.base_price for b in books) * 0.0725)
    }

# Create books
books = [
    Book("Python Crash Course", "Eric Matthes", 39.99),
    Book("Automate the Boring Stuff", "Al Sweigart", 29.99),
    Book("Learning Python", "Mark Lutz", 54.99)
]

# Process order with variable arguments
result = process_order(
    "Alice Johnson",
    *books,
    discount=15,
    email="[email protected]",
    membership="Gold"
)

print(f"\nOrder Summary:")
print(f"  Subtotal: ${result['subtotal']:.2f}")
print(f"  Total (with tax & discount): ${result['total']:.2f}")
print(f"  You saved: ${result['savings']:.2f}")

Output:

Order for: Alice Johnson
Customer info:
  email: [email protected]
  membership: Gold

Books (3):
  Python Crash Course: $36.42
  Automate the Boring Stuff: $27.31
  Learning Python: $50.05
  (with 15% discount)

Order Summary:
  Subtotal: $124.97
  Total (with tax & discount): $113.78
  You saved: $20.25

Summary

In this lesson, we explored advanced function concepts:

  • LEGB scope: How Python looks up variable names (Local, Enclosing, Global, Built-in)
  • Closures: Functions that remember their enclosing scope, useful for creating specialized functions
  • *args: Accept any number of positional arguments (collected as tuple)
  • **kwargs: Accept any number of keyword arguments (collected as dictionary)
  • Partial functions: Pre-fill arguments using functools.partial
  • Type hints: Document expected types for better code clarity and IDE support

These concepts make functions more flexible and expressive:

  • Closures let you create function factories
  • *args and **kwargs let you write highly flexible functions
  • Type hints improve code documentation and tooling

In the next lesson, we’ll build on these concepts to understand decorators—a powerful pattern that uses closures to modify function behavior.