DaleSchool

Collections Deep Dive: Vec, HashMap, String

Intermediate20min

Learning Objectives

  • Use key Vec<T> methods effectively
  • Create HashMap<K, V> and use the entry API
  • Explain why String indexing is restricted due to UTF-8
  • Understand how ownership and borrowing work with collections

You got a taste of Vec in Lesson 06. Now let's dig deeper into Vec, HashMap, and String — the three most important collections.

Working Code

use std::collections::HashMap;

fn main() {
    // Vec — ordered list
    let mut scores = vec![85, 92, 78];
    scores.push(95);
    println!("Scores: {:?}", scores);

    // HashMap — key-value store
    let mut contacts = HashMap::new();
    contacts.insert("Alice", "555-1234");
    contacts.insert("Bob", "555-5678");
    println!("Alice: {}", contacts["Alice"]);

    // String — UTF-8 text
    let mut greeting = String::from("hel");
    greeting.push_str("lo!");
    println!("{greeting}");
}

Vec<T> In Depth

Useful Methods

fn main() {
    let mut v = vec![10, 20, 30, 40, 50];

    // insert — insert at a specific position
    v.insert(2, 25); // insert 25 at index 2
    println!("After insert: {:?}", v); // [10, 20, 25, 30, 40, 50]

    // remove — remove at a specific position (returns the removed value)
    let removed = v.remove(0);
    println!("Removed: {removed}, rest: {:?}", v); // 10, [20, 25, 30, 40, 50]

    // retain — keep only elements matching a condition
    v.retain(|x| *x >= 30);
    println!("After retain: {:?}", v); // [30, 40, 50]

    // split_off — split at a position
    let mut a = vec![1, 2, 3, 4, 5];
    let b = a.split_off(3);
    println!("a: {:?}, b: {:?}", a, b); // a: [1, 2, 3], b: [4, 5]

    // contains — check membership
    println!("Contains 3? {}", a.contains(&3)); // true

    // sort, dedup — sort and remove duplicates
    let mut nums = vec![3, 1, 4, 1, 5, 9, 2, 6, 5, 3];
    nums.sort();
    nums.dedup();
    println!("Sorted + deduped: {:?}", nums); // [1, 2, 3, 4, 5, 6, 9]
}

Safe Access: get() vs Indexing

fn main() {
    let v = vec![10, 20, 30];

    // Index access — panics if out of bounds
    println!("{}", v[1]); // 20
    // println!("{}", v[10]); // panic!

    // get() — returns Option for safety
    match v.get(10) {
        Some(val) => println!("Value: {val}"),
        None => println!("Index out of bounds"),
    }
}

HashMap<K, V>

Basic Usage

use std::collections::HashMap;

fn main() {
    let mut scores: HashMap<String, i32> = HashMap::new();

    // Insert
    scores.insert(String::from("Math"), 95);
    scores.insert(String::from("English"), 87);
    scores.insert(String::from("Science"), 92);

    // Lookup — get() returns Option<&V>
    match scores.get("Math") {
        Some(score) => println!("Math: {score}"),
        None => println!("No math score"),
    }

    // Iterate
    for (subject, score) in &scores {
        println!("{subject}: {score}");
    }

    // Remove
    scores.remove("English");
    println!("After removing English: {:?}", scores);
}

The entry API — Idiomatic "Insert If Missing"

A common pattern: "insert a default if the key doesn't exist; use the existing value if it does." The entry() API handles this cleanly:

use std::collections::HashMap;

fn main() {
    let text = "hello world hello rust hello";
    let mut word_count: HashMap<&str, i32> = HashMap::new();

    // entry API — one line does it all!
    for word in text.split_whitespace() {
        let count = word_count.entry(word).or_insert(0);
        *count += 1;
    }

    println!("{:?}", word_count);
    // {"hello": 3, "world": 1, "rust": 1}
}

entry(key).or_insert(default) inserts the default if the key is missing, then returns a mutable reference to the value.

Creating From Arrays

use std::collections::HashMap;

fn main() {
    // From an array
    let scores: HashMap<&str, i32> = HashMap::from([
        ("Math", 95),
        ("English", 87),
        ("Science", 92),
    ]);
    println!("{:?}", scores);

    // From iterators with collect()
    let names = vec!["Alice", "Bob", "Carol"];
    let ages = vec![25, 30, 28];
    let people: HashMap<&str, i32> = names
        .iter()
        .zip(ages.iter())
        .map(|(n, a)| (*n, *a))
        .collect();
    println!("{:?}", people);
}

String and UTF-8

String Doesn't Support Indexing!

In most languages, s[0] gets the first character. In Rust, it doesn't work:

fn main() {
    let hello = String::from("hello");
    // let first = hello[0]; // compile error!
    println!("Byte length: {}", hello.len()); // 5
}

"Why?" — Because of UTF-8

String is internally a sequence of UTF-8 bytes. Some characters take more than one byte. For example, Korean characters use 3 bytes each:

