Python Function Basics

Chapter 30 43 mins

Learning outcomes:

  1. What is a function
  2. Creating a function in Python
  3. The pass keyword
  4. The return keyword
  5. The concept of side effects
  6. First-class citizens
  7. Variable shadowing
  8. Name resolutions based on lexical scopes

Introduction

Back in the foundation unit, we got a brief introduction to functions in Python. Specifically, we saw how to define functions using def; how to call functions using the function's name followed by a pair of parentheses; how to return stuff using the return keyword; and a lot more.

Functions are crucial to programming and so to Python. In this unit, we shall explore all that we've already learnt and all that we've not yet learnt about functions in Python.

This chapter walks us through the basics of Python functions. In the next chapter, we go over function arguments in fine detail and finally in the last chapter, over lambda expression — a convenient way to define functions.

Let's begin!

What are functions?

Let's start by reviewing what exactly is a function in Python. At the basic level:

A function is a named block of code that can be executed whenever desired.

A function groups a block of code in what is known as the body of the function, which can be executed by means of calling the function.

Don't worry if all this doesn't make sense to you right now. Everything will become clear with time...

Programs generally have a lot of repeating code.

As an elementary example, imagine we have a program that outputs the distance between the coordinate. Writing the code to accomplish this task directly without any functions could surely work, but not without certain limitations.

Firstly, to compute the distance of any given pair of coordinate we would need to rewrite the code computing it. This comes at a cost — a change needs to be made in the code we would need to manually go everywhere it is used and make the changes one-by-one.

Not only this, but rewriting the same piece of code we unnecessarily drag the size of the Python program.

Managing programs with a lot of repeating code sooner becomes inefficient and impractical since nothing is flexible and unified in one location and then being reused from there.

Functions elegantly solve this hassle. They serve to group code, which can be executed at will any time. Not only this, but functions can also be provided arguments, to tailor the execution to a specific piece of data.

For instance, coming back to our previous example, we can construct a function that returns the distance of two given coordinates where each coordinate is supplied to the function as an argument.

Apart from this, since we can name functions in Python, different blocks of code can be named in such a way that their purpose becomes clear.

For instance, in our case we can call the function that calculates the distance between two coordinates calc_distance(). The name clearly indicates the underlying purpose of the function — that it calculates the distance.

In short, functions are more than just vital in modern day programming to make programs more readable, more flexible, more efficient and more powerful.

Creating a function

To create a function in Python we use the def keyword followed by the name of the function, followed by a pair of parentheses.

The general syntax of defining a function is shown as follows:

def func_name():
    # function's body

func_name is the name of the function. It follows the same naming rules as do normal variables in Python.

And as with all statements that denote a block of code, the function's statement ends with a : (colon). After this come the function's body in increased level of indentation.

This altogether denotes a function. Collectively, the header and the body is referred to as the function's definition.

The pair of parentheses here is used to denote parameters of the function. We shall discover this later on this course.

Let's create a simple function that says 'Hello' to the user.

def sayHello():
    print('Hello programmer!')

This here is the definition of the function sayHello().

Now let's call this function. To call a function, we write its name followed by a pair of parentheses:

sayHello()
Hello programmer!

Calling a function — or better to say, invoking a function — executes its body.

In our case, the body of sayHello() contains a print() statement; likewise we get some output in the shell.

There is no limitation on the amount of code we can put inside a function. It could be as small as a single line or as large as millions of lines.

However, it's generally recommended not to write very long functions as it gets increasingly difficult to maintain them.

Below we create another function that outputs the age you input into the shell:

def get_and_print_age():
    age = input("Enter your age: ")
    print(age)

get_and_print_age()
Enter your age: 20
20

As you get into writing more and more functions, you'll understand how to name, layout and work with a function.

The pass keyword

Usually when developers develop large-scale programs, they plan out how the programs will work and then try to come up with function names for each given reusable block of code.

In early development, it's sometimes required to just create all the functions without defining their bodies. The idea is to create a connection between each function and lay out the general algorithm to follow. The function definitions are worked on later in the development process.

