In this chapter, we will learn more about functions in Python. We have already encountered some functions earlier in the last chapter.

Can you list some of the functions that we have encountered?

We have already encountered a couple of built-in functions such print(), len(), round() and, ord(). Let's write a program to understand function more.

We will create a program that calculates the circumference and area of a circle of a given radius.

In geometry, the circumference would be the circle's length if it were opened up and straightened out to a line segment. We usually refer to the area enclosed by the circle as the area of the circle.

Fig 1: Circumference and Area of a circle

The area and circumference of a circle are given in the following:

$$
\text{Area of circle} = \pi r^2 \
\text{Circumference of circle } = 2\pi r
$$

Let's write a Python program that calculates the area and circumference with a given radius. To do that, we first need to define the mathematical constant Pi ($π$) in Python.

>>> pi = 3.14

Python also has a predefined mathematical constant Pi in the standard math module that we can use directly. The pi defined in the standard library has a lot more precision.

>>> from math import pi
>>> pi
3.141592653589793

We can get the area and circumference of a 50-meter radius circle in the following way.

>>> r = 50 				# in metres (m)
>>> area = pi * (r**2)
>>> circumference = 2*pi*r

Now that we got our result, we can display it nicely using the print() function.

>>> print("The area is", area, "squared metres and the circumference is", circumference, "metres")
The area is 150.79644737231007 squared meters, and the circumference is 314.1592653589793 meters
Although we correctly printed the result, the displayed string is unreadable. How can we make the result look a bit more readable?

The text looks unreadable due to a large number of decimal digits. Python provides a built-in round() function to round off decimal digits. We can round off many decimal digits by using the built-in round() function.

The round() function takes a floating-point number as the first argument and a second optional argument to specify the decimal's precision.

>>> area
150.79644737231007
>>> round(area, 2)  # Lets round off the digits to 2 decimal
150.8

If we do not provide the decimal precision, Python rounds off the number to the nearest integer.

>>> round(area)     # Rounds off to nearest integer
151

After using the rounding off excess decimal digits, our result looks way neater.

>>> print("The area is", round(area,2), "squared metres and the circumference is", round(circumference,2), "metres")
The area is 150.8 squared meters, and the circumference is 314.16 meters

We can rewrite the entire program to look like the following code listing.

>>> from math import pi
>>> r = 50
>>> area = round(pi*(r**2), 2)
>>> circumference = round(2*pi*r)
>>> print("Area :", area, "squared metres")
Area : 7853.98 squared metres
>>> print("Circumference :", circumference, "metres")
Circumference : 314 metres

The steps that we took for the program can be written as follows :

  1. Define or import the value of pi.
  2. Define the radius
  3. Calculate the area using the formulae and round off to 2 decimal digits
  4. Calculate the circumference using the formulae and round off to 2 decimal digits.
  5. Print the area and circumference.

We successfully wrote a program to calculate and display the result. Let's say we want to calculate the area and circumference of other radii such as $10$, $20$, and $30$ in the interactive Python interpreter.

Which of the above steps will we need to repeat?

To calculate for other radii in the interactive interpreter, we will have to repeat all the steps. However, one of the vital programming principles is to reuse code for as much as possible.

To reuse code, we need to package a set of statements that perform a specific task and then invoke or call it whenever we require it.These packaged callable units of statement are called functions.

Let's look into how we can write a custom function.

Functions

In Python, we define functions using the keyword def and writing the function's name. We refer to the code block within the function definition as the function body.

>>> def first_contact():
...    	print("Hello Aliens !")
...     print("This is Earth speaking.")

In the above code, we define our first function first_contact(). Its function body consists of printing just two lines greeting the aliens.

Calling or invoking the function signals Python that you want to execute the code block inside the function definition.

When we define a function, Python doesn't execute the statements in the function body. If you want Python to perform the function body, you need to call or invoke the function. We can invoke the function by typing a pair of closing parentheses at the function name's end.

In the code listing below, we invoke the first_contact() function that we defined earlier. You can see that Python prints our two lines of greetings.

...
>>> first_contact()				# Function is invoked
Hello Aliens!
This is Earth speaking.

We can invoke the function as many times as possible, which saves us from much repetitive work.

We invoke the function using a pair of parentheses such as first_contact(). What do you think happens when there is a bit of whitespace between the parentheses?

>>> first_contact(              )
  1. Raises SyntaxError
  2. Raises TypeError
  3. Executes without error
  4. Raises NameError

Python ignores the whitespace inside the function argument and invokes the function normally.

Function Arguments

We write functions to perform some specific tasks. Sometimes, the task requires additional value or data during the function call. In these cases, we need to change the function definition to accept additional values.

The additional values that we provide to a function in the function call or values that a function anticipates during a function call are called function arguments or function parameters.

Let's define a function add() that accepts two arguments and prints the sum of these arguments on the screen.

>>> def add(x, y):
	 	print(x + y)

We can call our add() function along with the arguments in the following way.

>>> add(5, 6)
14
>>> add(10, -8)
2
What do you think happens when you execute the code add(5, 6, 7)?

The positional order and number of arguments should match those that the function anticipates according to its definition.

In the case of a mismatch, Python raises a TypeError exception, as we can see from the code below.

>>> add(5, 6, 7)			# Wrong number of arguements
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: add() takes 2 positional arguments but 3 were given

The message accompanying the TypeError clearly states that add() takes two positional arguments, but three were given.

The message and the error type are useful clues for fixing any problem you might encounter while programming.

Let's return to the program we wrote that calculates the area and circumference for a given radius. Suppose we wish to convert the program into a generalized function that can calculate for any given circle. What should we take as a function argument?

The only thing that varies in the calculation is the radius (r).

Therefore, it makes sense to take the radius of the circle as the function argument to create a generalized function.

Let's take a look at how we can write such a function.

To define a function, we will again use the def keyword, followed by the function name. We will name our function, measure_circle.

>>> from math import pi
>>> def measure_circle(r):			# radius in ft
    	area = pi*(r**2)
        circumference = 2*pi*r
        print("Area: {} sq.ft \nCircumference: {} ft"
              .format(round(area, 2), round(circumference, 2)))

Whenever we invoke the measure_circle() function with a radius, it prints the area and circumference.

>>> measure_circle(10)
Area: 314.16
Circumference: 62.83
>>> measure_circle(20)
Area: 1256.64
Circumference: 125.66

Thus, we have created a function that we can reuse for later purposes.

Consider the round() function.

We can use the function with a decimal number and the precision value such as round(area, 2). We can also skip the precision value and use round(area), which floors it to the nearest integer. Earlier I mentioned that to invoke a function, we have to supply all the function arguments. How do you think we can skip an argument for the round() function?

Default Arguments

We can skip an argument during a function call if we assign it a default value at the time of function definition.

When we assign a default value to a function argument in the function definition, the argument becomes optional during the function call.

Let's create a new function with the name greeter. It takes an argument name and prints a string using the name argument.

>>> def greeter(name):
    	print("Hello", name, "!" )
>>> def greeter('Luffy')
Hello Luffy !

We can make the name argument optional by assigning a default value in the function definition. Let's redefine our greeter() function.

>>> def greeter(name = "Primer"):	# Default value assigned
    	print("Hello",name, "!")

