The Big History of Function Arguments in Python

Well, actually, the history of arguments in Python isn't that big.



I was always surprised that to work with arguments of Python functions, you just need to understand *argsand **kwargs. And I was surprised not in vain. As it turns out, arguments are far from easy. In this post, I want to give a general overview of everything related to function arguments in Python. I hope that in the end I will indeed be able to show the general picture of working with arguments, and that this article will not become another publication in which the reader will not be able to find anything new. And now - to the point.







Most of the readers of this article, I believe, understand the essence of function arguments. For beginners, let me explain that these are objects sent to a function by the initiator of its call. When passing arguments to a function, a lot of actions are performed, depending on what type of objects are dispatched to the function (mutable or immutable objects). A function call initiator is an entity that calls a function and passes arguments to it. Speaking of calling functions, there are some things to consider that we will now discuss.



The arguments, the names of which are specified when the function is declared, store the objects passed to the function when called. Moreover, if something is assigned to the corresponding local variables of functions, their parameters, this operation does not affect the immutable objects passed to the functions. For instance:



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

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


As you can see, the function call did not affect the variable in any way a. This is exactly what happens when an immutable object is passed to a function.



And if mutable objects are passed to functions, then you may encounter system behavior that differs from the above.



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

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


Have you noticed something new here? If you answer β€œNo,” you are right. But if we somehow influence the elements of the mutable object passed to the function, we will witness something different.



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

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


As you can see, the object from the parameter lstwas changed after the function call. This happened due to the fact that we are working with a reference to an object stored in a parameter lst. As a result, changing the content of this object is outside the scope of the function. You can avoid this by simply making deep copies of such objects and writing them to the function's local variables.



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

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


Didn't that surprise you yet? If not, then I would like to make sure that you skip what you know and immediately move on to new material for you. And if yes - then, mark my words, you, getting acquainted with the arguments, will learn a lot more interesting things.



So, here's what you should know about function arguments:



  1. The order in which positional arguments are passed to functions.
  2. The order in which named arguments are passed to functions.
  3. Assigning default argument values.
  4. Organization of processing of sets of arguments of variable length.
  5. Unpacking arguments.
  6. Using arguments that can only be passed by name (keyword-only).


Let's look at each of these points.



1. Order of passing positional arguments to functions



Positional arguments are processed from left to right. That is, it turns out that the position of the argument passed to the function is in direct correspondence with the position of the parameter used in the header of the function when it was declared.



def foo(d, e, f):
    print(d, e, f)

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


The variables a, band chave the values ​​1, 2 and 3. These variables play the role of the arguments with which the function is called foo. They, on the first call of the function, correspond to the parameters d, eand f. This mechanism applies to almost all of the above 6 points about what you need to know about function arguments in Python. The location of the positional argument passed to the function when it is called plays a major role in assigning values ​​to the function parameters.



2. Order of passing named arguments to functions



Named arguments are passed to functions with the names of these arguments corresponding to the names that were assigned to them when the function was declared.



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

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


As you can see, the function footakes 3 arguments. These arguments are named arg1, arg2and arg3. Pay attention to how we change the position of the arguments when calling the function. Named arguments are treated differently from positional arguments, although the system continues to read them from left to right. Python considers the names of the arguments, not their positions, when assigning the appropriate values ​​to the parameters of functions. As a result, it turns out that the function outputs the same thing regardless of the positions of the arguments passed to it. It is always 1 2 3.



Please note that the mechanisms described in paragraph 1 continue to operate here.



3. Assigning default argument values



Default values ​​can be assigned to named arguments. When using this mechanism in a function, certain arguments become optional. The declaration of such functions looks like what we considered in point # 2. The only difference is how these functions are called.



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

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


Please note that in this example we are not passing all the arguments to the function as described in its declaration. In these cases, the corresponding parameters are assigned the default values. Let's continue with this example:



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

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


These are simple and understandable examples of using the above described mechanisms for calling functions with passing named arguments to it. Now let's complicate our experiments by combining what we have talked about so far in points # 1, # 2 and # 3:



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

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


Here, both positional and named arguments are used when calling the function. When using positional arguments, the order in which they are specified continues to play a critical role in correctly passing the input to the function.



Here I would like to draw your attention to one remarkable detail. It consists in that positional arguments cannot be specified after named arguments. Here's an example to help you understand this idea better:



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


You can take it as a rule. Positional arguments do not have to follow named arguments when calling a function.



4. Organization of processing of sets of arguments of variable length