Now, in Python, to create a function with no body one would think of the following:

def abc():

However, as it might be clear, this is invalid. Following a : colon, Python expects a piece of code. But in the code above there is nothing following the : and therefore it's termed as invalid code by the interpreter.

We can't even use a comment to placehold the body, as follows:

def abc():
    # perform operation a, b and c

We have to put some code in the function. That's what Python says.

But wait... If we put some code in the function, then it would no longer be empty. What to do??

Well, use the pass keyword.

The pass keyword is Python's way to tell that nothing is desired in a given block of code i.e it's empty.

Below, we define the empty function abc():

def abc():
    pass

The return keyword

Let's create a function sayBye() that prints 'Bye' to the developer.

def sayBye():
    print('Bye')

And let's now actually say bye to the developer, by invoking the function:

sayBye()
Bye

sayBye() here is formally known as an function invocation, since it invokes a function — sayBye in this case.

A function's invocation is considered an expression in Python, similar to the expressions 2 + 2, 'Hello' + ' World!' etc. We already know that expressions evaluate to a particular value, and so a function's invocation expression also resolves down to a value.

The question is what value? Let's find the answer...

Whatever value a function returns is what its invocation expression is resolved with.

What does our function sayBye() return?

By default, every function in Python returns the None value. The functions that we've created uptil now, including sayBye(), all return None when they are called.

This means that when we call such functions, their invocation expressions are resolved down with the value None.

Let's check this out for our sayBye() function. We'll compare the invocation expression sayBye() with the value None and see if they are equal to one another:

sayBye() == None
Bye
True

First we get 'Bye' printed, as a result of executing the print() statement in the function sayBye(). Then we get the value True, which is the return value of the whole expression sayBye() == None.

The value True testifies the fact that the expression sayBye() resolves down to the value None. Therefore, the expression sayBye() == None is equivalent to None == None.

So do all functions return None? Clearly no.

Some functions return other values using a special keyword in Python — the return keyword.

The value to be returned is put after the return keyword, as shown below.

def func_name():
    # some code
    return value

When the interpreter executes the return statement, it resolves the function's invocation with the given value.

Let's consider a quick example using our same old sayBye() function:

def sayBye():
    print('Bye')
    return 'I was returned!'
sayBye()
Bye
I was returned!

First the function prints the string 'Bye', and then returns the value 'I was returned'. This return value is output in the shell after the first print.

Below we consider another example using return:

def returnBye():
    return 'Bye'

Here we've created a function returnBye() that returns the string 'Bye' whenever it's invoked.

Following we use the function's invocation just like we would use any other string:

returnBye()
'Bye'
returnBye() + ' Programmer!'
'Bye Programmer'
returnBye().lower()
'bye'
list(returnBye())
['B', 'y', 'e']

Just think of returnBye() as being replaced by the value 'Bye', and everything will make perfect sense!

Now there are a couple of things related to return that we should know before we can properly use it in functions.

When the interpreter encounters the return keyword, it exits the function. That is, execution moves out of the function.

Nothing following the return keyword ever gets the chance of execution, since return essentially marks the limit as to where a function is executed.

Consider the following code:

def returnBye():
    return 'Bye'
    print('Value returned')

Here we have redefined returnBye() to first return the value 'Bye' and then print 'Value returned'.

When we call returnBye(), however, we see that there is no print made — we don't get 'Value returned' output.

returnBye()
'Bye'

This happens because when the interpreter comes across the return statement in returnBye(), after we invoke it, it breaks out of the function, returning the given value.

Therefore, whenever constructing a function, make sure that you put everything before the return keyword. Even if you put something following return, make sure that return doesn't execute in every invocation of the underlying function. We'll see one such example later on.

Side effects

While we are understanding what are return values in the world of functions, it's useful to learn about a related concept which is that of a side effect.

A side effect refers to any change that a function makes to the outer world.

Outer world here refers to any environment excluding the function's own local environment.

A function that does not have any side effects i.e does not make any changes to the outer world, is referred to as a pure function.

A pure function simply performs a computation and returns the result.

Let's take a look over a function with a side effect:

