Function Definition with Catch-All Parameters
by Christoph Schiessl on Python
I have previously written several articles about various aspects of function definitions in Python. There's still much more to say about this topic, so today, I want to continue the series by introducing catch-all parameters. As before, we have to distinguish between positional and keyword parameters.
Positional Catch-All Parameters
If you have read my previous articles, you may recall that you can add a *
to force keyword notation for all parameters that come after the *
in your function's parameter list.
def foo(a, *, b):
pass
In the example above, the parameter a
can be provided using positional or keyword notation. But, due to the *
in the parameter list, b
must be provided using keyword notation. What's new is that you can optionally promote the *
to a catch-all parameter by appending an identifier. It's not enforced, but the convention is to use the identifier args
. You can also see this in the grammar in the line defining parameter_list_starargs
.
funcdef ::= [decorators] "def" funcname [type_params] "(" [parameter_list] ")"
["->" expression] ":" suite
decorators ::= decorator+
decorator ::= "@" assignment_expression NEWLINE
parameter_list ::= defparameter ("," defparameter)* "," "/" ["," [parameter_list_no_posonly]]
| parameter_list_no_posonly
parameter_list_no_posonly ::= defparameter ("," defparameter)* ["," [parameter_list_starargs]]
| parameter_list_starargs
parameter_list_starargs ::= "*" [parameter] ("," defparameter)* ["," ["**" parameter [","]]]
| "**" parameter [","]
parameter ::= identifier [":" expression]
defparameter ::= parameter ["=" expression]
funcname ::= identifier
So, to summarize, you can define a function as follows:
def bar(a, *args, b):
pass
As before, the parameter a
can still be provided using positional or keyword notation. Also, due to the *
in the parameter list, b
must still be provided using keyword notation. However, if the caller provides excess positional parameters (in addition to a
), they are made available as args
inside the function. Semantically, args
will be tuple
, containing the extra positional parameters in the order the caller provided them.
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def foo(a, *args, b):
... print(a, args, b)
...
>>> foo(1, b=2) # empty tuple because there are no excess params
1 () 2
>>> foo(a=1, b=2) # `a` can still be provided using keyword notation
1 () 2
>>> foo(1, 2, b=3) # single excess params is available in the tuple
1 (2,) 3
>>> foo(1, 2, 3, b=4) # two excess params are available in the tuple
1 (2, 3) 4
>>> foo(2, 3, a=1, b=4) # excess params prevent keyword notation for `a`
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: foo() got multiple values for argument 'a'
>>>
Keyword Catch-All Parameters
If you look again at the grammar and focus on the line defining parameter_list_starargs
, you'll see that there's also a **
notation. In this case, the **
must be followed by an identifier, and using the identifier kwargs
is customary for this purpose. So, you now know how to define a function with a catch-all parameter that captures excess keyword parameters and makes them available inside your function.
def foo(a, **kwargs):
pass
Semantically, kwargs
is a dictionary (i.e., an instance of dict
) with str
keys, which means the following code is correct (in the sense that it does not raise an AssertionError
exception).
def bar(**kwargs):
assert len(kwargs) == 1
assert kwargs["this_is_a_string"] == 123
bar(this_is_a_string=123)
Ever since Python 3.7, dictionaries preserve the order in which keys/values have been inserted.
Changed in version 3.7: Dictionary order is guaranteed to be insertion order. This behavior was an implementation detail of CPython from 3.6.
Therefore, in this context, function calls like foo(a=1, b=2)
and foo(b=2, a=1)
must be considered different because the value of the function's kwargs
parameter is different in both cases.
That's enough theory for now. Let's try it out:
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def foo(a, *, b, **kwargs):
... print(a, b, kwargs)
...
>>> foo(1, b=2) # empty dict because there are no excess params
1 2 {}
>>> foo(a=1, b=2, c=3, d=4) # excess params are available in a dict
1 2 {'c': 3, 'd': 4}
>>> foo(1, b=2, d=4, c=3) # order of excess params is retained
1 2 {'d': 4, 'c': 3}
>>> foo(1, b=2, a=3) # providing the same params twice is never allowed
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: foo() got multiple values for argument 'a'
Conclusion
On a personal note, I have to say that I don't recommend catch-all parameters unless you have a very good reason for them. The problem with catch-all parameters is that they obscure your functions' API — it's no longer possible to tell from the outside what your functions need to work properly. Anyway, that's everything for today. Thank you for reading, and see you soon!