Skip to content

Instantly share code, notes, and snippets.

@smarnach
Created May 16, 2017 11:36
Show Gist options
  • Save smarnach/578b085b2ea2960ef5ef5f964c990776 to your computer and use it in GitHub Desktop.
Save smarnach/578b085b2ea2960ef5ef5f964c990776 to your computer and use it in GitHub Desktop.

A comment on https://nedbatchelder.com/text/names.html

Myth: Python assigns mutable and immutable values differently.

While it is true that Python's assignment semantics are the same for both mutable and immutable types, the results look different for augmented assignments. E.g. the code

a = [1, 2, 3]
b = a
a += 4,

creates a new list, points both a and b to that list, and then appends another element. After this snippet ran, there is still only a single list, pointed to by both a and b. If you do the same with tuples, though

a = 1, 2, 3
b = a
a += 4,

you end up with two tuples: a is (1, 2, 3, 4), while b is still (1, 2, 3).

So augmented assignments behave differently for lists and tuples. This difference is not caused by different code being generated in both cases, but rather by a difference in the implementations of list.__iadd__() and tuple.__iadd__(). The line a += 4, generates code that is equivalent to

a = type(a).__iadd__(a, (4,))

for both the first and the second example. The implementation of __iadd__() for a list modifies the list in place and then returns a reference to the list that was passed in, so the assignment does not change the value a points to. For tuples, on the other hand, __iadd__() creates and returns a new tuple, so the augmented assignments results in an actual reassignment of the left-hand side.

Things get really crazy if you try augmented assignments of mutable elements in a tuple:

>>> a = [],
>>> a[0] += [1]
Traceback (most recent call last):
  File "<ipython-input-13-ea29ca190a4d>", line 1, in <module>
    a[0] += [1]
TypeError: 'tuple' object does not support item assignment

>>> a
([1],)

This code appends to a list embedded in a tuple, which should be possible, since a list is mutable. And the augmented assignment indeed modifies the list in place, but you still get an exception. If you look at the equivalent code the augmented assignment gets translated to, it's easy to understand why:

a[0] = type(a[0]).__iadd__(a[0], [1])
@haikuginger
Copy link

haikuginger commented May 20, 2017

All of this is also why using mutable arguments for functions is generally a Bad Idea. Names are local to the current scope, by default, but values are global.

So when you write something like this:

def append_to_list(to_append=0, value=[]):
    value.append(to_append)
    return value

the value is created when evaluating the function definition, and is kept around for later use when the function is called. So, this is what you get:

> append_to_list(to_append=5)
[5]
> append_to_list(to_append=9)
[5, 9]

Each time the function is called, we get a new namespace, but the backend value for value (the default value for the name if not otherwise defined) sticks around.

This does let us have some fun times with dynamic programming and implicit state passing:

def best_solution_for_location(location, solutions={}):
    if location in solutions:
        return solutions[location]
    elif location == (0, 0):
        return 0
    adjacent_locations = adjacent_options(location)
    best_adjacent = min(best_solution_for_location(x) for x in adjacent_locations)
    best = best_adjacent + 1
    solutions[location] = best
    return best

@smarnach
Copy link
Author

The main reason for the mutable default argument behaviour is the design decision to evaluate the default argument value at function definition time. It would have been perfectly possible to specify that the expression is evaluated everytime the function is called, within the scope of the function, but that's not how Guido decided to do things. This particular design decision is mostly orthogonal to Python's variable semantics.

@bradenmacdonald
Copy link

Names are local to the current scope, by default

Something that I find very interesting (and which can also cause bugs) is that python scans an entire function to look for name rebinding before determining the scope of a name.

Consider this REPL session:

>>> name = "Alice"
>>> 
>>> def foo():
...     print("Hello " + name)
... 
>>> def bar():
...     print("Hello " + name)
...     if False:
...         name = None
... 

At first glance, you would probably guess that these two functions should produce the same output. (For example, even if the False were changed to True, a C++ version of this code would still print "Hello Alice" for both functions, though/since name rebinding/scoping is explicit in C++.)

But here's what happens in Python:

>>> foo()
Hello Alice
>>> bar()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in bar
UnboundLocalError: local variable 'name' referenced before assignment

@mtyaka
Copy link

mtyaka commented May 29, 2017

Huh, I had no idea a += b could do something different from a = a + b. In ruby a += b is just shorthand for a = a + b.

Slightly off-topic, but I am not a fan of overloading operators or special methods. I once spent several hours debugging some code in edx-platform where there was an if-else statement that would use a different branch than what I expected, and it was only once I realized that AccessResponse overrides __nonzero__, which can make an AccessResponse object evaluate to False in if statements.

I find that modifying the boolean behaviour of the class is much more confusing and doesn't really add anything over simply implementing a has_access() method or something similar.

@smarnach
Copy link
Author

smarnach commented Jun 6, 2017

@mtyaka So in Ruby, when you have two arrays a and b, the statement a += b results in the two original arrays being unchanged, and a pointing to a new array? That's certainly weird as well. The statement definitely reads as if a is changed in place.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment