So far in this chapter, we have talked only about variables defined within the Python console. In 2.3 Local Variables and Function Scope, we saw how to represent function scope in the value-based memory model using separate “tables of values” for each function call. In this section, we’ll see how to represent function scope in the full Python memory model so that we can capture exactly how function scope works and impacts the variables we use throughout the lifetime of our programs.
Suppose we define the following function, and then call it in the Python console:
def repeat(n: int, s: str) -> str:
= s * n
message return message
# In the Python console
>>> count = 3
>>> word = 'abc'
>>> result = repeat(count, word)
Consider what the state of memory is when
repeat(count, word)
is called, immediately before
the return message
statement executes. Let’s first recall
how we would draw the value-based memory model for this
point:
Variable | Value |
---|---|
count |
3 |
word |
'abc' |
Variable | Value |
---|---|
n |
3 |
s |
'abc' |
message |
'abcabcabc' |
This memory model shows two tables, showing the variables defined in
the Python console (count
, word
), and the
variables local to the function repeat
(n
,
s
, and message
).
Here is how we would translate this into a full Python memory model diagram:
As with the diagrams we saw in the previous sections of this chapter,
our variables are on the left side of the diagram, and the objects on
the right. The variables are separated into two separate boxes, one for
the Python console and one for the function call for
repeat
. All variables, regardless of which box they’re in,
store only ids that refer to objects on the right-hand side. Notice that
count
and n
are aliases, as are
word
and s
.
Now that we have this full diagram, we’ll introduce a more formal piece of terminology. Each “box” on the left-hand side of our diagram represents a stack frame (or just frame for short), which is a special data type used by the Python interpreter to keep track of the functions that have been called in a program, and the variables defined within each function. We call the collection of stack frames the function call stack.
Every time we call a function, the Python interpreter does the following:
What we often call “argument passing” is a special form of variable
assignment in the Python interpreter. In the example above, when we
called repeat(count, word)
, it is as if we wrote
= count
n = word s
before executing the body of the function.
This aliasing is what allows us to define functions that mutate their argument values, and have that effect persist after the function ends. Here is an example:
def emphasize(words: list[str]) -> None:
"""Add emphasis to the end of a list of words."""
= ['believe', 'me!']
new_words
words.extend(new_words)
# In the Python console
>>> sentence = ['winter', 'is', 'coming']
>>> emphasize(sentence)
>>> sentence
'winter', 'is', 'coming', 'believe', 'me!'] [
When emphasize(sentence)
is called in the Python
console, this is the state of memory:
In this case, words
and sentence
are
aliases, and so mutating words
within the function causes a
change to occur in __main__
as well.
On the other hand, consider what happens with this version of the function:
def emphasize_v2(words: list[str]) -> None:
"""Add emphasis to the end of a list of words."""
= ['believe', 'me!']
new_words = words + new_words
words
# In the Python console
>>> sentence = ['winter', 'is', 'coming']
>>> emphasize_v2(sentence)
>>> sentence
'winter', 'is', 'coming'] [
After we call emphasize_v2
in the Python console, the
value of sentence
is unchanged! To understand why, let’s
look at two memory model diagrams. The first shows the state of memory
immediately after new_words = ['believe', 'me!']
is
executed:
The next statement to execute is
words = words + new_words
. The key to understanding the
next diagram is to recall variable reassignment: the right-hand
side (words + new_words
) is evaluated, and then the
resulting object id is assigned to words
. List
concatenation with +
creates a new list object.
Notice that in this diagram, words
and
sentence
are no longer aliases! Instead, words
has been assigned to a new list object, but sentence
has
remained
unchanged. Remember the rule of variable reassignment: an
assignment statement <name> = ...
only changes what
object the variable <name>
refers to, but never
changes any other variables. This illustrates the importance of
keeping variable reassignment and object mutation as distinct concepts.
Even though the bodies of emphasize
and
emphasize_v2
look very similar, the end result is very
different: emphasize
mutates its argument object, while
emphasize_v2
actually leaves it unchanged!