x = 10

def f(value):
    global x
    x = value

f(20)

The function here takes in an argument, changes the global x to it, and finally returns the result.

The side effect over here is the modification of the global variable x.

If we omit the global x statement here, the next x = value statement would not modify the global x, rather create a new local variable x with the given value. This would mean that the function would no longer have a side effect.

Note that if a function modifies its own local environment, then those modifications aren't classified as side effects. A side effect is, essentially, a modification made to the outer world i.e the environment outside the function.

An exception to this is modifying local variables that contain references to mutable values, also referenced by some identifier in the outer world.

An example follows:

nums = [1, 2]

def f(l):
    l.append(10)

f(nums)

Here the function f() takes in an argument and then modifies it. Even though, the function is technically modifiying its own local variable, it still has a side effect.

This is because, the local variable l (remember, arguments are also local variables), that it modifies, points to a list also referenced by the global variable nums. Changing l in place also changes nums.

Let's inspect nums and see the result:

nums
[1, 2, 10]

Clear as it is, nums has definitely changed.

The side effect of f() here is the modification of the global variable nums.

If we slightly change the function f(), by copying the provided list and then modifying it, we could make the function pure.

This can be seen as follows:

nums = [1, 2]

def f(l):
    l = l[:] # copy the list
    l.append(10)

f(nums)

If we inspect nums after the invocation of f(), we can see that it remains unchanged. This confirms the fact that the function f() is now pure i.e does not have any side effects.

nums
[1, 2]

Below shown is a pure function — a function without any side effects:

x = 10

def f():
    x = 20
    x += 1 # modification
    return x

The function creates a local variable, modifies it, and returns it. Nothing is done to the outer world. Likewise the function is indeed pure.

First-class citizens

Python functions are considered as first-class citizens of the language.

That is, they can be used just like any other value: assigned to variables; set as list elements, passed on to other functions as arguments; and so on.

Some languages like C++ don't classify functions as first-class.

Although we could assign functions to variables, pass functions to other functions somehow using pointers in C++, it doesn't truly support all the features for us to say that C++ functions are first-class citizens.

Below we demonstrate a few ways of using functions in Python that show that they are first-class members.

Assigning a function to a variable:

def a():
    return 10

b = a
print(b())
10

Setting a function as a list element:

def a():
    return 10

l = [10, True, a]
print(l[2]())
10

Passing a function to another function as argument:

def a():
    return 10

def b(f):
    f()

b(a)
10

Scoping of variables

When working with functions, it's utmost important to have a solid knowledge about scoping of variables. What variables are available in this function; how to modify a global variable; what is the scope of variables when this function is invoked — all these are instances of questions you must be able to answer while programming.

In this section, we shall unravel all the bits and pieces to scoping in Python functions.

Let's start by exploring the local scope.

The region inside a function is known as a local scope. A variable defined in this region is termed as a local variable, and is said to be local to the function. That is, it's available only within the function, not anywhere else.

A local variable gets deleted once the function, it's defined in, exits. This is the reason why a local variable is not accessible outside the function — it's simply because the variable does not exist anymore.

Let's consider an example to get a better idea of this discussion.

Below we create a function f() and define a local variable x which is then printed by the function. Next, we call the function f(), and finally print x from outside the function.

def f():
    x = 'inside' # local variable
    print(x)

f()

print(x)

Here's how the output looks:

inside
Traceback (most recent call last): File "stdin", line 7, in <module> print(x) NameError: name 'x' is not defined

Let's understand what's happening over here.

First f is called in line 5. It creates a local variable x and then prints it. Without any problems, we get 'inside' printed in the shell. The function completes and exits, as a result, deleting the local variable x.

Next up, we call print(x), in line 7. Since no variable x exists at the point of executing this statement, the interpreter flags an error while trying to execute it.

This confirms the fact that a local variable is only accessible within its defining function, NOT anywhere else.

Let's consider another example:

x = 'outside'

def f():
    x = 'inside' # local variable
    print(x)

f()

print(x)

Try to predict the output of this code. It ain't difficult at all!