fn main() {
    let s = String::from("abc");

    // View as bytes — 3 bytes
    println!("Bytes: {:?}", s.as_bytes());

    // View as chars — 3 characters
    for c in s.chars() {
        println!("Char: {c}");
    }

    // Get the first character
    let first = s.chars().next().unwrap();
    println!("First char: {first}"); // a

    // Slicing works on byte boundaries only
    let slice = &s[0..2]; // "ab" (2 bytes)
    println!("Slice: {slice}");
    // let bad = &s[0..1]; // works for ASCII, but be careful with multi-byte chars!
}

| Expression | "hello" | "abc" | | ------------------------------- | ----------- | ----------- | | .len() (bytes) | 5 | 3 | | .chars().count() (characters) | 5 | 3 | | s[0] | Not allowed | Not allowed | | .chars().next() | Some('h') | Some('a') |

Creating and Joining Strings

fn main() {
    // Multiple ways to create
    let s1 = String::from("hello");
    let s2 = "hello".to_string();
    let s3 = format!("Hello, {}!", "world");

    // Joining
    let greeting = format!("{} {}", s1, s2);
    println!("{greeting}");

    // Appending
    let mut s = String::from("hello");
    s.push_str(", world");
    s.push('!'); // single char
    println!("{s}");
}

Collections and Ownership

Be mindful of how ownership works with collections.

Pushing Into a Vec Moves Ownership

fn main() {
    let name = String::from("Alice");
    let mut names = Vec::new();

    names.push(name);
    // println!("{name}"); // error! name moved into Vec

    // Solution: store a clone, or use references
    let name2 = String::from("Bob");
    names.push(name2.clone()); // push a clone
    println!("{name2}"); // original still alive
}

HashMap Key Ownership

use std::collections::HashMap;

fn main() {
    let key = String::from("name");
    let value = String::from("Alice");

    let mut map = HashMap::new();
    map.insert(key, value);

    // println!("{key}");   // error! ownership moved to HashMap
    // println!("{value}"); // error! same

    // Using &str as keys avoids ownership issues
    let mut map2: HashMap<&str, &str> = HashMap::new();
    let k = "name";
    map2.insert(k, "Alice");
    println!("{k}"); // OK! &str has the Copy trait
}

Borrowing Rules Apply

fn main() {
    let mut v = vec![1, 2, 3, 4, 5];

    let first = &v[0]; // immutable reference
    // v.push(6);       // error! can't modify while immutable ref exists
    println!("First: {first}");

    // After first is no longer used, it's OK
    v.push(6); // OK
    println!("{:?}", v);
}

The borrowing rules from Lesson 14 apply here! When Vec reallocates memory during push(), existing references could become invalid — that's why Rust blocks it.

Exercise 1. Use the entry API to count how many times each character appears in a sentence (excluding spaces).

use std::collections::HashMap;

fn char_count(text: &str) -> HashMap<char, usize> {
    // Complete this
    // Hint: iterate with text.chars(), skip spaces
}

fn main() {
    let counts = char_count("hello world");
    println!("{:?}", counts);
    // {'h': 1, 'e': 1, 'l': 3, 'o': 2, 'w': 1, 'r': 1, 'd': 1}
}

Exercise 2. Complete the function to return the first N characters of a string.

fn take_chars(s: &str, n: usize) -> String {
    // Complete this
    // Hint: s.chars().take(n).collect()
}

fn main() {
    let text = "Hello, Rust!";
    println!("{}", take_chars(text, 5)); // "Hello"
    println!("{}", take_chars(text, 20)); // "Hello, Rust!"
}

Exercise 3 (challenge). Build a student score tracker using HashMap<String, Vec<i32>>. Complete the add_score and average functions.

use std::collections::HashMap;

fn add_score(records: &mut HashMap<String, Vec<i32>>, name: &str, score: i32) {
    // Complete this
    // Hint: entry API + or_insert_with(Vec::new)
}

fn average(records: &HashMap<String, Vec<i32>>, name: &str) -> Option<f64> {
    // Complete this
    // Return None if no scores exist
}

fn main() {
    let mut records = HashMap::new();
    add_score(&mut records, "Alice", 90);
    add_score(&mut records, "Alice", 85);
    add_score(&mut records, "Bob", 95);

    println!("Alice avg: {:?}", average(&records, "Alice"));   // Some(87.5)
    println!("Bob avg: {:?}", average(&records, "Bob"));       // Some(95.0)
    println!("Carol avg: {:?}", average(&records, "Carol"));   // None
}

Q1. Why can't you index a String with s[0] in Rust?

  • A) String is immutable
  • B) It was banned for performance reasons
  • C) UTF-8 encoding means a single character can span multiple bytes
  • D) Rust has no character type

Q2. What does HashMap's entry(key).or_insert(default) do?

  • A) Always overwrites with the default
  • B) Returns an error if the key exists
  • C) Inserts the default if the key is missing, then returns a mutable reference to the value
  • D) Deletes the key and re-inserts

Q3. Why does this code produce an error?

fn main() {
    let name = String::from("Alice");
    let mut names = Vec::new();
    names.push(name);
    println!("{name}");
}
  • A) Vec can't hold String values
  • B) names isn't mutable
  • C) push() moves name's ownership into the Vec, so it's no longer usable
  • D) println! can't accept String