You pass a list to a helper function, and when it returns, your list has changed. Nothing in your code assigned to it. Here is the smallest version:
original = [1, 2, 3]
alias = original
alias.append(4)
print(original) # [1, 2, 3, 4]
print(alias is original) # True
alias = original does not copy the list. It gives the same list a second
name. A change made through either name shows through the other, because
there is only one list.
Assigning a new object is different. alias = [9] points alias at a new
list and leaves original as it was.
A function shares its arguments with the caller
A parameter is one more name for the object the caller passed in. If the function changes that object in place, the caller's object changes. If it assigns a new object to the parameter, only the local name moves:
def change(items: list[int]) -> list[int]:
items.append(2) # changes the caller's list
items = [9] # points the local name at a new list
return items
values = [1]
returned = change(values)
print(values) # [1, 2]
print(returned) # [9]
append, extend, sort, remove and clear change a list in place.
+, sorted() and slicing build a new list. When a function should leave
its argument alone, use the second group, or copy the argument first.
+= changes a list in place too. On a tuple or a string it builds a new
object, because those types cannot change.
A shallow copy shares what is inside
list(items), dict(d), items.copy() and copy.copy(x) all make a
shallow copy: a new outer container that holds the same inner objects.
defaults = {"retries": {"max": 3}}
first = dict(defaults)
first["retries"]["max"] = 5
print(defaults["retries"]["max"]) # 5
first is a new dictionary, but first["retries"] and
defaults["retries"] are the same inner dictionary. Copy the inner level you
plan to change as well:
defaults = {"retries": {"max": 3}}
independent = {"retries": dict(defaults["retries"])}
independent["retries"]["max"] = 7
print(defaults["retries"]["max"]) # 3
For each function, decide which objects it owns and which it shares with
its caller. A type hint such as Sequence[str] copies nothing. Copying every level at once, with copy.deepcopy, comes later in
this track.
Check your understanding Would a tuple prevent every bug where shared data changes? Show answer Hide answer
No. A tuple cannot change which objects it holds, but one of those objects can still be a list or a dictionary. A change to that list shows through every name that shares it. Use unchangeable items too, such as strings, numbers and other tuples, when the whole value must stay fixed.
Create a default list inside the function
Python evaluates a default value once, when it runs the def statement. A
default list is therefore one list, shared by every call that leaves the
argument out. Use None as the default, and create the list in the body:
def add_tag(tag: str, tags: list[str] | None = None) -> list[str]:
if tags is None:
tags = []
tags.append(tag)
return tags
print(add_tag("python")) # ['python']
print(add_tag("typing")) # ['typing'], not ['python', 'typing']
Check your understanding
Does the None default stop this function changing its argument?
Show answer
Hide answer
No. A call that leaves tags out gets a fresh list. A call that passes its
own list still has that list changed. If the function should never change
its argument, copy the list the caller passes before you append to it.
is asks about the object, == about the value
left = [1, 2]
right = [1, 2]
print(left == right) # True: equal values
print(left is right) # False: two different lists
Use is to check for None. Use == to compare values. A later article,
"When to Use is and When to Use ==", covers the cases in between.
id(obj) returns a number that is unique among the objects that exist at
that moment. CPython can reuse the number once it frees the object, so
never store it as a record ID. "What Keeps an Object Alive", later in this track,
explains when CPython frees an object.