Rust Deep Dive Part 3 - Slices, Generics, Traits, and the Lifetime Mystery Finally Solved

Table of contents
- Why am I diving into these "advanced" topics ??
- Slices - Windows Into Your Data
- The String Confusion Finally Explained
- Generics - Writing Code That Works With Any Type
- Traits - Defining Shared Behavior
- Lifetimes - The Mystery Finally Solved
- Lifetimes in Structs - When Your Struct Holds References
- Multiple Lifetimes - When Things Get More Complex
- Multithreading - Rust's Superpower
- More Complex Threading - Sharing Data Safely
- Channel Communication - Threads Talking to Each Other
- What I Learned Today - The Big Picture Coming Together
- Something to Note

Welcome back to my Rust adventure! :)) If you've been following along from Parts 1 and 2, you know I've been on this crazy journey of understanding Rust's unique approach to programming. Today I'm tackling some of the more "advanced" concepts that honestly had me scratching my head for days. But you know what? Once they clicked, I realized they're not actually that scary - they're just different ways of thinking about code safety and reusability.
After wrestling with slices, getting confused by string types, having my mind blown by generics and traits, and finally understanding why everyone talks about lifetimes, I had to write this all down while it's still fresh. Plus, I dipped my toes into multithreading and surprisingly didn't break anything! Let me walk you through these concepts step by step, because I promise they're more approachable than they sound.
But first, you know the drill...
Why am I diving into these "advanced" topics ??
Here's the thing - I kept running into situations where I needed to write more flexible, reusable code. I wanted functions that could work with different data types without copying and pasting code everywhere. I needed to understand why the Rust compiler kept complaining about lifetimes in my structs. And honestly, I was curious about Rust's famous concurrency safety that everyone keeps praising.
My goal was clear: "I want to write real Rust code that's flexible, reusable, and can handle multiple tasks at once, while understanding why Rust makes me think about these concepts explicitly."
Slices - Windows Into Your Data
Let me start with something that confused me initially - slices. Think of a slice as a "window" or "view" into a larger piece of data. You're not copying the data, you're just looking at a specific portion of it. It's like highlighting a paragraph in a book without tearing out the page.
fn main() {
// Let's start with a simple array
let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// A slice is a reference to a portion of the array
let first_five = &numbers[0..5]; // Elements 0, 1, 2, 3, 4
let last_three = &numbers[7..]; // Elements 7, 8, 9 (to the end)
let middle_part = &numbers[2..6]; // Elements 2, 3, 4, 5
let everything = &numbers[..]; // All elements
println!("Original array: {:?}", numbers);
println!("First five: {:?}", first_five);
println!("Last three: {:?}", last_three);
println!("Middle part: {:?}", middle_part);
// Slices work with vectors too!
let mut words = vec!["hello", "world", "from", "rust"];
let first_two = &words[0..2];
println!("First two words: {:?}", first_two);
// You can pass slices to functions - very useful!
print_slice(first_five);
print_slice(last_three);
}
fn print_slice(slice: &[i32]) {
println!("This slice has {} elements: {:?}", slice.len(), slice);
}
The beautiful thing about slices is that they don't own the data - they just borrow a view of it. This means you can create multiple slices of the same data without copying anything. It's memory efficient and fast!
The String Confusion Finally Explained
Okkay, this part drove me absolutely crazy until I understood the fundamental differences. Rust has three main string-related types, and each serves a specific purpose. Let me break this down because once you get this, a lot of Rust's design decisions start making sense.
fn main() {
// 1. STRING LITERALS (&str) - stored in the program binary
let greeting = "Hello, World!"; // This is &str (string slice)
println!("String literal: {}", greeting);
// 2. STRING - owned, growable, stored on heap
let mut owned_string = String::from("Hello");
owned_string.push_str(", Rust!"); // Can modify it!
println!("Owned String: {}", owned_string);
// 3. STRING SLICES (&str) - borrowed view into string data
let slice_of_owned = &owned_string[0..5]; // "Hello"
let another_slice = &owned_string[7..]; // "Rust!"
println!("Slice 1: {}", slice_of_owned);
println!("Slice 2: {}", another_slice);
// Here's where it gets interesting - function parameters
print_string_slice("This is a literal"); // &str -> &str (works!)
print_string_slice(&owned_string); // String -> &str (works!)
print_string_slice(slice_of_owned); // &str -> &str (works!)
// But this is different:
take_ownership(owned_string); // This MOVES the String
// println!("{}", owned_string); // ERROR! owned_string was moved
}
fn print_string_slice(s: &str) {
println!("Function received: {}", s);
}
fn take_ownership(s: String) {
println!("I now own: {}", s);
// s is dropped here
}
Here's my mental model that finally made this click:
String literals (&str
) are like book titles printed on the book cover - they're part of the book itself, you can't change them, but you can read them forever.
String (String
) is like a notebook - you own it, you can write in it, erase parts, add more pages, but it takes up space in your backpack (heap memory).
String slices (&str
) are like sticky notes pointing to specific paragraphs - whether those paragraphs are in your notebook or printed in a book, the sticky note just shows you where to look.
Generics - Writing Code That Works With Any Type
Now here's where Rust gets really powerful. Generics let you write functions and structs that work with different types without duplicating code. It's like creating a template that can be filled in with different types later.
// Before generics - we'd need separate functions for each type
fn add_i32(a: i32, b: i32) -> i32 {
a + b
}
fn add_f64(a: f64, b: f64) -> f64 {
a + b
}
// With generics - one function works for any type that can be added!
fn add_generic<T>(a: T, b: T) -> T
where
T: std::ops::Add<Output = T>, // This is called a "trait bound"
{
a + b
}
// Generic structs - containers that can hold any type
struct Container<T> {
value: T,
}
impl<T> Container<T> {
fn new(value: T) -> Container<T> {
Container { value }
}
fn get_value(&self) -> &T {
&self.value
}
}
fn main() {
// Using the generic function with different types
let sum_int = add_generic(5, 10); // T becomes i32
let sum_float = add_generic(3.14, 2.86); // T becomes f64
println!("Integer sum: {}", sum_int);
println!("Float sum: {}", sum_float);
// Using generic structs
let int_container = Container::new(42);
let string_container = Container::new(String::from("Hello"));
let float_container = Container::new(3.14);
println!("Int container: {}", int_container.get_value());
println!("String container: {}", string_container.get_value());
println!("Float container: {}", float_container.get_value());
}
The <T>
syntax might look scary, but think of it as a placeholder. When you use the function or struct, Rust fills in the placeholder with the actual type you're using. It's like having a form with blank spaces that gets filled out when you use it.
Traits - Defining Shared Behavior
If generics are templates, then traits are like contracts or interfaces. They define what behavior a type must have. It's like saying "if you want to use this function, your type must be able to do these specific things."
// Define a trait - a contract that types can implement
trait Describable {
fn describe(&self) -> String;
}
// Different types implementing the same trait
struct Person {
name: String,
age: u32,
}
struct Car {
brand: String,
model: String,
year: u32,
}
// Implementing the trait for Person
impl Describable for Person {
fn describe(&self) -> String {
format!("Person: {} is {} years old", self.name, self.age)
}
}
// Implementing the trait for Car
impl Describable for Car {
fn describe(&self) -> String {
format!("Car: {} {} ({})", self.year, self.brand, self.model)
}
}
// Function that accepts any type implementing Describable
fn print_description(item: &impl Describable) {
println!("{}", item.describe());
}
// Alternative syntax - trait bounds
fn print_description_alternative<T: Describable>(item: &T) {
println!("{}", item.describe());
}
fn main() {
let person = Person {
name: String::from("Alice"),
age: 30,
};
let car = Car {
brand: String::from("Toyota"),
model: String::from("Camry"),
year: 2022,
};
// Both work with the same function!
print_description(&person);
print_description(&car);
// The alternative syntax works too
print_description_alternative(&person);
print_description_alternative(&car);
}
Traits are incredibly powerful because they let you write functions that work with any type that has the required behavior. It's like saying "I don't care what you are, as long as you can describe yourself, you can use this function."
Lifetimes - The Mystery Finally Solved
Alright, this is the big one. Lifetimes confused me for weeks until I realized they're just Rust's way of making sure references don't outlive the data they point to. Think of lifetimes as Rust asking "how long will this reference be valid?" and making sure you don't accidentally use it after the original data is gone.
Let me start with the simplest case and build up:
// Simple case - no lifetime annotation needed
fn get_first_word(s: &str) -> &str {
let words: Vec<&str> = s.split_whitespace().collect();
if words.is_empty() {
""
} else {
words[0]
}
}
// When Rust gets confused - we need to help it understand lifetimes
fn longest_string(s1: &str, s2: &str) -> &str {
// ERROR! Rust doesn't know if the returned reference
// should live as long as s1 or s2
if s1.len() > s2.len() {
s1
} else {
s2
}
}
// Fixed with lifetime annotations
fn longest_string_fixed<'a>(s1: &'a str, s2: &'a str) -> &'a str {
// Now Rust knows: the returned reference lives as long as
// the shorter of s1 and s2
if s1.len() > s2.len() {
s1
} else {
s2
}
}
fn main() {
let string1 = String::from("Hello, world!");
let string2 = String::from("Hi!");
let result = longest_string_fixed(&string1, &string2);
println!("Longest: {}", result);
// This would be a problem:
let result_ref;
{
let temp_string = String::from("Temporary");
// result_ref = longest_string_fixed(&string1, &temp_string);
// ERROR! temp_string doesn't live long enough
}
// println!("Result: {}", result_ref); // temp_string is gone!
}
The 'a
(pronounced "tick A") is a lifetime parameter. It's like giving a name to "how long this reference will be valid." When you see <'a>
, think "for some lifetime called 'a'."
Lifetimes in Structs - When Your Struct Holds References
This is where lifetimes become really important. If your struct holds references to data, Rust needs to know that the struct won't outlive the data it references:
// Struct that holds a reference - needs a lifetime parameter
struct BookExcerpt<'a> {
title: &'a str,
author: &'a str,
excerpt: &'a str,
}
impl<'a> BookExcerpt<'a> {
// Method that returns a reference with the same lifetime
fn get_title(&self) -> &'a str {
self.title
}
// Method that creates a summary
fn create_summary(&self) -> String {
format!("'{}' by {}: {}", self.title, self.author, self.excerpt)
}
}
fn main() {
let title = "The Rust Programming Language";
let author = "Steve Klabnik";
let excerpt = "Rust is a systems programming language...";
// Create the struct - all references must live at least as long as the struct
let book = BookExcerpt {
title,
author,
excerpt,
};
println!("Book title: {}", book.get_title());
println!("Summary: {}", book.create_summary());
// This won't work:
// let book_ref;
// {
// let temp_title = String::from("Temporary Book");
// book_ref = BookExcerpt {
// title: &temp_title, // ERROR! temp_title doesn't live long enough
// author: "Someone",
// excerpt: "Some text",
// };
// }
// println!("{}", book_ref.title); // temp_title is gone!
}
Think of lifetimes in structs like this: if your struct is like a photo album holding pictures (references), the pictures must exist at least as long as the photo album. You can't have a photo album pointing to pictures that have been thrown away!
Multiple Lifetimes - When Things Get More Complex
Sometimes you need multiple lifetime parameters when your function or struct deals with references that might have different lifetimes:
// Function with multiple lifetime parameters
fn compare_and_return<'a, 'b>(
first: &'a str,
second: &'b str,
use_first: bool
) -> &'a str
where
'b: 'a // 'b must live at least as long as 'a
{
if use_first {
first
} else {
// We can only return 'a references, so we need to ensure
// 'b lives at least as long as 'a
first // Always return first to satisfy the return type
}
}
// Struct with multiple lifetimes
struct Comparison<'a, 'b> {
left: &'a str,
right: &'b str,
}
impl<'a, 'b> Comparison<'a, 'b> {
fn get_left(&self) -> &'a str {
self.left
}
fn get_right(&self) -> &'b str {
self.right
}
}
fn main() {
let long_lived = String::from("This lives for a while");
{
let short_lived = String::from("This is temporary");
let comparison = Comparison {
left: &long_lived,
right: &short_lived,
};
println!("Left: {}", comparison.get_left());
println!("Right: {}", comparison.get_right());
}
// short_lived is gone, but that's okay because we're not using the comparison anymore
}
Multithreading - Rust's Superpower
Now for the grand finale - multithreading! This is where Rust really shines. Most languages make concurrent programming scary because of data races and shared mutable state. Rust's ownership system makes it safe by default!
use std::thread;
use std::time::Duration;
fn main() {
println!("Starting multithreading examples!");
// Basic thread spawning
let handle = thread::spawn(|| {
for i in 1..10 {
println!("Thread: counting {}", i);
thread::sleep(Duration::from_millis(1));
}
});
// Main thread doing work
for i in 1..5 {
println!("Main: counting {}", i);
thread::sleep(Duration::from_millis(1));
}
// Wait for the spawned thread to finish
handle.join().unwrap();
println!("Thread finished!");
// Moving data into threads
let data = vec![1, 2, 3, 4, 5];
let handle2 = thread::spawn(move || {
// 'move' transfers ownership of 'data' to this thread
println!("Thread received data: {:?}", data);
let sum: i32 = data.iter().sum();
sum // Return the sum
});
// data is no longer available in main thread - it was moved!
// println!("{:?}", data); // ERROR! data was moved
let result = handle2.join().unwrap();
println!("Thread calculated sum: {}", result);
}
The move
keyword is crucial here. It tells Rust to transfer ownership of variables from the current scope into the thread closure. This prevents data races because only one thread can own the data at a time.
More Complex Threading - Sharing Data Safely
When you need to share data between threads, Rust provides safe abstractions:
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// Arc = Atomically Reference Counted (shared ownership)
// Mutex = Mutual Exclusion (safe shared mutation)
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for i in 0..10 {
let counter_clone = Arc::clone(&counter);
let handle = thread::spawn(move || {
// Lock the mutex to get exclusive access
let mut num = counter_clone.lock().unwrap();
*num += 1;
println!("Thread {} incremented counter to {}", i, *num);
});
handles.push(handle);
}
// Wait for all threads to finish
for handle in handles {
handle.join().unwrap();
}
// Check final result
let final_count = *counter.lock().unwrap();
println!("Final counter value: {}", final_count);
}
Think of Arc
as a way to have multiple owners of the same data (like having multiple keys to the same house), and Mutex
as a lock that ensures only one person can be inside the house at a time.
Channel Communication - Threads Talking to Each Other
Another powerful pattern is using channels for thread communication:
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
// Create a channel (tx = transmitter, rx = receiver)
let (tx, rx) = mpsc::channel();
// Spawn a thread that sends data
thread::spawn(move || {
let messages = vec![
"Hello",
"from",
"the",
"spawned",
"thread!"
];
for msg in messages {
tx.send(msg).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
// Main thread receives data
for received in rx {
println!("Main thread received: {}", received);
}
}
Channels are like postal mail between threads - one thread sends messages, another receives them. It's a safe way to communicate without sharing mutable state.
What I Learned Today - The Big Picture Coming Together
After diving deep into these "advanced" concepts, I can see how they all connect to Rust's core philosophy of safety without sacrificing performance. Slices give you efficient views into data without copying. The string types each serve specific purposes in the memory model. Generics and traits provide powerful abstraction without runtime cost.
Lifetimes, which seemed so mysterious, are just Rust's way of preventing use-after-free bugs at compile time. Once you understand that they're about ensuring references don't outlive their data, the syntax becomes much less intimidating.
And multithreading? Rust makes it actually enjoyable! The ownership system prevents data races by design, and the standard library provides safe abstractions for the few cases where you need shared mutable state.
The most amazing realization is that all these safety guarantees happen at compile time. Your final program runs with zero overhead for these safety checks, but you get the confidence that whole categories of bugs simply can't happen.
Something to Note
Don't worry if lifetimes still feel confusing - they're probably the hardest concept in Rust to fully internalize. Start with simple cases and gradually work up to more complex scenarios. The Rust compiler has excellent error messages that will guide you when lifetime annotations are needed.
For multithreading, begin with simple examples like the ones I showed. Don't jump straight into complex shared state scenarios. Master the basics of spawning threads and moving data first, then gradually add complexity.
One more thing that helped me: when you see lifetime errors, read them as "this reference might not be valid when you try to use it." The solution is usually either to restructure your code so the data lives longer, or to use owned types instead of references.
Remember, these concepts exist to help you write correct programs. Every time Rust makes you think about lifetimes or ownership, it's preventing a potential bug that might not show up until your code is running in production.
Hope this Part 3 helped demystify these concepts! I'm already excited about Part 4, where I'm planning to explore error handling patterns, modules and crates, and maybe some async programming. The Rust journey continues, and it keeps getting more interesting :))
Happy Coding! By Abir
Subscribe to my newsletter
Read articles from Abir Dutta directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Abir Dutta
Abir Dutta
I am a Blockchain and MERN stack developer. While building real-life application based full-stack projects, I also like to connect and network with different types of people to learn more from them.