Now, we can skip the name argument and invoke the greeter() function. When we provide no value to the name argument at the time of function call, the greeter function uses the value assigned during the function definition.

>>> greeter()						# Function call without optional argument
Hello Primer !
>>> greeter('Charlie')				# Functional call with optional argument
Hello Charlie!

Let's add another default argument title and try to modify the greeter() function to have name as a non-default argument as shown below.

>>> def greeter(title="Sir", name)
		print("Hello", title, name, "!")

When we enter the interpreter's above code, Python raises SyntaxError, informing us that we cannot have non-default arguments following default arguments.

>>> def greeter(title="Sir", name):
    	print("Hello", title, name, "!")
File "<stdin>", line 1
SyntaxError: non-default argument follows default argument
Why do you think Python prohibits having non-default arguments following default arguments?

In Python, when you define optional arguments, the arguments that follow (in the positional order) also become optional and require a default value.

If you fail to assign all the optional arguments of a function, Python raises a SyntaxError exception.

Therefore keep in mind that to place optional or default arguments after required or non-default arguments.

>>> def greeter(title="Sir", name):
    	print("Hello", title, name, "!")
File "<stdin>", line 1
SyntaxError: non-default argument follows default argument

In the above code sample, the function argument title has a default value assigned, making it optional. At the same time, name is a required argument. We can fix the error by either rearranging the arguments,

>>> def greeter(name, title="Sir"):					# Arguments are rearranged
    	print("Hello", title, name, "!")
>>> greeter("Humphrey Appleby")						# only `name` provided
Hello Sir Humphrey Appleby !
>>>greeter("Humprey Applyby", "Mr.")				# both are provided
Hello Mr. Humprey Applyby!

Or making the name argument optional as well.

>>> def greeter(title="Sir", name="Humphrey Appleby"):		# Both are optional
    	print("Hello", title, name, "!")
>>> greeter()												# No Arguments
Hello Sir Humphrey Appleby !
>>> greeter("Mr.")											# Only `title` provided
Hello Mr. Humphrey Appleby!

The function's default arguments are references to an object. We can check this by using the built-in getrefcount() function of the sys module. Let's create a new function, current_feeling(), which takes the argument feeling. We then assign the string object referenced by the name mood as a default value for the feeling argument.

>>> from sys import getrefcount
>>> mood = "Happy"
>>> getrefcount(mood)
2
>>> def current_feeling( feeling = mood ):
    	print("Feeling", mood)
>>> getrefcount(mood)
3

If you assign the object as a default argument, the argument acts as a reference similar to a name.

The getrefcount(mood) function returns 3 as the default argument feeling adds another reference to the object referenced by the name mood. As you might recall, invoking a function using an object's reference temporarily increases its reference count.

We can invoke the function in the following way.

>>> current_feeling()		# without arguments
Feeling Happy

Let's reassign the name mood to Sad.

>>> mood = "Sad"

Let's invoke the function again.

>>> current_feeling()
We reassigned the name mood to another string. What do you think the result of the above function call?

Even if we reassign the name mood to some different object later on in the program, the default argument will always refer to the object referenced by the name mood at the function definition, which is Happy.

Python assigns the default arguments of a function to the objects you assign during the function definition.

So, the above code would result in the following.

>>> current_feeling()
Feeling Happy
>>> mood = "Sad"				# Reassigning the `name`
>>> current_feeling()
Feeling Happy					# Refers to the earlier assigned String object

While working in Python, it is useful to figure out the objects referred to by the names. It becomes important, especially when dealing with mutable objects.

When you assign a mutable object to a function argument in the definition, you should take extra precautions. Can you think of reasons why?

Mutable Default Values

A mutable object assigned as a default function argument may lead to unintended behavior. Let's understand by creating a function number_list() that takes two arguments, num1 and num2 and an optional argument items.

The items argument has the default value assigned to an empty list ([]) object. The number_list function takes the two arguments and stores their product and sum in the list object referenced by the items argument.

>>> def number_list(num1, num2, items=[]):
    	items += [num1*num2, num1 + num3]		# [Product, Sum]
        return items

Let's invoke the function.

>>> number_list(1, 2)
[2, 3]					# Expected Result

The first time we call the function, the result seems fine. Let's call the function again.

>>> number_list(3, 4)
[2, 3, 12, 7]					# Returns previous result as well

The next time we call the function with some different arguments, the result is incorrect.

The number_list() returns the previous result as well. Why do you think it does that?

The empty list object referenced by the optional argument items is modified by the first function call.

As Python always assigns the default argument values to the objects you supplied during the function definition, the default argument items still refer to the same list object that is no longer empty after the first function call.

Python continues to update the initially empty list object in the first and subsequent function calls.

Let's see how we can avoid this type of behavior.

To avoid this type of behavior, we can rewrite the function definition using a None type object as a default function argument. The None object is null, and there is only one None object in Python.

>>> def number_list (num1, num2, items = None):
    	if items is None:
            items = []
        items += [num1*num2, num1 + num2]
        return items

In case you don't provide any argument to the items during the function invocation, we are creating a new empty list and assigning it toitems.

Now, let's call the function again.

>>> number_list(1, 2)
[2, 3]
>>> number_list(3, 4)
[12, 7]						# Expected Result

We can understand why we should take a bit of precaution while working with mutable objects. In the next section, we will continue looking into functions in Python in more detail.

Named Arguments

Let's create a function name sample_function() which takes arguments: age, height and weight as shown below.

>>> def sample_function(age, height, weight)
        print("Age:", age, "years old")
        print("Height:", height, "centimetres")
        print("Weight:", weight, "kilograms")

We can invoke the sample_function() using positional arguments by supplying arguments in the same order as defined in the function definition.

# Function invocation with arguments in the same order
>>> sample(12, 180, 75)				# (age, height, weight)
Age: 12 years old
Height: 180 centimetres
Weight: 75 kilograms
Positional arguments are arguments that are to be included in the same order as defined in the function definition.

So far, while invoking a function, we provided the same order arguments as the function definition.

We can also invoke a function by naming each argument while assigning a value. The function arguments named while invoking a function are called keyword or named arguments.

>>> sample(weight=75, age=12, height=180 )
Age: 12 years old
Height: 180 centimetres
Weight: 75 kilograms

Notice that we don't need to conform to the order in which we defined the function arguments in the function definition.

While supplying function arguments while invoking a function, you must assign all the required or non-default function arguments.

In case you omit any of the required arguments, Python raises a TypeError exception.

You can invoke a function with both keyword and positional arguments together, given,

  • that ensure that positional arguments appear earlier than keyword arguments,
  • that you provide the required arguments with values,
  • and define no argument more than once.

Let's create another function sample_function_2() which takes 4 arguments:a, b, c and d.

>>> def sample_function_2 (a, b, c, d):
    	return (a + ((b+d)**c))
>>> sample_function_2(1,2, a = 1, d=4)

What is the output of the above code?

  1. Raises TypeError
  2. 44
  3. 66
  4. 214

The above code results in an error as we provide the value for argument a twice. As a appears first in the positional arguments, the first argument is taken as the value of a. Therefore, we end up defining a twice.

We can check in the Python interpreter as well.

Python raises a TypeError for the above code.

