1.2 The Python Memory Model: Functions and Parameters#

Terminology#

Let’s use this simple example to review some terminology that should be familiar to you:

# Example 1.

def mess_about(n: int, s: str) -> None:
    message = s * n
    print(message)

if __name__ == '__main__':
    count = 13
    word = 'nonsense'
    mess_about(count, word)

In the function declaration, each variable in the parentheses is called a parameter. Here, n and s are parameters of function mess_about. When we call a function, each expression in the parentheses is called an argument. The arguments in our one call to mess_about are count and word.

How function calls are tracked#

Python must keep track of the function that is currently running, and any variables defined inside of it. It stores this information in something called a stack frame, or just “frame” for short.

Every time we call a function, the following happens:

  1. A new frame is created and placed on top of any frames that may already exist. We call this pile of frames the call stack.

  2. Each parameter is defined inside that frame.

  3. The arguments in the function call are evaluated, in order from left to right. Each is an expression, and evaluating it yields the id of an object. Each of these ids is assigned to the corresponding parameter.

Then the body of the function is executed.

In the body of the function there may be assignment statements. We know that if the variable on the left-hand-side of the assignment doesn’t already exist, Python will create it. But with the awareness that there may be a stack of frames, we need a slightly more detailed rule:

If the variable on the left-hand-side of the assignment doesn’t already exist in the top stack frame, Python will create it in that top stack frame.

For example, if we stop our above sample code right before printing message, this is the state of memory:

A memory model diagram showing the state of memory before printing `message`.

Notice that the top stack frame, for our call to mess_about, includes the new variable message. We say that any new variables defined inside a function are local variables; they are local to a call to that function.

When a function returns, either due to executing a return statement or getting to the end of the function, the frame for that function call is deleted. All the variables defined in it—both parameters and local variables—disappear. If we try to refer to them after the function has returned, we get an error. For example, when we are about to execute the final line in this program,

# Example 2. (Same as Example 1, but with a print statement added.)

def mess_about(n: int, s: str) -> None:
    message = s * n
    print(message)

if __name__ == '__main__':
    count = 13
    word = 'nonsense'
    mess_about(count, word)
    print(n)

this is the state of memory,

variables

which explains why the final line produces the error NameError: name 'n' is not defined.

Passing an argument creates an alias#

What we often call “parameter passing” can be thought of as essentially variable assignment. In the example above, it is as if we wrote

n = count
s = word

before the body of the function.

If an argument to a function is a variable, what we assign to the function’s parameter is the id of the object that the variable references. This creates an alias. As you should expect, what the function can do with these aliases depends on whether or not the object is mutable.

Passing a reference to an immutable object#

If we pass a reference to an immutable object, we can do whatever we want with the parameter and there will be no effect outside the function.

Here’s an example:

# Example 3.

def emphasize(s: str) -> None:
    s = s + s + '!'

if __name__ == '__main__':
    word = 'moo'
    emphasize(word)
    print(word)

This code prints plain old moo. The reason is that, although we set up an alias, we don’t (and can’t) change the object that both word and s reference; we make a new object. Here’s the state of memory right before the function returns:

variables

Once the function is over and the stack frame is gone, the string object we want (with moomoo!) will be inaccessible. The net effect of this function is nothing at all. It doesn’t change the object that s refers to, it doesn’t return anything, and it has no other effect such as taking user input or printing to the screen. The one thing it does do, making s refer to something new, doesn’t last beyond the function call.

If we want to use this function to change word, the solution is to return the new value and then, in the calling code, assign that value to word:

# Example 4.

def emphasized(s: str) -> str:
    return s + s + '!'

if __name__ == '__main__':
    word = 'moo'
    word = emphasized(word)
    print(word)

This code prints out moomoo!. Notice that we changed the function name from emphasize to emphasized. This makes sense when we consider the context of the function call:

    word = emphasized(word)

Our function call is not merely performing some action, it is returning a value. So the expression on the right-hand side has a value: it is the emphasized word.

Passing a reference to a mutable object#

If we wrote code analogous to the broken code in Example 3, but with a mutable type, it wouldn’t work either. For example:

# Example 5.

def emphasize(lst: list[str]) -> None:
    lst = lst + ['believe', 'me!']

if __name__ == '__main__':
    sentence = ['winter', 'is', 'coming']
    emphasize(sentence)
    print(sentence)

This code prints ['winter', 'is', 'coming'] for the same reason we saw in Example 3. Changing a reference (in this case, making lst refer to something new) is not the same as mutating a value (in this case, mutating the list object whose id was passed to the function). This model of memory illustrates:

variables

The code below, however, correctly mutates the object:

# Example 6.
def emphasize(lst: list[str]) -> None:
    lst.extend(['believe', 'me!'])

if __name__ == '__main__':
    sentence = ['winter', 'is', 'coming']
    emphasize(sentence)
    print(sentence)

This is the state of memory immediately before function emphasize returns:

variables

Here are some things to notice:

  • When we begin this program, we are executing the module as a whole. We make an initial frame to track its variables, and put the module name in the upper-left corner.

  • When we call emphasize, a new frame is added to the call stack. In the upper-left corner of the frame, we write the function name.

  • The parameter lst exists in the stack frame. It comes into being when the function is called. And when the function returns, this frame will be discarded, along with everything in it. At that point, lst no longer exists.

  • When we pass argument sentence to emphasize, we assign it to lst. In other words, we set lst to id2, which creates an alias.

  • id2 is a reference to a list object, which is mutable. When we use lst to access and change that object, the object that sentence references also changed. Of course it does: they are the same object!

Moral of the story#

The situation gets trickier when we have objects that contain references to other objects, and you’ll see examples of this in the work you do this term. The bottom line is this: know whether your objects are mutable—at each level of their structure. Memory model diagrams offer a concise visual way to represent that.