cft

Arguments in Python (The Big Story)

The beauty of code


user

Srinaveen Desu

3 years ago | 16 min read

The Big story. Errrgh not that big of a story too.

I always wondered there was nothing more to the python function arguments other than understanding *args and **kwargs but apparently there’s a lot more to it than just this. In this article, I try to give the big picture behind Python’s function arguments. I would, however, like to know if this was really a big picture indeed in the end or just yet another article with nothing new to learn. Well, that was enough talking. Let’s learn something new.

Most of the readers would be having an obvious understanding of what arguments are, but for starters, they are the objects sent to the function as inputs by a caller. When passing arguments to functions, multiple things happen based on the type of objects that we are passing (mutable/immutable objects). A caller is someone who calls the function passing in the arguments. Some points to ponder upon here are:

The function’s local variable names are assigned the objects that are passed in as arguments by the caller. Assignments done to these local function variables do not affect the caller. For example,

def foo(a):
a = a+5
print(a) # prints 15

a = 10foo(a)
print(a) # prints 10

As we can see the variable a does not have any impact due to the function call. This is what we see when we pass immutable objects in function’s argument.

When passing mutable objects, we might see different behavior than what we saw earlier.

def foo(lst):
lst = lst + ['new entry']
print(lst) # Prints ['Book', 'Pen', 'new entry']

lst = ['Book', 'Pen']print(lst) # Prints ['Book', 'Pen']
foo(lst)
print(lst) # Prints ['Book', 'Pen']

Did we see anything different? If your answer was ‘No’ then you are correct. However, when we change an element of the mutable object that is in-place, we will not be seeing the same as above.

def foo(lst):
lst[1] = 'new entry'
print(lst) # Prints ['Book', 'new entry']

lst = ['Book', 'Pen']
print(lst) # Prints ['Book', 'Pen']
foo(lst)
print(lst) # Prints ['Book', 'new entry']

As seen, the objects in the variable lst have been changed after the function call. This is because we are changing the reference of the object it is pointing to and hence the change in the contents of the lst variable. We can avoid this by simply deep copying the mutable objects to a local variable in the function.

def foo(lst):
lst = lst[:]
lst[1] = 'new entry'
print(lst) # Prints ['Book', 'new entry']

lst = ['Book', 'Pen']
print(lst) # Prints ['Book', 'Pen']
foo(lst)
print(lst) # Prints ['Book', 'Pen']

Did that surprise you yet? If not I wish I had a way for you to just skip the known part. If yes, then mark my words you would be having a lot more fun getting to know more about these arguments.

Some things to remember when we pass arguments to a function are: 1) They are read from left to right 2) They can be matched by keyword argument name 3) Providing defaults to arguments 4) Collecting multiple positional or keyword arguments 5) Sending multiple arguments by the caller 6) Using keyword-only arguments. You must be wondering why I wrote all of them in a paragraph and not as pointers. It’s very common that when we understand the context we tend to skip lines while reading large paragraphs. And hence highlighting each point once again, will definitely catch your eye.

  1. Arguments are read from left to right, that is, the position of arguments passed directly maps to position of variables in the function’s header.
def foo(d, e, f):
print(d, e, f)

a, b, c = 1, 2, 3
foo(a, b, c) # Prints 1, 2, 3
foo(b, a, c) # Prints 2, 1, 3
foo(c, b, a) # prints 3, 2, 1

The variables a, b, c having the values 1, 2, 3 respectively are directly mapped to the variables d, e, f. This rule applies to all the rest of the 5 points mentioned above. The position of the argument passed in the caller plays the key role here while assigning to the corresponding function variables.

2. Matching keyword argument name: This directly maps a keyword to its corresponding name in the function header.

def foo(arg1=0, arg2=0, arg3=0):
print(arg1, arg2, arg3)

a, b, c = 1, 2, 3foo(a,b,c) # Prints 1 2 3
foo(arg1=a, arg2=b, arg3=c) # Prints 1 2 3
foo(arg3=c, arg2=b, arg1=a) # Prints 1 2 3
foo(arg2=b, arg1=a, arg3=c) # Prints 1 2 3

