Skip to content

Heap Storage

Object Allocation

1. All Objects

Everything on heap:

x = 42              # int on heap
s = "hello"         # str on heap
lst = [1, 2, 3]     # list on heap

2. Dynamic Growth

# Heap grows as needed
data = []
for i in range(1000000):
    data.append(i)  # Heap expands

Object Layout

1. Continuous Memory

# List items contiguous
lst = [1, 2, 3, 4, 5]

# In memory:
# [header][ptr1][ptr2][ptr3][ptr4][ptr5]

2. Scattered Objects

# Objects anywhere on heap
a = [1, 2]
b = "hello"
c = {'key': 'value'}

# Memory not sequential

Memory Pools

1. Small Objects

Python uses memory pools:

# Small objects (< 512 bytes)
# Allocated from pools
# Faster than malloc

x = 42              # From pool
s = "hi"            # From pool

2. Large Objects

# Large objects
# Direct malloc
big = [0] * 1000000

Fragmentation

1. Can Occur

# Create many objects
data = [[] for _ in range(1000)]

# Delete some
for i in range(0, 1000, 2):
    del data[i]

# Holes in memory

2. GC Helps

import gc

# Force collection
gc.collect()

# Helps reduce fragmentation

Object Sizes

1. Check Size

import sys

print(sys.getsizeof(42))        # Small
print(sys.getsizeof([1, 2, 3])) # Larger
print(sys.getsizeof("hello"))   # String

2. Container Overhead

# List overhead
empty_list = []
print(sys.getsizeof(empty_list))  # ~56 bytes

# Plus items
lst = [1, 2, 3]
print(sys.getsizeof(lst))  # ~80 bytes

Lifetime

1. Until GC'd

x = [1, 2, 3]       # Created
# Lives on heap
del x               # Reference removed
# Eventually GC'd

2. Reference Counting

import sys

x = [1, 2, 3]
print(sys.getrefcount(x))  # 2

y = x
print(sys.getrefcount(x))  # 3

del y
print(sys.getrefcount(x))  # 2

Heap vs Stack

1. Comparison

def function():
    # Local name on stack
    x = [1, 2, 3]   # Object on heap

    # x removed from stack at return
    # Object stays until GC'd
    return x

Summary

1. Heap

  • All objects stored here
  • Dynamic size
  • Slower than stack
  • Manual/GC management

2. Memory Management

  • Pools for small objects
  • Direct allocation for large
  • Fragmentation possible
  • GC handles cleanup

Runnable Example: mutable_types.py

"""
03_intermediate_mutable_types.py

TOPIC: Mutable Types and Reference Behavior
LEVEL: Intermediate
DURATION: 60-75 minutes

LEARNING OBJECTIVES:
1. Understand mutable types (list, dict, set) and their memory behavior
2. Learn about reference semantics and aliasing
3. Explore the dangers of unintended sharing
4. Master the difference between mutation and reassignment
5. Understand how mutable types behave as function parameters

KEY CONCEPTS:
- Mutable objects can be changed in-place
- Multiple variables can reference the same mutable object (aliasing)
- Mutations affect all references to the same object
- Mutable types: list, dict, set, bytearray, custom objects
- Mutable default arguments (common pitfall)
"""

# ============================================================================
# SECTION 1: Introduction to Mutable Types
# ============================================================================