>>> sample_function_2(1,2, a=1, d=4)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sample_function_2() got multiple values for argument 'a'
Let's again invoke the function in the following way.
>>> sample_function_2(d = 1, c=2, 4, 5)

What do you think happens when you execute the above code?

In the above function call, positional arguments follow keyword arguments. This is not supported in Python. Let's check in the interpreter as well.

Python raises SyntaxError as the positional argument follows the keyword argument.

# SyntaxError: positional argument follows keyword argument
>>> sample_function_2(d = 1, c=2, 4, 5)
  File "<stdin>", line 1
SyntaxError: positional argument follows keyword argument

The below code listing executes correctly.

>>> sample_function_2(1, d=1, c= 3, b =4 )
126											# No Error

As Python provides the option to provide keyword arguments during the function call, we recommend giving descriptive function arguments name during the function definition.

Which of the following do you think is the correct and recommended way to invoke the getPersonDetails()

Which of the following do you think is the correct and recommended way to invoke the getPersonDetails()
  1. getPersonDetails('Luffy', 16)
  2. getPersonDetails('Luffy', age=16)
  3. getPersonDetails(name='Luffy', age=16)
  4. getPersonDetails(age=16, 'Luffy')

Which of the following do you think is the correct and recommended way to invoke the getPersonDetails()

def getPersonDetails(name, age ):
    # Function body
  1. getPersonDetails('Luffy', 16)
  2. getPersonDetails('Luffy', age=16)
  3. getPersonDetails(name='Luffy', age=16)
  4. getPersonDetails(age=16, 'Luffy')

Using keyword arguments while invoking a custom function makes the code more readable.

The return keyword

You can use the keyword return with the object you want the function to return after a function invocation. The return keyword also marks the boundary to which Python will execute the function's code block.

If your function definition contains the return keyword, Python does not execute anything after the return statement. We can see this in the code below.

>>> def return_5():
    	print("This line will be executed")
        return 5
    	print("This line will not be executed")
>>> return_5
This will be executed
5

Python executes the first print() function but ignores the next as it is present after the return keyword.

To capture the object's reference returned by a function, you can assign the function call to a name. For example, the built-in function len() returns the length of a collection. We can store the length of a string and refer to it later.

>>> sample_string = "This is a perfectly fine string"
>>> length_of_sample_string = len(sample_string)
>>> length_of_sample_string
31

When a function doesn't return any values, the function returns None object. Built-in statement print() , for example, doesn't return any value. If you assign a name to the print() function call, it will be assigned to the None object.

>>> a = print("Hello World")
Hello World
>>> a is None
True
>>> type(a)
NoneType

What is the result of the following code?

>>> def greet():
    	return "Hello World"
>>> print(greet())
  1. None
  2. Hello World
  3. <function greet at 0x7f9aff152c80>
  4. None of the above

You can directly call a function to pass its return values as arguments to another function. However, try to avoid writing code that uses function calls during another function invocation, leading to lesser readability. Sometimes, we need to return more than one value or multiple values from a function simultaneously. Let's see how we can do just that.

We can return multiple values from a function by returning a tuple of the values using the return keyword. As we mentioned before, values separated by a comma are assumed to be tuples in Python.

>>> def sum_product_difference(x, y):
    	return x+y, x*y, y-x
>>> sum_product_difference(4, 5)
(9, 20, 1)

Multiple return values returned in a tuple can be assigned to different names.

Python allows assigning different names to individual elements in a tuple. This is called tuple unpacking.

>>> addition, product, difference = sum_product_difference(4, 5)
>>> addition
9
>>> product
20
>>> difference
1

In chapter 2, we simultaneously assigned three names.

>>> a, b, c = 10, 20, 30
>>> print(a, b, c)
10 20 30

This is done by unpacking the tuple (10, 20, 30) and assigning them names.

Now, let's unpack the tuple returned differently.

>>> _, product, _ = sum_product_difference(4, 5)
>>> product
20
In the above code, we used _ to assign two tuple elements. What use case do you think this can have?

Sometimes, we are interested in only some elements of a tuple.

We can assign a dummy name such as _ to that particular tuple while dropping the unneeded elements.

Packing and unpacking forms an important part of Python. Let's understand them in a bit more detail.

Packing & Unpacking Tuples

Unpacking in Python refers to an operation that consists of assigning an iterable such as tuple, list, or set to a tuple or list of names in a single assignment statement. Simultaneously, packing refers to the operation when we collect several values in a single container. Let's first understand tuple unpacking.

In Python, we can put a tuple of names on the left side of an assignment operator (= ) and a tuple of values on the right side. According to their position in the tuple, the right values are automatically assigned to the variables on the left.

For instance:

>>> a, b, c = (10, 20, 30)

Here, according to the position of a, b, c, they are assigned the value 10, 20, 30 respectively. As we can create tuple object without parenthesis, the code shown below are equivalent.

>>> (a, b, c) = (10, 20, 30)
>>> (a, b, c) = 10, 20, 30
>>> a, b, c = 10, 20, 30 		# Mostly used while unpacking

Even though all the above syntax is valid, the last one is mostly used for unpacking operation.

Let's take another code instance.
>>> x, y = 10, 20, 30

What do you think is the result of the code operation?

The number of names in the tuple on the left-hand side is two, while there are three elements tuple to unpack on the right-hand side.

To unpack an iterable, there must be an equal number of names on the left-hand side to unpack.

Therefore, Python doesn't allow this assignment and raises ValueError.

When the tuple of names has more elements than the tuple of values, Python raises a ValueError with the message too many values to unpack (expected 2).

>>> x, y = 10, 20, 30
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: too many values to unpack (expected 2)

When the tuple of values has fewer elements than the tuple of names, Python again raises ValueError, albeit with the message not enough values to unpack.

>>> x, y = 10,
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: not enough values to unpack (expected 2, got 1)

The length of the tuple of names on the left-hand side must be equal to the length of tuple of values on the right side. The exception to this is the use of the unpacking operator or (*) asterisk. Let's take a look.

It is tedious to assign individual names to elements for a tuple with a length greater than 4. For instance, let's take the example of the following tuple.

>>> long_tuple = (1, 2, 3, 4, 5, 6, 7)

Now, let's say we are interested in the second and last element of the tuple, and we wish to assign it to a name so that we can refer to it again. By unpacking the tuple, we can assign the name as follows.

>>> _, second_element, _, _, _, _,  last_element = long_tuple
>>> second_element, last_element
2, 7

As you can see, this way of unpacking becomes tedious. Python provides a better way to unpack using the asterisk (*) operator, and we can see it below.

>>> _, second_element, *rest, last_element = long_tuple
>>> _
1
>>> second_element
2
>>> last_element
7

The first element, second and last element of the tuple is assigned the names.

Where did the rest of the tuple go?

The rest of the elements are stored in a list and assigned to the name rest. Prefixing * to a name, for instance, *rest, is a special syntax to help unpack tuples, lists, and sets.

Earlier, we used the asterisk(*) operator to multiply two numbers and also to repeat a container object.

>>> 4 * 5
20
>>> [1, 2]*4
[1, 2, 1, 2, 1, 2, 1, 2]

Python also uses the asterisk operator (*) to unpack elements from a container.

In the code listing below, we use *b while unpacking a tuple. Python groups elements between the second and last elements list and assign them to the name b.

