Create your own
Lesson illustration

Dynamic Collections: Vec, String, and HashMap

Hello!

In our last lesson, you learned how to use the Result<T, E> enum and the ? operator for robust, recoverable error handling. This is a cornerstone of reliable Rust programming, especially in Solana where operations can frequently fail and must be handled gracefully.

Today, we shift our focus from handling single outcomes to managing groups of data. So far, we've mostly worked with types that hold a single value, like integers or structs. However, real-world programs almost always need to work with lists of items, user input, or key-value data. In this lesson, we will cover Rust's three most common dynamic collection types provided by the standard library.

Your goal for this lesson is to create and manage dynamic collections with Vec, String, and HashMap. These are the workhorses for handling variable-sized data that lives on the heap, and you'll use them constantly when building Solana programs—for everything from processing instruction arguments to managing lists of accounts.

An Introduction to Collections

Unlike the array and tuple types you've seen, which have a fixed size known at compile time, collections are dynamic. Their size can grow or shrink as the program runs.

Let's start with a brief introduction from the official Rust book that outlines the three collections we'll cover today.

Common Collections - The Rust Programming Language

This brief introductory text from The Rust Programming Language sets the stage by defining what collections are and why they are different from fixed-size types.

Read the introductory section under the main heading 'Common Collections'. It introduces Vec, String, and HashMap and explains that their data is stored on the heap.

Now, let's dive into each one.

Vec<T>: The Dynamic Array

The most common collection you'll use is the vector, or Vec<T>. You can think of a Vec as a growable array. Coming from front-end development, it's very similar in purpose to a standard JavaScript Array. It stores a variable number of values of a single type T next to each other in memory.

Let's watch a short video that introduces the Vec and contrasts it with fixed-size arrays.

Collections - Part 13 of Idiomatic Rust in Simple Steps

This video from Fio's Quest provides a concise and clear explanation of what a Vec is, how it's stored on the heap, and some of its basic operations.

Watch the segment from 05:32 to 08:51. Focus on how a Vec is instantiated, how it handles memory resizing (capacity vs. length), and the safe way to access elements using .get().

Creating and Manipulating Vectors

As you saw, there are a few ways to create a Vec:

// Create an empty vector. The type must be annotated if not inferred.
let mut v1: Vec<i32> = Vec::new();

// Use the vec! macro to create a vector with initial elements.
let mut v2 = vec![1, 2, 3];

// Add elements using the push method. The vector must be mutable.
v1.push(5);
v2.push(4);

println!("v1: {:?}", v1); // Output: v1: [5]
println!("v2: {:?}", v2); // Output: [1, 2, 3, 4]

Ownership in Vectors