The function foo takes in 3 arguments namely arg1 , arg2 and arg3 . Notice how the caller has changed the position of the arguments. Though it is read from left to right, python maps each argument passed by the caller and with the corresponding argument name in the function foo header. Python matches the arguments by name here and not by position. Thus, irrespective of the position of keyword argument in the caller function we see the same print result which is 1 2 3 .

Note: #1 still applies.

3. Providing defaults to keyword arguments: When we use defaults in a function, it makes certain arguments optional. The function definition would look similar to #2 . The only difference that we would see is how the caller calls the function.

def foo(arg1=0, arg2=0, arg3=0):
print(arg1, arg2, arg3)

a, b, c = 1, 2, 3
foo(arg1=a) # Prints 1 0 0
foo(arg1=a, arg2=b ) # Prints 1 2 0
foo(arg1=a, arg2=b, arg3=c) # Prints 1 2 3

Notice how we have not passed all the arguments in the first two function calls. In this case, the default values associated with the arguments in the header are assigned for those arguments which were not passed by the caller.

foo(arg2=b)                         # Prints 0 2 0
foo(arg2=b, arg3=c ) # Prints 0 2 3

foo(arg3=c) # Prints 0 0 3
foo(arg3=c, arg1=a ) # Prints 1 0 3

These were the straight forward approaches to sending arguments to the function foo . Let’s play around a little more by keeping #1, #2 and #3 in mind.

foo(a, arg2=b)                      # Prints 1 2 0
foo(a, arg2=b, arg3=c) # Prints 1 2 3
foo(a, b, arg3=c) # Prints 1 2 3

foo(a) # Prints 1 0 0
foo(a,b) # Prints 1 2 0

Here we have used both positional arguments and keyword arguments for passing arguments to functions. When positional arguments come into the picture, the order of arguments matters very much.

One important point to note here is you cannot pass positional arguments after keyword arguments. Let’s look at an example to understand this more:

foo(arg1=a, b)
>>>foo(arg1=a, b)
^
SyntaxError: positional argument follows keyword argumentfoo(a, arg2=b, c)
>>>foo(a, arg2=b, c)
^
SyntaxError: positional argument follows keyword argument

You can take is as a rule. Positional arguments must not follow the keyword argument.

4. Collecting positional arguments ( *args and **kwargs )

When we use *args and **kwargs in the function definition, we expect that the function collects all the variables in the call and assigns them to the corresponding variable args and kwargs based on the type of call. The *args takes in multiple arguments, collects them and forms a tuple of values and assigns the tuple to the variable args. Similarly, **kwargs takes in multiple keyword arguments, collects them into a dictionary of key-value pairs and assigns the dictionary to the variable kwargs.

def foo(*args):
print(args)

a, b, c = 1, 2, 3

foo(a, b, c) # Prints (1, 2, 3)
foo(a, b) # Prints (1, 2)
foo(a) # Prints (1)
foo(b, c) # Prints (2, 3)

The above code shows that variable args holds the tuple of values passed during the function call.

def foo(**kwargs):
print(kwargs)


foo(a=1, b=2, c=3) # Prints {'a': 1, 'b': 2, 'c': 3}
foo(a=1, b=2) # Prints {'a': 1, 'b': 2}
foo(a=1) # Prints {'a': 1}
foo(b=2, c=3) # Prints {'b': 2, 'c': 3}

The above code shows that variable kwargs holds the dictionary of key-value pairs passed during the function call.

However, we cannot pass a keyword argument that expects positional arguments to a function and (vice-versa) positional arguments to a function that expects keyword arguments.

def foo(*args):
print(args)


foo(a=1, b=2, c=3)
>>>foo(a=1, b=2, c=3)
TypeError: foo() got an unexpected keyword argument 'a'#########################################################def foo(**kwargs):
print(kwargs)

a, b, c = 1, 2, 3foo(a, b, c)>>>
TypeError: foo() takes 0 positional arguments but 3 were given

