Rust offers a variety of collection types to provide a means to store and retrieve data efficiently. Each type has different characteristics when it comes to performance and capacity. Collections allows you to store multiple values sequentially but unlike arrays or tuples, collections are allocated on the heap meaning the size of the collection could grow or shrink as the program runs.
In this article, we’ll provide an overview of Rust collections, specifically the most common Rust collections types: vectors, strings, and hash maps. We’ll also discuss which collections are most appropriate for different tasks and how to efficiently use each collection.
Jump ahead:
Collections are data structures that are provided by Rust’s standard library. These structures store information in sequences or groups. While most other Rust data types contain only one particular value, collections may contain multiple values at a time.
Rust collections can be grouped into four categories:
Vec
, VecDeque
, LinkedList
HashMap
, BTreeMap
HashSet
, BTreeSet
BinaryHeap
The art of choosing the best collection for a given task is essential since each collection has different characteristics when it comes to its usage, performance, and capacity. Most collections provide a capacity
method to query the actual capacity, or space allocation, of the collection. It is most efficient when the collection has the appropriate capacity
to size the elements that are added to it.
The Vec
(vector) and HashMap
collections cover most use cases for generic data storage and processing. The vector is the default choice for storing items together. We can use vectors when we want a resizable array, a heap-allocated array, or a sequence of elements in a particular order.
HashMap
is optimal for use cases where we want to associate random keys with random values, a map without additional functionality, or a cache.
BinaryHeap
(a binary tree) is useful when we want a priority queue or when we’re looking to process only the most important or largest element each time.
There’re many other use cases for different collections in Rust, and this guide can serve as a reference to learn more.
Rust’s standard library includes a number of rich collections for storing values. Let’s take a closer look at the three of the most commonly used Rust collections: vectors, strings, and hash maps.
A vector is a global continual stack of items of the same type that allows us to store multiple values next to each other, just like an array. In fact, vectors are resizable arrays, meaning they can increase or decrease in size dynamically during runtime.
Note that vectors, just like any other type stored on the heap, will be dropped when they go out of scope or are no longer used by the program.
The Vec::new()
function is used to create an empty vector.
In the below code, we declare and initialize the v
variable as a vector type by calling the new
function on the vector type. This creates an empty vector. Because we haven’t yet populated the vectors with any values, Rust can’t infer what type of value we want the vector to contain. Therefore, we have to manually annotate the type using 64-bit integer, i64
, generic syntax.
fn main() { let v : Vec<i64> = Vec::new(); }
In order to add elements to a vector variable, we make the vector mutable by using the mut
keyword. Then, we call the push()
method to push elements into the vector.
fn main() { let mut v : Vec<i64> = Vec::new(); v.push(1); v.push(2); }
There are two ways to access elements in a vector. We can directly reference an index in the vector, or we can use the get()
method.
In the below snippet, we create a variable second
that is equal to &v
. This specifies a reference to the vector, and then we specify the desired index in square brackets. We pass 1
in the square bracket to get the second element in the vector because, just like arrays, vectors are zero indexed.
fn main() { let v: Vec<i64> = vec![1, 2, 3, 4]; let second = &v[1]; println!(“the second number is {}”, second); }
In the above example, we’ll get an error if we pass an index that exceeds the length of the vector.
The get()
method is a safer approach in terms of handling index-out-of-bound
errors:
fn main() { let v: Vec<i64> = vec![1, 2, 3, 4] match v.get(index: 12) { Some(second: &i64) => println!(“The second number is {}”, second), None => println!(“Out of bound index”), } }
In the above Some
case, we store the value in a variable called second
and print it to the standard output. In the None
case, we print the "Out of bound index"
message. The None
case will only be triggered if we pass an index that is greater than the length of the vector.
We can use the for in
loop to iterate over elements in a vector.
fn main(){ let v: Vec<i64> = vec![1, 2, 3, 4]; for i in &v { println!("{}", i); } }
As stated earlier in this article, vectors only store values of the same type. However, we can store elements of different types in a vector by making use of the enum
variant.
... enum MyVector { Int(i32), Float(f64), Text(String), }; let row = vec![ MyVector::Int(20), MyVector::Text(String::from("This is a string")), MyVector::Float(15.12), ]; ...
In Rust, strings are stored as a collection of UTF-8 encoded bytes, and are basically just a secondary type for the vector, Vec
, type.
A program needs to interpret these values in order for the computer to print out the correct characters. This is where encoding comes in. UTF-8 is a variable-width character encoding for Unicode, supporting strings ranging from one to four bytes.
There is only one string type in the Rust core language; the string slice. This is generally seen in its borrowed form, &str
.
We can create an empty string using the new
function. We can also use the string slice, &str
, to create a string of characters and then use the to_string()
method to convert the string slice into an owned string.
fn main() { let mut myString: String = string::new(); let myString2: &str = “This is a string”; let myString3: String = myString2.to_string(); }
The push_str()
method can be used to add elements to the end of a string.
Here we build a string that contains "John"
and use the push_str
method to append "Doe"
to the string:
fn main() { let mut myString = String::from(“John”); myString.push_str(string: “Doe”); }
Now, when we run our code, myString
will return “John Doe”. We can also concatenate strings using the +
operator. However, with this technique, it’s important to maintain type consistency among the variables.
In the below code, we move the ownership of s1
into s3
. Then, we concatenate a copy of the characters of s2
to the end of s3
. This saves a bit of memory, and the implementation is more efficient than copying both strings and then creating a new string with the copies. Because we’ve transferred ownership of s1
, we can no longer use the s1
element.
fn main() { let s1: string = String::from(“John”); let s2: string = String::(“Doe”); let s3: string = s1 + &s2; println!("{}", s3); } // "John Doe"
We can also concatenate strings using the format
macro. Unlike the +
operator, the format
macro doesn’t transfer ownership of the strings.
fn main() { let s1: string = String::from(“John”); let s2: string = String::(“Doe”); let s3: string = format!("{}{}", s1,s2); }
The hash map is an associative container that allows us to store data in key value pairs called entries. In hash maps, no two entries can have the same key.
To create a new hash map, we need to bring the hash map into scope from the standard library. Also, just like with vectors and strings, we can use the new
function to create an empty hash map and then insert values into the hash map using the insert
function.
use std::collections::HashMap; fn main() { let john = String::from("John"); let peter = String::from("Peter"); let mut person: HashMap<String, i32> = HashMap::new(); person.insert(john, 40); person.insert(peter, 20); }
In the above code, passing in john
and peter
in the insert()
function will transfer ownership of the strings into the HashMap
.
We can access values in a hash map using the get()
method. The get()
method takes a reference to a key and returns an optional value. We get the Option
enum because we can’t guarantee that a value will be returned.
... let person_name = String::from("John"); let new_person: &i32 = if let Some(age) = person.get(&person_name) { age } else { &0i32 }; println!("{}", new_person); ...
Rust collections are data structures that allow us to store and retrieve data in a sequence. Collections are stored on the heap which means we can grow or shrink the data within them at runtime.
In this article, we investigated Rust collections and shared some examples and use cases for specific collections. We discussed the commonly used collections like vectors, strings, and hash maps. Vectors and hash maps cover most use cases for generic data storage and processing.
Rust collections are very useful for essential operations such as caching using hash maps, mapping and sorting values by their keys using BTreeMap, and storing and processing values using arrays. Rust collections can also serve as a form of local storage to store and retrieve data in programs and processes that are not heavy duty.
To learn more about Rust collections, refer to the official docs.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowReact Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.