A critical point to remember is that when you push a value into a Vec, the Vec takes ownership of that value (unless the value's type implements the Copy trait).

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

names.push(name); // `name` is moved into the `names` vector.

// The line below would cause a compile error because `name` has been moved.
// println!("The name is: {}", name); 

Accessing Elements

You can access elements using index syntax (v[i]) or the get method (v.get(i)).

  • Indexing (v[2]): This is fast but will cause your program to panic if the index is out of bounds.
  • .get(2): This is safer. It returns an Option<&T>, which will be Some(&value) if the element exists or None if the index is out of bounds. This lets you handle the absence of an element gracefully, for instance with a match or if let.
let v = vec![10, 20, 30, 40, 50];

// Safe access with .get()
match v.get(2) {
    Some(third) => println!("The third element is {}", third),
    None => println!("There is no third element."),
}

// Access that panics if out of bounds
// let does_not_exist = &v[100]; // This would panic!

Iterating Over Vectors

You can iterate over the elements of a vector in a few ways:

let v = vec![100, 32, 57];
// 1. Immutable iteration
for i in &v {
    println!("{}", i);
}

let mut v_mut = vec![100, 32, 57];
// 2. Mutable iteration
for i in &mut v_mut {
    *i += 50; // Use the dereference operator (*) to modify the value
}
println!("{:?}", v_mut); // Output: [150, 82, 107]
Test your understanding!

Write a function sum_even_numbers that takes a Vec<i32> as input. The function should iterate through the vector and return the sum of all the even numbers.

Hint: Use the modulo operator (%) to check for even numbers.

Show answer
fn sum_even_numbers(numbers: &Vec<i32>) -> i32 {
    let mut sum = 0;
    for &num in numbers { // Iterate by value (since i32 is Copy)
        if num % 2 == 0 {
            sum += num;
        }
    }
    sum
}

// You can test it like this:
fn main() {
    let my_vec = vec![1, 2, 3, 4, 5, 6];
    let total = sum_even_numbers(&my_vec);
    println!("The sum of even numbers is: {}", total); // Output: 12
}

Notice how the function takes a reference to the vector (&Vec<i32>). This is good practice to avoid taking ownership and moving the vector, allowing the caller to still use it after calling the function.

String: Growable, Owned Text

The next fundamental collection is String. You've already seen string literals (&str), which are string slices—immutable, borrowed references to string data stored in the program's binary.

A String, on the other hand, is a growable, mutable, owned, UTF-8 encoded string type. It is stored on the heap. You can think of a String as a specialized Vec<u8>.

For a deep dive into String and its relationship with &str, the Rust Book is the best resource.

Storing UTF-8 Encoded Text with Strings

This chapter of The Rust Programming Language provides a comprehensive look at String, covering how to create and update them, and explaining the important complexities of UTF-8 encoding and indexing.

Please read the entire section 'Storing UTF-8 Encoded Text with Strings'. Pay close attention to: The difference between String and &str. Methods for creating and updating a String (push_str, + operator, format! macro). Why Rust doesn't allow direct character indexing (string[i]) and how to iterate over characters instead. This is due to its UTF-8 encoding, a key difference from how strings work in many other languages.

Key String Operations

Here is a quick summary of the most common operations you'll use:

// Create a new, empty String
let mut s = String::new();

// Create a String from a string literal
let s1 = String::from("hello");
let s2 = "world".to_string();

// Appending to a String
let mut s3 = String::from("foo");
s3.push_str("bar"); // push_str takes a &str and doesn't take ownership
println!("{}", s3); // "foobar"

// Concatenation with the `+` operator
// Note: s1 is moved here and can no longer be used. s2 is borrowed.
let s4 = s1 + " " + &s2; 
println!("{}", s4); // "hello world"

// The format! macro is often the clearest way to combine strings.
// It doesn't take ownership of any of its parameters.
let ticket = format!("Destination: {}, Seat: {}", "Mars", "42A");
println!("{}", ticket);

HashMap<K, V>: Key-Value Pairs

The final collection for today is the hash map, or HashMap<K, V>. A HashMap stores a mapping from keys of type K to values of type V. It's Rust's equivalent of JavaScript's Map or Object, or Python's dictionary.

A HashMap is incredibly useful for associating data, such as storing user balances where the key is a PublicKey and the value is a u64.

This video provides an excellent introduction to HashMap and a few of its powerful features.

Collections - Part 13 of Idiomatic Rust in Simple Steps

This segment from Fio's Quest covers HashMap creation, insertion, and retrieval. It also introduces the very useful entry API for conditional updates.

Watch the segment from 11:32 to 13:25. Focus on how to insert key-value pairs, how get returns an Option, and the entry() method for checking if a key exists and inserting only if it doesn't.

Creating and Using a HashMap

To use HashMap, you first need to bring it into scope from the standard library's collections module.

use std::collections::HashMap;

let mut scores = HashMap::new();

// Inserting values. The HashMap takes ownership of the keys and values.
scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

// Accessing values using .get()
let team_name = String::from("Blue");
// .get() returns an Option<&V>
let score = scores.get(&team_name).copied().unwrap_or(0);
println!("Score for Blue team: {}", score);

// Iterating over key-value pairs
for (key, value) in &scores {
    println!("{}: {}", key, value);
}

// Updating a value based on the old value with the entry API
// This is very efficient.
let mut map = HashMap::new();
map.insert("counter", 0);

let count = map.entry("counter").or_insert(0);
*count += 1;
println!("Counter: {}", map.get("counter").unwrap()); // Counter: 1

A common pattern you'll see in Solana is using a HashMap to count things or aggregate data from accounts during instruction processing. The entry API is perfect for this.

Test your understanding!

Write a function count_words that takes a text string (&str) as input and returns a HashMap<String, u32> where each key is a word from the text and the value is the number of times that word appeared.

Hint: Use the .split_whitespace() method on the string to get an iterator over the words. Then, use the entry API to increment the count for each word.

Show answer
use std::collections::HashMap;

fn count_words(text: &str) -> HashMap<String, u32> {
    let mut word_counts = HashMap::new();

    for word in text.split_whitespace() {
        let count = word_counts.entry(word.to_string()).or_insert(0);
        *count += 1;
    }

    word_counts
}

// You can test it like this:
fn main() {
    let text = "hello world hello";
    let counts = count_words(text);
    println!("{:?}", counts); // Output: {"hello": 2, "world": 1}
}

This solution perfectly showcases the power of the entry API. The line word_counts.entry(word.to_string()).or_insert(0) gets a mutable reference to the word's count, initializing it to 0 if it doesn't exist. We can then dereference it (*count) and increment it.

Conclusion

You've now been introduced to the three most important collection types in Rust. Mastering them is essential for writing virtually any non-trivial program.

Here are the key takeaways:

  • Vec<T> is your go-to for a dynamic, ordered list of items of the same type. It's the Rust equivalent of a JavaScript Array.
  • String is the owned, growable type for text data. It's more complex than strings in many languages because Rust handles UTF-8 encoding explicitly and safely.
  • HashMap<K, V> is used for storing key-value associations, much like a JavaScript Map or Object.
  • A crucial concept for all three is ownership: when you put data into a collection, the collection typically takes ownership of that data.

In our next lesson, we will look at slices. Slices are "views" or "windows" into a part of a collection, like a Vec or a String. They allow you to borrow and work with a sequence of elements from another collection without taking ownership or making a copy, which is a powerful and efficient pattern in Rust.

Can't find a good explanation? Sign up and we'll make it for you

Sign up