Now we would be able to combine all the pointers that we have read until now ( #1, #2, #3 and #4) and try out different combinations.

def foo(*args,**kwargs):
print(args, kwargs)

foo(a=1,)
# () {'a': 1}

foo(a=1, b=2, c=3)
# () {'a': 1, 'b': 2, 'c': 3}

foo(1, 2, a=1, b=2)
# (1, 2) {'a': 1, 'b': 2}

foo(1, 2)
# (1, 2) {}

As can be seen, we get a tuple and dictionary of values in args and kwargs.

Here we come upon one more rule which is “We must not define * after **.”

def foo(**kwargs, *args):
print(kwargs, args)>>>
def foo(**kwargs, *args):
^
SyntaxError: invalid syntax

The same applies to function calls as well.

foo(a=1, 1)
>>>
foo(a=1, 1)
^
SyntaxError: positional argument follows keyword argumentfoo(1, a=1, 2)
>>>
foo(1, a=1, 2)
^
SyntaxError: positional argument follows keyword argument

We can include positional arguments and keyword arguments with * and ** as follows:

def foo(var, *args,**kwargs):
print(var, args, kwargs)

foo(1, a=1,) # Call1
# 1 () {'a': 1}

foo(1, a=1, b=2, c=3) # Call2
# 1 () {'a': 1, 'b': 2, 'c': 3}

foo(1, 2, a=1, b=2) # Call3
# 1 (2,) {'a': 1, 'b': 2}foo(1, 2, 3, a=1, b=2) # call4
# 1 (2, 3) {'a': 1, 'b': 2}foo(1, 2) # Call5
# 1 (2,) {}

The foo function here assumes to have one mandatory positional argument, followed by a variable-length of positional arguments, followed by a variable-length of keyword arguments. Now we can easily decipher each call.

In call1 the arguments passed are 1 and a=1 which are positional argument and keyword argument respectively. call2 is another variant of call1 . Here the variable-length arguments are zero.

In call3 we are passing 1 , 2 and a=1,b=2 which deciphers to two positional arguments and two keyword arguments. According to the definition of the function, 1 goes to the mandatory positional argument and then 2 goes to the variable-length positional argument and lastly a=1,b=2 go to the variable-length keyword argument.

In this case, we must be passing at least one positional argument for every function call, else we would be seeing an error as follows:

def foo(var, *args,**kwargs):
print(var, args, kwargs)

foo(a=1)
>>>
foo(a=1)
TypeError: foo() missing 1 required positional argument: 'var'

Another extension to the above function would be creating a mandate positional and keyword argument followed by a variable-length of positional and keyword arguments

def foo(var, kvar=0, *args,**kwargs):
print(var,kvar, args, kwargs)

foo(1, a=1,) # Call1
# 1 0 () {'a': 1}

foo(1, 2, a=1, b=2, c=3) # Call2
# 1 2 () {'a': 1, 'b': 2, 'c': 3}

foo(1, 2, 3, a=1, b=2) # Call3
# 1 2 (3,) {'a': 1, 'b': 2}

foo(1, 2, 3, 4, a=1, b=2) # call4
# 1 2 (3, 4) {'a': 1, 'b': 2}

foo(1, kvar=2) # Call5
# 1 2 () {}

The foo function here assumes to have one mandatory positional argument and one mandatory keyword argument followed by a variable length of positional arguments and finally followed by a variable length of keyword arguments. Each call in the above sample code deciphers similar to the previous example.

In this case, we must be passing at least one positional argument for every function call, else we would be seeing an error as follows:

foo()
>>>foo()
TypeError: foo() missing 1 required positional argument: 'var'foo(1)
# 1 0 () {}

Note how foo(1) worked. It is because the variable kvar is taking the default value when no argument is passed by the caller.

Some more errors that we might see are as follows:

foo(kvar=1)                             #call2
>>>
TypeError: foo() missing 1 required positional argument: 'var'foo(kvar=1, 1, a=1) #call2>>>
SyntaxError: positional argument follows keyword argumentfoo(1, kvar=2, 3, a=2) #call3
>>>
SyntaxError: positional argument follows keyword argument

Note how call3 raised an error.

5. Unpacking the arguments in the call: While in the previous section we saw how multiple arguments are collected in the function definition, in this we try to look at the inverse. Sending multiple variables in a function call a.k.a unpacking arguments in the call.

args = (1, 2, 3, 4)
print(*args) # Prints 1 2 3 4
print(args) # Prints (1, 2, 3, 4)

kwargs = { 'a':1, 'b':2}
print(kwargs) # Prints {'a': 1, 'b': 2}
print(*kwargs) # Prints a b

The unpacking of variables can be done using the * and ** syntax. We can pass the variables using some of these notations in function calls.

def foo(a, b=0, *args, **kwargs):
print(a, b, args, kwargs)

tup = (1, 2, 3, 4)
lst = [1, 2, 3, 4]
d = {'e':1, 'f':2, 'g':'3'}

foo(*tup) # foo(1, 2, 3, 4)
# 1 2 (3, 4) {}

foo(*lst) # foo(1, 2, 3, 4)
# 1 2 (3, 4) {}

foo(1, *tup) # foo(1, 1, 2, 3, 4)
# 1 1 (2, 3, 4) {}

foo(1, 5, *tup) # foo(1, 5, 1, 2, 3, 4)
# 1 5 (1, 2, 3, 4) {}

foo(1, *tup, **d) # foo(1, 1, 2, 3, 4 ,e=1 ,f=2, g=3)
# 1 1 (2, 3, 4) {'e': 1, 'f': 2, 'g': '3'}

foo(*tup, **d) # foo(1, 1, 2, 3, 4 ,e=1 ,f=2, g=3)
# 1 2 (3, 4) {'e': 1, 'f': 2, 'g': '3'}
d['b'] = 45
foo(2, **d) # foo(1, e=1 ,f=2, g=3, b=45)
# 2 45 () {'e': 1, 'f': 2, 'g': '3'}

Take a look at each call using the unpacking arguments notation and what would each call look like without using * and ** . Try to understand what is happening in each call and how variables are unpacked.

One new type of error you might see when we mess up calling is the following.

foo(1, *tup, b=5)
>>>
TypeError: foo() got multiple values for argument 'b'foo(1, b=5, *tup)
>>>
TypeError: foo() got multiple values for argument 'b'

This happens because the keyword argument b=5 is trying to override a positional argument. As we saw in the second section when passing keyword arguments, the order does not matter. We see a similar error in either case.

6. Using the keyword-only argument: In some cases, we would like to pass a mandate keyword argument. When the function's definition uses keyword-only arguments, all calls to function must pass the keyword-only argument.

def foo(a, *args, b):
print(a, args, b)

tup = (1, 2, 3, 4)


foo(*tup, b=35)
# 1 (2, 3, 4) 35

foo(1, *tup, b=35)
# 1 (1, 2, 3, 4) 35

foo(1, 5, *tup, b=35)
# 1 (5, 1, 2, 3, 4) 35

foo(1, *tup, b=35)
# 1 (1, 2, 3, 4) 35

foo(1, b=35)
# 1 () 35

foo(1, 2, b=35)
# 1 (2,) 35

foo(1)
# TypeError: foo() missing 1 required keyword-only argument: 'b'

foo(1, 2, 3)
# TypeError: foo() missing 1 required keyword-only argument: 'b'

We can also use * only in the function definition thus making the function to accept the keyword-only arguments and not variable length arguments.

def foo(a, *, b, c):
print(a, b, c)

tup = (1, 2, 3, 4)

foo(1, b=35, c= 55)
# 1 35 55

foo(c= 55, b=35, a=1)
# 1 35 55

foo(1, 2, 3)
# TypeError: foo() takes 1 positional argument but 3 were given

foo(*tup, b=35)
# TypeError: foo() takes 1 positional argument but 4 positional arguments (and 1 keyword-only argument) were given

foo(1, b=35)
# TypeError: foo() takes 1 positional argument but 4 positional arguments (and 1 keyword-only argument) were given

As seen above, the function takes one positional argument and two keyword-only arguments. This forces caller to send in the mandatory keyword-only arguments. We could also use defaults after * giving an optional choice on what must be passed in arguments.

def foo(a, *, b=0, c, d=0):
print(a, b, c, d)

foo(1, c= 55)
# 1 0 55 0

foo(1, c= 55, b = 35)
# 1 35 55 0

foo(1)
# TypeError: foo() missing 1 required keyword-only argument: 'c'

Notice how we have not passed the arguments b and d because they would be using the default values from the function definition.

That was really a long long story. Pheww. I wonder how the readers of this article felt? Did you learn something new or was it just refreshing your mind? There’s a small part (rules) that I didn’t write about, which I would be writing in another article just not to bore my readers with a lot of content to digest.

Upvote


user
Created by

Srinaveen Desu

A pythonista by nature. I like learning new technologies and building innovative solutions out of it.


people
Post

Upvote

Downvote

Comment

Bookmark

Share


Related Articles