Names, objects and the mutation model
Assignment binds a name to an object and copies nothing. Almost every Python surprise a Java or TypeScript engineer hits in their first week follows from that one sentence, so this lesson works through the consequences deliberately.
On this page
What you will be able to do
- Explain assignment as name binding rather than copying or referencing a slot
- Predict aliasing behaviour when a mutable object is passed to a function
- Apply the rule for `is` against `==` without relying on interning
- Identify the mutable default argument and late-binding closure traps in unfamiliar code
You already know that List<String> b = a in Java gives you two names for one list. Python goes
further: there is no second kind of assignment. No primitive copies a value on the way in, no final
freezes a binding, and no const protects anything. x = 5 does not put five into a slot called
x. It attaches the label x to an integer object.
Nearly every surprise in a first Python week is one surprise wearing five costumes. Work from the model below and you can predict all five instead of memorising them.
The model, in three sentences
Every value is an object with an identity that never changes, a type and a value. A name is a label bound to an object, and a name has no type and no storage of its own. Assignment rebinds the label and copies nothing.
- Name binding
Attaching a name to an object. Rebinding one name never disturbs another name bound to the same object. Parameters, loop variables, module-level names, attributes and the targets of an unpacking assignment are all bound this way.
Mutability is a property of the object, never of the name in front of it. list, dict, set and
ordinary class instances can be changed in place; int, str, tuple and frozenset cannot. That
distinction explains the rest of this lesson.
xs = [1, 2, 3]
ys = xs # one object, two names
ys.append(4)
print(xs) # [1, 2, 3, 4]
print(xs is ys) # True
xs = [9] # rebinds the label xs, mutates nothing
print(ys) # [1, 2, 3, 4]
limits = [1, 2]
limits.append(3) # allowed
limits = [] # also allowed: no keyword can freeze a binding
const limits = [1, 2];
limits.push(3); // allowed: const freezes the binding, not the array
// limits = []; // compile error
final List<Integer> limits = new ArrayList<>(List.of(1, 2));
limits.add(3); // allowed: final freezes the binding, not the list
// limits = null; // compile error
Both anchors distinguish a frozen binding from a frozen object. Python has only the object.
Passing an argument is another binding
A call binds the parameter names to the objects the caller is already holding. Nothing is copied on the way in, so a mutation inside the function is visible outside it, and a rebinding inside the function is not.
- Aliasing
Two or more names bound to one object, so a change made through any of them is visible through all of them. Aliasing is not a defect; it is what makes a large object cheap to pass. It becomes a defect when one side of the code does not know the other side can see the change.
Aliasing also appears where no function is involved. grid = [[0] * 2] * 3 builds one inner list and
three references to it, so grid[0][0] = 9 changes what looks like every row. Build nested
structures with a comprehension, [[0] * 2 for _ in range(3)], which evaluates the inner expression
once per row.
The convention that keeps this readable is worth adopting on day one: a function that mutates its
argument returns None, following list.append and list.sort, and a function that returns a new
object leaves its input alone. A function that does both is where a caller loses data quietly.
A default is evaluated once, at definition time
The def statement is a statement. It runs, it evaluates the default expressions, and it binds the
resulting objects to the function. It does not re-evaluate them per call, so a mutable default is one
object shared by every call for the lifetime of the program.
def collect(item, into=[]):
into.append(item)
return into
print(collect("a")) # ['a']
print(collect("b")) # ['a', 'b'], the same list, still there
print(collect.__defaults__) # (['a', 'b'],) the default is an object you can inspect
The fix is a sentinel default and a rebinding inside the body.
def collect(item, into=None):
if into is None:
into = []
into.append(item)
return into
The same fix is built into the Application Programming Interface (API) for value classes:
dataclasses
require field(default_factory=list) rather than a bare [], for exactly this reason.
A closure captures the variable, not its value
A nested function looks up an enclosing name when it runs, not when it is created. If the loop that created three functions has finished, all three see the loop variable's final binding.
callbacks = []
for name in ("alpha", "beta", "gamma"):
callbacks.append(lambda: name)
print([c() for c in callbacks]) # ['gamma', 'gamma', 'gamma']
The other fix is functools.partial(render, path), which binds the argument immediately and is
usually clearer than a default parameter that exists only to close over something. A TypeScript
reader feels this one hardest, because let in a loop gives a fresh binding per iteration and
Python does not. Scope resolution itself belongs to
functions, arguments and closures.
is against ==
is compares identity, meaning whether two names are bound to the same object. == calls __eq__,
which is the question you almost always mean. The two diverge as soon as a value arrives from outside
your process.
a = "learn"
b = "".join(["l", "e", "a", "r", "n"])
print(a == b) # True
print(a is b) # False
Comparing against a literal directly, mode is "strict", is the one version CPython warns about at
compile time. Hiding the literal behind a constant, which is what a real codebase does, removes the
warning and keeps the bug.
Truthiness is not the same question as presence
0, 0.0, "", [], {}, set() and None are all falsy, so if x: and if x is not None:
ask different questions. Empty and zero are legitimate answers in most programs, which is what makes
this expensive.
if items: for an empty container is idiomatic and correct. The rule is narrower: when a value can
be absent as well as empty or zero, test for absence explicitly.
Two closing shocks from the same model
There are no private members. A single leading underscore is a convention meaning "not part of the
interface", enforced by nothing. A double leading underscore triggers name mangling: self.__store
inside class Cache is stored as _Cache__store, which prevents an accidental collision in a
subclass and stops nobody who wants it. And there is no final: a name in a module, a class or a
frame can be rebound at run time, including a method after the class exists. Classes themselves are
a later lesson; what
matters here is that class attributes and instance attributes are bound by the same rule as
everything else.
Check your understanding
Sign in to take this check
4 questions on this lesson, one at a time, with the reasoning for every option as soon as you answer. Each answer is marked on the server and stored against your account.
An account is free. There is no paid plan, no tier and nothing to buy.