Hello, and welcome to day 13 of the 30 Days of Python series! Today we're going to start to learn about the very important topic of scope.

We can think of scope as a description of where a given name can be referenced in our application. Understanding how this works in Python is a vital step in our learning journey.

We're also going to be expanding the ways we can use our functions. So far all of our functions have just printed something to the console, but there are cases where we want to get something back from our functions as well. We're going to talk about how to do that in today's post.

A demonstration of scope

As I mentioned already, scope is a concept describing where a given name can be referenced in our application.

We haven't really encountered any cases where a name we've defined hasn't been accessible yet, so let's look at an example.

First, we're going to define a simple function called greet.

def greet(name):
	greeting = f"Hello, {name}!"
	print(greeting)

All greet does is take in a name and print a greeting to the user. Inside greet we've defined a variable called greeting which is where we assign our formatted string, and we pass this greeting string to print as an argument.

The question is, can we access this greeting string outside of the function?

def greet(name):
	greeting = f"Hello, {name}!"
	print(greeting)


print(greeting)

In this case, it looks like we get an error:

Traceback (most recent call last):
  File "main.py", line 6, in <module>
    print(greeting)
NameError: name 'greeting' is not defined

But maybe this isn't a fair example. After all, we never actually called greet, so the code where greeting is defined was never really run. How could it? We haven't even told it what name means.

Maybe this will work after we call greet.

def greet(name):
	greeting = f"Hello, {name}!"
	print(greeting)


greet("Phil")
print(greeting)

Now our output looks like this:

Hello, Phil!
Traceback (most recent call last):
  File "main.py", line 7, in <module>
    print(greeting)
NameError: name 'greeting' is not defined

As we can see, "Hello, Phil!" printed when greet was called, but still greeting is undefined. What's going on?

The issue is actually that greeting is "out of scope".

Namespaces

To better understand what's going on in the example above, we need to think about what happens when we define a variable.

Python actually keeps a record of the variables we've defined, and the values that are associated with those names. We call this record a namespace, and you can think of it as being a dictionary. We can get a little peek at this dictionary by calling the globals function and printing the result.

First, let's look at what happens when we call globals in an empty file.

print(globals())

If you run this code, it may surprise you to find that this dictionary is not empty. We have all kinds of interesting things in it.

{
	'__name__': '__main__',
	'__doc__': None,
	'__package__': None,
	'__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f2b30020bb0>,
	'__spec__': None,
	'__annotations__': {},
	'__builtins__': <module 'builtins' (built-in)>,
	'__file__': 'main.py',
	'__cached__': None
}

This makes some sense though. After all, we don't have to define every single name we use in Python. We never defined print, input, or len, for example.

Don't worry too much about the actual content of this dictionary right now. Just take note of the things that already exist, so that we can see what happens when we define some names of our own.

Let's change our file so that it actually has some content. I'm going to define a couple of variables and a function, just so we have a bit of variety:

names = ["Mike", "Fiona", "Patrick"]
x = 53657

def add(a, b):
	print(a, b)


print(globals())

Now let's looks at what we have in our globals dictionary. Pay particular attention to the last three items.

{
	'__name__': '__main__',
	'__doc__': None,
	'__package__': None,
	'__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7fae511ffbb0>,
	'__spec__': None,
	'__annotations__': {},
	'__builtins__': <module 'builtins' (built-in)>,
	'__file__': 'main.py',
	'__cached__': None,
	'names': ['Mike', 'Fiona', 'Patrick'],
	'x': 53657,
	'add': <function add at 0x7fae512021f0>
}

We can see the names we defined (names, x, and add) have been written as keys, and associated with each of our names is the value we assigned to that name.

add looks a little bit strange, but this <function add at 0x7fae512021f0> is just a representation of the function we defined. We can see that it's a function, its name is add, and the last number is a memory address for this function.

When we use a name in our application, Python just looks in the namespace to see if it's defined. If it is, it can just reference the value associated with that name. If it can't find the name we requested, Python says that the variable is undefined.

Functions and namespaces

Looking back at our globals output, there are a couple of names which are notably absent: the parameters a and b that we defined for add. Maybe you expected those to be there alongside the other names we defined. What happened to those?

Python actually has more than one namespace. The one we're looking at here is just the global namespace, but the parameters for a function are not part of this global namespace.

When we call a function, Python creates a new namespace. In other words, it creates a new dictionary to store any names we want to use while running this function. Once the function finishes running, this namespace is destroyed, so that when we run the function next time, we're working with a blank slate.

Just like when we used globals, we can get a peek at this function namespace by calling locals. Let's make a small change to add to see this:

def add(a, b):
	print(locals())
	print(a, b)


add(7, 25)

The output we get in this case is as follows:

{'a': 7, 'b': 25}
7 25

The first line is the locals dictionary, and contains the names located within the namespace for this function call.

Now we can better explain what's going on in the greet example from earlier in the post.

def greet(name):
	greeting = f"Hello, {name}!"
	print(greeting)

