Skip to content

Latest commit

 

History

History
611 lines (443 loc) · 12.9 KB

File metadata and controls

611 lines (443 loc) · 12.9 KB

Lab 2.1: Python Data Structures and References

Overview

Python's data structures are powerful, but they have behavior that surprises many developers - especially around copying and references. Understanding how Python handles objects in memory is critical for writing correct code.

In this lab, you'll learn:

  • Core data structures: List, Dict, Tuple, Set
  • The difference between mutable and immutable types
  • Why assignment doesn't create copies
  • When to use copy() vs deepcopy()
  • How functions modify objects in-place
  • Sets and hashability (clearing up a common misconception)

Part 1: Core Data Structures Overview

Lists - Ordered, Mutable Collections

# Launch Python REPL
python

>>> # Create lists
>>> numbers = [1, 2, 3, 4, 5]
>>> mixed = [1, "hello", 3.14, True]
>>> nested = [[1, 2], [3, 4], [5, 6]]

>>> # Common operations
>>> numbers.append(6)
>>> numbers
[1, 2, 3, 4, 5, 6]

>>> numbers.pop()
6

>>> numbers[0] = 100  # Modify by index
>>> numbers
[100, 2, 3, 4, 5]

>>> len(numbers)
5

>>> 3 in numbers
True

>>> # Slicing - [start:stop:step] (stop is exclusive)
>>> nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> nums[2:5]      # Index 2 to 4 (5 is exclusive)
[2, 3, 4]
>>> nums[:3]       # First 3 items
[0, 1, 2]
>>> nums[7:]       # From index 7 to end
[7, 8, 9]
>>> nums[::2]      # Every 2nd item
[0, 2, 4, 6, 8]
>>> nums[::-1]     # Reverse (step backwards)
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

>>> # Slicing works with strings and tuples too
>>> "hello"[1:4]
'ell'
>>> (10, 20, 30, 40, 50)[::2]
(10, 30, 50)

Dictionaries - Key-Value Pairs, Mutable

>>> # Create dictionaries
>>> server = {
...     "hostname": "web-01",
...     "ip": "192.168.1.10",
...     "port": 8080,
...     "active": True
... }

>>> # Access and modify
>>> server["hostname"]
'web-01'

>>> server["port"] = 443
>>> server["location"] = "us-east-1"  # Add new key

>>> server.keys()
dict_keys(['hostname', 'ip', 'port', 'active', 'location'])

>>> server.values()
dict_values(['web-01', '192.168.1.10', 443, True, 'us-east-1'])

>>> server.get("hostname")
'web-01'

>>> server.get("missing", "default")
'default'

Tuples - Ordered, Immutable Collections

>>> # Create tuples
>>> coordinates = (10.5, 20.3)
>>> config = ("localhost", 8080, "admin")

>>> # Access
>>> coordinates[0]
10.5

>>> # Tuples are immutable - this fails!
>>> coordinates[0] = 15
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

>>> # Unpacking
>>> host, port, user = config
>>> host
'localhost'

Sets - Unordered, Unique Elements, Mutable

  • Objects inside the set must be Hashable. This is the same for Dictionary Keys.
>>> # Create sets
>>> numbers = {1, 2, 3, 4, 5}
>>> duplicates = {1, 2, 2, 3, 3, 3}
>>> duplicates
{1, 2, 3}  # Duplicates removed!

>>> # Set operations
>>> numbers.add(6)
>>> numbers.remove(1)
>>> numbers
{2, 3, 4, 5, 6}

>>> 3 in numbers
True

>>> # Set math
>>> a = {1, 2, 3, 4}
>>> b = {3, 4, 5, 6}
>>> a & b  # Intersection
{3, 4}
>>> a | b  # Union
{1, 2, 3, 4, 5, 6}
>>> a - b  # Difference
{1, 2}

>>> # Hashable vs Unhashable
>>> hash((1, 2, 3))  # Tuples are hashable
2528502973977326415
>>> {(1, 2), (3, 4)}  # Tuples can be in sets
{(1, 2), (3, 4)}

>>> hash([1, 2, 3])  # Lists are NOT hashable
TypeError: unhashable type: 'list'
>>> {[1, 2], [3, 4]}  # Lists cannot be in sets
TypeError: unhashable type: 'list'

Exit the REPL for now:

>>> exit()

Part 2: The Reference Problem (The Big Gotcha!)

This is where most bugs come from. Let's see it in action.

Step 1: Primitives Work as Expected

Create reference_demo.py:

# Primitives (int, float, str, bool) create copies
x = 10
y = x
y = 20