Here we will talk about constructions *argsand **kwargs. When these constructs are used in a function declaration, we expect that when the function is called, argument sets of arbitrary lengths will be represented as parameters argsand kwargs. When the construct is applied *args, the parameter argsreceives positional arguments represented as a tuple. When applied **kwargsin the kwargsfall named arguments, listed in a dictionary.



def foo(*args):
    print(args)

a, b, c = 1, 2, 3

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


This code proves that the parameter argsstores a tuple containing what was passed to the function when it was called.



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

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


The code above shows that the parameter kwargsstores a dictionary of key-value pairs representing the named arguments passed to the function when called.



However, it should be noted that a function designed to accept positional arguments cannot be passed named arguments (and vice versa).



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, 3
foo(a, b, c)
>>>
TypeError: foo() takes 0 positional arguments but 3 were given


Now let's put together everything that we analyzed in points # 1, # 2, # 3 and # 4, and experiment with all of this, exploring different combinations of arguments that can be passed to functions when they are called.



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 you can see, we have a tuple argsand a dictionary at our disposal kwargs.



And here is another rule. It lies in the fact that the structure *argscannot be used after the structure **kwargs.



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


The same rule applies to the order in which arguments are specified when calling functions. Positional arguments must not follow named arguments.



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


When declaring functions, you can combine positional arguments, *argsand *kwagrsas follows:



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

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

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

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


When declaring a function, foowe assumed that it must have one required positional argument. It is followed by a set of variable length positional arguments, and this set is followed by a set of variable length named arguments. Knowing this, we can easily "decrypt" each of the above function calls.



The 1function is passed arguments 1and a=1. These are, respectively, positional and named arguments. 2Is a variety 1. Here, the length of the set of positional arguments is zero.



In 3we pass functions 1, 2and a=1,b=2. This means that it now accepts two positional arguments and two named arguments. According to the function declaration, it turns out that1taken as a required positional argument, 2goes into a set of variable length positional arguments, and a=1and b=2ends up in a set of variable length named arguments.



In order to call this function correctly, we must pass at least one positional argument to it. Otherwise, we will face an error.



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

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


Another variation of this function is a function that declares that it takes one required positional argument and one named argument, followed by variable length sets of positional and named arguments.



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

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

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

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

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

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


Calls to this function can be "decrypted" in the same way as it was done when analyzing the previous function.



When calling this function, it must be passed at least one positional argument. Otherwise, we will encounter an error:



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


Note that the call foo(1)works fine. The point here is that if a function is called without specifying a value for a named argument, the value is automatically assigned to it.



And here are some more errors that can be encountered if this function is called incorrectly:



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


Pay special attention to the runtime error 3.



5. Unpacking arguments



In the previous sections, we talked about how to collect sets of arguments passed to functions into tuples and dictionaries. And here we will discuss the reverse operation. Namely, we will analyze the mechanism that allows you to unpack the arguments supplied to the function input.



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

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


You can unpack variables using the syntax *and **. This is how they are used when passing tuples, lists, and dictionaries to a function.



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'}


Deconstruct each of the function calls shown here using argument unpacking, and notice how the corresponding calls would look without using *and **. Try to understand what happens when you make these calls and how the various data structures are unpacked.



Experimenting with unpacking arguments, you may encounter a new error:



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 error occurs due to a conflict between the named argument,, b=5and the positional argument. As we found out in section # 2, the order of named arguments does not matter when passed. As a result, the same error occurs in both cases.



6. Using arguments that can only be passed by name (keyword-only)



In some cases, you need to make the function accept required named arguments. If, when declaring a function, they describe arguments that can be passed only by name, then such arguments must be passed to it whenever it is called.



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'


As you can see, it is expected that the function will necessarily be passed a named argument b, which, in the function declaration, is specified after *args. In this case, in the function declaration, you can simply use a symbol *, after which, separated by commas, there are identifiers of named arguments that can be passed to the function only by name. Such a function would not be designed to accept a set of variable length positional 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


The function declared in the previous example takes one positional argument and two named arguments, which can only be passed by name. This leads to the fact that for a function to be called correctly, it needs to pass both named arguments. After that, *you can also describe the named arguments, which are given the default values. This gives us a certain amount of freedom when calling such functions.



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'


Note that the function can be called normally without passing any arguments to it, band dbecause they have been given default values.



Outcome



Perhaps we have, indeed, a very long story about arguments. I hope the readers of this material have learned something new for themselves. And by the way, the story of function arguments in Python goes on. Perhaps we will talk about them later.



Did you learn anything new about function arguments in Python from this material?






All Articles