The global namespace doesn't contain the name, greeting, so when we try to reference it outside of the function, Python can't find it. Inside greet, however, we're working with a second local namespace that was created when we called the function. This namespace contains greeting, because we added it when we defined greeting in the function body.

We can see that in more detail by doing something like this:

def greet(name):
	print(locals())
	greeting = f"Hello, {name}!"
	print(locals())
	print(greeting)


greet("Phil")

If we run this code, we see the following:

{'name': 'Phil'}
{'name': 'Phil', 'greeting': 'Hello, Phil!'}
Hello, Phil!

We can see that when we enter the function body for this function call, we only have name defined. We then move onto the second line where greeting gets assigned the result of our string interpolation. When we print locals() again on the third line of the function body, we can see that greeting has been added to this local namespace for the function call.

I think this makes some intuitive sense. The function has some private variables that only it knows about, and we have a table of variable names and values that we create when we're running the function to keep track of what's going on inside. When we're done with the function, we wipe the table clean, because we no longer need any of those values.

Getting values out of a function

Since all of the variables we define in our functions only exist inside of our functions, the question is, how do we get a value back out of the function? The input function is able to provide us the user's response to our prompt, for example. We want to be able to do something similar.

The way that we get a value out of a function is using a return statement.

The return statement actually has two roles. First, it's used to end the execution of a function. When Python encounters the return keyword, it immediately terminates the function.

For example, if we write something like this:

def my_func():
	return
	print("This line will never run")

We'll never run the print call on the second line of the function body. The return keyword will cause the function to terminate before we reach that line with the print call.

We can also put an expression directly after the return keyword, and this is how we get values out of the function. We put the values we want to get out on the same line as the return keyword.

For example, let's write a new version of our add function from yesterday's exercises. Instead of printing the result, we're going to return the result instead.

def add(a, b):
	return a + b

If we run this function, nothing seems to happen. We're no longer printing anything, after all. However, we can now assign the return value to a variable if we want.

def add(a, b):
	return a + b


result = add(5, 12)
print(result)  # 17

So, what's actually happening here?

Remember that a function call is an expression. It evaluates to some value. What value? A function call evaluates to the value that was returned by the function when we called it.

In the example above, we returned the result of the expression, a + b. Since a was 5 and b was 12, the result of that expression was the integer 17. This is what was returned by the function, and this is the value the function call evaluated to.

By the way, because add(5, 12) is just an expression, we could have passed the function call to print directly:

def add(a, b):
	return a + b


print(add(5, 12))  # 17

But what about when we don't have a return statement? If we don't specify a value to return, Python implicitly returns None for us.

For example, if we try to find the value of our greet function call, we get None:

def greet(name):
	greeting = f"Hello, {name}!"
	print(greeting)


print(greet('Phil'))

The output we get is:

Hello, Phil!
None

First we get the result of calling the function, because Python needs to call the function to figure out what the value of greet('Phil') is; then we print the return value of the function, which is None.

The same thing happens if you type return without a value. Python will return None.

def greet(name):
	greeting = f"Hello, {name}!"
	print(greeting)
	return


print(greet('Phil'))

Finally, remember we can use the return value of the function anywhere we might use a plain old value. For example, in variable assignment or as part of a string:

def greet(name):
	greeting = f"Hello, {name}!"
	print(greeting)


print(f"The value of greet('Phil') is {greet('Phil')}.")

Here we get the string telling us that the value of greet('Phil') is None:

Hello, Phil!
The value of greet('Phil') is None.

Multiple return statements

Sometimes a function definition might have more than one return statement. This is totally legal, but only useful if we have some kind of conditional logic that directs us towards just one of the return statements. Remember that a function is going to terminate as soon as we encounter any return statement, so if we have more than one in series, we'll never hit the ones after the first.

An example where multiple return statements makes sense is with our divide function:

def divide(a, b):
	if b == 0:
		return "You can't divide by 0!"
	else:
		return a / b

This makes sense, because while we have multiple return statements, we're being directed to just one return statement by our conditional logic.

Because return will cause a function call to terminate, we can make a slight modification to the code above:

def divide(a, b):
	if b == 0:
		return "You can't divide by 0!"

	return a / b

This still works, because in any cases where b is 0, we hit this return statement and break out of the function. We therefore never hit the point where we perform the calculation. The only way we get there is if b is not 0.

This is a very common pattern that people use to save writing this else clause. There's no harm putting on in though, and feel free to include it in your own code. Whatever is more comfortable for you.

Exercises

1) Define a exponentiate function that takes in two numbers. The first is the base, and the second is the power to raise the base to. The function should return the result of this operation. Remember we can perform exponentiation using the ** operator.

2) Define a process_string function which takes in a string and returns a new string which has been converted to lowercase, and has had any excess whitespace removed.

3) Write a function that takes in a tuple containing information about an actor and returns this data as a dictionary. The data should be in the following format:

("Tom Hardy", "English", 42)  # name, nationality, age

You can choose whatever key names you like for the dictionary.

4) Write a function that takes in a single number and returns True or False depending on whether or not the number is prime. If you need a refresher on how to calculate if a number is prime, we show one method in day 8 of the series.

You can find our solutions to the exercises here.