Let's understand what's happening here, and what should be output, before seeing the actual output.

  • First we define a global variable x, set to the value 'outside'. Then we define a function f().
  • After this, the function is invoked. It creates a local variable x and prints it. As a result, we get 'inside' printed to the shell. The function completes and so the local x is deleted.
  • Next up, comes the print(x) statement. The interpreter finds whether there is a binding for the name x in the scope of this statement i.e the global scope. Since a match is found, x is resolved with that. And that is the value 'outside'.
  • As a result, we get 'outside' printed to the shell.

Below shown is the actual output. Let's see whether it matches our explanation:

inside
outside

Perfect!

Variable shadowing

Often times, it's the case that we define a variable inside a function with a name similar to that of a global variable.

For example, in the last code snippet above, we have a global variable x, and a function f() defining its own local variable with the same name i.e x.

x = 'outside'

def f():
    x = 'inside' # local variable
    print(x)

f()

print(x)

In such cases, we say that the local variable shadows the global variable, or that the local variable masks the global variable.

This simply means that if we use the name x inside the function, it will be resolved using the value of the local variable, not of the global variable.

We never get the chance to view or change the global variable, unless we explicity state so. This we shall see in the section below.

Variable shadowing is when a local variable overrides an outer variable.

Let's consider all this in action.

def f():
    x = ''

Although, we've demonstrated shadowing in terms of local and global variables, the concept applies to any pair of local of outer variables.

The local variable, obviously, is defined within a function, whereas the outer variable is defined in a outer environment.

Consider the following code:

def outer():
    x = 'outer'
    
    def inner():
        x = 'inner'
        print(x)
    
outer()

We have two functions, outer() and inner() with the latter defined within the former (as even the function names suggest). A local variable x exists in outer() as well as in inner().

The print(x) statement inside inner() is used to inspect which value does the name x inside inner() refer to.

When we run this code, as expected, we get 'inner' printed.

inner

The reason is pretty straightforward.

A local variable x is defined within inner(), and so referring to the name x inside inner() would resolve down to this instance.

When resolving names inside a function, Python first looks into the local environment of the function, then to its outer environment, then to the next outer environment and so on and so forth.

In this case, when Python looks into the local environment of inner(), it finds a match and uses its value.

Variable shadowing isn't a really surprising concept. It is fairly sensible that later and closer variables have precedence over earlier and farther away variable, with the same name.

Variable access before definition

When defining variables inside a function, there is one special scenario to look out for.

If a local variable is accessed in a function before it's defined, Python throws an error.

This happens regardless of the fact whether there is an outer variable with the same name or not.

Let's see an example.

In the code below, we call the function f() and witness an error:

x = 'outer'

def f():
    print(x)
    x = 'inner'

f()
Traceback (most recent call last): File "<stdin>", line 7, in <module> File "<stdin>", line 4, in f UnboundLocalError: local variable 'x' referenced before assignment

The error is caused by the statement print(x) in line 4, as Python recognises that we are referring to a local variable before creating it.

The most important thing to note over here is that even though there is a global variable x available to resolve the name x in the statementprint(x), Python doesn't use it. It only uses it, if it's sure that there is no local variable with the same name i.e there is no shadowing variable.

Now some people may have a fairly reasonable question at this stage that how does Python know that we are referring to a local variable x before its creation.

Had Python given the description 'Unknown variable' for the error, that would've made a bit more sense, but it doesn't say so. It says something like: 'Variable accessed before its creation', which implies that Python knows exactly when is the local variable defined.

How does the interpreter know that the variable x is defined later on, given that it has not yet executed the statement actually defining it!

Let's see how...

How does Python know about the local variables of a function, before executing it?

When Python compiles a function definition, it preevaluates all the local variables that are defined in the function, through code analysis, and then saves them all inside the function.

Then on subsequent occasions, when the function is actually invoked, each name in the function is checked against the set of local names stored inside the function object.

If a match is found for a name in the list of local variables of the function, and that variable isn't defined yet, Python raises an error.

Accessing a global variable

