- Item 30: Know That Function Arguments Can Be Mutated
- Item 31: Return Dedicated Result Objects Instead of Requiring Function Callers to Unpack More Than Three Variables
- Item 32: Prefer Raising Exceptions to Returning None
- Item 33: Know How Closures Interact with Variable Scope and nonlocal
- Item 34: Reduce Visual Noise with Variable Positional Arguments
- Item 35: Provide Optional Behavior with Keyword Arguments
- Item 36: Use None and Docstrings to Specify Dynamic Default Arguments
- Item 37: Enforce Clarity with Keyword-Only and Positional-Only Arguments
- Item 38: Define Function Decorators with functools.wraps
- Item 39: Prefer functools.partial over lambda Expressions for Glue Functions
Item 34: Reduce Visual Noise with Variable Positional Arguments
Accepting a variable number of positional arguments can make a function call clearer and reduce visual noise. These positional arguments are often called varargs for short, or star args, in reference to the conventional name for the parameter *args. For example, say that I want to log some debugging information. With a fixed number of arguments, I would need a function that takes a message and a list of values:
def log(message, values): if not values: print(message) else: values_str = ", ".join(str(x) for x in values) print(f"{message}: {values_str}") log("My numbers are", [1, 2]) log("Hi there", []) >>> My numbers are: 1, 2 Hi there
Having to pass an empty list when I have no values to log is cumbersome and noisy. It’d be better to leave out the second argument entirely. I can do this in Python by prefixing the last positional parameter name with *. The first parameter for the log message is required, and any number of subsequent positional arguments are optional. The function body doesn’t need to change; only the callers do:
def log(message, *values): # Changed if not values: print(message) else: values_str = ", ".join(str(x) for x in values) print(f"{message}: {values_str}") log("My numbers are", 1, 2) log("Hi there") # Changed >>> My numbers are: 1, 2 Hi there
This syntax works very similarly to the starred expressions used in unpacking assignment statements (see Item 16: “Prefer Catch-All Unpacking over Slicing” and Item 9: “Consider match for Destructuring in Flow Control; Avoid When if Statements Are Sufficient” for more examples).
If I already have a sequence (like a list) and I want to call a variadic function like log, I can do this by using the * operator. This instructs Python to pass items from the sequence as positional arguments to the function:
favorites = [7, 33, 99] log("Favorite colors", *favorites) >>> Favorite colors: 7, 33, 99
There are two problems with accepting a variable number of positional arguments.
The first issue is that these optional positional arguments are always turned into a tuple before they are passed to your function. This means that if the caller of your function uses the * operator on a generator, it will be iterated until it’s exhausted (see Item 43: “Consider Generators Instead of Returning Lists” for background). The resulting tuple includes every value from the generator, which could consume a lot of memory and cause the program to crash:
def my_generator(): for i in range(10): yield i def my_func(*args): print(args) it = my_generator() my_func(*it) >>> (0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
Functions that accept *args are best for situations where you know the number of inputs in the argument list will be reasonably small. *args is ideal for function calls that pass many literals or variable names together. It’s primarily for the convenience of the programmer who calls the function and the readability of the calling code.
The second issue with *args is that you can’t add new positional arguments to a function in the future without migrating every caller. If you try to add a positional argument in the front of the argument list, existing callers will subtly break if they aren’t updated. For example, here I add sequence as the first argument of the function and use it to render the log messages:
def log_seq(sequence, message, *values): if not values: print(f"{sequence} - {message}") else: values_str = ", ".join(str(x) for x in values) print(f"{sequence} - {message}: {values_str}") log_seq(1, "Favorites", 7, 33) # New with *args OK log_seq(1, "Hi there") # New message only OK log_seq("Favorite numbers", 7, 33) # Old usage breaks >>> 1 - Favorites: 7, 33 1 - Hi there Favorite numbers - 7: 33
The problem with the code above is that the third call to log used 7 as the message parameter because a sequence argument wasn’t provided. Bugs like this are hard to track down because the code still runs without raising any exceptions. To avoid this possibility entirely, you should use keyword-only arguments when you want to extend functions that accept *args (see Item 37: “Enforce Clarity with Keyword-Only and Positional-Only Arguments”). To be even more defensive, you could also consider using type annotations (see Item 124: “Consider Static Analysis via typing to Obviate Bugs”).
Things to Remember
You can have functions accept a variable number of positional arguments by using *args in the def statement.
You can use the items from a sequence as the positional arguments for a function with the * operator.
Using the * operator with a generator may cause a program to run out of memory and crash.
Adding new positional arguments to functions that accept *args can introduce hard-to-detect bugs.