>>> _, second_element, *b, last_element = long_tuple
>>> b
[3, 4, 5, 6]

The special unpacking syntax using asterisk works on lists, tuples, and sets. To understand how it works, let's look at more examples.

>>> a, *b = (1, 2, 3, 4, 5)			# Assign first element to a and rest to b
>>> a
1
>>> b
[2, 3, 4, 5]

Here, a is assigned to the first element of the list, while b is a list with the rest of the elements.

>>> *c, d = (1, 2, 3, 4, 5)			# Assign all elements except the last one to c
>>> c
[1, 2, 3, 4]
>>> d
5

In the above code, d is assigned to the last element, while c is assigned to a list with elements before the last element.

What's the value of the g in the following code?

>>> e, f, *g, h, i = (1, 2, 3, 4, 5)
  1. [2, 3, 4]
  2. [1, 2, 3, 4, 5]
  3. 3
  4. [3]

In the above exercise, the tuple is unpacked to elements e, f, g, h, i.

As we used *g instead of g, a list containing the third element of the tuple is assigned to the name g.

The special syntax of using unpacking operator works only in list or tuple and cannot be used standalone. Let's understand this.

Suppose we wish to unpack every element to the name z. You might think *z will work, but it won't.

>>> *z = [1, 2, 3, 4, 5]
  File "<stdin>", line 1
SyntaxError: starred assignment target must be in a list or tuple

To use the *name syntax, we have to provide a tuple of names. We can do that by converting the left-hand side as a tuple, as you can see below.

>>> *z, = [1, 2, 3, 4, 5]  # The left side of unpacking should be list or tuple. Notice the comma (,)
>>> z
[1, 2, 3, 4, 5]

The left side of the assignment must be a tuple or a list. That's why we use a trailing comma to create a single item tuple consisting of *z.

Earlier, we used _ to name the useless elements of a tuple. We can use the _ along with the unpacking operator to drop unneeded values.

>>> *_, a, b = [None, (), None, 4, 5]
>>> _
[None, (), None]
Based on examples you have seen so far, can you try to articulate how the asterisk * operator works in unpacking?

During unpacking of a list, set or tuple, the special syntax *arg_name packs the remaining elements into the arg_name provided.

In case no other elements are unpacked, the special syntax packs every element to a new name.

If no elements remain, the *arg_name results in an empty list.

What's the output of the below code?

>>> a, b, *c = [1, 2]
>>> c
  1. []
  2. ()
  3. Raises TypeError
  4. Raises ValueError

Variable-length arguments

*args

So far, we have defined functions with a fixed number of arguments. Sometimes, we may wish to write a function that can accept any number of arguments.

We have already used such a built-in function to accept any number of comma-separated arguments. Can you guess which one?

The print() function can accept any number of arguments. We can provide any number of arguments, and Python will display all of them on the screen.

Let's provide the print() function with many values as function arguments.

>>> print(1, 2, 3, 4, 5, 6, 7, 8, 9)
1 2 3 4 5 6 7 8 9
>>> print(1, 2, 3, 4)
1 2 3 4
>>> print(1, 2, 3)
1 2 3

The print() function takes a variable-length argument list. We can create our custom function, accepting a variable-length argument list using the asterisk operator (*).

The asterisk operator (*) is used for special syntax *args to create a function that can accept a variable-length argument list. Adding an asterisk to an argument name enables the function to accept any number of arguments and store in a tuple object that can be referenced by the argument name.

In the following function, we define a variable-length argument items with an asterisk.

>>> def foo(*items):
    	return items			# Return the tuple object

We can invoke the foo() function in the following way:

>>> foo("Hello", 1, 2)				# Three Arguments
('Hello', 1, 2)
>>> foo([1,2,3], "Another Item")	# Two Arguments
([1, 2, 3], 'Another Item')
>>> foo()							# Zero Arguments
()									# Empty Tuple

What's the output of the following code?

>>> def bar(*items):
    	return items
>>> bar()
  1. []
  2. ()
  3. None
  4. Raises ValueError

The *args syntax in a function argument returns a tuple instead of a list. You can convert the tuple() object into a list by using the built-in list() function. Converting to a list object leads to some interesting applications. Let's take a look.

Let's write a new function that takes any number of items and stores them in a cart.

>>> def shopping_cart(*items, cart=None):
        if cart is None:
            cart = []
        return cart + list(items)					# `list()` converts tuple into list

Now, let's invoke our function.

#  Function call with 2 items
>>> shopping_cart("Nachos", "Soda")
['Nachos', 'Soda']

# Function call with 4 items
>>> shopping_cart("Pizza", "Burger", "Pasta", "Salad")
['Pizza', 'Burger', 'Pasta', 'Salad']

# Functional call with 0 items
>>> shopping_cart()
[]

Although we can name the variable-length argument anything, we call it *args by convention.

The *args syntax is used to pass a non-keyword variable-length argument list to the function, which is useful when we don't know in advance how many arguments we need to pass in.

Time for an exercise.

What's the output of the following code?

>>> def foo(*args):
    	return args
>>> foo(hello="Hola")
  1. {'hello': 'Hola'}
  2. Raise TypeError
  3. ['Hola']
  4. []

The *args syntax is used only for non-keyword arguments. If you try to pass keyword arguments to *arg, Python raises TypeError as shown below.

>>> def foo(*args):
    	return args
>>> foo(hello="Hola")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo() got an unexpected keyword argument 'hello'

We can use double asterisks (**) if we want to pass keyword variable-length arguments to a function.

**kwargs

Let's see keyword variable-length arguments in action to understand better.

>>> def get_form_value(**keywords):
    	return keywords
>>> get_form_value(username="primer",
                   password="correctHorseBatteryStaple",
                   first_name="Primer",
                   last_name="labs")
{'username': 'primer', 'password': 'correctHorseBatteryStaple', 'first_name': 'Primer', 'last_name': 'labs'}
>>> get_form_value(username="primer", password="qwerty")
{'username': 'primer', 'password': 'qwerty'}

In the above code sample, we can see that function get_form_value() stores every keyword arguments into a dictionary that can be accessed using the variable-argument name keywords.

The variable-argument by convention is named **kwargs short for keyword-arguments. If you pass a positional argument to function with the only argument **kwargs, Python will again raise the TypeError exception.

>>> def foo(**kwargs):
    	return kwargs
>>> foo(12)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo() takes 0 positional arguments but 1 was given

What's the output of the following function?

>>> def bar(*args, **kwargs):
    	return args, kwargs
>>> bar("Cakes", "Latte", name="Queen")

  1. (('Cakes', 'Latte'), {'name': 'Queen'})
  2. [('Cakes', 'Latte'), {'name': 'Queen'}]
  3. ('Cakes', 'Latte', {'name': 'Queen'})
  4. Raises TypeError

As shown in the previous exercise, often, *args and **kwargs can be used together in a function. In such a case, we place *args earlier than the **kwargs in the function definition. If the function has any required argument, then they should come earlier than *args in the first definition.

*args & **kwargs together

Let's assume we have a function that has a required argument and takes variable-length keywords (**kwargs) and non-keyword (*args) arguments.

Then, the function definition resembles the syntax below.

>>> def foobar(reqd_arg, *args, **kwargs):
    	# do something

