AW Dev Rethought

⚖️ There are two ways of constructing a software design: one way is to make it so simple that there are obviously no deficiencies - C.A.R. Hoare

🧠 Python DeepCuts — 💡 Mutability, Immutability & Object Identity


Description:

Every object in Python has three properties — identity, type, and value.

But not every object behaves the same when modified.

Some can be changed in place (mutable), while others create a new object every time (immutable).

Understanding this difference is essential to avoid subtle bugs and memory surprises.


🧩 Mutable vs Immutable

Mutable objects can change in place — their id() stays the same.

Immutable objects create a new one each time they’re modified.

nums = [1, 2, 3]
print("Before:", id(nums))
nums.append(4)
print("After:", id(nums))   # same ID → mutated in place

name = "AW"
print("\nBefore:", id(name))
name += " Dev"
print("After:", id(name))   # new ID → new object created

🧭 Identity vs Equality

Python separates object identity (is) from value equality (==).

a = [1, 2, 3]
b = [1, 2, 3]

print(a == b)   # True  → same value
print(a is b)   # False → different objects

Two objects can look identical but live at different memory locations.


🧠 Object Caching & Interning

Python optimizes memory by reusing small immutable objects — a technique called interning.

# Integer caching (-5 to 256)
x = 256
y = 256
print(x is y)    # True

a = 257
b = 257
print(a is b)    # False

# String interning
s1 = "hello"
s2 = "hello"
print(s1 is s2)  # True

Small integers and short string literals are cached by default. However, dynamically created strings may not be interned:

x = "".join(["py", "thon"])
y = "".join(["py", "thon"])
print(x is y)    # Usually False

🔍 Shared References in Mutable Objects

When two names reference the same object, changes through one name reflect in the other.

a = [1, 2, 3]
b = a
a.append(99)
print(a, b)        # both changed
print(a is b)      # True

That’s why careless sharing of mutable data structures often leads to hard-to-track bugs.


⚡ Mutable Default Pitfall

A classic gotcha: mutable default arguments.

def add_item(item, container=[]):
    container.append(item)
    return container

print(add_item("A"))
print(add_item("B"))  # accumulates across calls

Fix:

def add_item_fixed(item, container=None):
    if container is None:
        container = []
    container.append(item)
    return container

🧬 Reference Counting

Every object tracks how many references point to it.

import sys

x = [1, 2, 3]
print(sys.getrefcount(x))  # 2 (x + function argument)
y = x
print(sys.getrefcount(x))  # 3
del y
print(sys.getrefcount(x))  # back to 2

This is the basis for Python’s memory management and garbage collection.


✅ Key Points

  • Mutable → can change in place (same identity).
  • Immutable → any change creates a new object.
  • ✅ is checks identity; == checks value.
  • ✅ Python reuses small immutable objects through interning.
  • 🧠 Shared references can lead to subtle bugs if not handled carefully.

Code Snippet:

import sys      # to inspect object IDs and reference counts


# Mutable example
nums = [1, 2, 3]
print("Before mutation:", nums, "id:", id(nums))
nums.append(4)
print("After mutation:", nums, "id:", id(nums))     # same ID

# Immutable example
name = "AW"
print("\nBefore change:", name, "id:", id(name))
name += " Dev"
print("After change:", name, "id:", id(name))       # new ID


a = [1, 2, 3]
b = [1, 2, 3]

print("a == b:", a == b)        # True → same value
print("a is b:", a is b)        # False → different objects
print("id(a):", id(a))
print("id(b):", id(b))


# Small integers (-5 to 256) are cached and reused
x = 256
y = 256
print("x is y (256):", x is y)       # True → cached

a = 257
b = 257
print("a is b (257):", a is b)       # False → new objects

# String interning for small constants
s1 = "hello"
s2 = "hello"
print("\ns1 is s2:", s1 is s2)       # True → interned by Python

# Dynamic strings are not always interned
x = "".join(["py", "thon"])
y = "".join(["py", "thon"])
print("x is y:", x is y)             # Might be False


a = [1, 2, 3]
b = a
print("Before mutation:", a, b)
a.append(99)
print("After mutation:", a, b)     # both changed
print("Same object:", a is b)


def add_item(item, container=[]):     # ⚠️ BAD: default is shared
    container.append(item)
    return container

print(add_item("A"))
print(add_item("B"))     # unexpected accumulation
print(add_item("C"))

# Correct approach
def add_item_fixed(item, container=None):
    if container is None:
        container = []
    container.append(item)
    return container

print("\nFixed version:")
print(add_item_fixed("A"))
print(add_item_fixed("B"))


x = [1, 2, 3]
print("Ref count for x:", sys.getrefcount(x))   # +1 because of function argument
y = x
print("Ref count after aliasing:", sys.getrefcount(x))
del y
print("After deleting y:", sys.getrefcount(x))

Link copied!

Comments

Add Your Comment

Comment Added!