As we saw above, creating a variable with a name similar to that of an outer variable, inside a function masks the outer variable.

So does this mean that a function can't modify a global variable.

For example, in the code below we desire to modify the global variable x to the value 10, however what happens is that we end up creating a local variable shadowing the global x — not modifying it at all.

x = 9

def f():
    # we want to change the global x to 10
    x = 10

f()

print(x)
9

Does this always have to be the case?

No. Python recognises the fact that at times we might want to modify a global variable from within a function and thereby provides a way to explicitly specify it.

That is using the global keyword.

The syntax of global is shown as follows:

def func_name():
    global var_1, var_2 ..., var_n
    
    # function body

All the variables that we wish to obtain from the global scope are referred to after the keyword global, with commas separating multiple variables.

Let's use global in our old example to refer to the global x:

x = 9

def f():
    # let's link to the global x
    global x
    x = 10

f()

print(x)
10

The moment Python reads global x inside the function f(), it binds the value of the global variable x with the name x inside the function. Not just this, but assignments to x happen to the global x, instead of creating a new local x.

The statement x = 10, in line 6, modifies the global variable x; likewise when the statement print(x) in line 10 is executed, we get 10 output in the shell.

As stated before, the global keyword can be used to bring on more than one global variable into action inside a function.

In the example below, we bring on two global variables using the global keyword.

x = 10
y = 20
z = 30

def f():
    global x, y
    x = 11
    y = 21
    z = 31

f()

print(x, y, z)

Here we have three globals x, y and z and a function f (which is also a global, giving us a total of 4 globals). The function f() refers to the two variables x and y via the statement global x, y; likewise any reference or assignment to x and y inside f() would be made to the global variables x and y, respectively.

Moving on, the function does indeed modify x and y, but then creates a new local variable z. Note that z = 31 does not modify the global z, since we have not specified z in the global statement; rather it creates a new local variable z and assigns to it the value 31.

Finally when f() completes, we print the values of x, y and z, to which we get 11, 21 and 30 returned, respectively.

Name resolution in functions

In the Python Scoping chapter of the foundation unit, we got introduced to the LEGB rule used by Python in resolving names occuring anywhere in a script.

Now we shall see that rule more closely and meaningfully.

LEGB stands for Local Enclosing Global Built-in. It simply states the order of environments that Python looks into in search of a given name. It starts off with the local environment. Then it moves to the outer environment, which is also known as the enclosing environment. After this to the global, and finally to the built-in environment.

Name resolution sure is really tedious task without us ever realising it.

Below shown are a handful of name resolution problems to build our knowledge on Python's way to tackle them.

In each of the following snippets, go through the code and try to predict the output. Give a solid reason for your choice.

Let's start with a basic problem..

x = 10

def f():
    x = 20
    print(x)

Predict the output of the code above.

  • 10
  • 20

Based on LEGB, Python starts at the local environment of f() to resolve the name x in the statement print(x). Since a local variable is found, x is resolved with its value, which is 20.

Over to the second problem..

x = 10

def f1():
    x = 20
    def f2():
        print(x)
    f2()

f1()

Predict the output of the code above.

  • 10
  • 20

Reading line 6 here, Python tries to resolve the name x. Following LEGB, the search starts in the local environment of f2(), yielding no matches.

Next up, search begins in the enclosing environment of f2(), which in this case is the local environment of f1(). Since a match is found, x (in print(x)) is resolved with its value which is 20.

The third problem..

x = 10

def f1():
    def f2():
        print(x)
    f2()

x = 20
f1()

Predict the output of the code above.

  • 10
  • 20

Following LEGB, search for x begins in the local environment of f2(), and when nothing is found in it, to the local environment of f1().

When nothing is found even here, search shifts to the next outer environment, which turns out to be the global environment. Here, since an x exists, the name x (in print(x)) is resolved with its value which is 20.

Sometimes, name resolution is not apparent as in the example below:

x = 10

def f1():
    print(x)

def f2():
    x = 20
    f1()

f2()

What do you think would be the output here?

Predict the output of the code above.

  • 10
  • 20

10 is the correct answer!