print(f"x = {x}")  # x is still 10
print(f"y = {y}")  # y is 20

# Strings too
name1 = "Alice"
name2 = name1
name2 = "Bob"

print(f"name1 = {name1}")  # Still "Alice"
print(f"name2 = {name2}")  # Now "Bob"

Run it:

python reference_demo.py

Expected output:

x = 10
y = 20
name1 = Alice
name2 = Bob

This works because primitives are immutable. Assignment creates a new value.

Step 2: Collections DON'T Create Copies!

Create list_reference.py:

# Lists - this is the GOTCHA!
list1 = [1, 2, 3]
list2 = list1  # This does NOT create a copy!

# Modify list2
list2.append(4)

print(f"list1 = {list1}")  # SURPRISE! list1 changed too!
print(f"list2 = {list2}")

# They're the same object!
print(f"list1 is list2: {list1 is list2}") # is compares memory locations
print(f"id(list1) = {id(list1)}") # id prints memory location
print(f"id(list2) = {id(list2)}")

Run it:

python list_reference.py

Output:

list1 = [1, 2, 3, 4]
list2 = [1, 2, 3, 4]
list1 is list2: True
id(list1) = 140234567890123
id(list2) = 140234567890123  # Same memory address!

BOTH LISTS CHANGED! list2 = list1 created a reference, not a copy.

Step 3: The Solution - Use .copy()

Create list_copy.py:

# Method 1: .copy()
list1 = [1, 2, 3]
list2 = list1.copy()

list2.append(4)

print(f"list1 = {list1}")  # [1, 2, 3] - unchanged!
print(f"list2 = {list2}")  # [1, 2, 3, 4]
print(f"list1 is list2: {list1 is list2}")

print()

# Method 2: list() constructor
list3 = [10, 20, 30]
list4 = list(list3)

list4.append(40)

print(f"list3 = {list3}")  # [10, 20, 30]
print(f"list4 = {list4}")  # [10, 20, 30, 40]

print()

# Method 3: Slice notation
list5 = [100, 200, 300]
list6 = list5[:]

list6.append(400)

print(f"list5 = {list5}")  # [100, 200, 300]
print(f"list6 = {list6}")  # [100, 200, 300, 400]

Run it:

python list_copy.py

Step 4: Dictionaries Have the Same Problem

Create dict_reference.py:

# Dictionaries - same issue!
server1 = {"hostname": "web-01", "port": 8080}
server2 = server1  # Reference, not copy!

server2["port"] = 443

print(f"server1 = {server1}")  # Port changed to 443!
print(f"server2 = {server2}")

print()

# Solution: use .copy()
server3 = {"hostname": "db-01", "port": 5432}
server4 = server3.copy()

server4["port"] = 3306

print(f"server3 = {server3}")  # Port still 5432
print(f"server4 = {server4}")  # Port is 3306

Run it:

python dict_reference.py

Part 3: Deep Copy for Nested Structures

Shallow copy (.copy()) only copies the top level. Nested objects are still references!

Step 1: The Nested Object Problem

Create shallow_copy_problem.py:

# Nested lists - shallow copy isn't enough!
list1 = [[1, 2], [3, 4]]
list2 = list1.copy()  # Shallow copy

# Modify a nested list
list2[0].append(999)

print(f"list1 = {list1}")  # SURPRISE! list1 changed!
print(f"list2 = {list2}")

# The outer lists are different objects
print(f"list1 is list2: {list1 is list2}")  # False

# But the inner lists are the same!
print(f"list1[0] is list2[0]: {list1[0] is list2[0]}")  # True!

Run it:

python shallow_copy_problem.py

Output:

list1 = [[1, 2, 999], [3, 4]]  # Inner list changed!
list2 = [[1, 2, 999], [3, 4]]
list1 is list2: False
list1[0] is list2[0]: True  # Inner lists are references!

Step 2: Solution - Use deepcopy()

Create deep_copy_solution.py:

import copy

# Deep copy copies EVERYTHING recursively
list1 = [[1, 2], [3, 4]]
list2 = copy.deepcopy(list1)

# Modify nested list
list2[0].append(999)

print(f"list1 = {list1}")  # Unchanged!
print(f"list2 = {list2}")  # Only list2 changed

print(f"list1[0] is list2[0]: {list1[0] is list2[0]}")  # False now!

print()

# Works with nested dictionaries too
config1 = {
    "database": {
        "host": "localhost",
        "port": 5432
    },
    "cache": {
        "host": "localhost",
        "port": 6379
    }
}

config2 = copy.deepcopy(config1)
config2["database"]["port"] = 3306

