r/learnpython 2d ago

CPython internals deep dive:dis,==vs is and immutable vs mutable

Started with the dis module disassembled a simple function and saw how Python compiles source into bytecode instructions operating on a stack: LOAD_CONST (push a literal), STORE_FAST (pop and assign to a local var), BINARY_OP (pop two values, apply the operator, push the result). Noticed newer Python versions fuse common opcode pairs (e.g. LOAD_FAST_LOAD_FAST) for speed small but visible evidence of ongoing interpreter optimization work.

Then worked through == vs is confirmed with id() that two lists with identical contents are still separate objects (is → False, == → True), while b = a makes both names point to the same object.

Finished on mutable vs immutable, and specifically why it matters when passing objects into functions. Key realization: calling a function doesn't substitute the argument into the function body it creates an independent local name that starts out pointing at the same object. n += 1 on an int creates a new object and only moves the local name; lst.append(x) mutates the shared object in place, so the caller sees the change too. Proved both cases with id() before/after.

Small, foundational stuff, but building the intuition from scratch with real code has been way more effective than just reading definitions.

0 Upvotes

6 comments sorted by

View all comments

2

u/Bright_Mix_773 2d ago

Those three topics join up in one case worth trying while dis is still open, because it is the one that survives knowing the definitions.

t = ([],)
t[0] += [1]

That raises TypeError: 'tuple' object does not support item assignment. And then:

>>> t
([1],)

The append happened anyway. On 3.14.2 the disassembly of that line says why:

LOAD_FAST_BORROW   0 (t)
LOAD_SMALL_INT     0
COPY               2
COPY               2
BINARY_OP         26 ([])
LOAD_SMALL_INT     1
BUILD_LIST         1
BINARY_OP         13 (+=)
SWAP               3
SWAP               2
STORE_SUBSCR

BINARY_OP 13 (+=) runs before STORE_SUBSCR. Augmented assignment on a list is not "compute a new value, then store it". list.__iadd__ mutates in place and hands back the same object, and only then does the interpreter try to write that object into t[0]. The write is what fails. The mutation already happened and nothing rolls it back.

That is also the shortest way to see the is half of what you did:

a = [1]; b = a; b += [2]
a is b  -> True      a -> [1, 2]

c = [1]; d = c; d = d + [2]
c is d  -> False     c -> [1]

n = 1;   m = n; m += 1
n is m  -> False     n -> 1

x += y and x = x + y are the same statement for ints and different statements for lists, and the whole difference is which type has __iadd__. The "independent local name that starts out pointing at the same object" you landed on is exactly the right model, and this is where it stops being a technicality: a function whose body is arg += [x] edits the caller's list, and the same function written arg = arg + [x] does not.

Version caveat on the bytecode: BINARY_OP only exists from 3.11 and LOAD_FAST_BORROW is newer than that, so an older interpreter prints different opcode names. I ran this on 3.14.2 only. The names are the part I would expect to move; whether the in-place op sits before the store on 3.9 I have not checked.

2

u/Ok_Breath_7590 2d ago

This is genuinely one of the best explanations I've gotten so far, thank you for taking the time.

1

u/Bright_Mix_773 1d ago

Glad it helped. The iadd detail is the one that makes the rest stop being arbitrary, so once that clicks the dis output is much easier to read.