This follows from the fact that Python is a lexically-scoped language. The next section addresses all the questions you would have at this stage regarding this alien term.

Lexically-scoped language

Many modern programming languages including C, Python, JavaScript, etc. are lexically-scoped languages. They work on a lexical-scoping model.

So what does this actually mean?

Lexical-scoping implies the fact that names are accessible only where they are defined — not anywhere else.

A bit more specifically, it means that a name is available only in its lexical context i.e the place where it is defined in the source code, not in the calling context (also known as dynamic context).

The word 'lexical' means 'source code', if we interpret it in terms of programming.

Before we can understand all this mess, it would be useful to understand what exactly is meant by the term 'context'.

At any given point in a piece of code, the set of all names available is known as a context.

A context is sometimes also referred to as an environment.

Consider the following code:

a = 10
b = 20

def f1():
    pass

Strictly speaking, here the global scope is the region outside the function f1(). In contrast, the global context is the set of all variables in the global scope. That is the global context holds the variables a and b.

Often times, the terms scope and context are used interchangeably, and it works since the meaning of the respective statement remains intact regardless of the term we use. However, it's crucial to note that both these terms technically do not mean the same thing.

The lexical context (or the lexical environment) depends on the source code. For example, in the example below, the local lexical context of f() holds the variables c and d, while its outer lexical context holds the variables a and b.

# outer lexical context of f()
a = 10
b = 20

def f():
    # local lexical context
    c = 30
    d = 40

In contrast, a dynamic context, or an execution context, depends upon the state of a program i.e a particular stage of its execution.

For instance, in the code below, the local execution context of f2() contains the variables a and b while its outer execution context contains the variables c and d.

def f1():
    a = 10
    b = 20

def f2():
    c = 30
    d = 40
    f1()

f2()

Python is a lexically-scoped language and likewise the source code governs name resolutions.

When a name is encountered in Python, first the local lexical context is searched for it, then the outer (enclosing) lexical context, then the global lexical context and finally the built-in context.

This gives a more rigorous explanation of the LEGB rule.

Let's clarify this with the help of a rock-solid example. And that example is the code snippet in the section above.

Here's the code:

x = 10

def f1():
    print(x)

def f2():
    x = 20
    f1()

f2()

One might expect that the output here should be 20 since it's the latest value of x when f1() is called in line 8, but this would've only been the case if Python was a dynamically-scoped language.

However, Python is a lexically-scoped language (also known as statically-scoped), where name resolution starts from the local lexical context, NOT from the local execution context.

What happens in the code above is shown as follows:

  1. f2() is called.
  2. A local variable x is created and the assigned the value 20.
  3. f1() is called.
  4. The print(x) statement is executed.
  5. The name x inside f1() has to be resolved.
  6. It's searched for in the local lexical context of f1() i.e the local scope of f1().
  7. Nothing is found here, so searching shifts to the outer lexical context i.e the region outside f1() in the source code. In this case, the region outside f1() is the global scope of the script.
  8. Here a variable x does exist and so the name x (in print(x)) is resolved using its value — 10.

Simple lexical theory in action!

If Python was dynamically-scoped...

Had Python been a dynamically-scoped language, the series of events following the invocation of the f2() function, in the code above, would've been as follows:

  1. A global variable x is created and assigned the value 10.
  2. f2() is called.
  3. A new execution context is popped onto a stack of contexts originating from the built-in context, followed by the global context. This new execution context is the local context of f1(), and contains all its local variables.
  4. In this case, it contains the variable x pointing to 20.
  5. f1() is called.
  6. After calling f1(), a new execution context is pushed onto the stack, and this is the local context of f1(), containing all its local variables.
  7. In this case, it contains nothing.
  8. The statement print(x) is encountered.
  9. Search is begun for x, starting from the latest execution context in the stack i.e the local context of f1(). No match is found here, and so searching moves to the previous execution context (in the same stack) i.e the local context of f2().
  10. Here x is found, and so the name x (in print(x)) is resolved using its value, which is 20.

This is name resolution performed in the dynamic-scoping discipline, which Python does not follow.