title: “HashMap Panic Safety and Invariants in Rust” meta_description: “Learn how to maintain HashMap invariants through panics, implement drop guards, and ensure memory safety in unsafe code.” keywords: [“rust panic safety”, “hashmap invariants”, “drop guard”, “panic unwinding”, “memory safety rust”]
⚠️ Reinventing from Scratch: Building HashMap
Chapter 7 — “Panic Safety and Invariants”
“Your code will panic. The question is: will it leak memory when it does?”
🧭 7.1. The Panic Problem
Rust functions can panic (like exceptions in other languages):
map.insert(key, value); // What if Hash::hash panics?
map.remove(&key); // What if Eq::eq panics?
If a panic occurs mid-operation, will our HashMap be in a valid state?
💣 7.2. Invariants We Must Maintain
Our HashMap has critical invariants:
I1: Length Consistency
// len always equals number of occupied slots
self.len == self.entries.iter().filter(|e| e.is_occupied()).count()
I2: No Dangling Pointers
// All occupied entries have valid, initialized key/value
I3: No Double-Drops
// Each entry is dropped exactly once
I4: Valid State After Panic
// If any operation panics, HashMap remains usable
🎯 7.3. Where Panics Can Occur
During Hash Computation
impl Hash for BadKey {
fn hash<H: Hasher>(&self, state: &mut H) {
if self.value == "panic" {
panic!("Hash panicked!"); // ❌
}
self.value.hash(state);
}
}
map.insert(BadKey { value: "panic".into() }, 42);
// Panic! HashMap state???
During Equality Check
impl PartialEq for BadKey {
fn eq(&self, other: &Self) -> bool {
panic!("Eq panicked!"); // ❌
}
}
map.get(&key); // Panic during lookup!
During Drop
impl Drop for BadValue {
fn drop(&mut self) {
panic!("Drop panicked!"); // ❌❌❌
}
}
🛡️ 7.4. Panic-Safe Insert
Let’s make insert panic-safe:
pub fn insert(&mut self, key: K, value: V) -> Option<V> {
// Step 1: Hash the key (might panic)
let hash = {
let mut hasher = self.hasher_builder.build_hasher();
key.hash(&mut hasher); // Panic possible here!
hasher.finish()
};
// If panic occurred, HashMap is unchanged ✓
// Step 2: Check if we need to grow (might panic during allocation)
if self.should_grow() {
self.grow(); // Panic possible here!
}
// If panic during grow, old HashMap is dropped cleanly ✓
// Step 3: Insert the entry
self.insert_entry(hash, key, value) // Panic possible in Eq!
}
Analysis
Panic during hash:
- HashMap unchanged
keyandvalueare dropped cleanly- ✅ Safe!
Panic during grow:
- Old entries moved to new map
- If panic during rehash, partially-built map is dropped
- Original map already consumed
- Might lose some entries, but no UB!
- ⚠️ Not ideal, but safe
Panic during insert (in Eq check):
- HashMap is partially modified
- Need to be careful about
lentracking
🔒 7.5. The Length Problem
Consider this insert:
fn insert_entry(&mut self, hash: u64, mut key: K, mut value: V) -> Option<V> {
let index = self.find_slot(&key); // Might panic in Eq!
match &mut self.entries[index] {
Empty => {
self.entries[index] = Occupied(Entry { key, value, hash });
self.len += 1; // ← What if we panic before this?
None
}
Occupied(old) if old.key == key => { // ← Panic possible here!
Some(std::mem::replace(&mut old.value, value))
}
// ...
}
}
Problem: If we panic after inserting but before incrementing len, invariant I1 is broken!
Solution: Update len LAST
fn insert_entry(&mut self, hash: u64, key: K, value: V) -> Option<V> {
// Track if we're adding a new entry
let mut added_new = false;
let index = self.find_slot(&key); // Might panic
let old_value = match &mut self.entries[index] {
Empty => {
self.entries[index] = Occupied(Entry { key, value, hash });
added_new = true;
None
}
Occupied(old) if old.key == key => {
Some(std::mem::replace(&mut old.value, value))
}
// ...
};
if added_new {
self.len += 1; // Only increment if we actually added
}
old_value
}
🔥 7.6. Drop Guards
For complex operations, use guard objects:
struct LenGuard<'a> {
len: &'a mut usize,
increment: bool,
}
impl<'a> LenGuard<'a> {
fn new(len: &'a mut usize) -> Self {
Self { len, increment: false }
}
fn will_increment(&mut self) {
self.increment = true;
}
}
impl Drop for LenGuard<'_> {
fn drop(&mut self) {
if self.increment {
*self.len += 1;
}
}
}
Usage:
fn insert_entry(&mut self, hash: u64, key: K, value: V) -> Option<V> {
let mut guard = LenGuard::new(&mut self.len);
let index = self.find_slot(&key); // Might panic
match &mut self.entries[index] {
Empty => {
self.entries[index] = Occupied(Entry { key, value, hash });
guard.will_increment(); // Only increments if we reach here
None
}
// ...
}
// Guard drops here, increments len if will_increment was called
}
🧹 7.7. Safe Resize
Resizing is the most dangerous operation:
fn grow(&mut self) {
let new_capacity = self.capacity * 2;
let mut new_entries = vec![Empty; new_capacity];
// Danger zone: moving entries
for old_entry in self.entries.drain(..) {
if let Occupied(entry) = old_entry {
// Reinsert into new_entries
// What if Eq panics here?
}
}
self.entries = new_entries;
}
Problem: If panic during rehash:
- Old entries partially moved
- New entries partially filled
- Some entries might be lost!
Solution: Build Completely, Then Swap
fn grow(&mut self) {
let new_capacity = self.capacity * 2;
// Build new map completely
let mut new_map = Self::with_capacity_and_hasher(
new_capacity,
self.hasher_builder.clone()
);
// Move entries (might panic, but new_map is separate)
for entry in self.entries.drain(..) {
if let Occupied(e) = entry {
// If panic here, new_map is dropped, old entries still in self
new_map.insert_hashed(e.hash, e.key, e.value);
}
}
// Only swap if everything succeeded
*self = new_map;
}
Better: All-or-nothing semantics.
🧪 7.8. Testing Panic Safety
#[test]
#[should_panic]
fn test_panic_during_insert() {
struct PanicKey;
impl Hash for PanicKey {
fn hash<H: Hasher>(&self, state: &mut H) {
panic!("Intentional panic!");
}
}
impl PartialEq for PanicKey {
fn eq(&self, _: &Self) -> bool { true }
}
impl Eq for PanicKey {}
let mut map = MyHashMap::new();
map.insert("safe", 1);
// This will panic
let _ = map.insert(PanicKey, 2);
// If we could reach here (we can't, but in theory):
// HashMap should still have "safe" entry and be valid
}
Using catch_unwind for Testing
use std::panic::catch_unwind;
#[test]
fn test_map_valid_after_panic() {
let mut map = MyHashMap::new();
map.insert("a", 1);
map.insert("b", 2);
// Try to insert panic-inducing key
let result = catch_unwind(|| {
map.insert(PanicKey, 3);
});
assert!(result.is_err()); // Panicked as expected
// HashMap should still work!
assert_eq!(map.len(), 2);
assert_eq!(map.get("a"), Some(&1));
}
📋 7.9. Invariant Checklist
After every operation, verify:
#[cfg(debug_assertions)]
fn check_invariants(&self) {
// I1: Length matches occupied count
let actual_len = self.entries.iter()
.filter(|e| matches!(e, Occupied(_)))
.count();
assert_eq!(self.len, actual_len, "Length invariant violated!");
// I2: All entries have correct distances
for (i, entry) in self.entries.iter().enumerate() {
if let Occupied(e) = entry {
let ideal = self.ideal_index(e.hash);
let distance = self.probe_distance(e.hash, i);
// Distance should be reasonable
assert!(distance < self.capacity, "Invalid distance!");
}
}
// I3: No duplicate keys
let mut seen = std::collections::HashSet::new();
for entry in &self.entries {
if let Occupied(e) = entry {
assert!(seen.insert(&e.key), "Duplicate key!");
}
}
}
🧠 7.10. Chapter Takeaways
| Concept | Purpose |
|---|---|
| Panic safety | HashMap valid even after panic |
| Drop guards | Ensure invariants via RAII |
| All-or-nothing | Complete operation or rollback |
| Invariant checks | Debug-mode validation |
| catch_unwind | Test panic behavior |
🚀 7.11. What’s Next
We now have a robust, panic-safe HashMap!
Next: Chapter 8 — Tests, Benchmarks, and Miri
Learn how to:
- Write comprehensive test suites
- Benchmark performance
- Use Miri to catch undefined behavior
💡 Exercises
- Add check_invariants calls after each operation
- Implement drop guard for len tracking
- Test panic scenarios with catch_unwind
- Verify no memory leaks when panic occurs
- Measure panic overhead (should be zero in happy path)
Next: Chapter 8 — Tests, Benchmarks, and Miri 🧪