*args and **kwargs
Sometimes you do not know how many arguments a function will receive. Python provides two special syntax features - *args and **kwargs - that allow functions to accept any number of arguments. These are essential for writing flexible, reusable code.
Variable-Length Arguments
*args (arbitrary positional arguments) collects any extra positional arguments passed to a function into a tuple. **kwargs (arbitrary keyword arguments) collects any extra keyword arguments into a dictionary. The names "args" and "kwargs" are conventions - you can use any valid identifier after the asterisks.
def func(*args, **kwargs): # args is tuple, kwargs is dict
*args and **kwargs Flow
*args (Tuple)
func(1, 2, 3, 4, 5)
args = (1, 2, 3, 4, 5)
Collects all extra positional arguments into a tuple
**kwargs (Dict)
func(name="Alice", age=25)
kwargs = {"name": "Alice", "age": 25}
Collects all extra keyword arguments into a dictionary
Using *args
The *args parameter collects any number of positional arguments into a tuple. The name "args" is a convention; you can use any name after the asterisk. Inside the function, args behaves like a regular tuple - you can iterate over it, check its length, or access elements by index.
| Without *args | With *args |
|---|---|
def add(a, b, c): return a+b+c |
def add(*args): return sum(args) |
| Fixed number of arguments only | Any number of arguments |
add(1, 2, 3) - works |
add(1, 2, 3, 4, 5, 6) - works |
add(1, 2) - error! |
add(1) - works |
def sum_all(*args):
"""Sum any number of arguments."""
total = 0
for num in args:
total += num
return total
print(sum_all(1, 2)) # Output: 3
print(sum_all(1, 2, 3, 4, 5)) # Output: 15
print(sum_all(10)) # Output: 10
# Combine with regular parameters
def greet(greeting, *names):
for name in names:
print(f"{greeting}, {name}!")
The *args parameter makes functions incredibly flexible by accepting any number of positional arguments. Regular parameters must come first in the function definition, then *args captures all remaining positional arguments as a tuple. Inside the function, you can iterate over args, check its length, or access elements by index. This pattern is essential for writing wrapper functions and APIs that need to forward arguments.
Using **kwargs
The **kwargs parameter collects any number of keyword arguments into a dictionary. This is useful for functions that need to accept arbitrary named options, configuration settings, or metadata. The keys become strings matching the argument names, and the values are the passed values.
class or return as keyword arguments. Use class_ or cls instead.
def build_profile(**kwargs):
"""Build a user profile from keyword arguments."""
for key, value in kwargs.items():
print(f"{key}: {value}")
build_profile(name="Alice", age=25, city="NYC")
# Output:
# name: Alice
# age: 25
# city: NYC
# Combine all parameter types
def complex_func(required, *args, **kwargs):
print(f"Required: {required}")
print(f"Args: {args}")
print(f"Kwargs: {kwargs}")
The **kwargs parameter collects all extra keyword arguments into a dictionary where keys are argument names and values are the passed values. Parameter order is strictly enforced: regular parameters first, then *args, then **kwargs. You can combine all three types in one function to create extremely flexible APIs. Access kwargs values using dictionary methods like .get(), .items(), or direct key access.
Unpacking with * and **
You can also use * and ** to unpack sequences and dictionaries when calling functions.
# Unpack a list as positional arguments
numbers = [1, 2, 3, 4, 5]
print(sum_all(*numbers)) # Same as sum_all(1, 2, 3, 4, 5)
# Unpack a dict as keyword arguments
user_data = {"name": "Bob", "age": 30, "job": "Developer"}
build_profile(**user_data)
# Combine both
def create_message(template, *values, **options):
msg = template.format(*values)
if options.get("uppercase"):
msg = msg.upper()
return msg
The * operator unpacks any iterable (list, tuple, set) into separate positional arguments when calling a function. The ** operator unpacks dictionaries into keyword arguments, matching dict keys to parameter names. This is the reverse of *args/**kwargs - instead of collecting arguments, you are spreading them out. Combining both operators lets you dynamically construct function calls from existing data structures.
Practice: *args and **kwargs
Task: Create a function multiply_all that takes any number of arguments using *args and returns their product. Test with 2, 3, and 5 numbers.
Show Solution
def multiply_all(*args):
result = 1
for num in args:
result *= num
return result
print(multiply_all(2, 3)) # Output: 6
print(multiply_all(2, 3, 4)) # Output: 24
print(multiply_all(1, 2, 3, 4, 5)) # Output: 120
Task: Create a function make_tag that takes a tag name and **kwargs for attributes. Return an HTML opening tag like <div class="container" id="main">.
Show Solution
def make_tag(tag, **kwargs):
attrs = " ".join(f'{k}="{v}"' for k, v in kwargs.items())
if attrs:
return f"<{tag} {attrs}>"
return f"<{tag}>"
print(make_tag("div", id="main", class_="container"))
# Output:
print(make_tag("img", src="photo.jpg", alt="Photo"))
# Output:
Task: Create a function log_message that takes a required level (like "INFO"), *args for message parts joined by spaces, and **kwargs for metadata displayed as key=value pairs.
Show Solution
def log_message(level, *args, **kwargs):
message = " ".join(str(a) for a in args)
meta = " | ".join(f"{k}={v}" for k, v in kwargs.items())
if meta:
print(f"[{level}] {message} ({meta})")
else:
print(f"[{level}] {message}")
log_message("INFO", "User", "logged", "in", user="alice")
# Output: [INFO] User logged in (user=alice)
log_message("ERROR", "Connection", "failed", code=500, retry=True)
# Output: [ERROR] Connection failed (code=500 | retry=True)
Task: Create a function merge_dicts that takes any number of dictionaries using *args and merges them into one. Later dictionaries should override earlier ones for duplicate keys.
Show Solution
def merge_dicts(*dicts):
result = {}
for d in dicts:
result.update(d)
return result
# Test
d1 = {"a": 1, "b": 2}
d2 = {"b": 3, "c": 4}
d3 = {"c": 5, "d": 6}
print(merge_dicts(d1, d2)) # {'a': 1, 'b': 3, 'c': 4}
print(merge_dicts(d1, d2, d3)) # {'a': 1, 'b': 3, 'c': 5, 'd': 6}
Lambda Functions
Lambda functions are small, anonymous functions defined in a single line. They are useful for short operations, especially when passing functions as arguments to other functions like map(), filter(), and sorted().
Lambda Function
A lambda function (also called an anonymous function) is a small, inline function defined with the lambda keyword. Unlike regular functions defined with def, lambdas have no name and consist of a single expression whose result is automatically returned.
lambda parameters: expression
def vs lambda Comparison
Regular Function (def)
def square(x):
return x ** 2
- Has a name
- Can have multiple statements
- Can have docstrings
- Better for complex logic
Lambda Function
square = lambda x: x ** 2
- Anonymous (no inherent name)
- Single expression only
- No docstrings
- Best for simple operations
Basic Lambda
Lambda functions can replace simple one-line functions. They are often assigned to variables or passed directly to other functions.
# Regular function
def square(x):
return x ** 2
# Equivalent lambda
square = lambda x: x ** 2
print(square(5)) # Output: 25
# Lambda with multiple parameters
add = lambda a, b: a + b
print(add(3, 4)) # Output: 7
# Lambda in-line (no variable)
print((lambda x: x * 2)(10)) # Output: 20
Lambda functions are best suited for simple, one-line expressions that can be written concisely. They can take any number of parameters but must return a single expression result. While you can assign lambdas to variables, this practice is discouraged by PEP 8 style guide - use def instead for named functions. Reserve lambdas for inline use where defining a full function would be overkill.
Lambda with Built-in Functions
Lambdas shine when used with functions like map(), filter(), and sorted() that take a function as an argument.
# sorted() with lambda key
students = [("Alice", 85), ("Bob", 92), ("Carol", 78)]
by_score = sorted(students, key=lambda s: s[1], reverse=True)
print(by_score) # [('Bob', 92), ('Alice', 85), ('Carol', 78)]
# filter() with lambda
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4, 6, 8, 10]
# map() with lambda
doubled = list(map(lambda x: x * 2, numbers))
print(doubled) # [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
Lambda functions shine when used with higher-order functions like map(), filter(), and sorted() that require a function argument. They eliminate the need to define separate named functions for simple operations used only once. The key parameter in sorted() is especially common - it determines what value to sort by. Remember that map() and filter() return iterators, so wrap them in list() to see all results at once.
| Use Case | Lambda Example | Description |
|---|---|---|
| Sorting by key | sorted(items, key=lambda x: x.name) |
Sort objects by a specific attribute |
| Filtering | filter(lambda x: x > 0, nums) |
Keep only positive numbers |
| Transforming | map(lambda x: x.upper(), words) |
Convert all strings to uppercase |
| Conditional | lambda x: "even" if x%2==0 else "odd" |
Return based on condition (ternary) |
def for anything more complex.
Practice: Lambda Functions
Task: Create a lambda function is_even that returns True if a number is even, False otherwise. Test it with 4 and 7.
Show Solution
is_even = lambda x: x % 2 == 0
print(is_even(4)) # Output: True
print(is_even(7)) # Output: False
Task: Given a list of dictionaries with "name" and "age" keys, use sorted() with a lambda to sort by age in ascending order.
Show Solution
people = [
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25},
{"name": "Carol", "age": 35}
]
by_age = sorted(people, key=lambda p: p["age"])
print(by_age)
# [{'name': 'Bob', 'age': 25},
# {'name': 'Alice', 'age': 30},
# {'name': 'Carol', 'age': 35}]
Task: Given a list of numbers 1-10, use filter() to keep only odd numbers, then use map() to square them. Use lambdas for both operations.
Show Solution
numbers = list(range(1, 11))
# Filter odd numbers, then square them
odds = filter(lambda x: x % 2 != 0, numbers)
squared_odds = list(map(lambda x: x ** 2, odds))
print(squared_odds) # [1, 9, 25, 49, 81]
Task: Create a lambda function grade that takes a score and returns "Pass" if the score is 60 or above, otherwise "Fail". Test with scores 75 and 45.
Show Solution
grade = lambda score: "Pass" if score >= 60 else "Fail"
print(grade(75)) # Output: Pass
print(grade(45)) # Output: Fail
print(grade(60)) # Output: Pass
# Using with map
scores = [85, 42, 67, 55, 90]
results = list(map(grade, scores))
print(results) # ['Pass', 'Fail', 'Pass', 'Fail', 'Pass']
Decorators
Decorators are functions that wrap other functions to extend their behavior without modifying their code. They are one of Python's most powerful features, enabling clean separation of concerns and reusable functionality like logging, timing, authentication, and caching.
Decorator
A decorator is a function that takes another function as input and returns a new function that usually extends or modifies the behavior of the original. The @decorator syntax is syntactic sugar for func = decorator(func).
@decorator is equivalent to function = decorator(function)
@app.route for URL routing, Django uses @login_required for authentication, and Python itself provides @property, @staticmethod, and @classmethod.
Decorator Wrapper Pattern
@decoratordef my_function():
Creating a Decorator
A decorator is a function that takes a function as input and returns a new function that usually calls the original.
def my_decorator(func):
def wrapper():
print("Before the function")
func()
print("After the function")
return wrapper
@my_decorator
def say_hello():
print("Hello!")
say_hello()
# Output:
# Before the function
# Hello!
# After the function
The @decorator syntax above a function definition is syntactic sugar for say_hello = my_decorator(say_hello). The decorator receives the original function, creates a wrapper that adds behavior before and/or after calling it, then returns the wrapper. When you call the decorated function, you are actually calling the wrapper which controls when and how the original executes. This pattern enables aspect-oriented programming in Python.
Decorators with Arguments
To create decorators that work with functions taking arguments, use *args and **kwargs to pass through all parameters.
import time
def timer(func):
def wrapper(*args, **kwargs):
start = time.time()
result = func(*args, **kwargs)
end = time.time()
print(f"{func.__name__} took {end - start:.4f} seconds")
return result
return wrapper
@timer
def slow_function(n):
total = sum(range(n))
return total
result = slow_function(1000000)
# Output: slow_function took 0.0312 seconds
Using *args and **kwargs in the wrapper function ensures your decorator works with any function signature - whether it takes no arguments, positional arguments, keyword arguments, or any combination. Always capture and return the result of the wrapped function call, otherwise decorated functions that return values will return None instead. The timer decorator pattern shown here is invaluable for performance profiling and optimization work.
Common Decorator Use Cases
Decorators are used for logging, authentication, caching, validation, and more. Here is a practical logging decorator.
def log_calls(func):
def wrapper(*args, **kwargs):
args_str = ", ".join(repr(a) for a in args)
kwargs_str = ", ".join(f"{k}={v!r}" for k, v in kwargs.items())
all_args = ", ".join(filter(None, [args_str, kwargs_str]))
print(f"Calling {func.__name__}({all_args})")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result!r}")
return result
return wrapper
@log_calls
def add(a, b):
return a + b
add(3, 5)
# Calling add(3, 5)
# add returned 8
This logging decorator automatically tracks all function calls, their arguments, and return values without modifying the original function code. The repr() function formats arguments in a readable way, and the !r format specifier does the same in f-strings. This pattern is invaluable for debugging, creating audit trails, and understanding program flow. In production, you would typically write to a log file instead of printing.
| Pattern | Purpose | Example Use |
|---|---|---|
| Timer | Measure function execution time | Performance profiling, optimization |
| Logger | Log function calls and arguments | Debugging, audit trails |
| Cache/Memoize | Store and reuse computed results | Expensive calculations, API calls |
| Auth Check | Verify user permissions before access | Web apps, API endpoints |
| Retry | Automatically retry on failure | Network requests, flaky operations |
| Validator | Check inputs before execution | Type checking, range validation |
functools.wraps(func) in your decorators to preserve the original function's name, docstring, and metadata. This helps with debugging and documentation.
Practice: Decorators
Task: Create a decorator add_greeting that prints "Hello!" before calling the wrapped function. Apply it to a function that prints "Nice to meet you."
Show Solution
def add_greeting(func):
def wrapper():
print("Hello!")
func()
return wrapper
@add_greeting
def introduce():
print("Nice to meet you.")
introduce()
# Output:
# Hello!
# Nice to meet you.
Task: Create a decorator double_result that doubles the return value of the wrapped function. Apply it to a function that returns a number.
Show Solution
def double_result(func):
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
return result * 2
return wrapper
@double_result
def get_number(n):
return n
print(get_number(5)) # Output: 10
print(get_number(21)) # Output: 42
Task: Create a decorator retry that catches exceptions and retries the function up to 3 times. Print which attempt is being made. If all attempts fail, re-raise the exception.
Show Solution
def retry(func):
def wrapper(*args, **kwargs):
for attempt in range(1, 4):
try:
print(f"Attempt {attempt}")
return func(*args, **kwargs)
except Exception as e:
if attempt == 3:
raise
print(f"Failed: {e}, retrying...")
return wrapper
@retry
def risky_operation():
import random
if random.random() < 0.7:
raise ValueError("Random failure!")
return "Success!"
Task: Create a decorator count_calls that counts how many times a function has been called. Store the count as an attribute on the wrapper function and print it each time.
Show Solution
def count_calls(func):
def wrapper(*args, **kwargs):
wrapper.calls += 1
print(f"{func.__name__} called {wrapper.calls} times")
return func(*args, **kwargs)
wrapper.calls = 0
return wrapper
@count_calls
def say_hello(name):
print(f"Hello, {name}!")
say_hello("Alice") # say_hello called 1 times \n Hello, Alice!
say_hello("Bob") # say_hello called 2 times \n Hello, Bob!
print(say_hello.calls) # 2
Interactive: Decorator Explorer
Select a function and decorator to see how decorators transform behavior.
# Select options above
Click Run to see the output
Higher-Order Functions
Higher-order functions are functions that take other functions as arguments or return functions as results. This powerful concept enables functional programming patterns in Python, making code more modular, reusable, and expressive.
Higher-Order Function
A higher-order function is a function that does at least one of the following: (1) takes one or more functions as arguments, or (2) returns a function as its result. This is possible because in Python, functions are first-class objects - they can be assigned to variables, stored in data structures, and passed around like any other value.
map(func, iterable) and filter(func, iterable) are higher-order functions
Types of Higher-Order Functions
Takes Function as Input
def apply(func, value):
return func(value)
apply(str.upper, "hello") # "HELLO"
Examples: map(), filter(), sorted(key=...)
Returns Function as Output
def make_adder(n):
def adder(x):
return x + n
return adder
add5 = make_adder(5) # Returns a function
Examples: decorators, function factories
Functions as Arguments
In Python, functions are first-class objects. You can pass them as arguments to other functions just like any other value.
def apply_operation(func, x, y):
"""Apply any two-argument function to x and y."""
return func(x, y)
def add(a, b):
return a + b
def multiply(a, b):
return a * b
print(apply_operation(add, 5, 3)) # Output: 8
print(apply_operation(multiply, 5, 3)) # Output: 15
The apply_operation function demonstrates that functions are first-class objects in Python - they can be passed as arguments just like integers or strings. Notice we pass add and multiply without parentheses; parentheses would call the function and pass its return value instead. This pattern enables powerful abstractions where behavior can be swapped at runtime. It is the foundation for callbacks, event handlers, and plugin architectures.
Built-in Higher-Order Functions
Python provides several built-in higher-order functions that operate on iterables using a function you provide.
# map() applies a function to each element
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(squared) # [1, 4, 9, 16, 25]
# filter() keeps elements where function returns True
evens = list(filter(lambda x: x % 2 == 0, numbers))
print(evens) # [2, 4]
# reduce() accumulates values (from functools)
from functools import reduce
total = reduce(lambda acc, x: acc + x, numbers, 0)
print(total) # 15
These three functions form the core of functional programming in Python. map() applies a transformation to every element, creating a new sequence of the same length. filter() tests each element and keeps only those that pass. reduce() (from functools) combines all elements into a single accumulated value using a two-argument function. While list comprehensions often replace map/filter in modern Python, understanding these functions is essential for functional programming patterns.
Functions Returning Functions
A higher-order function can also return a new function. This pattern is useful for creating specialized or configured functions.
def make_multiplier(n):
"""Return a function that multiplies by n."""
def multiplier(x):
return x * n
return multiplier
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # Output: 10
print(triple(5)) # Output: 15
print(double(triple(4))) # Output: 24
make_multiplier is a function factory - it manufactures and returns new functions configured with specific behavior. Each call creates an independent function with the value of n "baked in" through closure. The returned functions remember their configuration and can be stored, passed around, or composed together. This pattern is the basis for currying, partial application, and building domain-specific languages in Python.
Practice: Higher-Order Functions
Task: Create a function apply_to_all(func, items) that applies the given function to each item in the list and returns a new list with the results. Do not use map().
Show Solution
def apply_to_all(func, items):
result = []
for item in items:
result.append(func(item))
return result
# Test
numbers = [1, 2, 3, 4, 5]
print(apply_to_all(lambda x: x * 2, numbers)) # [2, 4, 6, 8, 10]
print(apply_to_all(str.upper, ["a", "b", "c"])) # ['A', 'B', 'C']
Task: Create a function make_power(n) that returns a function which raises its argument to the power n. Create square and cube functions using it.
Show Solution
def make_power(n):
def power(x):
return x ** n
return power
square = make_power(2)
cube = make_power(3)
print(square(4)) # Output: 16
print(cube(3)) # Output: 27
print(make_power(4)(2)) # Output: 16
Task: Create a function compose(f, g) that returns a new function which applies g first, then f. So compose(f, g)(x) equals f(g(x)).
Show Solution
def compose(f, g):
def composed(x):
return f(g(x))
return composed
# Test
add_one = lambda x: x + 1
double = lambda x: x * 2
add_then_double = compose(double, add_one) # double(add_one(x))
double_then_add = compose(add_one, double) # add_one(double(x))
print(add_then_double(5)) # (5+1)*2 = 12
print(double_then_add(5)) # (5*2)+1 = 11
Task: Create a function pipeline(*funcs) that takes multiple functions and returns a new function that applies them left-to-right. For example, pipeline(f, g, h)(x) equals h(g(f(x))).
Show Solution
def pipeline(*funcs):
def apply(x):
result = x
for func in funcs:
result = func(result)
return result
return apply
# Test
add_one = lambda x: x + 1
double = lambda x: x * 2
square = lambda x: x ** 2
process = pipeline(add_one, double, square)
print(process(3)) # ((3+1)*2)**2 = 64
Closures
A closure is a function that remembers values from its enclosing scope even after that scope has finished executing. Closures are the mechanism behind decorators, function factories, and data hiding in Python.
Closure
A closure is a nested function that captures and retains access to variables from its enclosing (outer) function's scope, even after the outer function has finished executing. The inner function "closes over" those variables, keeping them alive in memory.
inner_func.__closure__ - contains the captured variables (cell objects)
How Closures Work
1. Outer Function Called
outer("Hello")
Creates local variable message
2. Inner Function Returned
return inner
Inner captures message in closure
3. Closure Accessed Later
greet()
message still accessible!
Understanding Closures
When an inner function references a variable from an outer function, Python keeps that variable alive in the closure, allowing the inner function to access it later. This happens automatically when you define a nested function.
def outer(message):
# message is in outer's local scope
def inner():
# inner "closes over" message
print(message)
return inner
greet = outer("Hello, World!")
greet() # Output: Hello, World!
# message is still accessible even though outer() has returned
farewell = outer("Goodbye!")
farewell() # Output: Goodbye!
The inner function captures and "closes over" the message variable from its enclosing scope. Even after outer() finishes executing and its local scope would normally be destroyed, the message variable survives because inner holds a reference to it. Each call to outer() creates a completely independent closure with its own copy of message. You can verify this by checking inner.__closure__ which contains the captured variables.
Practical Closure Example
Closures are useful for creating functions with private state that persists across calls.
def make_counter(start=0):
count = start
def counter():
nonlocal count
count += 1
return count
return counter
counter1 = make_counter()
print(counter1()) # Output: 1
print(counter1()) # Output: 2
counter2 = make_counter(100)
print(counter2()) # Output: 101
print(counter1()) # Output: 3 (independent state)
The nonlocal keyword is essential here - without it, assigning to count would create a new local variable instead of modifying the enclosed one. Each call to make_counter() creates a fresh closure with its own independent count variable. This demonstrates how closures can maintain private state that persists across function calls. The state is truly private - there is no way to access count directly from outside the closure.
Closures vs Classes
Closures can sometimes replace simple classes when you need to maintain state with a single method.
# Using a closure
def make_accumulator(initial=0):
total = initial
def add(value):
nonlocal total
total += value
return total
return add
acc = make_accumulator(10)
print(acc(5)) # Output: 15
print(acc(3)) # Output: 18
# Equivalent class approach
class Accumulator:
def __init__(self, initial=0):
self.total = initial
def add(self, value):
self.total += value
return self.total
Closures provide a lightweight alternative to classes when you need a single callable with private state. They have less syntactic overhead and work naturally with functional programming patterns. However, classes are better when you need multiple methods, inheritance, or more complex state management. The closure approach is common in JavaScript but less idiomatic in Python, where classes are often preferred for stateful objects.
Practice: Closures
Task: Create a function make_greeter(greeting) that returns a function which takes a name and returns "greeting, name!". Create hello and hi greeters.
Show Solution
def make_greeter(greeting):
def greet(name):
return f"{greeting}, {name}!"
return greet
hello = make_greeter("Hello")
hi = make_greeter("Hi")
print(hello("Alice")) # Output: Hello, Alice!
print(hi("Bob")) # Output: Hi, Bob!
Task: Create a closure make_averager() that returns a function. Each call to the returned function adds a number and returns the running average of all numbers added so far.
Show Solution
def make_averager():
numbers = []
def add(value):
numbers.append(value)
return sum(numbers) / len(numbers)
return add
avg = make_averager()
print(avg(10)) # Output: 10.0
print(avg(20)) # Output: 15.0
print(avg(30)) # Output: 20.0
Task: Create a function memoize(func) that returns a memoized version of func. It should cache results and return cached values for repeated calls with the same arguments.
Show Solution
def memoize(func):
cache = {}
def memoized(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return memoized
@memoize
def slow_add(a, b):
print(f"Computing {a} + {b}...")
return a + b
print(slow_add(2, 3)) # Computing 2 + 3... then 5
print(slow_add(2, 3)) # Just 5 (cached)
print(slow_add(4, 5)) # Computing 4 + 5... then 9
Task: Create a function make_rate_limiter(max_calls) that returns a function. The returned function should return True for the first max_calls invocations, then False for any additional calls.
Show Solution
def make_rate_limiter(max_calls):
calls = 0
def check():
nonlocal calls
if calls < max_calls:
calls += 1
return True
return False
return check
limiter = make_rate_limiter(3)
print(limiter()) # True (call 1)
print(limiter()) # True (call 2)
print(limiter()) # True (call 3)
print(limiter()) # False (exceeded limit)
print(limiter()) # False
Concept Summary
Quick reference comparing all advanced function concepts covered in this lesson.
| Concept | What It Does | Syntax | Best For |
|---|---|---|---|
| *args | Collects extra positional args into tuple | def f(*args) |
Variable number of inputs |
| **kwargs | Collects extra keyword args into dict | def f(**kwargs) |
Optional named parameters |
| Lambda | Creates anonymous inline function | lambda x: x+1 |
Simple one-expression functions |
| Decorator | Wraps function with extra behavior | @decorator |
Logging, auth, caching |
| Higher-Order | Takes/returns functions | map(func, list) |
Functional programming patterns |
| Closure | Remembers enclosing scope variables | Nested function + free variable | Stateful functions, factories |
Key Takeaways
*args Collects Positional
*args gathers extra positional arguments into a tuple, allowing functions to accept any number of values
**kwargs Collects Keywords
**kwargs gathers extra keyword arguments into a dictionary for flexible named parameters
Lambda for Inline
Lambda creates anonymous one-line functions, ideal for map(), filter(), sorted() key functions
Decorators Wrap Functions
Decorators add behavior before/after functions without modifying their code using @syntax
Unpack with * and **
Use * to unpack iterables and ** to unpack dicts when calling functions with existing data
Practical Patterns
Decorators enable logging, timing, caching, and authentication without code duplication
Knowledge Check
Quick Quiz
Test your understanding of advanced Python functions