If the function has more than one required argument, then all of them should come before *args.

>>> def foobar_2(reqd_arg1, reqd_arg2, reqd_arg3, *args, **kwargs):
    	# do something

You should keep in mind that the *args and **kwargs are conventions, and we can use any other name instead of it. For instance, take a look at the person_details function shown below.

def person_details (name, *friends, **details):
	return [friends,details]

Let's invoke the function.

>>> person_details("Luffy", "Zorro",
                   "Sanji", "Nami", "Chopper",
                   favorite_hat="Strawhat",
                   ship="Going Merry", likes="Robots")
[('Zorro', 'Sanji', 'Nami', 'Chopper'), {'favorite_hat': 'Strawhat', 'ship': 'Going Merry', 'likes': 'Robots'}]

Another use of asterisk operator is unpacking an iterable inside container. For instance,

>>> a = [1, 2, 3]
>>> [*a, 4, 5, 6]			# Unpacks the value of the list
[1, 2, 3, 4, 5, 6]

Similarly, the double-asterisk operator ** unpacks a dictionary when used inside another dictionary.

>>> a = {"name": "Luffy", "friends":["Zorro"]}
>>> {**a, "ship" : "Going Merry" }			# Unpacks the dictionary
{'name': 'Luffy', 'friends': ['Zorro'], 'ship': 'Going Merry'}

Take a look at the code below.

>>> def person(name, *args, **kwargs):
		return {"name": name, "friends": list(args), __A__}		# What's A?

>>> person("Luffy", "Zorro", "Sanji", ship="Going Merry", likes="Robots")
{'name': 'Luffy', 'friends': ['Zorro', 'Sanji'], 'ship': 'Going Merry', 'likes': 'Robots'}

What's the value of A?

  1. **kwargs
  2. kwargs
  3. kwargs[1:2]
  4. kwargs[0]

Side Effects

Next, let's look at an important point regarding passing arguments to a function. We earlier saw that when we invoke a function, the function arguments become the name or references to the underlying passed objects. This means the function can modify the items.

To understand better, let's first define a function add_5_at_end().

>>> def add_5_at_end(itemlist):
    	itemlist.append(5)
        return itemlist

Now, let's pass a mutable object such as the list x to the above function.

>>> x = [1, 2, 3]
>>> add_5_at_end(x)
[1,2,3,5]				# Returns a list with 5 appended to the end
>>> x
[1,2,3,5]				# Original x object is modified as well

In the above code sample, we can see that the function modifies or mutates the list object referenced by the name x.

Functions that mutate their input arguments or other parts of the program are said to have side-effects.

Generally, we should avoid functions having side effects, as it leads to errors when the program's size grows.

We can get over this issue, by rewriting the function as follows:

>>> def add_5_at_end(itemlist):
    	return itemlist + [5]
[1,2,3,5]					# Adds 5 to the end of list
>>> x
[1,2,3]						# Original objects is unaffected

We can also remove the side-effects of the add_5_at_the_end() function by mutating a copy of the input value rather than the object itself. Let's modify the function below.

>>> import copy
>>> def add_5_at_end(itemlist):
    	# Create a deepcopy of the object
    	duplicate = copy.deepcopy(itemlist)
        # Mutate the `duplicate`
        duplicate.append(5)
        return duplicate

Let's again try to pass arguments to the function.

>>> x = [1,2,3]
>>> add_5_at_end(x)
[1,2,3,5]	# Adds 5 to the end of list
>>> x
[1,2,3]		# Original objects is unaffected

What's the output of the code below?

>>> cart = []
>>> def add_to_cart(*items, cart=cart):
		cart += items
    	return cart

>>> add_to_cart("Phone", "Shoes", "Dosa")
['Phone', 'Shoes', 'Dosa']
>>> add_to_cart("Book")

  1. ['Phone', 'Shoes', 'Dosa', 'Book']
  2. ['Book']
  3. None
  4. []

Anonymous Functions

So far, all of our functions have a name that we can use to invoke them. But we can also create functions in Python, which do not require a name to invoke them.

Until now, we have been defining the functions using the following format.

def function_name ( arg1, arg2, ... )
	# Function Body

This format of defining regular functions has four key components :

  1. The def keyword
  2. A function name or identifier
  3. The arguments or parameters for the function
  4. The function body

In Python, we can define small functions that don't need a name called Lambda Functions or Lambdas or Lambda Expression using the lambda keyword. Because lambda functions do not require a name, we can call them anonymous functions, although we can assign a name to a lambda.

The format for defining lambda is a bit different from regular functions. It has three essential parts:

The lambda keyword

The parameters

An expression

A lambda function can have any number of parameters. Still, the lambda function body can only contain one expression. The function body defined within lambda must be a valid expression.

Multiple statements and other non-expression statements, such as if, return, and while, cannot appear in a lambda expression.

$$
\text{lambda  } \text{ } p_1,..., p_n \text{ }\text{ } \text{: expression}
$$

Let's see a lambda expression in action.

# A regular function that adds 1 to the input
>>> def add_1(x):
    	return x + 1

# Corresponding lambda
>>> lambda x : x + 1
<function <lambda> at 0x7f883ce30700>

# Function invocation
>>> add_1(3)
4

# Lambda invocation
>>> (lambda x : x + 1)(3)
4

What is the output of the following code?

>>> lambda x : x**2
<function <lambda> at 0x7f883ce46af0>
>>> _(3)
  1. Raises ValueError
  2. Raises SyntaxError
  3. Raises TypeError
  4. 9

Earlier, we learned that the underscore _ character in the interactive python interpreter returns the last returned value. We can also invoke the lambda function, as shown in the previous exercise. Let's assign the lambda expression to a name and invoke the function.

Let's create a lambda that accepts two parameters and returns their sum. We will name the lambda as add.

>>> add = lambda x, y: x + y		# Assigning name to a lambda
>>> add(97, 3)						# Invoking the lambda
100

Although we can add names to lambdas, we really shouldn't, as it goes against lambdas' purpose.

When you require a function for a short period or one-time use, you can think about using lambda functions.

For example, the built-in function filter() works in the following way:

  • The built-in function filter() takes a function as the first argument and an iterable (such as list, tuple, etc.) as the second.
  • The function is called for every item in the iterable and returns either True or False.
  • Python discards every item in the iterable for which the function returns False, and thus the iterable is filtered.

Let's filter out a list of numbers by retrieving only those numbers which are divisible by 3. To do that, let's define a function div_by_3() and a list object a as shown below.

>>> def div_by_3(x):
    	return not x % 3
>>> a = [ 12, 4, 6, 9, 25, 100, 102, 77, 55 ]

Now, when you invoke the filter() function using the div_by_3() and the list a, it returns a filter object which can be converted to list using the list() function. This is shown below.

>>> filter(div_by_3, a)
<filter object at 0x7fd5eaaf00f0>		# Returns a filter object
>>> list(filter(div_by_3, a))			# Convert to list
[12, 6, 9, 102]

In the above code sample, the function div_by_3() is defined only to filter the list a, and we will not possibly require it in the later parts of the program.
In such cases, we can define a lambda function and pass it to the filter function instead.

>>> a = [ 12, 4, 6, 9, 25, 100, 102, 77, 55 ]
>>> list(filter(lamdba x : not x % 3, a))			# Lambda function
[12, 6, 9, 102]