if __name__ == "__main__":

    print("=" * 70)
    print("SECTION 1: Understanding Mutability")
    print("=" * 70)

    # MUTABLE means "can be changed"
    # Mutable objects can be modified IN-PLACE without creating a new object

    # Let's demonstrate with a list:
    my_list = [1, 2, 3]
    print(f"Initial list: {my_list}, id = {id(my_list)}")

    # Modify the list in-place
    original_id = id(my_list)
    my_list.append(4)  # Modifies the SAME object

    print(f"After append: {my_list}, id = {id(my_list)}")
    print(f"Same object? {original_id == id(my_list)}")
    print("Yes! The object was modified, not replaced.")

    # CONTRAST with immutable types:
    my_int = 10
    original_id = id(my_int)
    my_int = my_int + 1  # Creates a NEW object

    print(f"\nFor immutable int, same object? {original_id == id(my_int)}")
    print("No! A new object was created.")

    # ============================================================================
    # SECTION 2: Common Mutable Types
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 2: Mutable Types in Python")
    print("=" * 70)

    # Python's mutable built-in types:
    mutable_examples = {
        "list": [1, 2, 3],
        "dict": {"key": "value"},
        "set": {1, 2, 3},
        "bytearray": bytearray(b"hello"),
    }

    print("\nDemonstrating mutable types:")
    for type_name, value in mutable_examples.items():
        print(f"  {type_name:12} : {value} (type: {type(value).__name__})")

    # All of these can be modified in-place!

    # ============================================================================
    # SECTION 3: Aliasing - Multiple Names for the Same Object
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 3: Aliasing with Mutable Objects")
    print("=" * 70)

    # Aliasing occurs when multiple variables reference the same object
    # This is CRUCIAL to understand with mutable types!

    list1 = [1, 2, 3]
    list2 = list1  # list2 is an ALIAS for list1 (same object!)

    print(f"list1 = {list1}, id = {id(list1)}")
    print(f"list2 = {list2}, id = {id(list2)}")
    print(f"Are they the same object? {list1 is list2}")

    # Now modify through list1:
    list1.append(4)

    print(f"\nAfter list1.append(4):")
    print(f"list1 = {list1}")
    print(f"list2 = {list2}")  # list2 also changed!

    print("\nIMPORTANT: Both variables reference the same object!")
    print("Modifying through one affects the other.")

    # MEMORY MODEL:
    # STACK           HEAP
    # ┌───────┐      ┌─────────────┐
    # │ list1─┼─────>│ [1, 2, 3, 4]│
    # │ list2─┼─────>│             │
    # └───────┘      └─────────────┘

    # ============================================================================
    # SECTION 4: Mutation vs. Reassignment
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 4: Mutation vs. Reassignment")
    print("=" * 70)

    # MUTATION: Changing the object in-place
    # REASSIGNMENT: Making a variable reference a different object

    # Setup:
    list_a = [1, 2, 3]
    list_b = list_a

    print("Initial state:")
    print(f"  list_a = {list_a}, id = {id(list_a)}")
    print(f"  list_b = {list_b}, id = {id(list_b)}")

    # MUTATION: Modifies the shared object
    list_a.append(4)
    print("\nAfter MUTATION (list_a.append(4)):")
    print(f"  list_a = {list_a}, id = {id(list_a)}")
    print(f"  list_b = {list_b}, id = {id(list_b)}")
    print("  Both changed! (same object)")

    # REASSIGNMENT: Changes what list_a references
    list_a = [10, 20, 30]
    print("\nAfter REASSIGNMENT (list_a = [10, 20, 30]):")
    print(f"  list_a = {list_a}, id = {id(list_a)}")
    print(f"  list_b = {list_b}, id = {id(list_b)}")
    print("  Only list_a changed! (different objects now)")

    # ============================================================================
    # SECTION 5: Lists - In-Place Modifications
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 5: List Mutation Methods")
    print("=" * 70)

    # Methods that MUTATE the list (modify in-place):
    nums = [3, 1, 4, 1, 5]
    print(f"Original: nums = {nums}, id = {id(nums)}")

    original_id = id(nums)

    # Mutating operations:
    nums.append(9)          # Add to end
    print(f"After append(9): {nums}, same object? {id(nums) == original_id}")

    nums.extend([2, 6])     # Add multiple elements
    print(f"After extend([2,6]): {nums}, same object? {id(nums) == original_id}")

    nums.insert(0, 0)       # Insert at index
    print(f"After insert(0,0): {nums}, same object? {id(nums) == original_id}")

    nums.remove(1)          # Remove first occurrence
    print(f"After remove(1): {nums}, same object? {id(nums) == original_id}")

    nums.sort()             # Sort in-place
    print(f"After sort(): {nums}, same object? {id(nums) == original_id}")

    nums.reverse()          # Reverse in-place
    print(f"After reverse(): {nums}, same object? {id(nums) == original_id}")

    print("\nAll operations modified the SAME object!")

    # ============================================================================
    # SECTION 6: Dictionaries - In-Place Modifications
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 6: Dictionary Mutation")
    print("=" * 70)

    person = {"name": "Alice", "age": 30}
    print(f"Original: person = {person}, id = {id(person)}")

    original_id = id(person)

    # Mutating operations:
    person["city"] = "NYC"                    # Add new key
    print(f"After adding key: {person}, same object? {id(person) == original_id}")

    person["age"] = 31                        # Modify existing key
    print(f"After modifying: {person}, same object? {id(person) == original_id}")

    person.update({"country": "USA"})         # Update with dict
    print(f"After update(): {person}, same object? {id(person) == original_id}")

    del person["city"]                        # Delete key
    print(f"After del: {person}, same object? {id(person) == original_id}")

    person.pop("country")                     # Pop key
    print(f"After pop(): {person}, same object? {id(person) == original_id}")

    print("\nAll operations modified the SAME dictionary object!")

    # ============================================================================
    # SECTION 7: Sets - In-Place Modifications
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 7: Set Mutation")
    print("=" * 70)

    numbers = {1, 2, 3}
    print(f"Original: numbers = {numbers}, id = {id(numbers)}")

    original_id = id(numbers)

    # Mutating operations:
    numbers.add(4)                           # Add element
    print(f"After add(4): {numbers}, same object? {id(numbers) == original_id}")

    numbers.update({5, 6})                   # Add multiple elements
    print(f"After update: {numbers}, same object? {id(numbers) == original_id}")

    numbers.remove(1)                        # Remove element (error if not present)
    print(f"After remove(1): {numbers}, same object? {id(numbers) == original_id}")

    numbers.discard(2)                       # Remove element (no error if absent)
    print(f"After discard(2): {numbers}, same object? {id(numbers) == original_id}")

    print("\nAll operations modified the SAME set object!")

    # ============================================================================
    # SECTION 8: Mutable Objects as Function Parameters
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 8: Mutable Parameters in Functions")
    print("=" * 70)

    def modify_list(lst):
        """
        This function modifies the list parameter in-place
        """
        print(f"  Inside function, before: lst = {lst}, id = {id(lst)}")
        lst.append(100)  # Mutates the SAME object
        print(f"  Inside function, after: lst = {lst}, id = {id(lst)}")

    def reassign_list(lst):
        """
        This function reassigns the local parameter
        """
        print(f"  Inside function, before: lst = {lst}, id = {id(lst)}")
        lst = [100, 200, 300]  # Creates NEW object, rebinds local lst
        print(f"  Inside function, after: lst = {lst}, id = {id(lst)}")

    # Test mutation:
    my_list = [1, 2, 3]
    print(f"Before modify_list(): my_list = {my_list}, id = {id(my_list)}")
    modify_list(my_list)
    print(f"After modify_list(): my_list = {my_list}, id = {id(my_list)}")
    print("The original list was MODIFIED!")

    print()

    # Test reassignment:
    my_list = [1, 2, 3]
    print(f"Before reassign_list(): my_list = {my_list}, id = {id(my_list)}")
    reassign_list(my_list)
    print(f"After reassign_list(): my_list = {my_list}, id = {id(my_list)}")
    print("The original list was NOT affected (local reassignment only)!")

    # ============================================================================
    # SECTION 9: Mutable Default Arguments - DANGEROUS PITFALL!
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 9: Mutable Default Arguments (COMMON BUG!)")
    print("=" * 70)

    # WRONG WAY - DON'T DO THIS:
    def append_to_list_wrong(item, my_list=[]):  # BAD! Mutable default
        """
        BUGGY FUNCTION: Uses mutable default argument
        """
        my_list.append(item)
        return my_list

    # The default list is created ONCE when the function is defined
    # It's shared across all calls that use the default!

    print("Calling with mutable default argument:")
    result1 = append_to_list_wrong(1)
    print(f"  First call: {result1}, id = {id(result1)}")

    result2 = append_to_list_wrong(2)
    print(f"  Second call: {result2}, id = {id(result2)}")

    result3 = append_to_list_wrong(3)
    print(f"  Third call: {result3}, id = {id(result3)}")

    print("\nAll calls share the SAME default list object!")
    print(f"result1 is result2 is result3: {result1 is result2 is result3}")

    # CORRECT WAY:
    def append_to_list_correct(item, my_list=None):  # GOOD! Use None as default
        """
        CORRECT FUNCTION: Uses None as default, creates new list each time
        """
        if my_list is None:
            my_list = []  # Create NEW list each time
        my_list.append(item)
        return my_list

    print("\nCalling with None default and creating new list:")
    result1 = append_to_list_correct(1)
    print(f"  First call: {result1}, id = {id(result1)}")

    result2 = append_to_list_correct(2)
    print(f"  Second call: {result2}, id = {id(result2)}")

    result3 = append_to_list_correct(3)
    print(f"  Third call: {result3}, id = {id(result3)}")

    print("\nEach call gets a NEW list object!")

    # ============================================================================
    # SECTION 10: Nested Mutable Structures
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 10: Nested Mutable Structures")
    print("=" * 70)

    # Lists can contain lists - nested mutable structures
    matrix = [[1, 2], [3, 4], [5, 6]]
    print(f"Original matrix: {matrix}")

    # What if we try to create a copy by aliasing?
    matrix_alias = matrix
    matrix_alias[0][0] = 999  # Modify nested element

    print(f"After modifying through alias:")
    print(f"  matrix = {matrix}")
    print(f"  matrix_alias = {matrix_alias}")
    print("Both changed! They reference the same nested structure.")

    # Even modifying a nested element affects all aliases:
    row = matrix[0]  # Get reference to first row
    print(f"\nrow = {row}, id = {id(row)}")
    print(f"matrix[0] id = {id(matrix[0])}")
    print(f"Same object? {row is matrix[0]}")

    row.append(100)
    print(f"After row.append(100):")
    print(f"  matrix = {matrix}")

    # ============================================================================
    # SECTION 11: Comparing Mutable vs Immutable Behavior
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 11: Side-by-Side Comparison")
    print("=" * 70)

    print("IMMUTABLE (tuple):")
    t1 = (1, 2, 3)
    t2 = t1
    print(f"  t1 = {t1}, id = {id(t1)}")
    print(f"  t2 = {t2}, id = {id(t2)}")
    t1 = t1 + (4,)  # Creates new tuple
    print(f"  After t1 = t1 + (4,):")
    print(f"  t1 = {t1}, id = {id(t1)} (NEW object)")
    print(f"  t2 = {t2}, id = {id(t2)} (unchanged)")

    print("\nMUTABLE (list):")
    l1 = [1, 2, 3]
    l2 = l1
    print(f"  l1 = {l1}, id = {id(l1)}")
    print(f"  l2 = {l2}, id = {id(l2)}")
    l1.append(4)  # Mutates existing list
    print(f"  After l1.append(4):")
    print(f"  l1 = {l1}, id = {id(l1)} (SAME object)")
    print(f"  l2 = {l2}, id = {id(l2)} (CHANGED!)")

    # ============================================================================
    # SECTION 12: When Mutable Types Behave Like Immutable
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 12: Operations That Return New Objects")
    print("=" * 70)

    # Some operations on mutable types return NEW objects instead of mutating

    original_list = [1, 2, 3]
    print(f"Original: {original_list}, id = {id(original_list)}")

    # These create NEW lists:
    sorted_list = sorted(original_list)  # sorted() returns new list
    print(f"sorted(list): {sorted_list}, id = {id(sorted_list)} (NEW)")

    concatenated = original_list + [4, 5]  # + creates new list
    print(f"list + [4,5]: {concatenated}, id = {id(concatenated)} (NEW)")

    sliced = original_list[:]  # Slicing creates new list
    print(f"list[:]: {sliced}, id = {id(sliced)} (NEW)")

    print(f"\nOriginal unchanged: {original_list}, id = {id(original_list)}")

    # Compare to in-place methods:
    original_list.sort()  # Mutates in place
    print(f"After .sort(): {original_list}, id = {id(original_list)} (SAME)")

    # ============================================================================
    # SECTION 13: Practical Implications
    # ============================================================================

    print("\n" + "=" * 70)
    print("SECTION 13: Real-World Implications")
    print("=" * 70)

    print("""
    WHEN MUTABILITY CAUSES PROBLEMS:

    1. Unexpected Shared State:
       - Function modifies a list you passed
       - Multiple parts of code share the same object
       - Debugging becomes difficult

    2. Mutable Default Arguments:
       - Default list/dict is shared across function calls
       - Leads to subtle bugs that are hard to track

    3. Concurrent Programming:
       - Mutable objects are not thread-safe
       - Need locks or synchronization

    WHEN MUTABILITY IS USEFUL:

    1. Performance:
       - In-place modification is faster
       - Avoids creating many intermediate objects

    2. Shared State (intentional):
       - Multiple parts of code need access to same data
       - Caching mechanisms

    3. Data Structures:
       - Stacks, queues, graphs often need mutation
       - Building complex structures incrementally
    """)

    # ============================================================================
    # SECTION 14: Key Takeaways
    # ============================================================================

    print("\n" + "=" * 70)
    print("KEY TAKEAWAYS")
    print("=" * 70)

    print("""
    1. Mutable types: list, dict, set, bytearray, custom objects
    2. Mutable objects can be modified in-place
    3. Multiple variables can reference the same mutable object (aliasing)
    4. Mutations affect all references to the object
    5. Mutation ≠ Reassignment (different operations!)
    6. Mutable objects passed to functions can be modified by the function
    7. NEVER use mutable default arguments (use None instead)
    8. Some operations return new objects (sorted, +, [:])
    9. Other operations mutate in-place (.sort(), .append(), etc.)
    10. Understanding mutability prevents many common bugs!

    GOLDEN RULE: 
    When in doubt, check if id() changes after an operation!
    """)

    # ============================================================================
    # PRACTICE EXERCISES
    # ============================================================================

    print("\n" + "=" * 70)
    print("PRACTICE EXERCISES")
    print("=" * 70)

    print("""
    Try these exercises to master mutable types:

    1. Create a function that accidentally modifies a list argument.
       Then fix it to avoid the modification.

    2. Demonstrate the mutable default argument bug with a dictionary.

    3. Create a 3x3 matrix (list of lists). Create an alias and modify
       one element. What happens to both references?

    4. Write a function that takes a list and returns a modified version
       WITHOUT changing the original list.

    5. Compare the performance of in-place sort (.sort()) vs. creating
       a new sorted list (sorted()) for a large list.

    6. Create a scenario where aliasing is INTENTIONAL and beneficial.

    See exercises_02_intermediate.py for complete practice problems!
    """)