Murphy’s law: whatever can go wrong, will go wrong.
The *
character does more than just multiply in Python. The alternate usage of it shows up in several places, often feeling like magic…
>>> def func(*args):
return [str(each) for each in args]
>>> first, *rest = list(range(5))
>>> stuff = {*rest}
>>> func(*stuff)
["1", "2", "3", "4"]
For me, there was a moment where it all “clicked” and it made perfect sense how *
and **
work. It can take time and experience to build this intuition, so in this issue, we’ll just have a look at how *
is used specifically in function parameters.
When we define functions, we can give them parameters that allow them to vary their behaviour. Let’s take a super-simple example of a function that finds the mean of 2 numbers:
def average(x, y):
return (x + y) / 2
When we call this function and pass in arguments, each argument is mapped to a corresponding parameter:
# x is set to 1991 (first arg)
>>> average(1991, 2025)
# y is set to 2025 (second arg)
2008
These are known as positional arguments – Python uses their position in the series of arguments to determine which parameter they’re meant to be.
But we can also pass in the arguments by naming their corresponding parameter explicitly:
>>> average(x = -1, y = 1)
0
These are known as keyword arguments – probably since you’re identifying them via their parameter as a keyword. Interestingly, this means you don’t necessarily need to pass in the arguments in the exact order the parameters are listed:
>>> average(y = 2, x = 5)
3.5
Ok, so now let’s say we want our average()
function to work with more numbers. We could hard-code it to take 3 numbers:
def average(x, y, z):
return (x + y + z) / 3
But now we can only pass in 3 numbers, not 2. Clearly things are getting out of hand.
Sometimes what you need is a function that can take an arbitrary number of arguments. Something you could call like so:
>>> echo()
>>> echo("never")
>>> echo("never", "gonna")
>>> echo("never", "gonna", "give")
>>> echo("never", "gonna", "give", "you")
This is the magic of *
. Put it before a parameter’s identifier, and that parameter will become arbitrary, absorbing all positional arguments into one tuple
.
>>> def test(*stuff)
for each in stuff:
print(each)
>>> test("after", "prod")
after
prod
>>> test(0, 0)
0
0
>>> test()
Hopefully this should work pretty intuitively. Where it can start to get confusing is when you also have positional parameters coming before:
def mix(base, accent, *more):
...
Here’s what happens when we call this function:
- As many positional arguments are matched with positional parameters as possible (
base
,accent
) - The remainder are ‘bundled’ into the arbitrary parameter (
*more
)
Now you might look at this and think, “cool, but why wouldn’t you just use a single parameter which should be a list?”
>>> def normalise(items: list[float]):
...
>>> normalise([0.1, 0.7, -0.2, 1.3])
Well, this totally works, so it’s a difficult question to answer.
As always, it depends on context. There’s rarely a technical reason why you would need *args
over an arg: list
– especially given how easily you can convert between them. What it does often do is make function calls a lot more ergonomic, and avoid bracketing nightmares.
Fun fact, print()
is defined with *args
! This is why you can pass in multiple things, and they’ll all get printed out in sucession:
>>> print("one")
one
>>> print("one", "two")
one two
>>> print("one", "two", "skip a few", "-1/12")
one two skip a few -1/12
Can you imagine writing print(["just one thing please"])
? bleugh.