We can see that lambda expressions can be useful in writing small functions for one-time use.

A good rule of thumb while using lambda is that if you find yourself trying to write a function that a lambda expression doesn't support, it is a sign that a regular function would be better suited.

Lambda expressions are often difficult to read and are sometimes unnecessarily clever.

The style guide of Python, PEP 8, reads:

Always use a def statement instead of an assignment statement that binds a lambda expression directly to an identifier.

This strongly discourages using a named lambda, mainly where functions should be used and have more benefits.

What's the output of the following code?

>>> a = [1, 2, 3, 4, 5, 6, 7, 8]
>>> list(filter(lambda x: not (x - 4), a))

  1. [4]
  2. [1, 2, 3, 5, 6, 7, 8]
  3. [1, 2, 3, 4, 5, 6, 7, 8]
  4. []

Documentation

When we define a function, it is usually an excellent idea to write describing its usage. In Python, it is common for a function body's first statement to be a documentation string or doc-string. Let's write a function powers_of(x) and also writes its doc-strings.

>>> def powers_of(x):
    """Returns five powers of a given number in the form of a list.
    For example:
    >>> powers_of(3)
    [3, 9, 27, 81, 243]
 	>>> powers_of(2)
 	[2, 4, 8, 16, 32]
    """
    	return [x**1, x**2, x**3, x**4, x**5]

The docstring of a function can be accessed using the built-in function help().

>>> help(powers_of)
Help on function powers_of in module __main__:

powers_of(x)
    Returns five powers of a given number in the form of a list.
    For example:
    >>> powers_of(3)
    [3, 9, 27, 81, 243]
    >>> powers_of(2)
    [2, 4, 8, 16, 32]

We have introduced many built-in functions as of now such as:len(), print(), filter() and round().

You can check the documentation on these functions by using the help() built-in function. This brings us to the end of this section. In the next section, we will look into namespace and scope in Python.

Namespace and Scope

As of now, we assign names to Python objects so that we can reference them later. When we reference the names later, Python figures out the objects assigned to the name and returns (or executes, in case of a function call) the object. Figuring out how objects and names are assigned to each other is an important function of Python.

Can you guess how Python might be managing the names and objects?

Whenever we assign a name to an object, Python stores the name assignment in a namespace.

A namespace is a mapping from names to objects, and when we assign a name to an object, Python stores the name assignment in a namespace.

Python implements most namespaces as dictionaries. For every name you enter in the interpreter, Python looks for them in the namespace and returns the corresponding value.

When we start the Python interpreter, it already has a namespace called built-in Namespace with some predefined names present. This is how Python recognises the names of built-in functions such as print(), len(), slice() and built-in errors such as NameError, SyntaxError etc.

When you import modules, Python stores the names from the modules in the Global Namespace.

A function also creates a namespace where it stores the names defined in the function body, called Local Namespace.

One of the primary purposes of a namespace is to avoid name-based conflicts. Let's say we have already defined the name pi.

>>> pi = "3.14"

We wish to use the pi constant from math module. If we directly import the constant, the pi name will cease to refer the string object and refer to the constant instead. You can check it out.

>>> from math import pi
>>> pi
3.141592653589793

The pi string object we defined earlier is overwritten by importing the pi constant.

At any given moment in a given namespace, a name refers to a single object.

Different namespaces can use an identical name and map it to a different object.

We can use the name pi for constant and the string object in the following way:

>>> pi = "3.14"
>>> import math
>>> math.pi
3.141592653589793

Whenever we specify the pi, Python understands we are referring to the string object. To access the mathematical constant, we can use the dot notation on the math module.

A namespace is essentially a system to ensure that all the names in a program are unique and can be used without any conflict. Name conflicts happen all the time in real life. Can you think of an instance of such name-based conflict?

Sometimes, when two people have an identical first name, we refer to their surname or environment to specify which one we meant.

It helps us differentiates between John Doe and John Lennon, or Jay, the neighbor, and Jay, the co-worker.

In the real-world, surnames and environments often act as a namespace.

Not every namespace, which may be used in a program, is accessible at any moment during the script's execution. Namespaces are often created at different points in time and therefore have different lifetimes.

The namespace containing the built-in namespace is created when the Python interpreter starts up, and is never deleted. The built-in namespace is present from the beginning to the end.

The global namespace of a module is generated when the module is read in. Global namespaces normally last until the script ends, i.e., the interpreter quits.

When a function is called, a local namespace is created for this function. This namespace is removed either if the function ends, i.e., returns, or if the function raises an exception.

Here are the namespaces we have introduced so far:

  • Local Namespace: Includes names inside a function. It is created when you invoke the function and lasts until it returns or completes execution.
  • Global Namespace: Includes names from various imported modules you are using in a script and lasts until the script ends.
  • Built-in Namespace: Includes built-in functions and built-in exception names.

The important thing to know about namespaces is that there is no relation between names in different namespaces; for instance, two different modules may both define a function max() without confusion. To use both the function max(), the user must prefix with the module name. Let's say the module mod1, and mod2 both include the function max(), then:

>>> import mod1, mod2
>>> mod1.max()		# Invoke the `max` in mod1
>>> mod2.max()		# Invoke the `max` in mod2

Python uses something called scoping rules to access the namespace and determine the referenced object. In order the understand the scoping rules, let's first look into scope.

Scope

What do you think the word scope means?

The word scope usually means the extent of the area that something deals with or to which it is relevant. Whenever someone starts on a new project, they usually define the scope of the project. This is done to prioritize and draw a boundary on relevant things.

In programming, the word scope something quite similar.

A scope is a textual region of a Python program where a namespace is directly accessible.

The Built-in and Global namespace are available throughout a Python program. The Local Namespace of a function is available only inside the function body.

We can better understand the local namespace by checking the current names in the local scope using the built-in function dir(). If you pass no parameters to the function dir(), it returns a list of names in the current local scope.

When we boot up the interactive python interpreter, the following are present in the scope.

>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__']

To check the list of names present in the built-in namespace, you can use dir(__builtins__).

>>> dir(__builtins__)
# Result Shortened
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BlockingIOError', 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError'...'zip']

You can see that Python stores the built-in functions and exceptions in the built-in namespace. We can access all of the objects directly without using a prefix.

When you assign a new name to an object in the interpreter, it appears in the scope.

>>> lucky_number_15 = 15
>>> dir()
# Shortened result
['__builtins__', ... , '__spec__', 'lucky_number_15']

We can now directly access the lucky_number_15 throughout our program unless we remove it using the del keyword.

Let's import the math module.

>>> import math
How do you think the current scope is going to get affected?

If you import a module from the Python standard library, it also appears in the current scope. Python creates Global namespaces for each imported modules.

>>> import math
>>> dir()
['__builtins__', ... , '__spec__', 'lucky_number_15', 'math']

The name math is a global or module namespace present in the current scope. The math contains various names of the objects and functions, which, if directly imported, may overwrite our custom definitions with identical names. Therefore, the global namespace is convenient to access the names within the module.

We can check the names defined in the Global namespace by using the dir() function.

