codejump

AcademyPython

Slicing and Mutability in Python

How start:stop:step slicing works on any sequence, why a slice always copies while an index into a list of lists does not, and where is diverges from ==.

3 min readUpdated

3 exercises

Waiting at the end of this page.

The three numbers in a slice

python
letters = ['a', 'b', 'c', 'd', 'e']
letters[1:3]     # ['b', 'c']
letters[:2]      # ['a', 'b']       — start defaults to 0
letters[3:]      # ['d', 'e']       — stop defaults to len(letters)
letters[::2]     # ['a', 'c', 'e']  — every second element
letters[::-1]    # ['e', 'd', 'c', 'b', 'a'] — the whole sequence, reversed

stop is exclusive — letters[1:3] takes indices 1 and 2, never 3 — which is what makes letters[:n] + letters[n:] reconstruct the original list for any n, with no overlap and no gap. A negative step walks backward, which is why [::-1] is the idiomatic way to reverse any sequence: string, tuple, or list, all support the same three-number syntax because slicing is part of the sequence protocol, not something lists implement specially.

A slice always returns a new object

python
original = [1, 2, 3]
copy = original[:]
copy.append(4)
original   # [1, 2, 3] — untouched

original[:] — every element, no filtering — is the classic idiom for a shallow copy of a list, and it works because slicing never returns a view into the original in Python the way it does in NumPy; it always builds a new list object. Mutating the copy cannot touch the original for exactly that reason.

Shallow copies stop at one level

python
matrix = [[1, 2], [3, 4]]
shallow = matrix[:]
shallow[0].append(99)
matrix   # [[1, 2, 99], [3, 4]] — the inner list was never copied

The outer list is new, but its elements are the same inner list objects the original holds references to — a shallow copy copies one level of container and shares everything nested inside it. Reaching shallow[0] and mutating it in place therefore mutates the object matrix[0] also points at. copy.deepcopy is the fix when nested mutable structures need full independence, recursively copying every level instead of stopping at the first.

is checks identity, == checks value

python
a = [1, 2, 3]
b = [1, 2, 3]
a == b   # True — same contents
a is b   # False — two different list objects
a is a   # True — the same object, trivially

== calls __eq__ and compares whatever that method defines as equal — usually contents. is compares object identity: whether two names refer to the literal same object in memory, with no method call involved at all. The confusion usually surfaces with small integers and short strings, which CPython caches and reuses:

python
x = 5
y = 5
x is y   # True — small ints are cached, this is an implementation detail

x = 1000
y = 1000
x is y   # False, usually — outside the cached range

Relying on is for integer or string equality happens to work by accident within the cached range and breaks outside it — == is correct for value comparison regardless of type, and is should be reserved for checking against None, where it is not only idiomatic but the only correct choice, since a custom __eq__ could otherwise make x == None behave unexpectedly.

Mutable types share this risk everywhere, not just in copies

Lists, dicts and sets are mutable; strings, tuples, numbers and frozensets are not. Passing a mutable object into a function and modifying it in place changes the caller's object too, because Python passes object references, not copies — the identical mechanism behind the shallow-copy surprise above and the mutable-default-argument bug elsewhere in this catalogue. Recognising which built-in types are mutable is what predicts, without running the code, whether a given line of Python can have an effect the reader does not see written down anywhere.

Now practice it

2 challenges · 1 quiz

Reading this page is the cheap half. These are the exercises that make you use it.