Prerequisites
- Basic understanding of programming concepts ๐
- Python installation (3.8+) ๐
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand the concept fundamentals ๐ฏ
- Apply the concept in real projects ๐๏ธ
- Debug common issues ๐
- Write clean, Pythonic code โจ
๐ฏ Introduction
Welcome to this exciting tutorial on functional data structures and persistent types! ๐ Have you ever wondered how to work with data that never changes, yet still allows you to create new versions efficiently? Thatโs the magic of persistent data structures!
In this guide, weโll explore how persistent types can transform your Python development experience. Whether youโre building concurrent applications ๐, implementing undo/redo functionality ๐, or creating immutable data pipelines ๐, understanding persistent data structures is essential for writing robust, functional code.
By the end of this tutorial, youโll feel confident using persistent types in your own projects! Letโs dive into this fascinating world! ๐โโ๏ธ
๐ Understanding Persistent Data Structures
๐ค What are Persistent Data Structures?
Persistent data structures are like a time machine for your data ๐ฐ๏ธ. Think of them as creating a new photo ๐ธ every time you make a change, rather than erasing and redrawing on the same canvas. The original never changes, but you get a new version with your modifications!
In Python terms, persistent data structures maintain their previous versions when modified. This means you can:
- โจ Keep all historical versions of your data
- ๐ Share data safely between threads without locks
- ๐ก๏ธ Implement undo/redo with ease
- ๐ฏ Debug by examining past states
๐ก Why Use Persistent Types?
Hereโs why developers love persistent data structures:
- Immutability by Design ๐: Data never changes unexpectedly
- Thread Safety ๐: No race conditions or locks needed
- Time Travel โฐ: Access any previous version instantly
- Memory Efficiency ๐พ: Structural sharing saves space
- Functional Programming ๐จ: Perfect for pure functions
Real-world example: Imagine building a collaborative text editor ๐. With persistent data structures, multiple users can edit simultaneously without conflicts, and you get version history for free!
๐ง Basic Syntax and Usage
๐ Simple Example with pyrsistent
Letโs start with a friendly example using the pyrsistent
library:
# ๐ Hello, Persistent Types!
from pyrsistent import pvector, pmap, pset
# ๐จ Creating a persistent vector
numbers = pvector([1, 2, 3, 4, 5])
print(f"Original: {numbers}") # ๐ Original: pvector([1, 2, 3, 4, 5])
# โจ Adding an element creates a new version
new_numbers = numbers.append(6)
print(f"New version: {new_numbers}") # ๐ pvector([1, 2, 3, 4, 5, 6])
print(f"Original unchanged: {numbers}") # ๐ก๏ธ pvector([1, 2, 3, 4, 5])
# ๐ฏ Creating a persistent map (dictionary)
user = pmap({
'name': 'Alice',
'score': 100,
'level': 1
})
# ๐ Updating creates a new version
leveled_up_user = user.set('level', 2).set('score', 150)
print(f"Original user: {user}") # ๐ค Original state preserved
print(f"Leveled up: {leveled_up_user}") # ๐ New state created
๐ก Explanation: Notice how we use emojis in comments to make code more readable! Each modification returns a new version while preserving the original.
๐ฏ Common Patterns
Here are patterns youโll use daily:
from pyrsistent import pvector, pmap, freeze, thaw
# ๐๏ธ Pattern 1: Converting from regular Python types
regular_list = [1, 2, 3, 4, 5]
persistent_list = freeze(regular_list) # ๐ง Freeze it!
# ๐จ Pattern 2: Building complex structures
game_state = pmap({
'player': pmap({
'name': 'Hero',
'hp': 100,
'inventory': pvector(['sword', 'shield'])
}),
'enemies': pvector([
pmap({'type': 'goblin', 'hp': 20}),
pmap({'type': 'dragon', 'hp': 200})
])
})
# ๐ Pattern 3: Transforming data
def take_damage(state, damage):
current_hp = state['player']['hp']
return state.set_in(['player', 'hp'], max(0, current_hp - damage))
# ๐ซ Each transformation creates a new state
damaged_state = take_damage(game_state, 30)
print(f"Original HP: {game_state['player']['hp']}") # ๐ 100
print(f"After damage: {damaged_state['player']['hp']}") # ๐ 70
๐ก Practical Examples
๐ Example 1: Shopping Cart with History
Letโs build a shopping cart that tracks all changes:
from pyrsistent import pmap, pvector
from datetime import datetime
# ๐๏ธ Define our shopping cart system
class PersistentCart:
def __init__(self):
self.history = pvector([pmap({
'items': pvector([]),
'timestamp': datetime.now(),
'action': 'cart_created'
})])
@property
def current(self):
# ๐ฏ Always get the latest state
return self.history[-1]
def add_item(self, product, quantity=1):
# โ Add item to cart
current_items = self.current['items']
# ๐ Check if item already exists
for i, item in enumerate(current_items):
if item['product'] == product:
# ๐ Update quantity
updated_item = item.set('quantity', item['quantity'] + quantity)
new_items = current_items.set(i, updated_item)
break
else:
# ๐ Add new item
new_items = current_items.append(pmap({
'product': product,
'quantity': quantity
}))
# ๐ธ Create new snapshot
new_state = pmap({
'items': new_items,
'timestamp': datetime.now(),
'action': f'added_{product}'
})
self.history = self.history.append(new_state)
print(f"โ
Added {product} to cart!")
def remove_item(self, product):
# โ Remove item from cart
current_items = self.current['items']
new_items = pvector([item for item in current_items
if item['product'] != product])
new_state = pmap({
'items': new_items,
'timestamp': datetime.now(),
'action': f'removed_{product}'
})
self.history = self.history.append(new_state)
print(f"๐๏ธ Removed {product} from cart!")
def undo(self):
# โช Go back to previous state
if len(self.history) > 1:
self.history = self.history[:-1]
print("โฉ๏ธ Undid last action!")
else:
print("โ ๏ธ Nothing to undo!")
def show_cart(self):
# ๐ Display current cart
print("\n๐ Current Cart:")
total = 0
for item in self.current['items']:
price = {'apple': 0.50, 'banana': 0.30, 'coffee': 4.99}.get(
item['product'], 1.00
)
subtotal = price * item['quantity']
total += subtotal
print(f" {item['product']} x{item['quantity']} = ${subtotal:.2f}")
print(f"๐ฐ Total: ${total:.2f}")
def show_history(self):
# ๐ Show action history
print("\n๐ Cart History:")
for i, state in enumerate(self.history):
print(f" {i}: {state['action']} at {state['timestamp'].strftime('%H:%M:%S')}")
# ๐ฎ Let's use it!
cart = PersistentCart()
cart.add_item('apple', 3)
cart.add_item('banana', 2)
cart.add_item('coffee', 1)
cart.show_cart()
cart.remove_item('banana')
cart.show_cart()
# ๐ฑ Oops! Let's undo that
cart.undo()
cart.show_cart()
cart.show_history()
๐ฏ Try it yourself: Add a redo
method and implement cart merging for multiple users!
๐ฎ Example 2: Game State Manager
Letโs make a game with save states:
from pyrsistent import pmap, pvector, pset
import copy
# ๐ Game state manager with persistent data
class GameStateManager:
def __init__(self):
self.states = {} # ๐พ Named save states
self.current_state = self._create_initial_state()
self.checkpoint_stack = pvector([self.current_state])
def _create_initial_state(self):
# ๐ฎ Initial game state
return pmap({
'player': pmap({
'name': 'Hero',
'hp': 100,
'mp': 50,
'level': 1,
'exp': 0,
'position': pmap({'x': 0, 'y': 0}),
'inventory': pvector(['potion', 'sword']),
'skills': pset(['slash', 'heal'])
}),
'world': pmap({
'current_area': 'town',
'unlocked_areas': pset(['town']),
'npcs_met': pset([]),
'quests_completed': pset([])
}),
'stats': pmap({
'play_time': 0,
'enemies_defeated': 0,
'items_collected': 0
})
})
def move_player(self, dx, dy):
# ๐ถ Move player and create checkpoint
old_pos = self.current_state['player']['position']
new_pos = pmap({'x': old_pos['x'] + dx, 'y': old_pos['y'] + dy})
self.current_state = self.current_state.set_in(
['player', 'position'], new_pos
)
self._add_checkpoint()
print(f"๐ถ Moved to ({new_pos['x']}, {new_pos['y']})")
def gain_exp(self, amount):
# โญ Gain experience and maybe level up
player = self.current_state['player']
new_exp = player['exp'] + amount
new_level = player['level']
# ๐ Check for level up
if new_exp >= new_level * 100:
new_level += 1
new_exp = new_exp - (player['level'] * 100)
print(f"๐ LEVEL UP! You're now level {new_level}!")
# ๐ช Increase stats
player = player.set('hp', player['hp'] + 10)
player = player.set('mp', player['mp'] + 5)
player = player.set('exp', new_exp).set('level', new_level)
self.current_state = self.current_state.set('player', player)
self._add_checkpoint()
print(f"โญ Gained {amount} exp! (Total: {new_exp})")
def learn_skill(self, skill_name):
# ๐ Learn a new skill
current_skills = self.current_state['player']['skills']
if skill_name not in current_skills:
new_skills = current_skills.add(skill_name)
self.current_state = self.current_state.set_in(
['player', 'skills'], new_skills
)
self._add_checkpoint()
print(f"โจ Learned new skill: {skill_name}!")
else:
print(f"๐ญ You already know {skill_name}")
def save_game(self, slot_name):
# ๐พ Save current state
self.states[slot_name] = self.current_state
print(f"๐พ Game saved to slot: {slot_name}")
def load_game(self, slot_name):
# ๐ Load saved state
if slot_name in self.states:
self.current_state = self.states[slot_name]
self.checkpoint_stack = pvector([self.current_state])
print(f"๐ Game loaded from slot: {slot_name}")
else:
print(f"โ No save found in slot: {slot_name}")
def _add_checkpoint(self):
# ๐ธ Add automatic checkpoint
self.checkpoint_stack = self.checkpoint_stack.append(self.current_state)
# Keep only last 10 checkpoints
if len(self.checkpoint_stack) > 10:
self.checkpoint_stack = self.checkpoint_stack[-10:]
def rewind(self, steps=1):
# โช Rewind to previous checkpoint
if len(self.checkpoint_stack) > steps:
self.checkpoint_stack = self.checkpoint_stack[:-steps]
self.current_state = self.checkpoint_stack[-1]
print(f"โช Rewound {steps} step(s)")
else:
print("โ ๏ธ Can't rewind that far!")
def show_status(self):
# ๐ Display current status
player = self.current_state['player']
print(f"\n๐ฎ {player['name']} - Level {player['level']}")
print(f"โค๏ธ HP: {player['hp']} | ๐ MP: {player['mp']}")
print(f"โญ EXP: {player['exp']}/{player['level'] * 100}")
print(f"๐ Position: ({player['position']['x']}, {player['position']['y']})")
print(f"๐ฏ Skills: {', '.join(player['skills'])}")
print(f"๐ Inventory: {', '.join(player['inventory'])}")
# ๐ฎ Let's play!
game = GameStateManager()
game.show_status()
# ๐ฏ Play the game
game.move_player(5, 3)
game.gain_exp(50)
game.learn_skill('fireball')
game.save_game('checkpoint1')
game.move_player(2, -1)
game.gain_exp(100) # Level up!
game.learn_skill('ice_shield')
# ๐ฑ Oh no! Let's rewind
game.rewind(2)
game.show_status()
# ๐พ Or load our save
game.load_game('checkpoint1')
game.show_status()
๐ Advanced Concepts
๐งโโ๏ธ Advanced Topic 1: Custom Persistent Types
When youโre ready to level up, create your own persistent types:
from pyrsistent import PClass, field, pvector_field, pmap_field
# ๐ฏ Define a persistent class
class PersistentUser(PClass):
name = field(type=str)
email = field(type=str)
scores = pvector_field(int)
preferences = pmap_field(str, str)
def add_score(self, score):
# โจ Returns new instance with added score
return self.set('scores', self.scores.append(score))
def update_preference(self, key, value):
# ๐ง Returns new instance with updated preference
return self.set('preferences', self.preferences.set(key, value))
def get_average_score(self):
# ๐ Calculate average without modifying state
if not self.scores:
return 0
return sum(self.scores) / len(self.scores)
# ๐ช Using our custom type
user = PersistentUser(
name="Alice",
email="[email protected]",
scores=pvector([85, 92, 78]),
preferences=pmap({'theme': 'dark', 'language': 'en'})
)
# ๐ All operations return new instances
user2 = user.add_score(95)
user3 = user2.update_preference('theme', 'light')
print(f"Original avg: {user.get_average_score():.1f}") # 85.0
print(f"After new score: {user2.get_average_score():.1f}") # 87.5
print(f"Theme unchanged: {user.preferences['theme']}") # dark
print(f"New theme: {user3.preferences['theme']}") # light
๐๏ธ Advanced Topic 2: Structural Sharing
For the brave developers, letโs explore how persistent types save memory:
from pyrsistent import pvector
import sys
# ๐ Demonstrating structural sharing
def show_sharing():
# ๐ Create a large vector
original = pvector(range(10000))
print(f"Original size: {sys.getsizeof(original)} bytes")
# โจ Append one element
modified = original.append(10000)
print(f"Modified size: {sys.getsizeof(modified)} bytes")
# ๐ฏ Most of the structure is shared!
# Only the changed parts are new
# ๐ Let's visualize with smaller example
v1 = pvector([1, 2, 3, 4])
v2 = v1.set(2, 'changed')
v3 = v1.append(5)
print("\n๐ณ Structural sharing:")
print(f"v1: {v1}")
print(f"v2: {v2} (shares elements 0,1,3 with v1)")
print(f"v3: {v3} (shares all elements with v1)")
# ๐ซ Advanced: Creating a persistent trie
class TrieNode:
def __init__(self, value=None):
self.value = value
self.children = pmap({})
def insert(self, key, value):
# ๐ Persistent insertion
if not key:
return TrieNode(value).set_children(self.children)
first, *rest = key
child = self.children.get(first, TrieNode())
new_child = child.insert(rest, value)
new_children = self.children.set(first, new_child)
new_node = TrieNode(self.value)
new_node.children = new_children
return new_node
def set_children(self, children):
self.children = children
return self
show_sharing()
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Forgetting Immutability
# โ Wrong way - trying to modify in place
from pyrsistent import pvector
numbers = pvector([1, 2, 3])
numbers.append(4) # This doesn't modify numbers!
print(numbers) # Still [1, 2, 3] ๐ฐ
# โ
Correct way - capture the new version
numbers = pvector([1, 2, 3])
numbers = numbers.append(4) # Reassign to new version
print(numbers) # Now [1, 2, 3, 4] ๐
๐คฏ Pitfall 2: Performance Concerns
# โ Inefficient - creating many intermediate versions
from pyrsistent import pvector
result = pvector([])
for i in range(1000):
result = result.append(i) # ๐ฅ Creates 1000 versions!
# โ
Efficient - use evolver for bulk updates
from pyrsistent import pvector
v = pvector([])
e = v.evolver() # ๐ง Create an evolver
for i in range(1000):
e.append(i) # ๐ Efficient mutations
result = e.persistent() # ๐ธ Get final version
# โ
Even better - convert at the end
regular_list = []
for i in range(1000):
regular_list.append(i)
result = pvector(regular_list) # ๐ฏ Convert once
๐ ๏ธ Best Practices
- ๐ฏ Use Evolvers for Bulk Updates: Donโt create many intermediate versions
- ๐ Freeze at Boundaries: Convert to persistent types at API boundaries
- ๐ก๏ธ Type Hints: Use type hints with persistent types
- ๐จ Choose Right Structure: PVector for lists, PMap for dicts, PSet for sets
- โจ Leverage Immutability: Design functions that return new states
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Persistent Undo/Redo System
Create a text editor with full undo/redo functionality:
๐ Requirements:
- โ Store document as persistent data structure
- ๐ท๏ธ Track cursor position with each change
- ๐ค Support multiple operations (insert, delete, replace)
- ๐ Add timestamps to each change
- ๐จ Implement branching history (like Git)
๐ Bonus Points:
- Add text highlighting
- Implement collaborative editing
- Create operation merging
๐ก Solution
๐ Click to see solution
from pyrsistent import pmap, pvector
from datetime import datetime
import uuid
# ๐ฏ Our persistent text editor!
class PersistentTextEditor:
def __init__(self):
self.history = pvector([self._create_state("", 0)])
self.current_index = 0
self.branches = pmap({}) # ๐ณ For branching history
def _create_state(self, text, cursor, operation="init"):
# ๐ธ Create a state snapshot
return pmap({
'id': str(uuid.uuid4()),
'text': text,
'cursor': cursor,
'timestamp': datetime.now(),
'operation': operation
})
@property
def current(self):
return self.history[self.current_index]
def insert(self, text_to_insert):
# โ Insert text at cursor
current_text = self.current['text']
cursor = self.current['cursor']
new_text = (current_text[:cursor] +
text_to_insert +
current_text[cursor:])
new_cursor = cursor + len(text_to_insert)
self._add_state(new_text, new_cursor, f"insert '{text_to_insert}'")
print(f"โ๏ธ Inserted: '{text_to_insert}'")
def delete(self, count):
# ๐๏ธ Delete characters before cursor
current_text = self.current['text']
cursor = self.current['cursor']
if cursor >= count:
new_text = current_text[:cursor-count] + current_text[cursor:]
new_cursor = cursor - count
self._add_state(new_text, new_cursor, f"delete {count} chars")
print(f"๐๏ธ Deleted {count} character(s)")
else:
print("โ ๏ธ Not enough characters to delete")
def move_cursor(self, position):
# ๐ Move cursor
current_text = self.current['text']
if 0 <= position <= len(current_text):
self._add_state(current_text, position, f"move to {position}")
print(f"๐ Cursor moved to position {position}")
else:
print("โ ๏ธ Invalid cursor position")
def _add_state(self, text, cursor, operation):
# ๐ Add new state to history
new_state = self._create_state(text, cursor, operation)
# If we're not at the end, we're creating a branch
if self.current_index < len(self.history) - 1:
# ๐ฟ Create a branch
branch_name = f"branch_{len(self.branches)}"
self.branches = self.branches.set(
branch_name,
self.history[self.current_index + 1:]
)
print(f"๐ฟ Created branch: {branch_name}")
# Truncate main history
self.history = self.history[:self.current_index + 1]
self.history = self.history.append(new_state)
self.current_index += 1
def undo(self):
# โช Undo last operation
if self.current_index > 0:
self.current_index -= 1
op = self.history[self.current_index + 1]['operation']
print(f"โฉ๏ธ Undid: {op}")
else:
print("โ ๏ธ Nothing to undo")
def redo(self):
# โฉ Redo operation
if self.current_index < len(self.history) - 1:
self.current_index += 1
op = self.current['operation']
print(f"โช๏ธ Redid: {op}")
else:
print("โ ๏ธ Nothing to redo")
def show_document(self):
# ๐ Display current document state
state = self.current
text = state['text']
cursor = state['cursor']
# Insert cursor marker
display_text = text[:cursor] + '|' + text[cursor:]
print(f"\n๐ Document (cursor at {cursor}):")
print(f"'{display_text}'")
print(f"๐ Last operation: {state['operation']}")
def show_history(self):
# ๐ Show operation history
print("\n๐ History:")
for i, state in enumerate(self.history):
marker = "โ" if i == self.current_index else " "
print(f"{marker} {i}: {state['operation']} at "
f"{state['timestamp'].strftime('%H:%M:%S')}")
if self.branches:
print("\n๐ณ Branches:")
for name, states in self.branches.items():
print(f" {name}: {len(states)} state(s)")
# ๐ฎ Test our editor!
editor = PersistentTextEditor()
# Type some text
editor.insert("Hello ")
editor.insert("World")
editor.insert("!")
editor.show_document()
# Make some edits
editor.move_cursor(6)
editor.insert("Python ")
editor.show_document()
# Try undo/redo
editor.undo()
editor.undo()
editor.show_document()
# Create a branch by making different edits
editor.insert("Beautiful ")
editor.show_document()
editor.show_history()
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Create persistent data structures with confidence ๐ช
- โ Avoid mutation bugs that trip up many developers ๐ก๏ธ
- โ Apply functional programming patterns in real projects ๐ฏ
- โ Debug with time travel like a pro ๐
- โ Build thread-safe applications with persistent types! ๐
Remember: Persistent data structures are your friend for safe, functional programming! They help you write code thatโs easier to reason about and debug. ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered persistent data structures in Python!
Hereโs what to do next:
- ๐ป Practice with the exercises above
- ๐๏ธ Build a small project using
pyrsistent
- ๐ Explore other libraries like
immutables
orpydantic
- ๐ Share your functional programming journey with others!
Remember: Every functional programming expert was once a beginner. Keep coding, keep learning, and most importantly, have fun with immutable data! ๐
Happy coding! ๐๐โจ