>>> dir(math)
['__doc__', '__loader__', '__name__', '__package__', '__spec__', 'acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'copysign', 'cos', 'cosh', 'degrees', 'e', 'erf', 'erfc', 'exp', 'expm1', 'fabs', 'factorial', 'floor', 'fmod', 'frexp', 'fsum', 'gamma', 'gcd', 'hypot', 'inf', 'isclose', 'isfinite', 'isinf', 'isnan', 'ldexp', 'lgamma', 'log', 'log10', 'log1p', 'log2', 'modf', 'nan', 'pi', 'pow', 'radians', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'tau', 'trunc']

You can use the names defined in the global namespace defined by a module by prefixing the module name and accessing the name using the dot notation. For example, we can access the factorial() function defined in the math module in the following way.

>>> math.factorial(5)
120

You can have another name, factorial, defined in the current scope.

>>> factorial = "FACTORIAL"
>>> dir()
['__builtins__', ... , '__spec__', 'a', 'math', 'factorial']

We can see that the string object with the name factorial is in the current scope and therefore directly accessible. Simultaneously, the factorial function from the math needs to be accessed by prefixing the math module.

Whenever we assign a name, Python directly adds it to the current scope, as we can see using the dir() function. Python doesn't necessarily make all the names we always define directly accessible.

Let's create a secret_text() function which has the secret defined inside its body.

>>> def secret_text():
    	secret = "This is a secret text"
    	return secret
>>> secret_text()
'This is a secret text'

Now, let's check up the current scope using the dir() function.

>>> dir()
['__builtins__', ... , 'secret_text']

We can see that only the function name secret_text is available in the current scope, and we can invoke the function name. However, the name secret we defined in the function body is not available in the current scope.

We can verify this by referencing the name directly.

>>> secret
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'secret' is not defined

As the name is not present in the current scope, Python raises NameError.

Although we correctly defined the name secret, Python can still not find it in the current scope. In which scope do you think the name secret exists?

Local Namespace

Python stores the name secret in the Local Namespace of the function secret_text(). We can check the local namespace's scope using the built-in function dir() inside the function definition.

Let's rewrite the secret_text() function to print the output of dir() whenever it is invoked.

>>> def secret_text():
...     secret = "This is a secret text"
...     print(dir())
...     return secret
...
>>> secret_text()
['secret']
'This is a secret text.'

When the dir() function is invoked inside a function, it returns the local scope. The local scope returns the names present in the local namespace created by the function secret_text().

We can understand this more by starting a fresh session and creating another function, secret_numbers() with the dir() function inside the function definition. Inside the function, we have defined a couple of names such as a, b, c, d, e, secret_sum.

>>> def secret_numbers(a, b, c = 15):
    	d, e = 4, 5
		secret_sum = a + b + c + d + e
        print(dir())			# to check the local scope
        return secret_sum

Let's first check the current scope accessible from the interpreter prompt.

>>> dir()
['__builtins__', ... , '__spec__', 'secret_numbers']

As you can see only, the function secret_numbers() is directly accessible. Let's check the local scope by invoking the function.

>>> secret_numbers(1, 2)
['a', 'b', 'c', 'd', 'e', 'secret_sum']				# Local Names inside the function
27													# Result

The new names assigned inside the function body d, e, and secret_sum are present in the local scope of the function and the function arguments a, b, and c.

As you can see, the function's scope returns the names defined either in the function body or function arguments.

Do you think the names that we define outside the function are not accessible from inside the function definition?

The answer is: it depends.

The names defined in the global namespace and built-in namespace are always accessible.

When you refer to a name, Python searches for the name first in local scope, then in the global namespace, and, finally, in the built-in namespace.

At the top or outermost level, any name that we define becomes a global name accessible throughout the program. If the name is defined in another function's local scope, it is not accessible from within the function.

However, a nested function can access its parent function scope. Let's learn more about nested functions.

Nested Functions

In Python, you can also put a function defined inside another function definition. The enclosed function is called a nested function.

Take a look at the following code listing.

>>> def outer_func():
    	print("Hi from Function 1")
        def inner_func():
            print("Hello from Function 2")
        inner_func()

We created a function outer_func() which has another function inner_func() defined within it. The inner_func() defined is above code is a nested function.

In the function definition of the outer_func(), we have invoked the inner_funct(). While we have invoked the inner_func() in the outer_func() definition, it is not executed until we invoke the outer_func(). Thus, when we invoke the outer_func(), the nested function is invoked as well.

>>> outer_func()
Hi from Function 1
Hello from Function 2

Each function creates its local namespace where the names defined inside the function body are stored. When you invoke a function, Python searches for any name it encounters, during the invocation, in the local namespace first.
We can test this out by an example.

Let's define three objects with the name a present in different scopes.

>>> a = 0
>>> def outer_func():
    	a = 15
        print("Outer function a:", a)
        def inner_func():
            a = 100
            print("Inner function a:", a)
       	inner_func()
>>> outer_func()
Outer function a: 15
Inner function a: 100
>>> print("Global a:", a) 		# The global a
Global a: 0

We can the which value does the name a refers to by invoking the outer_func().

>>> outer_func()
Outer function a: 15
Inner function a: 100
Inner function b: 10			# Fetches the global b

Continuing from the above code listing, what is the value of the following:

>>> print("Global a:", a) 		# The global a
  1. Global a: 0
  2. Global a: 15
  3. Global a: 100
  4. NameError

The value we assigned to the name a in the global scope is unchanged. The global name a value remains unchanged even though we assigned two different values inside the functions. The apparent discrepancy in the result is due to the way Python searches for names.

Let's look into that next.

Python searches for the value of a name in the following way:

  • First, Python searches the innermost scope, which contains the local names.
  • Then Python searches scopes of enclosing outer functions from the nearest enclosing to the last enclosing scope next.
  • Then, the next-to-last scope includes names from Global namespace
  • The outermost scope includes names from Builtin Namespace
  • If Python is unable to find the name in any scopes, it raises a NameError exception.

What do you think is the result of the following code?

>>> a = 10
>>> def outer_func():
    	a = 15
        del a
        def inner_func():
            print(a)
>>> outer_func()

  1. 10
  2. NameError
  3. Returns nothing
  4. 15

In the previous exercise, as the inner function is not invoked, the output returns nothing.

One reason for defining nested functions is to protect the nested function from outside by hiding them. Another reason is to avoid name-based conflicts with other names.

If you try calling inner_func() from outside the outer_func(), Python will raise NameError exception as the nested function inner_func() is available only inside the function's local scope of the function.

>>> inner_func()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'inner_func' is not defined

At this point, all you need to know is that Python supports nested functions. We will cover its primary advantages in the subsequent courses on Python.

We can reference the values of the names outside function local scope. For example,

>>> a = 10
>>> def outer_func():
        b = 15
        def inner_func():
            c = 100 + a + b
            print("Value of c:", c)
            print("Value of a:", a)
            print("Value of b:", b)
            inner_func()

In this function, the nested function inner_func() references to a and b outside of its local scope. We can see the result of invoking the function as below:

>>> outer_func()
Value of a: 10
Value of b: 15
Value of c: 125

We can see from the above code that Python allows referencing the names outside the local scope of a function. Python doesn't allow to modify a name which is not available in the local scope. We can verify this by writing a sample function.

Let's first define an object with the name a and create a function increase_a_by_5(), which increases the global name a by 5.

>>> a = 2
>>> def increase_a_by_5():
    	a = a + 5
        return a

Now, let's invoke the function increase_a_by_5().

>>> increase_a_by_5()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in increase_a_by_5
UnboundLocalError: local variable 'a' referenced before assignment

As you can see, when you try to modify the name a, which is not present in the local namespace, we get an UnboundLocalError exception. The error message is local variable 'a' referenced before assignment.

This shows that Python does not allow modifying a name that is present outside its local scope.

In Python, when you modify a name, it first checks if it's present in the current local scope. If the name is not present inside the local scope, Python raises the UnboundLocalError exception.

We will understand how to modify names outside of the local scope in the next section.

global keyword

To modify a name present in the global scope from inside a function definition, we must use the global keyword. The way it works is that we must first tell Python that we are referencing a global name and then modifying it. For the previous example, we can modify it by adding a global keyword.

>>> a = 2
>>> def increase_a_by_5():
    	global a				# Using global name `a`
        a = a + 5
        print("Value of `a` changed")

Let's re-invoke our function.

>>> increase_a_by_5()
Value of `a` changed
>>> a
7

The global name a is modified.

Take a look at the following code sample.

>>> def outer_func():
    	z = 10
        def inner_func():
            global z
            z = z*5
            print("Value of z:", z)
       	inner_func()
>>> outer_func()

We have defined z inside the outer_func() while modifying it inside the nested function. What do you think happens when you execute the code?

  1. Value of z: 10
  2. Raises NameError
  3. Raises UnboundLocalError
  4. Value of z: 50

If we try to modify the name present in the outer scope but not in global scope, python will still raise UnboundLocalError.

nonlocal Keyword

To modify a name that is not present in the global scope but present in a nested function's outer scope, we need to use the nonlocal keyword. The nonlocal keyword tells Python that the name is neither local nor global rather exists in the outer scope of a function. Let's see it in a bit of detail.

When we execute the above code in the previous exercise, Python raises UnboundLocalError, as we can see below.

>>> def outer_func():
        z = 10
        def inner_func():
            z = z*5
            print("Value of z:", z)
		inner_func()
>>> outer_func()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in outer_func
  File "<stdin>", line 4, in inner_func
UnboundLocalError: local variable 'z' referenced before assignment

In the above code listing, we cannot modify the name z as it belongs to the local scope of outer_func() and not present in the global scope. To modify the name z, we will use the nonlocal keyword.

>>> def outer_func():
        z = 10
        def inner_func():
            # Bind `z` to a name `z` in outer scope
            nonlocal z
            z = z*5
            print("Value of z:", z)
		inner_func()
>>> outer_func()
Value of z: 50
We can use the nonlocal keyword to bind to a name that is not present in either local or global scope.

In case there are two same names in the enclosing outer scopes, the name is bind to the name present in the nearest enclosing scope. We can see it in the code below.

>>> def outer_func():
        y = 1
        def inner_1():
            y = 15
            def inner_2():
                nonlocal y			# Bind to `y` in the
                y *= 5     			# y = y*5
                print("The value of y in inner_2:", y)
            inner_2()
            print("The value of y in inner_1:", y)
        inner_1()
        print("The value of y in outer_func:", y)
>>> outer_func()
The value of y in inner_2: 75
The value of y in inner_1: 75
The value of y in outer_func: 1

In the above example, we can see that even though both inner_1() and outer_func() has the name y in their local scope, the keyword nonlocal binds the name y to the name stored in the inner_1() function's local scope. The name y of the inner_function_1() is modified as the nearest enclosing scope of the function inner_2()is inner_1().

Based on the above examples, can you summarise how Python searches for the names in nested functions?
Figure 2: Namespace & Scope

The way Python searches for names can be acronymed as L-E-G-B.

Local scopes, Enclosing Scopes, Global Scopes and finally Built-in scopes.

The figure 2 summarizes what we have learned so far. Here are a couple of things you should remember about Python's global and local scope.

Here are a couple of things you should remember about Python's global and local scope.

  • If a name is assigned a value anywhere within the function's body, it's assumed to be present in Local Namespace unless explicitly declared as global or nonlocal.
  • To use a name present in the global scope, we use the global keyword
  • There is no need to use the global keyword outside a function.
  • The nonlocal keyword binds to the name in the nearest local function scope.

So far, we have covered using built-in functions present in the Built-in Namespace and creating our functions.

Apart from these functions, every object in Python has certain built-in functions depending on its type. These are called object methods or methods.

Can you think of some of the built-in functions for a list type object that we have already covered?

We saw earlier that a list type could be modified using the append() function. The append() function is called object method or simply method of the list type object.

We will look into methods in the next section.

Methods and Attributes

In Python, almost everything is an object having methods and attributes. A method is a function that belongs to an object, while an attribute is a value associated with the object.

The methods and attributes can be accessed using dot notation. For example, if an object foo has a method bar and attributes doo, it can be invoked by foo.bar(). At the same time, we can access the attributes via foo.doo.

>>> def foo():
    	pass

Now, let's check up its documentation.

>>> help(foo)
Help on function foo in module __main__:

Nothing is returned because we forgot to provide a documentation string while defining the function. We can get the documentation of a function by using its __doc__ attribute.

>>> foo.__doc__		# Returns nothing

Later on, we can set the __doc__ attribute of the function directly.

 >>> foo.__doc__ = "Does absolutely nothing"
>>> help(foo)
Help on function foo in module __main__:

foo()
	Does absolutely nothing

Now, we have helpful documentation for our dummy foo() function. Different types of objects have different methods.

We had seen an example of a list method earlier when we used append() to add an item to a list.

>>> a = [1, 2, 3]
>>> a.append(40)		# Append method of a list
>>> a
[1, 2, 3, 40]			# The list is modified

Can you think of a method for a string type object?

We earlier used the upper() method to capitalize strings.

To get a list of strings of available methods and attributes of an object, built-in function dir(object) can be used. A description of available methods and attributes for objects can be seen using the built-in help(object) function.

Let's take a look.

When we invoke the dir() function by supplying an object, it returns the list of available methods and attributes. For instance,

>>> dir([])
['__add__', '__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']

You can see that that a list type object has lots of attributes and methods.

There are several special types of attributes with the syntax __foo__. The methods with the syntax __foo__ are called dunder (double underscores) methods or magic methods or special methods.

Special Methods

In Python, you can create custom types of objects using classes. We will learn more about special methods and implement them when we cover creating your custom objects using class in the next course. For now, let's understand the basic overview of such methods.

When you create a new type of object, you may want to take advantage of Python's built-in operators and functions. Python has a lot of special methods designed to implement intuitive comparisons between objects using operators.

Table 1: Some of the special methods and their functionality
Special Method Description
__eq__ Defines behaviour for equality operator
__ge__ Defines behaviour for greater-than-or-equal-to operator , >=
__lt__ Defines behaviour for less-than operator , <

If you want to create a new object type, say Foo, and wish to compare one type of Foo object with another, you can define the special methods to take advantage of the comparison operators (<>).

We don't need to delve deeper into this right now, as we will cover it in future courses.

And this brings us to the end of the topic as well as the chapter.

Earlier, we covered some basic types of Python. In the next chapter, we will cover the built-in types and their methods in much more detail.