Python from First PrinciplesPost 2 of 20 · 20 of 20 written
- How to use this guide
- 01What Python actually is
- 02Names, objects and references
- 03Built-in types and what they cost
- 04Control flow and expressions
- 05Functions in depth
- 06Iteration and generators
- 07Classes and the data model
- 08Context managers and exceptions
- 09Modules, packages, imports and environments
- 10Type hints and Pydantic
- 11The performance model
- 12Concurrency and parallelism
- 13NumPy: the array model everything else copies
- 14Data and serialization
- 15PyTorch mental model
- 16AI systems engineering in Python
- 17Tooling, debugging and testing
- 18Asking the right questions
- 19Roadmap and build-to-learn projects
- 20Glossary and resources
2. Names, objects and references
Assignment in Python never copies data; it binds a name to an object. This single rule explains most of Python’s “surprising” behavior.
The model
Every object has three properties:
- Identity: which object it is, shown by
id(x). It never changes. - Type: what it can do, shown by
type(x). In practice it never changes either. - Value: its contents. These may change, but only if the type is mutable.
A name (a variable) is a label in a namespace pointing at an object. Names have no type; objects do.
a = [1, 2, 3]
b = a # no copy: b is a second label on the same list
b.append(4)
print(a) # [1, 2, 3, 4]
print(a is b) # True
c = list(a) # a real (shallow) copy: a new list object
print(c is a) # FalseWhenever a snippet confuses you, draw names on the left, objects on the right, and arrows between them. That habit resolves most Python puzzles.
[embedded content: names and objects after b = a and c = list(a)]
Each arrow is a reference. a and b reach the same list, so a change made through either name shows through both; c reaches a separate list that merely has an equal value.
Rebinding versus mutating
These two look alike and behave completely differently:
a = [1, 2]
b = a
b = b + [3] # builds a NEW list and rebinds b; a is unchanged
b = a
b += [3] # list.__iadd__ mutates in place; a changes too!
x = (1, 2)
y = x
y += (3,) # tuples are immutable, so += must rebind; x is still (1, 2)The rule: x = ... always rebinds the name. x.method(...), x[i] = ... and x.attr = ... mutate the object that x points to.
Mutable and immutable types
- Immutable:
int,float,bool,str,bytes,tuple,frozenset,None. - Mutable:
list,dict,set,bytearray, most of your own classes, NumPy arrays, PyTorch tensors.
Immutability belongs to the object, not the name. A tuple of lists cannot gain or lose elements, but the lists inside it can still change. Only hashable objects can be dict keys or set members (Part 3).
is versus ==
a == bcallsa.__eq__(b)and asks: are the values equal?a is bcompares identity and asks: is this the same object?
Use is only for singletons such as None, True, False and sentinel objects: write x is None, not x == None. CPython caches small integers (−5 to 256) and some strings, so is sometimes “works” on them by accident. Never rely on that.
Array twist: in NumPy and PyTorch, arr == 0 returns an array of booleans, not one bool. That is why if arr == 0: raises “the truth value of an array is ambiguous”. Write (arr == 0).all() or .any().
Shallow and deep copies
import copy
grid = [[0, 0], [0, 0]]
shallow = list(grid) # same as grid.copy() or grid[:]
deep = copy.deepcopy(grid)
grid[0][0] = 9
shallow[0][0] # 9: the inner lists are shared
deep[0][0] # 0: everything was duplicated
bad = [[0] * 3] * 3 # three references to ONE inner list
good = [[0] * 3 for _ in range(3)]AI context: teacher = student creates no second model; both names train the same weights. copy.deepcopy(model) creates a real copy (for EMA or teacher models), and doubles the parameter memory.
Passing arguments: call by sharing
A function receives a new name bound to the same object the caller passed. Mutating that object is visible to the caller; rebinding the parameter is not.
def add_item(lst):
lst.append(1) # the caller sees this
def replace(lst):
lst = [99] # local rebinding; the caller sees nothingThis is neither “pass by value” nor “pass by reference” in the C++ sense. It is passing the arrow.
The mutable default argument trap
Default values are evaluated once, when def runs, and stored on the function object. Every call then shares that one object.
def chat(prompt, history=[]): # one list shared by every call
history.append(prompt)
return history
chat("hi"); chat("again") # ['hi', 'again']: history leaks between calls
def chat(prompt, history=None): # the fix
if history is None:
history = []
history.append(prompt)
return historyIn LLM code this bug leaks conversation history between unrelated requests.
Memory: reference counting plus a cycle collector
- CPython counts the references to every object. When the count reaches zero, the object is freed immediately.
del xremoves a name, not an object. The object dies only when nothing references it any more.- Reference cycles (a points to b, b points to a) are freed by a periodic garbage collector (
gc.collect()).
AI context: GPU memory is released when a tensor’s last reference disappears. Appending loss tensors to a list (instead of loss.item()) keeps every step’s computation graph alive and looks like a memory leak. torch.cuda.empty_cache() cannot free tensors you still reference.
Questions to ask:
- Who else holds a reference to this object?
- Does this operation mutate in place or create a new object?
- Is this a copy, or the same object under a second name?