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:
*argsand**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 xEach 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.00Closures 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: 3Output:
Added 'Python Basics' - Total books: 1
Added 'Python Advanced' - Total books: 2
Added 'Data Science 101' - Total books: 3Variable-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)) # 100The 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: 350Combining Regular Arguments, *args, and **kwargs
You can combine all three, but they must follow this order:
- Regular positional arguments
*args- Regular keyword arguments (with defaults)
**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: GoldUnpacking 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.38Partial 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.40Partial 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: floatmeanspriceshould be a float-> floatmeans 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: 1Common type hints:
List[str]: List of stringsDict[str, int]: Dictionary with string keys and int valuesTuple[str, float]: Tuple with a string and a floatOptional[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.25Summary
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
*argsand**kwargslet 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.