print(f"config1 port: {config1['database']['port']}")  # Still 5432
print(f"config2 port: {config2['database']['port']}")  # Changed to 3306

Run it:

python deep_copy_solution.py

Rule of thumb:

  • Simple structures: Use .copy()
  • Nested structures: Use copy.deepcopy()

Part 4: Functions Modify Objects In-Place

When you pass an object to a function, the function gets a reference to the original object!

Step 1: Functions Modify Lists

Create function_modify.py:

def add_item(my_list, item):
    """Add item to list"""
    my_list.append(item)
    print(f"Inside function: {my_list}")

# The function modifies the original list!
numbers = [1, 2, 3]
print(f"Before: {numbers}")

add_item(numbers, 4)

print(f"After: {numbers}")  # Changed!

Run it:

python function_modify.py

Output:

Before: [1, 2, 3]
Inside function: [1, 2, 3, 4]
After: [1, 2, 3, 4]  # Original list modified!

Step 2: Avoiding Unwanted Modifications

Create function_safe.py:

def add_item_safe(my_list, item):
    """Add item to a COPY of the list"""
    new_list = my_list.copy()
    new_list.append(item)
    return new_list

numbers = [1, 2, 3]
print(f"Before: {numbers}")

result = add_item_safe(numbers, 4)

print(f"After: {numbers}")    # Unchanged
print(f"Result: {result}")    # New list with item added

print()

# Alternative: create copy when calling
def add_item(my_list, item):
    my_list.append(item)
    return my_list

numbers2 = [10, 20, 30]
result2 = add_item(numbers2.copy(), 40)  # Pass a copy!

print(f"numbers2: {numbers2}")  # Unchanged
print(f"result2: {result2}")

Run it:

python function_safe.py

Key insight: This is why many functions return None - they modify in-place!

  • list.append() returns None (modifies in-place)
  • list.sort() returns None (modifies in-place)
  • sorted() returns a new list (doesn't modify original)

Part 5: Hands-On Challenge

Your task: Create a server inventory manager

Requirements:

  1. Store server information in nested dictionaries
  2. Implement functions that:
    • Add a server (without modifying original inventory)
    • Remove a server (without modifying original inventory)
    • Get unique list of all regions (using sets)
    • Update server status (safely)
  3. Use copy.deepcopy() where needed
  4. Demonstrate that original inventory isn't modified

Example data structure:

inventory = {
    "web-servers": [
        {"hostname": "web-01", "region": "us-east", "status": "active"},
        {"hostname": "web-02", "region": "us-west", "status": "active"},
    ],
    "db-servers": [
        {"hostname": "db-01", "region": "us-east", "status": "active"},
    ]
}

Hints:

  • Use copy.deepcopy() for nested dictionaries
  • Use sets to collect unique regions
  • Test that the original inventory is unchanged

Success Criteria

You've completed this lab when you can:

  • Explain the difference between mutable and immutable types
  • Understand why list2 = list1 creates a reference, not a copy
  • Use .copy() for shallow copies
  • Use copy.deepcopy() for nested structures
  • Predict how functions will modify parameters
  • Explain why sets require hashable (not just immutable) types
  • Write functions that don't accidentally modify collections

Key Takeaways

What you learned:

  • Reference behavior - Assignment creates references for collections
  • Shallow vs deep copy - .copy() vs copy.deepcopy()
  • Function parameters - Collections are modified in-place
  • Hashability - Sets need hashable items (related to, but not the same as immutability)
  • Sets = Dict keys - Similar implementation, similar constraints

Why this matters:

  • Prevents hard-to-debug mutations in production code
  • Essential for writing correct configuration management tools
  • Critical for API response handling and data processing
  • Avoids race conditions in concurrent code
  • Common interview topic and source of real-world bugs

Next steps: Use these skills when working with JSON configs, API responses, and automation scripts.


Additional Resources


Quick Reference

# Creating collections
my_list = [1, 2, 3]
my_dict = {"key": "value"}
my_tuple = (1, 2, 3)  # Immutable
my_set = {1, 2, 3}    # Unique, unordered

# Copying
shallow = my_list.copy()          # Top level only
deep = copy.deepcopy(my_list)     # Recursive copy

# Checking identity
list1 is list2        # Same object?
id(list1)             # Memory address

# Sets
unique = set([1, 2, 2, 3])  # {1, 2, 3}
frozen = frozenset([1, 2])  # Immutable set

# Hashable check
hash(42)         # Works - int is hashable
hash([1, 2])     # Fails - list is not hashable

Congratulations! You now understand Python's reference behavior and data structures - knowledge that will save you from countless debugging sessions!