Names, References, and the Assignment Operator

Introduction

Python has a relatively straightforward system to handle asignment operations. However, this usage surprises most people, so we will cover it in detail.

Things you will learn:

  • a basic understanding of assignment
  • more interesting cases that you wouldn't expect
  • scoping, and the consequences therein

What is an Assignment?

The first premise is that names refer to values.

When we make a simple assignment:

x = 23

This is not assigning 23 to x; it is in fact pointing the name x to the value 23. So x refers to, or is a reference to, 23.

../_images/names_single_assignment.png

Multiple names, one value

If we have this code:

x = 23
y = x

People coming from c-like languages would think this assigns 23 to x, and copies 23 to y.

This is incorrect. In fact, both x and y point to a single instance of 23.

../_images/names_multiple_assignment.png

Names are reassigned independently

Starting with the previous example:

x = 23
y = x

# now, assign a new value to x
x = 12

We start with both x and y pointing to 23.

../_images/names_reassignment_before.png

Then we assign another value to x. y is still pointing to 23. The names do not become linked.

../_images/names_reassignment_after.png

Values live until there are no references

Reference counting is critical to garbage collection. As long as a reference exists, a value is not deleted.

For instance:

x = "hello"
x = "world"

x initially points to "hello". It is then reassigned to "world" without deleting "hello". "hello" is only removed when the reference count is checked during garbage collection.

Assignment never copies data

To reiterate a point mentioned earlier, assignment does not copy the value:

nums = [1, 2, 3]
other = nums
../_images/names_assignment_no_copy.png

There is only one copy of the list, and two references to it.

Assignment never copies data

As a consequence of never copying data, changes are visible to all names that reference the data. For instance:

nums = [1, 2, 3]
other = nums
nums.append(4)
print(other)       # prints [1, 2, 3, 4]
../_images/names_change_all_names.png

This is one of the key points people get confused on, so I will reiterate: changes are visible to all names. This is known as mutable aliasing.

Immutable values

Certain simple values are immutable, thus they cannut be used for mutable aliasing.

Immutable types: int, float, string, tuple

An Example:

x = "hello"
y = x
x = x + " there"
../_images/names_immutable_values.png

"Change" is an unclear description

If we refer to changing an int, we are rebinding:

x = x + 1

An int is immutable, so cannot be mutated.

If we are changing a list, we are usually mutating:

nums.append(7)

We can also rebind lists:

nums = nums + [7]

Mutable and immutable use the same assignment. Aliasing just makes it seem different.

Assignment Interesting Cases

Suppose we have:

x += y

This is conceptually equal to:

x = x + y

But this is not what happens. Instead:

x = x.__iadd__(y)

Assignment Interesting Cases

Importantly, the list __iadd__ method is defined like:

class List:
    def __iadd__(self, other):
        self.extend(other)
            return self

Thus, these two lines are the same:

num_list += [4, 5]
num_list.extend([4, 5]); num_list = num_list

So we are not creating a new list, just mutating the current list.

References are more than names

List elements are refernces too:

nums = [1, 2, 3]
x = nums[1]
../_images/names_list_references.png

Lots of things are references

  • object attributes
  • list elements
  • dict values and keys
  • anything on the left side of an assignment operator

All of these assign to x:

x = ...
for x in ...
class x(...):
def x(...):
def fn(x):
import x
from ... import x
except ... as x:
with ... as x:

For loops

As an example of a tricky thing, iterating through a for loop:

nums = [1, 2, 3]
for x in nums:
    x = x * 10
print(nums)        # prints [1, 2, 3]

So what went wrong?

When assigning to x, we are repointing the reference x to a new value, not modifying the old one.

Function arguments are assignments

Given this code:

def func(x):
    print(x)
    return

num = 17
func(num)

The x in func(x) is just another reference to 17. It works exactly like this code:

num = 17
x = num

Function arguments are assignments

Where this has consequences is:

def append_twice(a_list, val):
    a_list.append(val)
    a_list.append(val)

nums = [1, 2, 3]
append_twice(nums, 7)
print(nums)               # prints [1, 2, 3, 7, 7]

Even though the action happens in the function, and in a different scope, a_list is just a reference to the list [1, 2, 3]. When it mutates the value, nums is also mutated.

I reiterate: mutable aliasing.

Function arguments are assignments

Remember to be careful and check what operations are actually occurring:

def append_twice(a_list, val):
    a_list = a_list + [val, val]
nums = [1, 2, 3]
append_twice(nums, 7)
print(nums)               # prints [1, 2, 3]

In this case, a_list is rebound inside the function so no changes are made to the initial value. To get the output you must return it:

def append_twice(a_list, val):
    return a_list + [val, val]
nums = [1, 2, 3]
nums = append_twice(nums, 7)
print(nums)               # prints [1, 2, 3, 7, 7]

2D list (matrix)

This does not work as expected:

board = [[0] * 8] * 8
board[0][0] = 1
print(board)
# [[1, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0, 0],
#  [1, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0, 0],
#  [1, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0, 0],
#  [1, 0, 0, 0, 0, 0, 0, 0], [1, 0, 0, 0, 0, 0, 0, 0]]

What went wrong? The problem is that the inner list was referenced 8 times to create the outer list. A better way to do this is:

board = [[0] * 8 for _ in range(8)]

Scopes

Python does have a sense of scope, but it is very loose.

First let's define the basic namespaces in python. These are mappings from names to objects (basically dicts). The same name can be in different namespaces without causing a conflict. The namespaces include:

  • builtin
  • global
  • function
  • class

Scopes

A scope is a region of the program where a namespace is directly accessible with an unqualified name. There are four scopes:

  • local scope
  • the scopes of any enclosing functions
  • global scope of the current module
  • builtin namespace

All scopes except the local scope are read-only unless declared otherwise (with the global keyword). Assigning to a name in a non-local scope creates a new reference in the local scope.

Example

a = 1

def foo(c):
    c = 3 # reassigns local 'c' to 3, does not change 'b'
    a = 4 # makes a new name 'a' in local scope

b = 2
foo(b)

print(a) # 1
print(b) # 2

If we wanted to change a inside the function, the global keyword should be used:

def foo(c):
    global a
    a = 4

Function default arguments

Default arguments in functions can trip people up. For instance:

def foo(c=1):
    print(c)
    c += 1
foo() # prints 1
foo() # prints 1

This is expected, because c += 1 rebinds to a new value.

Function default arguments

def foo(c=[]):
    print(c)
    c += [1]
foo() # prints []
foo() # prints [1]
foo() # prints [1, 1]

Why does this happen? Two reasons:

  1. Function arguments are evaluated when the function is declared. They are not rebound every time the function is called.
  2. The c += [1] is a mutation of the original list.

These two reasons mean the function is mutating a single value throughout the lifetime of the program.

Class and instance variables

Class variables should not be used in instances if they are mutable (or just not at all to avoid confusion):

class Dog:
    tricks = []               # mistaken use of a class variable
    def __init__(self, name):
        self.name = name
    def add_trick(self, trick):
        self.tricks.append(trick)

d = Dog('fido')
e = Dog('buddy')
d.add_trick('roll over')
e.add_trick('play dead')
print d.tricks                # prints ['roll over', 'play dead']

Instead, define the tricks = [] inside __init__.